Featured image of post 关联用 ID 还是 CODE:数据库主键与外键设计实践

关联用 ID 还是 CODE:数据库主键与外键设计实践

外键到底应该指向自增 ID 还是业务编码?两种方案的优劣、适用边界和工程取舍

一个常被反复争论的设计问题

每个数据库表都有一个自增 id 主键——这件事几乎没人会争。但外键到底应该引用 id 还是引用业务编码(code),争论从未停止。

举个最常见的例子:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
-- 方案 A:用 ID 关联
CREATE TABLE orders (
    id          BIGINT PRIMARY KEY,
    status_id   BIGINT,           -- 关联 order_status.id
    ...
);

-- 方案 B:用 CODE 关联
CREATE TABLE orders (
    id          BIGINT PRIMARY KEY,
    status_code VARCHAR(32),       -- 关联 order_status.code
    ...
);

注:示例里用 orders 而不是 order——order 是 SQL 保留字,直接拿来当表名要么报语法错误,要么必须加反引号 `order`

哪种好?两派都各有铁粉,争论起来谁也说服不了谁。本文把两种方案的优劣、适用边界、工程取舍讲清楚——不给标准答案,但讲清楚什么场景该选什么。


第一原则:先区分两类外键

讨论这个问题前必须先把"外键"分成两大类——它们的处理逻辑完全不同

  • 关联类外键:链接两个业务实体。例如 order.user_id 指向 user.idorder_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 大量数据时差距明显。

1
2
3
4
5
-- 整数 join
WHERE order.user_id = user.id

-- 字符串 join
WHERE order.user_no = user.user_no

千万级数据下,前者比后者快 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. 可读性极强

1
2
3
4
-- 用 ID
SELECT * FROM order WHERE status_id = 3;
-- 用 CODE
SELECT * FROM order WHERE status_code = 'PAID';

不用 join 字典表都能看懂"已支付"。线上排查问题时这个差别非常大

2. 字典基本不会改

order_status 这种字典——PAIDSHIPPEDCOMPLETED——一旦确定,几乎不会变(业务有变化也是新增,不是修改)。code 不会改的前提下,ID 的"不变性优势"就消失了

3. 跨系统一致

字典经常要给前端、第三方对接、数据导出。PAID3 通用得多——前端不用维护"3=已支付"的对照表,直接看 code 写逻辑。

4. 代码层面更清爽

业务代码用枚举 + 字符串 code 是天然契合:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
public enum OrderStatus {
    UNPAID("UNPAID"),
    PAID("PAID"),
    SHIPPED("SHIPPED"),
    COMPLETED("COMPLETED");

    private final String code;
    OrderStatus(String code) { this.code = code; }
}

// SQL 里直接 status_code = 'PAID'
// Java 里 status == OrderStatus.PAID
// 前端直接 status === 'PAID'

如果用 ID,代码里到处是 status_id == 3 这种魔法数字,可读性差,加新状态时容易和已有 ID 撞。

5. 字典表本身也得有

国内项目还有种做法是根本不建字典表,code 直接写在代码枚举里。这有它的优势——一致性强、没有"代码和数据库不同步"的问题,但缺点是修改要发版,前端没法动态拉取选项。

工程上常见做法:

  • 简单字典直接代码枚举
  • 需要后台管理、能配置的字典建表(status_code 作为 PK 或唯一索引)

三、混用:一种实用模式

很多项目其实是混用——业务实体用 ID 关联,枚举用 code

1
2
3
4
5
6
7
CREATE TABLE orders (
    id            BIGINT PRIMARY KEY,
    order_no      VARCHAR(32) UNIQUE,    -- 业务编号,对外用
    user_id       BIGINT,                -- 关联 user.id(关联类)
    status_code   VARCHAR(32),           -- 关联 order_status.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"时,常常顺带聊另一个问题——要不要在数据库层面建外键约束

1
ALTER TABLE order ADD CONSTRAINT fk_user FOREIGN KEY (user_id) REFERENCES user(id);

国内绝大多数生产项目不建外键约束,原因:

  • 性能影响:每次写入都要校验外键,高并发下是负担
  • 分库分表后约束失效:跨库 FK 是不可能的
  • 运维痛点:表的清理、备份、迁移都被外键卡住

业内主流做法是——逻辑外键(应用层保证一致性,数据库不强制)。这意味着:

  • 表设计上仍然要有"外键"概念
  • 命名规范要清晰:user_id / product_id 这种命名让人一眼看出关联
  • 数据一致性靠应用代码 + 单测覆盖

六、实战建议

具体几条建议:

  1. 业务实体外键用 ID——user_idproduct_idorder_id
  2. 枚举/字典外键用 CODE——status_codetype_code
  3. xxx_no / xxx_code 字段建唯一索引——业务上对外暴露但 DB 关联用 ID
  4. 生产环境基本都不加 FK 约束——逻辑外键 + 应用层校验
  5. 不要把"自增 ID"暴露给前端——容易暴露业务规模 + 不好做加密短链
  6. 跨库场景上雪花 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 关联——稳定的东西用稳定的标识,可读的东西用可读的标识。

记住几条工程铁律:

  1. ID 主键永不暴露给业务规则
  2. 业务编码做唯一索引但不做外键
  3. 枚举字典 code 全大写下划线,永不复用
  4. 生产环境逻辑外键 + 应用层校验
  5. 代码里别留魔法数字,永远用枚举 + code 比较

把这几条吃透,数据库设计这层基本不会出大问题。

使用 Hugo 构建
主题 StackJimmy 设计