Featured image of post 逻辑主键还是业务主键?数据库主键设计的取舍与最佳实践

逻辑主键还是业务主键?数据库主键设计的取舍与最佳实践

自增 ID、UUID v4 / v7、业务编码、雪花 ID——主键选什么决定了一个表能演化多远。本文讲清五种方案的优劣

写在前面

数据库主键看似简单——但选错了能让一个表"永远改不动":

  • 业务编码当主键 → 业务规则一变全表 rebuild
  • UUID 当主键 → 索引性能差、空间膨胀
  • 自增 ID → 跨库迁移血泪
  • 雪花 ID → 时钟回拨能让你怀疑人生

新人常困惑——到底该选哪个?本文讲清五种主流方案(自增 BIGINT、UUID v4、UUID v7、雪花 ID、业务键作主键)的优劣、取舍、典型场景。


一、概念先分清:逻辑主键 vs 业务主键

逻辑主键

和业务无关的"内部标识"——纯技术存在。

  • 自增 BIGINT
  • UUID
  • 雪花 ID

特点:业务永远改不到它,因为它没有业务含义。

业务主键

业务上有意义的字段——天然唯一。

  • 用户表用 username
  • 订单表用 order_no
  • 商品表用 sku_code

特点:业务可能要求改格式、改前缀、改长度。

这两个概念是冲突的——主键应该用哪一种?答案不是非黑即白。


二、五种主流方案

方案 1:自增 BIGINT 逻辑主键(推荐默认)

1
2
3
4
5
6
CREATE TABLE user (
    id BIGINT PRIMARY KEY AUTO_INCREMENT,
    username VARCHAR(64) UNIQUE,
    name VARCHAR(64),
    -- ...
);

优点

  • 写入性能极好——InnoDB 按主键聚簇,递增 ID 永远 append
  • 索引体积小——8 字节
  • JOIN 高效——整数比较快
  • 业务编码可以单独建索引、独立演化

缺点

  • 跨库迁移痛——两库的自增 ID 必然冲突
  • 单库瓶颈——发号依赖单一数据库
  • 暴露业务规模——id=1234567 攻击者能猜测总量
  • 不适合分布式场景

适用:单库或简单分库(按业务键分库)的小到中型项目。80% 的业务表用这个

方案 2:UUID

1
2
3
4
CREATE TABLE user (
    id BINARY(16) PRIMARY KEY,
    -- ...
);

优点

  • 全球唯一——零中心化
  • 跨库无冲突
  • 不暴露业务规模

缺点

  • 写入性能差——UUID 无序,每次插入都可能引发页分裂
  • 空间大——16 字节,char(36) 文本形式更是 36 字节
  • 索引差——B+ Tree 上散列严重
  • JOIN 慢——字符串比较开销大

用 UUID 一定要 BINARY(16) 不能 CHAR(36)——16 字节 vs 36 字符,索引和 JOIN 性能差距巨大。

适用离线生成 ID 的场景(移动端先生成、再上传)、对中心化发号有强烈反感的场景。多数 Web 业务不推荐

方案 3:UUID v7(时间有序 UUID)

UUID v7(2024 RFC 9562 标准)解决了 UUID 的写入性能问题——前 48 位是毫秒时间戳

1
018D2DA1-7000-7XXX-XXXX-XXXXXXXXXXXX

优点

  • 时间递增——索引友好
  • 全球唯一——分布式无冲突

缺点

  • 仍然 16 字节,比 BIGINT 大
  • 会暴露生成时间(精确到毫秒)——和雪花 ID 一样,敏感场景需评估
  • 工具/框架支持还在普及中

这是 2024 后比 v4 更值得用的 UUID 形式——多数语言库已经支持。

方案 4:雪花 ID(Snowflake)

Twitter 设计的分布式 ID 算法——64 位结构:

1
2
| 1 bit | 41 bits        | 10 bits                                | 12 bits |
| 符号  | 时间戳(毫秒)  | 工作机器 ID(5 bit dc + 5 bit machine) | 序列号  |

10 bit 机器位 Twitter 原版就拆成 5 bit datacenter + 5 bit worker,最多支持 32 个数据中心 × 32 台机器 = 1024 节点。

优点

  • 64 位 BIGINT——和自增 ID 一样紧凑
  • 时间递增——索引友好
  • 分布式无中心
  • 高吞吐(每节点每毫秒 4096 个)

缺点

  • 时钟回拨问题——服务器时钟跳回会重复 ID
  • 机器 ID 分配——上千节点时管理麻烦
  • 还是暴露业务速率(每毫秒能生成多少)

适用分布式系统的标准选择。美团 Leaf、百度 UidGenerator 都是改进版。

方案 5:业务键作主键(不推荐)

1
2
3
4
CREATE TABLE user (
    username VARCHAR(64) PRIMARY KEY,
    -- ...
);

优点

  • 表自描述
  • 少一个字段

缺点

  • 业务规则一变就完蛋(比如 username 长度要扩展)
  • VARCHAR 主键聚簇索引大、慢
  • 关联表的外键也要 VARCHAR——空间和性能双爆炸
  • 业务编码可能为空(早期数据没填)→ 主键不能为空 → 全表数据迁移

多数 OLTP 场景都不推荐这么做——除非表很小且业务键真正稳定不变。


三、决策树

90% 的场景按这个流程决定

  • 小型系统、单库 → 自增 BIGINT
  • 可能分库或一开始就分布式 → 雪花 ID
  • 离线生成 / 强分布式无中心 → UUID v7

四、生产级雪花 ID 的实现要点

朴素雪花算法在生产里有不少坑——成熟方案要解决:

1. 时钟回拨

绝对禁止时钟跳回——可能导致 ID 重复。处理方案:

  • 启动时检查时钟,跳了就拒绝启动
  • 运行时检测,跳了就等回到原点再发号(短暂阻塞)
  • 改进版用 bit 当回拨标志位

2. 机器 ID 分配

朴素方案要在配置文件里写死 worker_id——上千节点时是地狱。生产方案:

  • ZooKeeper / etcd 自动分配
  • 进程启动时主动注册
  • 退出时主动释放

3. 数据中心 ID

如上所述 Twitter 原版就把 10 bit 拆成 5+5;如果你的部署只有一个 DC,可以把 datacenter 位让渡给 worker 位,扩展到 1024 台单 DC 节点。

4. 序列号回滚

朴素方案每毫秒序列号从 0 开始——容易在毫秒边界 burst 时浪费。改进:

  • 上一毫秒的序列号末位作为下一毫秒的起点(散列写入)

5. 实战推荐

直接用:

不要自己手写——这些库覆盖了所有边界。


五、对外暴露的"业务编码"

无论用什么主键,对外暴露通常都不直接用主键——而是用业务编码:

1
2
3
4
5
CREATE TABLE order (
    id BIGINT PRIMARY KEY AUTO_INCREMENT,
    order_no VARCHAR(32) UNIQUE NOT NULL,    -- 对外
    -- ...
);

order_no 用规则生成:

1
ORD202511250001234     (前缀 + 日期 + 序号)

为什么"对外用 order_no、对内用 id"?

  • 对外不暴露业务规模——order_no 看不出实际订单数
  • 支持改格式——order_no 改前缀不影响内部关联
  • 可读——客服发"订单号 ORD…",比"id=12345"友好

这是国内大多数生产系统的标准姿势


六、几个常见反模式

反模式 1:业务编码当主键 + 加 _old 字段做迁移

业务规则改了 → 加 username_old 字段保留老的 → 后续查询要 WHERE username = ? OR username_old = ? ——永远的技术债

反模式 2:UUID 用 char(36)

1
id CHAR(36) PRIMARY KEY    -- ❌

应该用 BINARY(16)——空间和性能差距巨大。

反模式 3:自增 ID 暴露给前端

前端 URL 里 ?orderId=12345 ——攻击者枚举可以拿到所有订单。用业务编码或者对 ID 做混淆(如 hashids 库)。

反模式 4:复合主键

1
PRIMARY KEY (user_id, order_id)

复合主键麻烦多——外键关联痛苦、ORM 支持不好、加新维度时改表复杂。永远用单字段主键 + 唯一索引表达组合唯一

反模式 5:自增 + UUID 双主键

“我两个都要!"——同时维护两个 ID 维护成本高、容易产生不一致。单一真理来源(single source of truth)——只有一个主键。


七、性能数据对比

10 亿行表,单条 INSERT 的耗时(实测大致比例):

写入耗时索引大小JOIN 速度
BIGINT 自增
雪花 ID
BINARY(16) UUID v71.2×1.2×
BINARY(16) UUID v45×(页分裂严重)1.2×
CHAR(36) UUID
VARCHAR 业务键

差距在大数据量场景被无限放大。


八、实战 Checklist

设计新表时按这个清单:

  • 主键用 BIGINT 自增 / 雪花 ID
  • 业务编码加唯一索引 但不当主键
  • VARCHAR 主键不允许(除非超小表)
  • UUID 用 BINARY(16) + v7 不要 v4 / char(36)
  • 关联表外键用主键 ID,不要用业务编码
  • 对外 API 用业务编码,不暴露主键
  • 重要表的业务编码生成有规则(前缀 + 日期 + 序号 / hashids)
  • 分布式场景用雪花 ID,不要自己撸

九、关于"零号 ID”

很多人争论 ID 应该从 0 还是 1 开始——约定俗成是从 1 开始

  • id = 0 在 Java / Go 里是基本类型默认值——容易混淆"未设" vs “设为 0”
  • 业务上"用户 0"听起来怪
  • 一些 ORM 把 id = 0 视为"新对象未保存"

让 ID 从 1 开始是最少踩坑的约定


小结

把全文压一句:

主键应该是业务无关的、稳定的、紧凑的——业务编码独立另存,对外用编码、对内用主键。这是数据库表能长期演化的基础。

工程纪律:

  1. 新表默认 BIGINT 自增——简单可靠
  2. 分布式上雪花 ID
  3. UUID 用 v7 + BINARY(16)
  4. 绝不用 VARCHAR 业务键作主键
  5. 业务编码加唯一索引 + 对外暴露
  6. 主键不暴露给前端

把这些做对,你的表能跑 10 年——而不是一年后就要做"主键迁移"这种最痛的事。

使用 Hugo 构建
主题 StackJimmy 设计