前两天给团队做设计评审,新同学提交的表结构里,订单表主键直接用了bigint auto_increment。我问他,这张表以后要不要分库分表?他愣了一下说,可能吧,但那是以后的事。我当场就提出了反对意见。不是为了教条,而是这个“以后的事”,一旦发生在自增主键上,代价远比多写几个 ID 生成器大得多。
“互联网公司严禁使用自增主键”这句话在技术圈里流传很广,但很多人只记住了结论,没搞懂背后的逻辑。这篇文章就把这笔账一笔一笔算清楚:分库分表的冲突、数据迁移的坑、安全泄露的风险、性能上的隐形代价,以及真正能落地的替代方案。内容偏向后端开发和架构设计,适合后端工程师、DBA、架构师,以及所有负责线上业务表设计的同学。
1. 先别急着否定:自增主键的便利与适用边界
1.1 单机时代它为什么这么好用
自增主键在单机数据库里几乎是“默认最优解”。一条AUTO_INCREMENT就搞定了唯一性,不用额外请求任何服务,数据库自动维护。对于早期 Web 应用,用户量几千、数据量几十万,这种设计简单、直接、不容易出错。
它最大的优点是对 B+ 树友好。InnoDB 的聚簇索引就是主键本身,自增主键按顺序递增写入,新记录永远插入到叶子节点的末尾,几乎不会触发页分裂。相比随机生成的 ID,写入性能更好,索引碎片更少,磁盘空间利用更充分。
加上bigint类型只占 8 字节,作为聚簇索引很紧凑,二级索引的额外开销也更小。所以单机小项目用自增主键,我不反对,甚至会推荐。问题出在“互联网公司”这四个字,核心业务表一旦脱离单机环境,自增主键的性质就变了。
1.2 什么时候它仍然是最佳选择
我得先替自增主键说句公道话,它不是毒药。判断标准不在于“是不是自增”,而在于这张表所在的环境会不会变化。
内部管理系统、后台统计分析库、配置表、字典表,数据量几千到几万,不会拆分、不会合并、不对外暴露,用自增主键完全没问题。甚至一些低并发的业务表,只要生命周期内确定不会到达分库分表的规模,自增就是最高效的方案。
但请注意一个关键词:确定。互联网业务的表,早期没人会想到它会涨到千万级、亿级。等反应过来的时候,表已经在线上了,这时候再动主键,比从一开始用全局 ID 痛苦得多。所以这里说的“适用”,必须建立在短期和长期都是小数据量的前提下。
2. 核心矛盾:分库分表后,自增主键的“唯一”失效了
2.1 互联网数据规模为什么必然走向分库分表
互联网应用的特点是用户量大、请求量大、数据增长快。单张表在千万级数据之前,索引和查询压力还算可控;一旦到了上亿行,B+ 树的深度增加,磁盘 IO 压力变大,单库的 CPU、内存、连接数都会成为瓶颈。
于是分库分表成了几乎所有业务系统的必经之路。可以按用户维度拆分、按订单时间拆分、按租户或业务线拆分。拆分之后,每个库、每张表都相对独立,数据总量被分散到几十甚至上百个数据库实例上。
问题就在这里。每张表的AUTO_INCREMENT都是从 1 开始计数的。库 A 的订单表有一条 id=1 的记录,库 B 的订单表也有一条 id=1 的记录。它们各自独立的时候没毛病,一旦数据需要汇总、合并、跨库关联,冲突立刻暴露。
2.2 多库多表下的 ID 冲突现场
我参与过的一个模拟项目 X 就是这样。订单系统做了分库,按用户 ID 拆到了 16 个库。每个库里的订单表都从 1 开始递增。单看某个库很正常,但要把 16 个库的订单数据汇总到一套报表系统时,需要拼接一个全局唯一标识。直接拿id来做唯一键,根本没有唯一性可言。
有人会说,MySQL 不是提供了参数可以让每个库错开吗?auto_increment_offset配合auto_increment_increment,通过设置不同的初始偏移量,例如库 1 生成 1、17、33…,库 2 生成 2、18、34…,确实可以做到一定程度的“全局不重复”。
但这种方案只能缓解“撞车”问题,解决不了扩容问题。万一库从 16 个扩到 32 个,步长和偏移全要重调,老数据还得重新处理。更麻烦的是主从切换、数据迁移、实例重建的时候,参数不一致就会重新产生冲突。它不是设计出来的全局 ID,而是一种补丁方案,长期来看很不可靠。
2.3 绕不开的复合主键之痛
另外有人提出,分库之后可以用“分库键 + 自增序列”作为复合主键。比如user_id + order_id。这样单个库内唯一,组合起来在全局也能区分。听起来合理,实践却不怎么好用。
问题在于,复合主键违反了“主键应该尽可能简单”的原则。业务表大量存在关联查询,子表外键要引用父表时,总不能靠两个字段去做外键;各种查询条件都必须带上分库键,否则无法定位。如果需要按订单号查询,不走分库键就会全库扫描,最终还是要单独加一个全局唯一业务单号。
绕了一圈,你会发现最后还是需要一个全局唯一 ID。既然如此,早早上全局 ID 方案,才是真正的低成本路径。
3. 数据迁移、合并与恢复:自增主键的连环坑
3.1 业务合并时,两套系统无法“拼图”
自增主键最怕的不是日常运行,而是“换环境”。互联网行业里,公司并购和业务整合非常常见。两套系统原本各自运行,都有订单表、用户表,ID 都是从 1 开始涨的。合并数据时,用户 A 在系统一和系统二里可能同时是 id=10086,但这两个 id 根本不能互相通用。
这个时候,必须靠业务唯一标识来建立映射关系,比如用户手机号、渠道侧订单号。如果没有提前预留业务键,历史数据几乎无法合并。我见过某个项目因为早期图省事直接用自增主键做业务标识,最后合并数据时只能手工做全量映射,整整延期了一个多月。
这不是极端场景,而是业务整合、监管审计、系统替换时一定会遇到的事。主键一旦承担了“业务语义”,就失去了它作为代理键的纯粹性。
3.2 备份恢复和主从切换中的 ID 回退
自增主键在恢复场景里也有隐性坑。数据库崩溃后做基于时间点的恢复,如果备份文件落后于实际数据,恢复后AUTO_INCREMENT计数器的值可能倒退到备份时刻的状态。此时插入新记录,自增序列可能只比旧最大值大一点,正常情况没事;但如果备份期间有数据被物理删除,或者备份文件是从某个从库拉下来的,计数器就可能小于现存数据的最大 ID,新插入的记录就会和已有记录撞主键。
主从切换也存在类似问题。主库和从库的自增配置如果不一致,切换后写入的 ID 可能比之前小,也可能在后续数据回放时造成重复。治理办法是切换后立刻检查max(id)并重置AUTO_INCREMENT,但这类操作在恢复窗口里很容易被忽略,背景噪音极大。
3.3 数据导入导出时外键关联的连锁反应
ETL 场景里,自增主键更让人头疼。把一张线上表同步到数仓或另一套环境时,通常要保留原 ID 吗?如果保留,新环境里的自增计数器不会自动跳到已有最大值;如果不保留,重建自增 ID,子表的外键就得跟着改。
我第一次做这种迁移时,主表 ID 重新生成了一轮,子表里的parent_id还指着旧值,结果报表全部错乱。后来才明白,迁移不能“原样拷贝”,需要先生成映射关系再加工子表。而如果这张表的主键从一开始是全局 ID,导出、导入、关联映射都要简单得多,因为新环境和旧环境里的 ID 本来就是同一个 ID。
自增主键最大的问题是:它只在“出生地”有效。任何一次跨环境流动,都会让这个 ID 失效。
4. 安全视角:连续 ID 等于把家底亮给对手
4.1 可枚举的资源地址与越权访问
安全团队里有一个常见的漏洞类型叫资源 ID 遍历。只要 URL 里出现/order/1001、/user/10086这种地址,而 ID 又是连续的,攻击者只需要把数字加一,就能遍历出所有资源。
倒也不一定是直接越权。很多应用习惯拿 ID 做文件名、下载路径、活动编号,虽然接口做了权限校验,但 ID 本身已经暴露了大量业务量信息。经典的越权问题就是 IDOR,如果后端没做“当前用户只能访问自己的订单”这种校验,连续自增 ID 会让攻击变得极其简单:登录自己的账号,把接口里的订单 ID 改一下,就能看到别人的订单。
这类问题用雪花 ID 或 UUID 并不能完全根治,因为无序 ID 也需要权限校验。但无序 ID 会显著增加攻击成本,无法通过简单的“遍历数字”来完成批量获取。安全上有一个共识:对外暴露的标识,尽量不要有规律。
4.2 从 ID 增长反推业务量
更隐蔽的是商业数据泄露。我在安全群听人提过一个案例,某平台的用户 ID 当天从 100000 涨到了 101200,等于直接把当日注册量暴露给了竞争对手。电商平台用订单 ID 也能估算一天的成交单量,外卖平台用订单号能推算高峰时段的单量分布。这些都是不需要爬数据就能获得的情报。
自增 ID 看起来只是个数字,但它是有业务含义的连续序列。任何暴露到前端的自增 ID,都在悄悄告诉别人:你系统里一共产生了多少数据、每天增长多少、高峰在哪个时间点。互联网公司对这类信息保护非常敏感,所以禁止核心业务表使用自增主键,安全是一个重要考虑。
4.3 安全加固只能缓解,不能根治
有人会说,我不用原始 ID 暴露,我把它做一次 hash,或者转成短码,不就安全了吗?可以,但这就等于另外做了一层 ID 映射,系统复杂度上去了,ID 服务本身变成了新的瓶颈。而且历史 hash、短码映射、反向解析都会引入额外的开发和运维成本。
与其花精力去掩盖,不如一开始就不用自增 ID。这就是为什么很多公司的设计规范里明确写着:对外暴露的业务标识禁止使用自增主键。这不只是防漏洞,更是降低整体风险面。
5. 性能代价:顺序插入的好日子只属于单机
5.1 聚簇索引与页分裂
互联网公司如果为了“分库不撞”改用随机 ID 或 UUID,单表性能会立刻出现变化。InnoDB 的主键是聚簇索引,数据行按照主键顺序存储在 B+ 树的叶子节点里。自增 ID 按顺序插入,新的数据行总是往树右侧追加,效率最高。
换成 UUID 或随机字符串后,新行的主键是无序的,InnoDB 必须把新记录插入到已有的页中。如果目标页已经满了,就要触发页分裂,把一个页拆成两个,重新分配数据。频繁的页分裂会带来三样东西:额外的写入放大、索引碎片化、缓存命中率下降。
实测里,一张千万级数据的表,如果主键从有序 ID 改成随机 UUID,插入性能下降明显,查询性能也会随着索引碎片化而变差。这就是为什么很多人用 UUID 做用户表主键之后,发现写入越来越慢。
5.2 高并发写入下的锁与竞争
MySQL 的自增主键在插入时并不是完全无锁的。innodb_autoinc_lock_mode有 0、1、2 三档,模式 0 和 1 下,批量插入或某些语句需要持有 AUTO-INC 锁,直到语句结束。简单插入在模式 1 下通常会走轻量锁,但如果同时有大量批量插入和普通插入,锁竞争就会显现。
在秒杀、抢购、订单创建这类高并发场景下,自增计数器本身就是一个全局热点。所有写入请求堆在一把锁或者一个计数节点上,吞吐很容易到顶。换成雪花算法或号段模式,ID 的生成可以在应用层本地完成,不再每次都等待数据库的计数器,整体吞吐会好看很多。
还有间隙锁。在 RR 隔离级别下,自增主键和范围查询组合在一起,容易因为 gap lock 产生死锁。这类死锁在低并发时很少出现,但一旦写入压力上来,自增主键的锁碰撞频率会明显增加,而全局 ID 方案反而能规避一部分这类问题。
5.3 批量导入时的关联查询地狱
自增主键在数据导入场景里的性能问题也很突出。导入一批订单时要先插主表拿自增 ID,再插子表填外键。无法预先知道这批记录的 ID 是多少,只能逐条执行INSERT,然后LAST_INSERT_ID()取回自增值,性能被限制得非常死。
如果使用雪花算法生成 ID,应用层可以一次性预生成整批 ID,然后并行批量插入主表和子表,外键关系早就预设好了。这种差异在大促历史数据回放、跨环境数据迁移的时候特别明显。自增主键在这些场景里不是“性能劣化”,而是直接限制了系统的并发能力。
6. 替代方案实操:雪花、号段与全局 ID 服务
6.1 雪花算法:结构、实现与时钟回拨处理
雪花算法是目前最主流的全局 ID 方案。它把一个 64 位整数拆成四个部分:1 位符号位,41 位毫秒级时间戳,10 位机器 ID,12 位序列号。总共恰好放进一个bigint类型,不占额外空间。
41 位时间戳以某个自定义纪元为起点,可以表示约 69 年;10 位机器 ID 可以支持 1024 个节点;12 位序列号意味着同一毫秒内最多生成 4096 个 ID。一千个并发请求下,每秒约 40 万个 ID 的生成能力,对绝大多数业务绰绰有余。
核心逻辑可以用一小段伪代码表达:
public synchronized long nextId() { long timestamp = System.currentTimeMillis(); if (timestamp < lastTimestamp) { // 时钟回拨 throw new IllegalStateException("Clock moved backwards"); } if (timestamp == lastTimestamp) { sequence = (sequence + 1) & 4095; if (sequence == 0) { // 当前毫秒序列用尽,等待下一毫秒 timestamp = waitNextMillis(lastTimestamp); } } else { sequence = 0; } lastTimestamp = timestamp; return ((timestamp - epoch) << 22) | (workerId << 12) | sequence; }时钟回拨是雪花算法最需要防御的问题。如果系统时间往回调了几十毫秒,就可能生成和之前重复的 ID。常见处理姿势是:回拨幅度小就短暂等待或拒绝生成;回拨幅度大就报错并走备用策略。上线前务必设计好机器 ID 的分配方式,可以放配置中心,也可以按 IP 取模,反正要保证各节点不重复。
6.2 号段模式:用批量取值降低数据库压力
号段模式是另一套非常实用的全局 ID 方案,特别适合对 ID 连续性要求不那么高、但希望保留“纯数字短 ID”的业务。它的核心思路是:数据库里维护一张序号分配表,应用每次批量取走一段值,然后在本地缓存使用。
CREATE TABLE id_allocator ( biz_key VARCHAR(64) NOT NULL COMMENT '业务标识', current_id BIGINT NOT NULL COMMENT '当前已分配的最大值', step INT NOT NULL COMMENT '每次分配的步长', updated_at DATETIME NOT NULL, PRIMARY KEY (biz_key) ) ENGINE = InnoDB;应用启动时执行一次UPDATE id_allocator SET current_id = current_id + step WHERE biz_key = 'order',拿到一段[oldValue, newValue]区间。之后这段区间就在应用内存里,用完再向数据库申请下一段。这种方式每申请一次最多访问一次数据库,却可以分发几千个 ID,性能非常高。
它的缺点是会“浪费”一部分号段,比如应用重启后没用完的 ID 不会再分配出去。但因为号段不重复,浪费只是数字跳跃,不影响唯一性,通常可以接受。对想要短数字、纯数字、无序可预测性要求不高的场景,号段模式比雪花更贴近业务直觉。
6.3 UUID 的改造思路
原生 UUID 直接做 MySQL 主键是灾难,36 位字符串太长、无序插入造成页分裂、聚簇索引占用空间巨大。但 UUID 并非一无是处,它零依赖、本地生成、不需要任何中心节点。
改造方向有两个:一是去掉横线,把它当作业务标识而不是主键;二是用 UUID v7 或有序 UUID,让生成结果按时间趋势递增,尽量改善 B+ 树的写入模式。还有一些数据库内置了有序 UUID 生成函数,原理类似。
如果对存储空间敏感,可以把 UUID 转换成二进制存BINARY(16),但在业务代码和查询条件里可读性较差。整体来看,UUID 更适合做“业务标识”或者“分布式系统里的 trace id”,直接用做主键不如雪花或号段顺手。
6.4 几种全局 ID 方案对比
| 方案 | ID 长度 | 是否有序 | 全局唯一 | 依赖中心 | 典型场景 | 主要短板 |
|---|---|---|---|---|---|---|
| 自增主键 | 8 字节 | 是 | 仅单库 | 数据库 | 单机小项目、内部系统 | 分库冲突、易遍历 |
| 雪花算法 | 8 字节 | 趋势递增 | 是 | 无中心 | 核心业务、高并发写 | 依赖机器时钟 |
| 号段模式 | 8 字节 | 区间递增 | 是 | 数据库表 | 需要短数字 ID 的业务 | 每次申请依赖数据库 |
| UUID | 16-36 字节 | 否 | 是 | 无 | 链路追踪、临时标识 | 无序、占用空间大 |
| 全局 ID 服务 | 可自定义 | 可有序 | 是 | 独立服务 | 多团队统一接入、管控严格的系统 | 增加部署和调用成本 |
我自己的选型习惯是:核心业务表、未来会分库分表、对外暴露 ID,全部用雪花算法或全局 ID 服务;内部非核心表、明确不会拆分的数据,不排斥自增主键。不要迷信任何一种方案,要给每张表留出足够的扩展空间。
7. 什么场景下自增主键还是可以用的
7.1 明确不会增长到分库分表的内部系统
内部运营后台、配置管理平台、数据字典这类系统,连用户量都没有,更不存在分库分表的前提。自增主键简单可靠,运行维护成本极低,完全可以用。
判断标准是“明确不会”,而不是“现在不会”。如果一张表将来可能变成核心表,或者连你自己都说不准五年后会落到什么规模,那就不要赌。
7.2 作为附属序号而不是逻辑主键
有些表有独立的业务主键,比如用全局 ID 生成的订单 ID,那关联的明细表、流水表、日志表里再用一个自增列做顺序号,是完全可以的。这时候它只是辅助排序的序号,不是跨系统关联的依据,撞不撞无所谓,丢了也能重建。
这种情况在真实业务里很多,一张大表由“全局业务 ID + 本地自增流水号”组合,既能保证跨库唯一,又能保留单节点内的顺序语义。注意这时自增列通常不作为主键,主键还是用全局 ID 或复合键。
7.3 贴一张主键设计评估清单
我后来给自己画了一张问题清单,每次评审表结构都用它过一遍:
- 这张表以后要不要分库分表?
- 这张表的 ID 会不会暴露在前端 URL、接口响应、日志里?
- 这张表的数据会不会被跨环境导出、导入、合并?
- 每秒写入量会不会高到扛不住数据库自增锁?
- 这张表能不能接受 ID 跳跃或乱序?
如果任何一个答案是“会”或“可能”,就老老实实上全局 ID 方案。自增主键不是不能用,而是用之前必须确认自己未来五年都不会踩到上面任何一个坑。
8. 常见问题与排查心得速查
8.1 高频问题清单
问:int自增主键满了怎么办?
答:int最大约 21 亿。一张核心业务表如果长时间运行且高频写入,满了就会主键冲突、无法插入。在线修改主键类型需要重建表,并锁表很长时间。所以现在新表主键一律建议bigint,至少容量大很多,也更符合全局 ID 的存储需求。
问:能不能拿自增 ID 当订单号?
答:不太建议。订单号通常会出现在前端回执、支付回调、客服输入框中,自增订单号等于把日单量直接暴露给外界,而且也很容易猜。更合理的是用雪花 ID 拼上业务前缀或者生成一段随机码,安全性和可读性都好得多。
问:早期已经用了自增主键,现在想迁移怎么办?
答:最稳妥的思路是新增一个全局 ID 字段作为新的业务标识,老数据用映射规则补齐,应用层做双写过渡,新旧系统并行跑一段时间,稳定后再把老 ID 降级为普通索引。不要直接在线上表上改主键类型,风险太大。
问:分库分表后原来的自增主键还能救吗?
答:可以加一个“实例编号 + 原 ID”的复合字段,勉强做到全局唯一,但它本质上已经不是一个代理主键了。倒不如直接把主键切换为雪花 ID,原来的自增列留作历史记录或排序用。
8.2 我的几条避坑铁律
第一,主键和业务语义彻底解耦。任何有业务含义的字段都不应该充当主键,包括手机号、邮箱、身份证号。主键只负责唯一标识,不负责描述业务。
第二,定义主键时先考虑“会不会迁移”,而不是“现在规模多大”。互联网业务的特点就是增长不可预判,一开始设计成全局 ID,后面省下的都是实际工时。
第三,对外暴露的 ID 一律不用自增。能暴露到前端的东西,都要默认攻击者会拿到,并假设他们会从数字规律里挖掘业务信息。
第四,主键类型直接上bigint。从int升bigint的代价远大于一开始用bigint的成本,这种亏吃一次就记住了。
最后说一个我自己的习惯。每次评审表结构,我一定会问一句:这张表五年后会是什么样子?如果回答是“会很大”或者“不确定”,那主键就现在换成全局 ID。一次返工的代价,足够搭十套 ID 服务。主键设计看着只是一个小字段,但它几乎决定了这张表未来十年的命运。