Skip to content

6.2 数据库基础概念

本节目标:彻底搞懂关系型数据库的核心概念——表、行、列、主键、外键、关系类型、约束机制。


小红的外卖创业

小红是个行动派。她发现校园里的外卖平台配送费太贵、商家选择太少,于是决定自己做一个校园外卖平台——只服务本校,配送靠同学兼职,抽成比美团低一半。

她先梳理了一下需要管理的数据:

  • 学生用户:谁在用这个平台?手机号、昵称、宿舍地址
  • 校园商家:食堂档口、奶茶店、水果店,每家有名字、评分、营业时间
  • 菜品菜单:每家店卖什么、多少钱、有没有图片
  • 订单记录:谁在哪家店买了什么、花了多少钱、现在什么状态
  • 订单明细:一个订单里具体点了哪些菜、各几份

小红一开始想用 Excel 管理——毕竟她大学里用 Excel 做过课程表、记过账,感觉挺顺手的。

但她很快发现:一个订单里可能有三道菜,每道菜属于不同的分类,同一道菜可能出现在几百个订单里。这些数据之间的关系像一张网,Excel 的"一行一条记录"根本装不下。她试过在一个格子里用逗号分隔多道菜名,但查询和统计时简直是噩梦。

她需要的是一个能管理"数据之间关系"的工具——这就是关系型数据库

"关系型"这三个字是关键。它不是说数据库能帮你管理人际关系,而是说它擅长管理数据与数据之间的关系:用户和订单的关系、订单和菜品的关系、商家和菜品的关系。这些关系在 Excel 里很难表达,但在关系型数据库里是天然支持的。


表:比 Excel 强大一百倍的"工作表"

数据库里的表(Table),你可以先理解为一个"超级 Excel 工作表"。如果你用过 Excel,理解数据库表就有了一个很好的起点。

先看相似的地方:

Excel 概念数据库概念说明
工作表 Sheet表 Table一类数据一张表,比如 users 表存所有用户
一行数据行 Row(也叫记录)一个具体的用户、一笔具体的订单
表头列名列 Column(也叫字段)数据的属性,比如 phoneaddress

再看不同的地方——这些差异才是数据库真正的价值,也是 Excel 永远做不到的:

数据类型严格。 Excel 里一列可以混放文字和数字,你在"年龄"列写个"很大"也没人拦你。数据库不行——integer 类型的列只能放整数,你写个文字进去,数据库直接报错拒绝。这看起来"不方便",但恰恰是保证数据质量的关键。想象小红的外卖平台,如果"价格"列里混进了文字,结算时就会出错。数据库的严格类型从源头杜绝了这种问题。

约束机制。 数据库能设置规则:手机号不能重复(UNIQUE)、昵称不能为空(NOT NULL)、订单的用户 ID 必须指向一个真实存在的用户(FOREIGN KEY)。违反规则的数据写不进去,从源头杜绝脏数据。这些规则一旦设定,无论是谁、通过什么方式写入数据,都必须遵守——不像 Excel,任何人都能随意修改任何格子。

表间关联。 这是数据库最核心的能力,也是它叫"关系型"数据库的原因。多张表可以通过"外键"建立关系,然后用 SQL 的 JOIN 操作把关联数据一次性查出来。比如"查出小红的所有订单以及每个订单里的菜品",一条 SQL 搞定。在 Excel 里,你得在多个 Sheet 之间来回跳,手动对照 ID,效率极低。

并发安全。 100 个用户同时下单,数据库通过事务和锁机制保证每笔订单都正确写入,不会互相覆盖、不会丢数据。Excel 做不到这一点——两个人同时编辑同一个文件,轻则产生冲突,重则丢失修改。

下面这个交互组件,让你直观感受一张表的结构——点击列名可以查看每个字段的详细说明:

点击任意列名,查看该列的详细说明。

idserialPRIMARY KEYphonetextNOT NULL, UNIQUEnicknametextNOT NULLaddresstextcreated_attimestampDEFAULT now()
1138****8888小明北京市朝阳区建国路88号2024-01-15 09:30
2139****9999小红上海市浦东新区陆家嘴2024-02-20 14:15
3137****7777老王NULL2024-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
                quantity

order_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主键 ID1, 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 写法。

三个常见的类型陷阱

  1. 金额用 real / float:浮点数有精度问题(0.1 + 0.2 ≠ 0.3),金额必须用 numericinteger(单位:分)
  2. 时间用 timestamp:不带时区的时间戳会在不同服务器上产生歧义,永远用 timestamptz
  3. 字符串用 varchar(255):PostgreSQL 里 textvarchar 性能完全一样,varchar(n) 只是多了个长度限制,没有任何性能优势

SQLite vs PostgreSQL

SQLitePostgreSQL
定位文件型数据库,零配置完整的数据库服务器
存储单个 .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 自增
  • 外键 = 表间关联的线索,保证引用完整性
  • 三种关系:一对多(最常见)、多对多(需中间表)、一对一(拆分大表)
  • 约束 = 数据库层面的规则守卫,是代码校验之外的最后防线
  • 数据类型 = 选对类型,存储高效、查询快速