主题
6.2 数据库基础概念
本节目标:彻底搞懂关系型数据库的核心概念——表、行、列、主键、外键、关系类型、约束机制。
小红的外卖创业
小红是个行动派。她发现校园里的外卖平台配送费太贵、商家选择太少,于是决定自己做一个校园外卖平台——只服务本校,配送靠同学兼职,抽成比美团低一半。
她先梳理了一下需要管理的数据:
- 学生用户:谁在用这个平台?手机号、昵称、宿舍地址
- 校园商家:食堂档口、奶茶店、水果店,每家有名字、评分、营业时间
- 菜品菜单:每家店卖什么、多少钱、有没有图片
- 订单记录:谁在哪家店买了什么、花了多少钱、现在什么状态
- 订单明细:一个订单里具体点了哪些菜、各几份
小红一开始想用 Excel 管理——毕竟她大学里用 Excel 做过课程表、记过账,感觉挺顺手的。
但她很快发现:一个订单里可能有三道菜,每道菜属于不同的分类,同一道菜可能出现在几百个订单里。这些数据之间的关系像一张网,Excel 的"一行一条记录"根本装不下。她试过在一个格子里用逗号分隔多道菜名,但查询和统计时简直是噩梦。
她需要的是一个能管理"数据之间关系"的工具——这就是关系型数据库。
"关系型"这三个字是关键。它不是说数据库能帮你管理人际关系,而是说它擅长管理数据与数据之间的关系:用户和订单的关系、订单和菜品的关系、商家和菜品的关系。这些关系在 Excel 里很难表达,但在关系型数据库里是天然支持的。
表:比 Excel 强大一百倍的"工作表"
数据库里的表(Table),你可以先理解为一个"超级 Excel 工作表"。如果你用过 Excel,理解数据库表就有了一个很好的起点。
先看相似的地方:
| Excel 概念 | 数据库概念 | 说明 |
|---|---|---|
| 工作表 Sheet | 表 Table | 一类数据一张表,比如 users 表存所有用户 |
| 一行数据 | 行 Row(也叫记录) | 一个具体的用户、一笔具体的订单 |
| 表头列名 | 列 Column(也叫字段) | 数据的属性,比如 phone、address |
再看不同的地方——这些差异才是数据库真正的价值,也是 Excel 永远做不到的:
数据类型严格。 Excel 里一列可以混放文字和数字,你在"年龄"列写个"很大"也没人拦你。数据库不行——integer 类型的列只能放整数,你写个文字进去,数据库直接报错拒绝。这看起来"不方便",但恰恰是保证数据质量的关键。想象小红的外卖平台,如果"价格"列里混进了文字,结算时就会出错。数据库的严格类型从源头杜绝了这种问题。
约束机制。 数据库能设置规则:手机号不能重复(UNIQUE)、昵称不能为空(NOT NULL)、订单的用户 ID 必须指向一个真实存在的用户(FOREIGN KEY)。违反规则的数据写不进去,从源头杜绝脏数据。这些规则一旦设定,无论是谁、通过什么方式写入数据,都必须遵守——不像 Excel,任何人都能随意修改任何格子。
表间关联。 这是数据库最核心的能力,也是它叫"关系型"数据库的原因。多张表可以通过"外键"建立关系,然后用 SQL 的 JOIN 操作把关联数据一次性查出来。比如"查出小红的所有订单以及每个订单里的菜品",一条 SQL 搞定。在 Excel 里,你得在多个 Sheet 之间来回跳,手动对照 ID,效率极低。
并发安全。 100 个用户同时下单,数据库通过事务和锁机制保证每笔订单都正确写入,不会互相覆盖、不会丢数据。Excel 做不到这一点——两个人同时编辑同一个文件,轻则产生冲突,重则丢失修改。
下面这个交互组件,让你直观感受一张表的结构——点击列名可以查看每个字段的详细说明:
点击任意列名,查看该列的详细说明。
| idserialPRIMARY KEY | phonetextNOT NULL, UNIQUE | nicknametextNOT NULL | addresstext | created_attimestampDEFAULT now() |
|---|---|---|---|---|
| 1 | 138****8888 | 小明 | 北京市朝阳区建国路88号 | 2024-01-15 09:30 |
| 2 | 139****9999 | 小红 | 上海市浦东新区陆家嘴 | 2024-02-20 14:15 |
| 3 | 137****7777 | 老王 | NULL | 2024-03-01 18:00 |
主键:每行数据的身份证
在现实世界里,每个人有一个身份证号,全国唯一,用来在各种系统里标识"你是谁"。去银行开户要身份证号,去医院挂号要身份证号,买火车票要身份证号——所有系统都通过这个号码来确认"你就是你"。
数据库里也一样。每张表都需要一个主键(Primary Key),用来唯一标识每一行数据。就像身份证号标识一个人,主键标识一条记录。
主键的规则很简单,就三条:
- 唯一:不能有两行的主键值相同(就像不能有两个人的身份证号一样)
- 不能为空:每行必须有主键值(每个人都必须有身份证号)
- 不可变:一旦分配,不应该修改(你不会隔三差五换身份证号)
实际开发中,主键通常用 id 列。现代 PostgreSQL 推荐用 bigint generated always as identity——意思是"用一个大整数做 ID,由数据库自动生成,每次自动 +1"(比老式的 serial 更标准、更安全):
sql
CREATE TABLE users (
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
phone text NOT NULL
);Drizzle ORM 里写
serial('id').primaryKey()也完全可以,AI 生成的代码两种都常见。核心是:让数据库自动分配 ID,你不需要操心。
你可能会问:手机号也是唯一的,为什么不用手机号当主键?
原因是手机号有"业务含义"——用户可能换号。一旦主键被其他表引用(比如订单表里存了 user_id),改主键就意味着要改所有引用它的地方,牵一发动全身。所以业界的最佳实践是:用一个无业务含义的自增 ID 当主键,简单、稳定、高效。
外键:表与表之间的线索
如果说主键是"我是谁",那外键就是"我和谁有关系"。
外键(Foreign Key) 是一张表里指向另一张表主键的列。它的作用是建立表与表之间的关联,同时保证关联的数据真实存在。
举个例子:小红的 orders 表里有一列 user_id,它的值必须是 users 表里某个真实存在的 id。这就是外键。通过这个 user_id,你可以从一笔订单追溯到下单的用户——"这笔订单是谁下的?user_id 是 42,去 users 表查 id=42 的那行,哦,是小红。"
sql
CREATE TABLE orders (
id serial PRIMARY KEY,
user_id integer REFERENCES users(id), -- 外键,指向 users.id
amount real NOT NULL,
status text DEFAULT '待支付'
);外键带来三个好处:
保证数据一致性。 你不能给一个不存在的用户创建订单。如果 users 表里没有 id=999 的用户,往 orders 表插入 user_id=999 的订单会直接报错。这就是"引用完整性"——你引用的东西必须真实存在,不能指向一个不存在的用户。
建立关联。 通过 user_id,你可以从一笔订单追溯到下单的用户,也可以从一个用户查到他的所有订单。数据之间的关系变得清晰可查。
级联操作(连锁反应)。 删除一个用户时,他的所有订单怎么办?数据库可以自动删除他的所有订单(级联删除),或者阻止删除(如果还有关联订单就不让删)。这个行为可以在建表时配置。
外键列必须加索引
PostgreSQL 不会自动给外键列创建索引。如果 orders 表有 10 万行,查"某个用户的所有订单"时,没有索引的 user_id 会导致全表扫描——从第一行翻到最后一行,像在没有目录的书里逐页找内容——慢 100 倍以上。所以每个外键列都要手动加索引。这个知识点在 6.4 会详细展开。
你可以在上方组件的「关系图」tab 中,点击连线查看外键的具体关联方式。
关系类型详解
数据库表之间的关系,归纳起来就三种。理解这三种关系,你就能看懂任何数据库设计。
一对多(1:N)— 最常见
一个用户可以下多个订单,但每个订单只属于一个用户。这就是一对多。
users (1) ──→ orders (N)
id user_id → users.id实现方式很直觉:在"多"的那张表加一个外键列。orders 表里的 user_id 指向 users.id,就建立了一对多关系。
一对多是最常见的关系类型,你在任何应用里都能找到大量例子:
- 一个商家有多道菜品(商家 → 菜品)
- 一个作者写多篇文章(作者 → 文章)
- 一个班级有多个学生(班级 → 学生)
- 一个用户发多条动态(用户 → 动态)
判断方法也很简单:问自己"一个 A 可以有多个 B 吗?一个 B 只属于一个 A 吗?"如果两个都是"是",就是一对多。
多对多(M:N)— 需要中间表
一个订单包含多道菜,一道菜也出现在多个订单里。这种"双向的多"就是多对多关系。
多对多不能直接用一个外键表示——因为外键只能指向一条记录,没法同时指向多条。解决方案是加一张中间表,把多对多拆成两个一对多:
orders (M) ←─ order_items ─→ dishes (N)
id order_id id
dish_id
quantityorder_items 就是中间表。每行记录"哪个订单买了哪道菜、几份"。一个订单在 order_items 里有多行(点了多道菜),一道菜在 order_items 里也有多行(被多个订单点过)。
中间表不只是"连接器",它还可以存储关系本身的属性。比如 quantity(数量)就是"订单和菜品之间的关系"的属性,不属于订单,也不属于菜品,只属于这个关联本身。
更多多对多的例子:
- 学生 ↔ 课程(中间表:选课记录,可以存成绩)
- 用户 ↔ 标签(中间表:用户标签,可以存打标时间)
- 文章 ↔ 分类(中间表:文章分类关联)
一对一(1:1)— 拆分大表
一个用户对应一份详细资料。为什么不放在同一张表里?
想象 users 表有 50 万用户。每次用户登录,系统都要查 users 表验证身份。如果这张表里塞了头像 URL、个人简介、偏好设置、地址列表等一大堆不常用的字段,表就变得很"重",查询速度会下降。
解决方案是把不常用的字段拆到另一张表:
sql
CREATE TABLE profiles (
id serial PRIMARY KEY,
user_id integer UNIQUE REFERENCES users(id), -- UNIQUE 保证一对一
avatar text,
bio text
);关键是 user_id 上的 UNIQUE 约束。没有它,一个用户就可以有多条 profile,变成一对多了。加了 UNIQUE,数据库保证每个 user_id 只出现一次,就是严格的一对一。
一对一在实际项目中不太常见,但在这些场景下很有用:
- 用户基本信息 ↔ 用户详细资料(拆分大表)
- 订单 ↔ 发票(不是每个订单都有发票)
- 员工 ↔ 工位(一个员工一个工位)
数据完整性:为什么约束重要
没有约束的数据库就像没有规则的仓库——什么都能往里塞,迟早乱套。今天塞进去一个"年龄 -5"的用户,明天塞进去一个"价格为空"的商品,后天你的应用就会在各种奇怪的地方崩溃,而且你根本不知道是数据的问题。
约束(Constraint) 是数据库自动执行的规则。每次写入数据时,数据库会检查这些规则,不合规的数据直接拒绝,连存都不让存。这就像机场安检——不管你是谁、从哪来,行李都要过 X 光机,违禁品一律拦截。
| 约束 | 作用 | 生活类比 |
|---|---|---|
| PRIMARY KEY(主键) | 唯一标识每行 | 身份证号,全国唯一 |
| FOREIGN KEY(外键) | 确保引用的数据存在 | 快递单上的收件人必须是真人 |
| NOT NULL(必填) | 不允许为空 | 表单里的必填项,不填不让提交 |
| UNIQUE(不可重复) | 不允许重复 | 手机号不能重复注册 |
| CHECK(条件检查) | 自定义条件检查 | 年龄必须大于 0,评分必须在 1-5 之间 |
| DEFAULT(默认值) | 未填时自动填入默认值 | 订单状态默认"待支付" |
在上方组件的「约束演示」tab 中,你可以看到每种约束的正确 vs 违规插入对比。
你可能会想:我在代码里做校验不就行了,为什么还要在数据库层面加约束?
三个原因:
代码可能有 bug,但数据库约束是最后一道防线。 你的校验逻辑写错了、漏了一个边界条件、某个 if 判断少了个等号——这些都可能让脏数据溜进去。数据库约束不会犯这种错误,它是铁面无私的守门员。
数据的入口不止一个。 你的应用有 API 接口、有后台管理面板、有数据迁移脚本、有定时任务……每个入口都可能写入数据。你不可能在每个入口都完美地实现一遍校验逻辑。但数据库约束只需要定义一次,所有入口都受保护。
脏数据的清理成本极高。 想象几万条订单的 user_id 指向了不存在的用户——你怎么修?删掉这些订单?那用户的钱怎么办?把 user_id 改成某个默认用户?那数据就失真了。预防永远比治疗便宜。
数据类型速查
每个列都必须指定数据类型。类型选对了,存储高效、查询快速;选错了,轻则浪费空间,重则数据丢失。
常用的 PostgreSQL 数据类型:
| 类型 | 用途 | 示例 | 注意事项 |
|---|---|---|---|
bigint / serial | 主键 ID | 1, 2, 3... | bigint 更安全,serial 也够用 |
text | 任意长度文本 | '你好世界' | 永远用 text,不要用 varchar(n) |
integer | 整数 | 42, -1, 0 | 最大 21 亿,够用就行 |
boolean | 真/假 | true / false | — |
timestamptz | 日期时间 | '2024-01-15 09:30:00+08' | 带时区,不要用 timestamp |
jsonb | 结构化 JSON | '{"tags":["vip"]}' | 可建 GIN 索引加速查询 |
numeric(10,2) | 精确小数 | 99.99 | 金额必须用这个,不要用 real |
uuid | 全局唯一标识 | 'a1b2c3d4-...' | 分布式系统常用 |
在上方组件的「数据类型」tab 中,可以查看每种类型对应的 Drizzle ORM 写法。
三个常见的类型陷阱
- 金额用
real/float:浮点数有精度问题(0.1 + 0.2 ≠ 0.3),金额必须用numeric或integer(单位:分) - 时间用
timestamp:不带时区的时间戳会在不同服务器上产生歧义,永远用timestamptz - 字符串用
varchar(255):PostgreSQL 里text和varchar性能完全一样,varchar(n)只是多了个长度限制,没有任何性能优势
SQLite vs PostgreSQL
| SQLite | PostgreSQL | |
|---|---|---|
| 定位 | 文件型数据库,零配置 | 完整的数据库服务器 |
| 存储 | 单个 .db 文件 | 独立的数据库服务进程 |
| 并发 | 单写多读,适合小规模 | 高并发读写,适合生产环境 |
| 数据类型 | 宽松(实际上只有 5 种) | 严格且丰富(50+ 种) |
| JSON 支持 | 基础 | 强大的 jsonb 类型 |
| 适用场景 | 本地开发、嵌入式、原型验证 | 生产环境、团队协作、高并发 |
| 本教程建议 | 学习阶段可用,快速上手 | 实际项目推荐,Supabase 免费提供 |
选择建议
如果你用 Supabase 或 Neon 等云数据库服务,直接就是 PostgreSQL,不需要纠结。本地开发想快速验证,SQLite 也完全够用——Drizzle ORM 支持两者无缝切换。
让 AI 帮你设计表结构
理解了这些概念后,你可以用这样的提示词让 AI 帮你设计数据库:
我要做一个 [应用描述],请帮我设计数据库表结构。
要求:
1. 使用 PostgreSQL + Drizzle ORM
2. 列出所有表、字段、类型、约束
3. 标注表之间的关系(一对多/多对多)
4. 每张表都要有 id、created_at、updated_at
5. 用中文注释解释每个字段的用途AI 会生成完整的 Drizzle schema 代码,你可以直接复制到项目里。
本节核心要点
- 表 = 一类数据的容器,行是记录,列是属性
- 主键 = 每行的唯一 ID,用
serial自增 - 外键 = 表间关联的线索,保证引用完整性
- 三种关系:一对多(最常见)、多对多(需中间表)、一对一(拆分大表)
- 约束 = 数据库层面的规则守卫,是代码校验之外的最后防线
- 数据类型 = 选对类型,存储高效、查询快速
