以为自增ID只是有空洞?聊聊双主架构下无声的“数据覆盖”

大家好,我是小耶,写功课只是为了我踩过的坑,你们别再踩了!

做DBA之前,我觉得"自增主键跳号"顶多算个强迫症问题。看着ID从 1,2,3 变成 10,11,心里膈应一下也就过去了。

直到有一次,我们做双机房双活改造。上线第二天凌晨,业务群炸了:大面积报 Duplicate entry '10086' for key 'PRIMARY',大量订单创建失败。

爬起来排查,发现是两边主库生成了同一个ID。

那次之后我才明白:跳号确实不是病,但在特定架构下,主键冲突和ID复用,是真的能把系统送进ICU的。

今天,我们先不急着谈怎么解决,先把底层这几个最容易让人犯迷糊的概念彻底梳理清楚。

一、现象梳理:为什么自增ID注定不连续(跳号)?

很多人以为只有批量插入才会跳号,其实在InnoDB的底层,跳号是常态。这跟 innodb_autoinc_lock_mode 参数密切相关。

在 MySQL 8.0 之前,为了控制自增值的分配,有三种模式:

  1. 模式0(传统模式) :所有 INSERT 都要等语句执行完才释放锁。并发极差,但ID绝对连续。

  2. 模式1(连续模式,老版本默认) :对于普通的单行插入,瞬间分配锁。但对于 INSERT ... SELECT 或批量插入,它采用的是"预分配"机制。

    • 底层逻辑 :语句刚开始,InnoDB 不知道你到底要插多少行。它为了不去频繁加锁,干脆先预估一个数量(比如100),把自增ID一口气从当前值推到 100。如果实际只插了 80 行,剩下的 20 个 ID 就出现了"空洞"。
  3. 模式2(交错模式,8.0默认):哪怕是批量插入,也是来一行分配一个ID,不同事务的ID交错分配。并发最高,但跳号最严重。

除了参数控制,事务回滚 也会导致跳号。你插了一行,拿到 ID=10,结果遇到异常 ROLLBACK。数据没了,但 ID=10 已经消耗掉了,下一行从 11 开始。InnoDB 为了保证唯一性,分配出去的自增ID绝不收回。

二、概念拆解:双主架构下的"主键冲突"是怎么发生的?

回到开头我遇到的那个事故。为什么两个主库会生成相同的 ID?

假设我们有两个主库 A 和 B,都允许写入。MySQL 默认的 auto_increment_increment(步长)是 1,auto_increment_offset(起始值)是 1。

  • 业务在 A 库插入数据:A 生成 ID=1,2,3...

  • 业务在 B 库插入数据:B 也会生成 ID=1,2,3...

  • 此时,A 的 Binlog 同步到了 B,B 发现要插入 ID=1,但 B 自己已经有 ID=1 了。触发主键冲突,从库 SQL 线程直接报错停摆(Slave_SQL_Running=No)。

这就是主键冲突的本质:在分布式写入的拓扑下,使用了单机的自增序列生成规则。 两个节点在各自的"信息孤岛"里闭门造车,一旦碰头,直接撞车。

三、最隐蔽的灾难:主从切换时的"ID复用"

主键冲突至少会报错,业务能拦截。但 ID复用 才是最无声的杀手,它是怎么发生的?

在 MySQL 5.7 及更早的版本中,自增计数器(AUTO_INCREMENT)的值仅仅保存在内存中 。当数据库重启时,InnoDB 会去扫描表里的最大主键:SELECT MAX(id) FROM table,然后把内存里的自增值设为 MAX(id) + 1。

灾难推演:

  1. 主库 A 当前最大 ID 是 100。

  2. 发生了主从切换,主库变成了 B(数据同步过来了,最大 ID 也是 100)。

  3. 业务在 B 上大量写入,B 的最大 ID 涨到了 200。

  4. 因为故障恢复,系统又切回了 A 库。

  5. 此时 A 库重启,扫描自己表的最大 ID,发现是 100。于是 A 把自增值设为 101,接着分配 101, 102...

  6. 但 101-200 这些 ID,在主库 B 上早已经被业务占用了!如果业务逻辑有漏洞,或者在分库分表合并时,新数据带着 101 这个 ID 覆盖了老数据,这种数据错乱,根本恢复不了。

四、MySQL 8.0 的"自增持久化"救了多少人的命?

为了解决这个致命问题,MySQL 8.0 引入了一个核心特性:自增计数器的持久化。

它的底层逻辑是:不再依赖扫描 MAX(id),而是把当前自增计数器的值,像普通数据一样记录在 Redo Log 中。每次数据库重启,直接读取 Redo Log 恢复计数器。

更重要的是,在 8.0 中,即使是 ROLLBACK 回滚的事务,自增值一旦写入了 Redo Log 就绝对不会后退 。这从底层彻底封死了主从切换和重启导致的"ID复用"风险。但要注意,如果是从 5.7 升级到 8.0,第一次启动时,它依然会扫描一次 MAX(id),所以升级前的数据对齐非常关键。

五、实战避坑:基于上述机制,我们该怎么办?

把概念理清了,解法的思路就非常清晰了:

方案一:双主架构必须配平步长

如果你非要用双主写入,必须修改参数:

  • 节点 A:auto_increment_increment=2,auto_increment_offset=1(生成奇数)

  • 节点 B:auto_increment_increment=2,auto_increment_offset=2(生成偶数)

    同时,千万别把这个自增主键当作业务的单号,因为它中间会有大量的跳号,强迫症看多了会疯。

方案二:彻底抛弃"自增主键做业务标识"的设计

这是架构演进的必经之路。自增主键只作为数据库内部的行标识(Primary Key),哪怕它乱成麻,只要唯一就行。

对外的业务单号(如订单号、流水号)必须独立生成。可以用时间戳+机器码+随机数。

方案三:走向分布式ID(号段或雪花)

如果系统面临分库分表,或者对高可用要求极高(随时可能切换),自增主键的连续性已经不可靠了。

  • 号段模式:用一个独立的表来发号,每次取 1000 个放在内存里。数据库压力极小,且完全不受主从切换影响。

  • 雪花算法:利用时间戳+机器ID+序列号生成。但极其依赖服务器的时钟同步(NTP),发生时钟回拨时,需要做短暂休眠或者备用位处理。

六、小结

不要被"跳号"的表象迷惑,那只是 InnoDB 为了并发性能做出的妥协。

真正需要你时刻警惕的,是分布式架构下的主键冲突,和版本缺陷导致的主键复用。理解了 MySQL 8.0 自增持久化的底层逻辑,才能在未来做架构选型时,不被这些"小坑"拖成 P0 级事故。

小耶在手,SQL 不愁

还有什么想了解的,欢迎留言!小耶一定知无不言言无不尽......我们下次见~

相关推荐
陈布衣1 小时前
MySQL(1)_MySQL 5.7.21 Linux 安装笔记
linux·笔记·mysql
钱栈up1 小时前
内网全断,怎么核实高炉和烧结系统的数据库对接信息
数据库
weavecodes1 小时前
双层缓存与权限实时生效:织码在线教育系统的 Caffeine + Redis 设计
数据库
bksczm2 小时前
InnoDB 页结构详解:页类型、页头页尾、数据行与页目录
数据库·mysql
bksczm2 小时前
InnoDB 行格式详解:DYNAMIC / COMPACT / COMPRESSED / REDUNDANT
java·开发语言·数据库
余槐i3 小时前
MySQL 8.0 联合索引走错:为什么加了索引查询反而从 200ms 变成 8s
java·spring boot·mysql·docker·性能优化
字节跳动开源3 小时前
OpenViking连接业务系统,LLM Wiki一键编译
数据库·开源
进击的荆棘4 小时前
MySQL——数据库基础
数据库·mysql
Decay__4 小时前
22_MySQL 运维自动化平台 —— 部署编排 + 监控 + 巡检 + 告警 + 工单闭环一体化
运维·数据库·sql·mysql·架构