写在前面
数据库主键看似简单——但选错了能让一个表"永远改不动":
- 业务编码当主键 → 业务规则一变全表 rebuild
- UUID 当主键 → 索引性能差、空间膨胀
- 自增 ID → 跨库迁移血泪
- 雪花 ID → 时钟回拨能让你怀疑人生
新人常困惑——到底该选哪个?本文讲清五种主流方案(自增 BIGINT、UUID v4、UUID v7、雪花 ID、业务键作主键)的优劣、取舍、典型场景。
一、概念先分清:逻辑主键 vs 业务主键
逻辑主键
和业务无关的"内部标识"——纯技术存在。
- 自增 BIGINT
- UUID
- 雪花 ID
特点:业务永远改不到它,因为它没有业务含义。
业务主键
业务上有意义的字段——天然唯一。
- 用户表用
username - 订单表用
order_no - 商品表用
sku_code
特点:业务可能要求改格式、改前缀、改长度。
这两个概念是冲突的——主键应该用哪一种?答案不是非黑即白。
二、五种主流方案
方案 1:自增 BIGINT 逻辑主键(推荐默认)
| |
优点:
- 写入性能极好——InnoDB 按主键聚簇,递增 ID 永远 append
- 索引体积小——8 字节
- JOIN 高效——整数比较快
- 业务编码可以单独建索引、独立演化
缺点:
- 跨库迁移痛——两库的自增 ID 必然冲突
- 单库瓶颈——发号依赖单一数据库
- 暴露业务规模——
id=1234567攻击者能猜测总量 - 不适合分布式场景
适用:单库或简单分库(按业务键分库)的小到中型项目。80% 的业务表用这个。
方案 2:UUID
| |
优点:
- 全球唯一——零中心化
- 跨库无冲突
- 不暴露业务规模
缺点:
- 写入性能差——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 位是毫秒时间戳:
| |
优点:
- 时间递增——索引友好
- 全球唯一——分布式无冲突
缺点:
- 仍然 16 字节,比 BIGINT 大
- 会暴露生成时间(精确到毫秒)——和雪花 ID 一样,敏感场景需评估
- 工具/框架支持还在普及中
这是 2024 后比 v4 更值得用的 UUID 形式——多数语言库已经支持。
方案 4:雪花 ID(Snowflake)
Twitter 设计的分布式 ID 算法——64 位结构:
| |
10 bit 机器位 Twitter 原版就拆成 5 bit datacenter + 5 bit worker,最多支持 32 个数据中心 × 32 台机器 = 1024 节点。
优点:
- 64 位 BIGINT——和自增 ID 一样紧凑
- 时间递增——索引友好
- 分布式无中心
- 高吞吐(每节点每毫秒 4096 个)
缺点:
- 时钟回拨问题——服务器时钟跳回会重复 ID
- 机器 ID 分配——上千节点时管理麻烦
- 还是暴露业务速率(每毫秒能生成多少)
适用:分布式系统的标准选择。美团 Leaf、百度 UidGenerator 都是改进版。
方案 5:业务键作主键(不推荐)
| |
优点:
- 表自描述
- 少一个字段
缺点:
- 业务规则一变就完蛋(比如 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. 实战推荐
直接用:
- 美团 Leaf:github.com/Meituan-Dianping/Leaf
- 百度 UidGenerator:github.com/baidu/uid-generator
不要自己手写——这些库覆盖了所有边界。
五、对外暴露的"业务编码"
无论用什么主键,对外暴露通常都不直接用主键——而是用业务编码:
| |
order_no 用规则生成:
| |
为什么"对外用 order_no、对内用 id"?
- 对外不暴露业务规模——order_no 看不出实际订单数
- 支持改格式——order_no 改前缀不影响内部关联
- 可读——客服发"订单号 ORD…",比"id=12345"友好
这是国内大多数生产系统的标准姿势。
六、几个常见反模式
反模式 1:业务编码当主键 + 加 _old 字段做迁移
业务规则改了 → 加 username_old 字段保留老的 → 后续查询要 WHERE username = ? OR username_old = ? ——永远的技术债。
反模式 2:UUID 用 char(36)
| |
应该用 BINARY(16)——空间和性能差距巨大。
反模式 3:自增 ID 暴露给前端
前端 URL 里 ?orderId=12345 ——攻击者枚举可以拿到所有订单。用业务编码或者对 ID 做混淆(如 hashids 库)。
反模式 4:复合主键
| |
复合主键麻烦多——外键关联痛苦、ORM 支持不好、加新维度时改表复杂。永远用单字段主键 + 唯一索引表达组合唯一。
反模式 5:自增 + UUID 双主键
“我两个都要!"——同时维护两个 ID 维护成本高、容易产生不一致。单一真理来源(single source of truth)——只有一个主键。
七、性能数据对比
10 亿行表,单条 INSERT 的耗时(实测大致比例):
| 写入耗时 | 索引大小 | JOIN 速度 | |
|---|---|---|---|
| BIGINT 自增 | 1× | 1× | 1× |
| 雪花 ID | 1× | 1× | 1× |
| BINARY(16) UUID v7 | 1.2× | 2× | 1.2× |
| BINARY(16) UUID v4 | 5×(页分裂严重) | 2× | 1.2× |
| CHAR(36) UUID | 8× | 4× | 5× |
| VARCHAR 业务键 | 6× | 3× | 4× |
差距在大数据量场景被无限放大。
八、实战 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 开始是最少踩坑的约定。
小结
把全文压一句:
主键应该是业务无关的、稳定的、紧凑的——业务编码独立另存,对外用编码、对内用主键。这是数据库表能长期演化的基础。
工程纪律:
- 新表默认 BIGINT 自增——简单可靠
- 分布式上雪花 ID
- UUID 用 v7 + BINARY(16)
- 绝不用 VARCHAR 业务键作主键
- 业务编码加唯一索引 + 对外暴露
- 主键不暴露给前端
把这些做对,你的表能跑 10 年——而不是一年后就要做"主键迁移"这种最痛的事。