前几天一个朋友找我,说他们订单表的主键 id 要见底了。我一看表结构,int(11) NOT NULL AUTO_INCREMENT,最大 21 亿多,业务量已经冲到 19 亿,后台开始报主键冲突。这场景太典型了——不是没想过换,而是当初压根没人把"MySQL 表主键 id 用自增还是雪花"当成一个需要提前设计的问题。
今天就把这事聊透。我会从两种方案的底层原理讲起,再落到实际接入 MySQL 时的字段选择、踩坑细节、以及不同业务规模下到底怎么选。这篇内容适合正在做表设计、分库分表方案、或者面试前想系统梳理主键选型思路的后端开发、DBA 和架构师。篇幅不短,但能让你少走我走过的弯路。
1. 主键选型不是"二选一",而是先搞清楚它到底在解决什么问题
1.1 一个合格主键的自我修养
很多人在设计表的时候,主键就随手写个 id int auto_increment,觉得"能唯一标识一行就行"。这话对了一半。主键确实是唯一标识,但它承担的责任远不止这个。
你用主键做物理外键时,它会被其他表引用;你建聚簇索引时,它是 InnoDB 组织数据的核心;你在做分库分表时,它可能还要当分片键;你在做接口幂等时,它可能又是天然的幂等凭证。同一个字段,在不同的设计维度里被反复使用,所以它至少要满足四个条件:唯一、非空、稳定、方案可扩展。
"唯一"和"非空"好理解。稳定指的是这个值一旦生成,在生命周期内不会被修改,也不会因为数据迁移、主从切换而变化。"可扩展"最容易被忽略——你今天单库单表用自增没毛病,明天分库分表了,这个自增 id 就马上变成灾难。我之前见过一个项目,用户表用自增,后来做数据归档,把历史数据导到另一个库,两边 id 直接冲突,最后只能停机洗数据,代价极高。
1.2 自增和雪花,代表两种完全不同的设计哲学
自增 id 和雪花 id 从来不只是在"怎么生成一个数字"上不同,它们背后是两种思路:
自增的哲学是"单机中心化"。它依赖数据库内部维护一个计数器,保证在当前这个 MySQL 实例里,每次生成的 id 都是递增的,而且是顺序的。优点极其明显:实现简单、查询快、order by id 天然按插入顺序排序、对 InnoDB 的聚簇索引极度友好。缺点是它把"序号分配"这件事和"单机数据库"绑定死了,一旦你要多库并行,或者跨系统合并数据,它就从优点变成致命的约束。
雪花的哲学是"分布式去中心化"。它在应用层生成一个 64 位整数,不需要依赖任何中心节点,每台机器随便生成,组合起来全局唯一。它不保证严格递增,但全局趋势递增;不保证可读,但保证在分布式环境下依然可用。代价是它需要处理时钟问题、机器 ID 分配问题,而且生成出来的 id 不像自增那样一眼就能看出"这是今天的第几条数据"。
所以,与其纠结"哪个好",不如先想清楚:你的系统到底是单机为主,还是天生就要分布式跑?你的数据量十年后大概是什么量级?你的 id 要不要暴露给外部、能不能被遍历?这几个问题想明白了,选型自然就清晰了。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 自增ID:AUTO_INCREMENT 用着省心,但坑都在你以为不会踩的地方
2.1 先搞清楚 AUTO_INCREMENT 的底层行为
自增 id 在 InnoDB 里的分配逻辑,比很多人想的复杂。它有一个专门的自增锁机制,由 innodb_autoinc_lock_mode 控制。这个参数有 0、1、2 三个值,默认是 1。
在默认模式下,普通的单行插入可以直接拿到自增值,不需要等事务提交;但在批量插入(比如 INSERT ... SELECT、LOAD DATA 这种预先不知道行数的操作)时,InnoDB 会加表级锁,一次性分配好所有自增值,直到语句结束才释放。这样设计的目的是保证批量插入时自增值是连续的,但代价就是并发插入遇到批量操作时,其他插入会被阻塞。
这里有个我见过很多人误解的点:自增值不连续是正常的。原因很多,最常见的有两个,一个是插入事务回滚了,InnoDB 不会回收已经分配出去的自增值;另一个是批量插入被中断,剩下没用到的值就直接丢弃了。所以你要是在 11、12 之后跳过 13 直接到 14,不要慌,这不是丢数据,只是序号分配器不会"悔棋"。
另外,MySQL 8.0 之后,自增值的最大值会被持久化到 redo log 里,重启后不会丢失。但 8.0 之前的版本,自增值是存在内存里的,重启后会用 max(id) + 1 重新计算。所以老版本里如果你删掉了最大的几行,重启后可能出现自增值回退,严重时和已有数据冲突。升级到 8.0 可以解决大部分这类问题。
2.2 实战中最容易被忽略的几个坑
第一个坑:int 溢出。这是我最想强调的。int 类型最大能存 2147483647,稍微算一下就知道,就算每天只涨一万,21 万天也就见底了,大约 580 年——听起来很久对吧?但这是按"线性"算的,真实业务往往是指数增长。很多项目上线没几年,核心表就冲到几千万甚至几亿,再叠加灰度、测试数据、异常重试写入,21 亿并不遥远。
我用过一个判断方法:对主键字段,要么用 bigint,要么提前把 UNSIGNED 加满。int unsigned 可以到 42 亿,比 int 多一倍,但也就是"多一点时间"而已。核心业务表,直接 bigint 是最省心的选择。有人问我 int(11) 里的 11 是不是限制长度,其实那只是显示宽度,和存储范围没有任何关系。真溢出时,MySQL 会报 Out of range value,然后写入失败,线上就是这么挂的。
第二个坑:delete 清表不会重置自增值。很多人以为 delete from t; 执行完,再插入数据,id 会从 1 开始。实际上不会,它会在原来的基础上继续自增。只有 truncate table t; 才会重置。所以你要是想彻底清空并重置自增,必须用 TRUNCATE。这个坑在测试环境几乎人人踩过,好在后果不算严重,但在生产环境如果归档表误用了 DELETE,就会让新的 id 和归档数据产生潜在混淆。
第三个坑:分库分表之后,自增 id 彻底失效。这是自增 id 最大的天花板。你拆了 10 个库,每个库单独自增,每个库都会从 1 开始,同一个逻辑主键就会有 10 个"1 号用户"。解决办法有几个,比如给每个库设置不同的初始值和步长:库 1 从 1 开始、步长 10,库 2 从 2 开始、步长 10。这能缓解冲突,但它让"谁先入库谁 id 小"的语义变得很模糊,而且扩展性很差——以后加到 16 个库,改配置非常痛苦。所以一旦确定要走分库分表,从第一天起就考虑全局唯一 ID 方案,比半路改造要省心得多。
第四个坑:数据迁移时 id 可能被重排。我在做数据库迁移时踩过一次:用 mysqldump 导出表,再导入新库,如果导出 SQL 里没有显式包含 id 字段,新库会重新分配自增值。结果就是所有关联表全部错乱。正确做法是导出时显式导出主键列,导入时也带上 id。但这个细节在默认配置下很容易被忽略,尤其是用一些图形化工具导数据时,勾选项藏得很深。
2.3 自增 id 到底该怎么用才合理
如果你的系统暂时就是单库单表,数据量可控,也没有多系统合并的需求,那我建议你直接自增,没必要为了"以后可能用得上"去引入雪花 id。自增的简单就是它最大的优势,所有 ORM 框架原生支持,调试、排查、order by id 分页都舒服。
但有几个操作我会建议你提前做:
- 核心表一律用
bigint,哪怕现在数据少,也别给自己埋定时炸弹。 - 设置自增初始值:
ALTER TABLE t AUTO_INCREMENT = 100000;这样从外部看,id 不会从 1 开始,稍微有点迷惑性。 - 如果有多实例写入的隐患(比如主从切换、多活改造),提前评估自增步长方案:
auto_increment_offset和auto_increment_increment两个参数配合,让不同实例错开。 - 别用
varchar做主键。有人为了"灵活",用业务编号或 UUID 字符串做主键,索引空间大、排序慢、随机插入还会导致页分裂,性能远不如整数。要是顺手把主键设成varchar还不加NOT NULL,那更是双重错误,主键必须非空。
3. 雪花ID:拆开 64 位二进制,看它凭什么"全局唯一、趋势递增"
3.1 雪花的经典位布局
雪花 ID(Snowflake ID)最早是 Twitter 提出的,核心思想很简单:用一个 64 位的长整数,通过拆分 bit 位来承载"时间 + 机器 + 序号"三个信息。
一个经典的雪花 ID 长这样:
- 第 1 位(bit 63):符号位,固定为 0,保证整个 ID 是正整数。
- 第 2 到 42 位(bit 62-22):41 位时间戳,单位是毫秒,一般用"当前时间戳 - 自定义起始时间戳"的差值来存储,这样可以撑更久。
- 第 43 到 52 位(bit 21-12):10 位机器 ID,最多支持 1024 台机器或进程。
- 第 53 到 64 位(bit 11-0):12 位序列号,表示同一毫秒内可生成的不同 ID 个数,最多 4096 个。
41 位时间戳能代表大约 69 年的时间范围。如果以 2020 年作为起始点,可以用到 2089 年。10 位机器 ID 最多 1024 个节点,12 位序列号每毫秒最多 4096 个。所以在极限情况下,这个经典布局支持千台级节点、单节点每毫秒四千多个 ID。
实际项目中,这个位分配是可以调的。比如你的机器可能没有 1024 台,只有 50 台,就可以把机器位从 10 位压到 7 位,多出来的 3 位给序列号,让单机并发量从每毫秒 4096 提到 32768。但要记住:位数分配是全局约定,一旦上线就不能随便改,否则不同节点生成出来的 ID 可能冲突。
3.2 趋势递增和时钟回拨,一好一坏两个核心特性
雪花的 ID 结构决定了它天然是趋势递增的:时间戳放在高位,就算同一毫秒内生成的多个 ID,序列号也是递增的。注意"趋势递增"不等于"严格递增",因为不同节点的机器 ID 不同,同一毫秒内机器 A 生成 100、机器 B 生成 200,从全局看并不是一个严格的递增序列。但对 InnoDB 的聚簇索引来说,这种趋势递增已经比完全随机的 UUID 友好太多了。
时钟回拨是雪花 ID 最大的坑。因为 41 位时间戳来自机器本地时钟,如果系统时间因为 NTP 校时、手动调时间、宿主机暂停等原因往回跳了几十毫秒,那么在当前时间戳小于上次生成时的时间戳时,就可能导致重复 ID。很多实现在生成前会判断一下,如果发现时钟回拨,就抛出异常或阻塞等待——但在高并发下"等待"也难办,回拨个 1 秒,系统要停 1 秒,这肯定不能接受。
我目前见到的处理方案有这么几种:
- 拒绝生成:回拨期间直接抛异常,适合对可用性要求不高的内部系统。
- 等待追赶:用
Thread.sleep等到时间戳追平,适合短时微小回拨。 - 借序列号:如果回拨幅度小于序列号能覆盖的范围(比如回拨几个毫秒),可以沿用上次的时间戳,只让序列号继续增加。
- 预留位方案:像美团 Leaf 的雪花模式,借鉴了"双 Buffer + 时钟检测"的思路,发现回拨直接从 MySQL 里拉取号段,不让生成本地不安全的 ID。
3.3 主流实现方案,到底选哪个
自己写雪花算法其实不难,几十行 Java 就能写完。难点在于机器 ID 的分发和时钟回拨的兜底,这两个都是"平时不出事,出一次就事故"的环节。所以我在真正做架构选型时,一般按下面的阶梯看:
- MyBatis-Plus 内置的 ASSIGN_ID:如果你用的是 MyBatis-Plus,默认的
IdType.ASSIGN_ID就是雪花算法变种。它能保证全局唯一,支持通过workerId和datacenterId配置节点标识。它省事,但时钟回拨处理得比较基础,适合中小型项目。 - 美团 Leaf:这个方案我很推荐。它同时提供号段模式和雪花模式。号段模式通过数据库维护一个区间,适合对趋势递增要求高的业务;雪花模式则用 MySQL 的
leaf_alloc表做机器 ID 注册和时钟回拨兜底。缺点是引进了额外的依赖组件,团队需要能运维它。 - 百度 UidGenerator、滴滴 Tinyid:前者以"自定义位分配 + 环形缓冲"出名,后者更偏号段模式,处理不了大量并发的场景时可以按需评估。
选型的关键不是"哪个更流行",而是"你的团队能承接多复杂的组件"。如果你就一个单体服务,直接用 MyBatis-Plus 的无感知配置就够了;如果未来要分布式扩展、要支持多语言客户端,那 Leaf 或自研 ID 服务会更合适。
4. 把雪花ID接进 MySQL,最容易翻车的五个细节
4.1 字段类型:为什么主键建议用 BIGINT,而不是 VARCHAR
雪花 ID 是一个 64 位整数,在 Java 里就是 long,在 MySQL 里最匹配的类型是 bigint。这里有个很多人犹豫的问题:id 这么长,直接用 varchar(20) 存不行吗?
能存,但性能很差。varchar 主键有几个明显的毛病:字符集和排序规则参与比较,索引体积大;字符串比较比整数比较慢;最要命的是,如果雪花 ID 生成顺序和字符序不一致,插入时可能产生随机 IO,导致 B+ 树频繁页分裂。在千万级数据量下,这些差异会被明显放大。
我建议直接用 bigint。要不要 unsigned?雪花 ID 最高位是符号位固定为 0,所以一定是个正整数,加上 unsigned 可以让存储的上限更大,本身没有坏处。但要注意,应用层如果用 Java 的 long 接收 unsigned bigint 超大值时会有兼容性问题,雪花 ID 本身没有这么大的值,所以这个隐患可以忽略。
还有就是那个经典问题:varchar 作为主键 id 能不能为空?答案很明确,不能。主键的定义就是非空 + 唯一,不管是什么类型,只要它被声明为主键,就必须满足这两个约束。MySQL 对主键会自动添加非空约束,就算你 alter table 时没写 not null,它也会强制加上。所以不存在"varchar 主键可以为空"的情况。
4.2 应用层生成还是数据库生成,决定了事务怎么写
自增 ID 是数据库生成的,所以你在插入前拿不到 id,必须插入后查出来。雪花 ID 恰恰相反,它通常是在应用内存里生成的,所以你可以在插入前就知道主键值。这个差异看起来只是"I got an ID early"的问题,其实影响很深远。
比如订单场景:你插入订单主表前,就要先拿到订单号,然后拿同样的订单号插入订单明细表、写库存流水、发 MQ 消息。如果用自增,你得先插主表,查出自增 id,再插子表——在一个事务里虽然能办到,但要多一次查询。如果用雪花,你可以在方法最开始就 Snowflake.nextId() 拿到订单号,后续所有操作都用同一个变量,逻辑更顺,也少一次 DB 查询。
MyBatis-Plus 里接入这个场景很简单。实体类的主键字段加上 @TableId(type = IdType.ASSIGN_ID),插入时框架会自动生成雪花 ID 并填充到实体属性里,你直接 order.getId() 就能拿到。这里有一个我在真实项目里反复提醒的点:如果你是在事务里先插入主表再插入子表,框架生成的 ID 是安全的;但如果你在事务外先拿了 ID,等事务提交后才发现异常回滚,这个 ID 就被"浪费"了。好在雪花 ID 不像自增 ID 那样有"浪费就是空隙"的强迫症,丢掉几个完全没影响,不用担心。
4.3 雪花ID 传到前端,Long 精度丢失是个高频事故
这是我见过最多次的线上事故,没有之一。雪花 ID 在 Java 里是 long,超过 JavaScript 的 Number.MAX_SAFE_INTEGER(9007199254740991)其实是很少的,但只要 ID 的位数达到 18-19 位,前端用 number 去接,后几位就可能变成 0,导致拿到的 id 和前端的 id 对不上。
我之前排查过一个 bug:列表里点开详情,详情接口报"数据不存在"。前端说 id 传对了,后端查了日志发现传过来的 id 末尾变成了 ...000,和列表里的值对不上。最后定位就是 JSON 序列化时,Long 被直接输出成了数字,前端 JS 精度丢失。
解决方案大体有两种:
- 后端在 JSON 序列化 Long 时统一转成字符串。Jackson 可以用
ToStringSerializer,Fastjson 可以配置SerializerFeature.WriteClassName之类的序列化特性。 - 前端把 id 字段的类型声明成
string,很多前端框架有对应的类型转换注解或工具。
我个人的建议是后端直接转字符串,因为前端改类型容易漏,而且不同接口、不同团队之间很难统一。你可以在配置层面把全局 Long 序列化策略改成 String,这样所有接口返回的 id 都是字符串,字段语义也清晰——反正雪花 ID 不需要前端做数学运算,字符串完全够用。
4.4 趋势递增不是完全有序,插入性能怎么评估
很多人以为用了雪花 ID,主键的顺序就一定接近插入顺序,于是毫不担心页分裂。实际上雪花 ID 是"趋势递增",不是"严格递增"。
同一毫秒内,不同机器生成的 ID 之间,机器 ID 和序号的不同会让它们并不是严格按时间先后排列。在单机部署、单实例生成 ID 时,这个差异很小,基本可以当作顺序插入。但一旦改成多实例并发生成,不同实例生成的 ID 落到同一张表时,插入顺序就会出现局部乱序,InnoDB 的聚簇索引就需要进行随机位置插入,可能引发页分裂,产生更多 IO 和碎片。
怎么判断你的场景受不受影响?简单做个压力测试就知道了,分别用自增和雪花生成主键,往同一张表里并发插入,观察 每秒事务数 和缓冲池的 写放大。我在一个百万级并发写入的日志场景里测过,雪花 ID 的插入性能大概比自增低百分之十到二十(看机器数量和混布情况),但相比 UUID 那种完全随机插入,已经是数量级的优势。对于大多数业务系统来说,这个损耗完全可以接受。
4.5 可预测性和安全边界,别把敏感数据暴露给遍历
自增 ID 最大的安全隐患是"可遍历"。用户 ID 是 10086,那 10085、10087 大概率也是用户,攻击者可以写一个脚本,把订单号、用户 ID 全部爬一遍。雪花 ID 虽然也是数字,不是完全不可预测,但因为它没有严格的"连续"关系,要猜出其他 ID 的难度高很多。这也是一些对外接口要求"订单号不能连续"的原因。
如果你的系统对安全敏感,比如涉及支付、用户隐私,我建议优先考虑雪花 ID 或 UUID,同时把对外接口的 ID 做一次映射或加密。如果你只是内部系统,数据不暴露给外部,自增 ID 的便利性还是很值得用的。一个常见折中方案是:数据库主键用自增,对外业务编号用雪花 ID,两个字段并存,互不干扰,既保证 DBA 运维时能看到清晰的插入顺序,又保证对外不可遍历。
5. 怎么选?我的决策经验和几条可复用的标准
5.1 一张表看懂自增和雪花的核心差异
| 对比维度 | 自增 ID | 雪花 ID |
|---|---|---|
| 唯一性范围 | 单库单实例内唯一 | 全局唯一 |
| 单调性 | 严格递增 | 趋势递增 |
| 可读性 | 高,一眼看出顺序 | 低,数字无业务语义 |
| 生成位置 | 数据库内部 | 应用层 |
| 是否依赖中心节点 | 依赖 MySQL 实例 | 不依赖,节点各自生成 |
| 分库分表支持 | 很难直接支持 | 天然支持 |
| 数据迁移风险 | 高,id 可能重复或错乱 | 低,id 独立于库表 |
| 前端展示精度 | 无风险 | 需要处理 Long 精度丢失 |
| 时钟/外部依赖 | 无 | 依赖系统时钟,需处理回拨 |
| 性能开销 | 极低 | 多一点点 CPU 计算,可忽略 |
这张表不是用来证明"谁更强",而是让你在写方案评审的时候能直接用。我会把这张表贴在每次表结构设计的文档里,评审会上大家对着场景一栏一栏过,比空对空讨论"XX 家在用雪花"有效得多。
5.2 按场景来选,而不是按技术热度
我一直给团队说一个判断顺序:先看未来三年的数据量和拓扑结构,再看现有技术栈能承接的复杂度,最后才考虑性能和安全。
如果你满足下面几个条件,直接自增:
- 单库单表,没有多活、没有分库分表计划。
- 核心表数据量在千万级,就算到亿级,
bigint也完全撑得住。 - 没有跨系统合并、数据归档、异构同步的需求。
- 团队对"引入分布式 ID 组件"这件事没有运维能力。
只要有一条命中,就应该认真考虑雪花 ID:
- 已经分库分表,或明确规划了分库分表。
- 业务数据可能从多个系统汇聚到一张表(比如订单中心、积分中心、用户中心)。
- 需要先拿到主键再插入关联数据,事务里不想多一次查询。
- 对外暴露的 ID 不能连续、不能被遍历。
- 数据量大到需要归档和迁移,不想在迁移时处理 ID 冲突。
5.3 从自增迁移到雪花,一个相对平滑的过渡方案
很多人不是从零开始做选型,而是老项目已经用自增跑了很久,现在要分库分表,不得不换。这时候不要想着"把表主键直接改成雪花"——那代价太大了,关联表全要改。
我建议做"业务主键和应用主键分离":保留原来的自增字段作为物理主键(或者直接保留作为主键),新增一个 biz_id bigint 字段,用雪花 ID 生成,在这个字段上建唯一索引。业务代码和对外接口逐步切到用 biz_id,最后新表再直接用 biz_id 作为主键。老数据的主键继续保留,新数据用新主键,并行期做好双写,切换完成后历史上遗留的问题就不会堵在路上。
这个方案有几个实际的好处:迁移过程中,老接口不用立刻改;关联表可以暂时继续用老主键关联,等 biz_id 沉淀完再逐步替换;数据库层面的唯一约束还能兜底,防止重复插入。
5.4 我踩过几次坑之后,沉淀下来的几条经验
- 永远不要用
int做主键,这是我最想说的一句话。哪怕你是个内部管理系统,数据量不大,bigint也不会多占几个字节,但int一旦溢出就是线上事故。我见过不止一个项目,上线两三年后核心表冲到 21 亿,全团队半夜起来做 DDL 迁移。 - "趋势递增"不等于"有序",如果你做的是强顺序依赖的读取(比如时间线、Feed 流),不要指望主键能当时间戳用,该加
created_at索引就加。 - 分布式 ID 中间件不是越多越好,团队如果能用 MyBatis-Plus 自带的雪花就先用着,别一上来就上 Leaf。任何中间件都会变成你系统的一部分,你得养它、监控它、处理它的故障。
- 选型决策要写进文档。很多团队建表时根本不写设计说明,半年后没人知道为什么这张表用自增、那张表用雪花,出了问题也找不到责任人。我在每个项目的数据库设计文档里都会加一节"主键方案说明",写清楚选择理由、适用边界、后续升级路径。这个习惯帮我避免了不少"历史遗留问题"。
我个人在实际操作中的体会是,主键选型这件事,没有一劳永逸的银弹,最关键的是在动手建表之前把数据规模和发展路径想清楚。如果你现在还在纠结自增和雪花,不妨直接用这张决策表对照一下业务,三分钟就能得出结论。要是你的系统已经踩到自增溢出的边沿,也别慌,biz_id 过渡方案是经过验证的稳妥路径,按步骤走能平稳切过去。
