一个常被反复争论的设计问题
每个数据库表都有一个自增 id 主键——这件事几乎没人会争。但外键到底应该引用 id 还是引用业务编码(code),争论从未停止。
举个最常见的例子:
| |
注:示例里用
orders而不是order——order是 SQL 保留字,直接拿来当表名要么报语法错误,要么必须加反引号`order`。
哪种好?两派都各有铁粉,争论起来谁也说服不了谁。本文把两种方案的优劣、适用边界、工程取舍讲清楚——不给标准答案,但讲清楚什么场景该选什么。
第一原则:先区分两类外键
讨论这个问题前必须先把"外键"分成两大类——它们的处理逻辑完全不同:
- 关联类外键:链接两个业务实体。例如
order.user_id指向user.id、order_item.product_id指向product.id - 枚举类外键:链接到字典/枚举。例如
order.status表示订单状态、user.gender表示性别
关联类外键和枚举类外键,结论不一样。下面分开讲。
一、关联类外键:建议用 ID
order.user_id 这种"指向另一个业务实体"的外键,强烈建议用 ID 关联——也就是数据库自增主键或雪花 ID。
为什么用 ID
1. ID 是不变的,业务编码可能变
user_no = "U20170315001" 这种"业务用户编号"看起来像永久标识,但业务发起人随时可能要求改格式:
“把所有用户编号前缀从 U 改成 A,因为合并了 A 公司的系统。”
如果 100 张表都用 user_no 关联,改一次格式要重写 100 张表的引用——可能涉及上亿行数据。如果用 ID,业务编码爱怎么变怎么变。
2. 数字索引比字符串索引快
数据库的 B+ Tree 索引中,整数 8 字节、字符串 32 字节起步。整数索引的 IO 和比较都快得多——尤其在 join 大量数据时差距明显。
| |
千万级数据下,前者比后者快 30%-50% 不是稀奇事。
3. ID 是单调递增的,写入友好
InnoDB 是按主键聚簇存储的——递增 ID 写入时永远是 append,热点页只有一个。如果用业务编码(不一定单调),写入时容易引发页分裂,影响写性能。
4. 不暴露业务信息
ID 是个"无意义数字",不会泄漏业务规模。order.id = 9876543 攻击者拿到也猜不出实际订单量;但 order.order_no = "ORD-20170315-00001" 一看就知道当天第几单。
但 ID 也不是完美
1. 跨库迁移痛
ID 是数据库自增,两个库的数据合并时 ID 必然冲突——常见做法是用雪花 ID 或一开始就用 UUID。
2. 读起来不友好
日志里看到 user_id=12345 你要去查另一张表才知道是谁。所以业务对外的 API 通常还是返回 user_no——内部 ID + 外部 code 双轨制。
二、枚举类外键:建议用 CODE
订单状态、性别、城市编码这种"指向字典"的外键,反过来——建议用 CODE 关联。
为什么用 CODE
1. 可读性极强
| |
不用 join 字典表都能看懂"已支付"。线上排查问题时这个差别非常大。
2. 字典基本不会改
order_status 这种字典——PAID、SHIPPED、COMPLETED——一旦确定,几乎不会变(业务有变化也是新增,不是修改)。code 不会改的前提下,ID 的"不变性优势"就消失了。
3. 跨系统一致
字典经常要给前端、第三方对接、数据导出。PAID 比 3 通用得多——前端不用维护"3=已支付"的对照表,直接看 code 写逻辑。
4. 代码层面更清爽
业务代码用枚举 + 字符串 code 是天然契合:
| |
如果用 ID,代码里到处是 status_id == 3 这种魔法数字,可读性差,加新状态时容易和已有 ID 撞。
5. 字典表本身也得有
国内项目还有种做法是根本不建字典表,code 直接写在代码枚举里。这有它的优势——一致性强、没有"代码和数据库不同步"的问题,但缺点是修改要发版,前端没法动态拉取选项。
工程上常见做法:
- 简单字典直接代码枚举
- 需要后台管理、能配置的字典建表(status_code 作为 PK 或唯一索引)
三、混用:一种实用模式
很多项目其实是混用——业务实体用 ID 关联,枚举用 code:
| |
这是一种"按字段类型选方案"的混合姿势,比"全 ID"或"全 CODE"都更贴合实际。
四、第三种争论:UUID / 雪花 ID
Long 自增 ID 适合单库,分布式场景常被替换为 UUID 或雪花 ID。
| 方案 | 优点 | 缺点 |
|---|---|---|
| 自增 ID | 紧凑、有序、写入友好 | 单库限制、跨库迁移痛 |
| UUID | 全局唯一、无中心化 | 二进制 16 字节 / 文本 36 字符、v4 无序、写入页分裂、读取慢 |
| 雪花 ID | 紧凑(Long)、有序、分布式 | 时钟回拨问题、需要部署机器号 |
关联类外键用什么主键,CODE 关联问题不大。但用 UUID 时务必走 BINARY(16) 存储而不是 CHAR(36)——前者只占一半空间且索引性能高得多(不止一倍)。
五、外键约束:要不要加 FK constraint
讨论"用 ID 还是 CODE"时,常常顺带聊另一个问题——要不要在数据库层面建外键约束:
| |
国内绝大多数生产项目不建外键约束,原因:
- 性能影响:每次写入都要校验外键,高并发下是负担
- 分库分表后约束失效:跨库 FK 是不可能的
- 运维痛点:表的清理、备份、迁移都被外键卡住
业内主流做法是——逻辑外键(应用层保证一致性,数据库不强制)。这意味着:
- 表设计上仍然要有"外键"概念
- 命名规范要清晰:
user_id/product_id这种命名让人一眼看出关联 - 数据一致性靠应用代码 + 单测覆盖
六、实战建议
具体几条建议:
- 业务实体外键用 ID——
user_id、product_id、order_id - 枚举/字典外键用 CODE——
status_code、type_code xxx_no/xxx_code字段建唯一索引——业务上对外暴露但 DB 关联用 ID- 生产环境基本都不加 FK 约束——逻辑外键 + 应用层校验
- 不要把"自增 ID"暴露给前端——容易暴露业务规模 + 不好做加密短链
- 跨库场景上雪花 ID 不要 UUID——写入性能差距很大
七、一些反例
反例 1:user_no 当外键
某项目用 U20170315001 这种用户编号当所有外键。三年后业务并购,要把所有 U 开头的用户改成 A 开头——全表 update + 全部关联表 update,停服 12 小时。
教训:业务编号永远可能改格式。
反例 2:状态用 ID
订单状态字典表里 1=未付款、2=已付款、3=已发货。某天产品提需求:“把’已付款’拆成’部分付款’和’全额付款’"。开发以为只要在字典加一条,结果全代码搜 status_id == 2 漏改了三处——线上有"全额付款的订单被识别为部分付款”。
教训:枚举用数字 ID 容易让代码里有魔法数字。
反例 3:用业务编码当主键
order_no 设为 order 表的主键——单字段、有序、看似优雅。但:
- VARCHAR 主键聚簇索引大、IO 多
- 业务编码格式偶尔要改(前缀、长度)→ 主键变化引发整表 rebuild
- 子表关联用 VARCHAR(32) 占空间多
教训:主键应当稳定、无意义、紧凑——bigint id 是最佳形态。
小结
把全文压一句:
业务实体之间用 ID 关联,枚举字典之间用 CODE 关联——稳定的东西用稳定的标识,可读的东西用可读的标识。
记住几条工程铁律:
- ID 主键永不暴露给业务规则
- 业务编码做唯一索引但不做外键
- 枚举字典 code 全大写下划线,永不复用
- 生产环境逻辑外键 + 应用层校验
- 代码里别留魔法数字,永远用枚举 + code 比较
把这几条吃透,数据库设计这层基本不会出大问题。