☰
揭秘InnoDB 自增主键插入延迟与页分裂机制
2026/9/27 21:44:24 网站建设 项目流程

主键自增明明是顺序插入,为什么还会偶尔“卡”一下?

高并发往一张 InnoDB 表里INSERT,主键是自增的。监控上经常能看到这样一条曲线:平时 QPS 挺稳,但隔一段时间延迟就会猛地翘一下,QPS 跟着掉一截;过几秒后自动恢复,再过一会儿,又翘一次。曲线呈规律的锯齿状。

很多人把这个现象粗略地归结为「页分裂(Page Split)」,认为是 B+ 树把数据页从中间劈开,一半记录被搬走,从而导致了慢。

但这只说对了一半。

页分裂确实发生了,但在自增主键的场景下,InnoDB 根本不会把页「从中间劈开」。它会将新行直接放到右边新开的页上,而左边的老页依然是满的,空间并没有被对半撕裂。

真正让所有INSERT瞬间卡住的元凶,是它们都挤在了同一片最右边的叶子页上。当这页快满需要分裂时(分配新页、改前后链表、改父节点),这段时间里所有后来的插入操作,全都在这页的门口排队死等。

分裂只是导火索,对最右页的Latch(闩锁)排队踩踏,才是你在监控上看到的那个“尖刺”。

MySQL 8.0的InnoDB就是这么干的。我们扒开源码,从底层来看看这个过程。


1. 先看一页里有什么?

InnoDB默认一页是16KB(innodb_page_size)。你可以把它想象成一张固定大小的带网格的纸,数据绝不是随便往上堆的。

纸的两头有两个固定的「哨兵」:infimum(比页内所有真实记录都小)和supremum(比所有真实记录都大)。用户记录从页头往页尾长,而用于二分查找的页目录(Page Directory)则从页尾往页头长,中间剩下一截空地。

叶子页还有指向「上一页/下一页」的指针(FIL_PAGE_PREV、FIL_PAGE_NEXT),串成了一个双向链表。范围扫描顺着链表走就行,不用每次都回根节点。

当插入一行数据时,InnoDB会先在页里找到合适的位置,再看中间的空地够不够。如果页内因为之前的删除留下了空洞,虽然总字节数够但连不成一整块,InnoDB会先在页内做一次整理(reorganize),把记录挤紧凑了再插。如果整理完还是放不下,才会触发分裂。

这条「先试试,不行再分裂」的代码入口叫btr_cur_optimistic_insert():

  • 乐观插入:放得下,只改这一页,写点Redo Log,结束。日常绝大多数插入走的都是这条路。

  • 悲观分裂:放不下,返回失败。上层接着进入btr_page_split_and_insert()。要申请新页、搬运数据、修改父节点,甚至导致B+树长高。

你在监控里看到的尖刺,对应的就是少数几次悲观分裂耗时,叠加当时所有并发插入都在这一页排队等待的综合结果。


2. 聚簇页vs二级索引页:痛点截然不同

两棵树的页结构长得一样,但叶子里装的“货”不一样,导致它们的并发痛点完全不同。

  • 聚簇索引(主键):叶子节点存的是完整的整行数据,附带InnoDB隐藏列trx_id和roll_ptr。

  • 二级索引:叶子节点只存索引列+主键列。

因为装的货不同,分裂时的代价差异巨大:

如果聚簇索引的一行非常宽(比如2KB),去掉页头信息,一页根本放不了几条数据。没插几下就要分裂,且一次搬走的字节量极大。行越宽,一页装得越少,监控上的尖刺就越密集。

二级索引的记录通常很窄,一页能塞很多条,分裂没那么频繁。但是,主键是顺序自增的,二级索引列(比如user_id)往往是无序散列的。

  • 主键的痛点:所有写入都集中在最右页排队。

  • 二级索引的痛点:插入点满树乱跳,页经常被从中间切开,索引更容易产生碎片变胖。

延伸提醒:主键如果发生修改,整行要在聚簇索引里搬家,所有二级索引里的主键副本也得跟着改。所以,主键千万别用业务上会变的列。


3. 自增插入,真的不是对半切

到底在哪切?这取决于btr_page_split_and_insert()怎么选切点。

InnoDB每个数据页的头部有个字段叫PAGE_LAST_INSERT,专门记录上一次数据插在了哪儿。如果本次插入的位置正好接在上一次的后面,InnoDB就会判定:“当前是顺序向右插入”,从而走btr_page_get_split_rec_to_right()逻辑:

当插到页的最右端时,**新记录会自己去当右边新页的第一条,左边的老页原封不动。**如果右边还剩几条旧记录,InnoDB会把它们搬到新页,并在当前页保留一条。源码注释解释,留这一条是为了让后续的顺序插入还能利用自适应哈希(AHI)在当前页对齐位置。

自增主键就是典型的这种模式:左页继续保持满载,右页刚打开,后面的INSERT继续往右页填,填满再来一次。所以,自增插入绝对不会让索引变成一堆半空的碎片页。

相反,如果主键是UUID,或者是状态值这种跳来跳去的数据,InnoDB看不出顺序,就会走page_get_middle_rec()从中间切。这才是大家口中常说的「劈成两半」。大约一半记录被搬走,产生更长的Redo Log,分裂后两页都只有半满。如果持续这样随机插,半满的页会越来越多,不仅浪费Buffer Pool,范围扫描也更吃亏。


4. 一次悲观分裂,底层到底在忙什么?

悲观分裂是一套组合拳,都在btr_page_split_and_insert()里挨个执行:

  1. 定切点:往左、往右,还是从中间切。

  2. 要新页:调用btr_page_alloc()申请新页,并尽量要求物理上靠近当前页,减少随机IO。申请不到直接报错返回。

  3. 挂链表:调用btr_attach_half_pages()把新页挂进B+树,修改前后页的指针,并在父节点加上指向新页的记录(如果父页也满了,分裂会向上传导,极端情况下根节点分裂,树高加一)。

  4. 释放树锁:如果新行放得下,InnoDB会尽早释放整棵索引树的Latch。如果树锁抓太久,别的插入连其他无关的页都进不去。

  5. 搬数据:最后才是把该搬的记录搬过去,把新行写进去。

一个小细节:预留的1/16空间在连续向一侧插入(且页未压缩)时,如果页内剩余空间小于「这一行的宽度+一页的1/16(约1KB)」,乐观插入会主动放弃,提前去分裂。 注释里的理由很实在:如果页被连续插入彻底塞死,以后要是发生UPDATE把变长字段改大,页会碎得很厉害。所以最右页「看着还有一点空就开始分裂」,多半是触发了这个1KB的保护线,而不是空间算错了。


5. 尖刺在监控上到底长什么样?

假设有一张普通的订单表:

CREATE TABLE orders ( id BIGINT NOT NULL AUTO_INCREMENT, user_id BIGINT NOT NULL, payload VARCHAR(512), PRIMARY KEY (id), KEY idx_user (user_id) ) ENGINE=InnoDB;

业务侧有几十个连接同时INSERT。因为id越来越大,所有线程算出来的插入目标,全落在最右边那片叶子上。

修改这页必须拿到排他(X)性质的Page Latch。同一时刻只能有一个插入在改它,其他人全在门口等。正常情况下,如果空地够,乐观插入极快,排队无感。 一旦空地不够,触发悲观分裂:申请新页、改链表、改父节点……操作耗时拉长,门口的队伍瞬间变长,监控上延迟直接翘起来,QPS掉下去。

分裂一结束,新的最右页诞生,继续飞速接客,延迟瞬间掉回去。等这页再次被填满,再来一轮。所以你看到的曲线是锯齿,而不是越跑越慢。如果payload字段很大,一页装不了几行,这个锯齿就会非常密集。

避坑指南:别把页Latch和表级的AUTO-INC锁混为一谈。在MySQL 8.0中,innodb_autoinc_lock_mode默认是2,普通INSERT获取自增值几乎没有表级锁等待。如果你在锁监控里没看到AUTO-INC锁,只有大量等待同一个index page的semaphore,那就是最右页Latch被打满了。


6. 两种常见的对比场景

删数据也会卡,但卡的不在同一处

当你批量DELETE历史数据时,页被掏空,btr_compress()会尝试把空页和相邻页合并。这同样需要拿Page Latch、改链表、改父节点。 白天高并发自增写入,卡在最右页;夜里跑定时任务删老数据,卡在被删的旧数据段。两件事经常叠在一张表上,遇到这种情况先排查慢在哪,别一上来就喊「索引坏了,重建吧」。

换成UUID主键会怎样?

UUID会让插入点散落在满树的各个节点,单页上的并发争抢立刻缓解,最右页的锯齿尖刺会大幅减轻。 但代价转移了:页经常从中间切开,产生大量半满页,同样的数据量占用更多的页,导致Buffer Pool命中率下降,磁盘随机IO和逻辑读大幅上升。自增主键是用「所有写挤在一页」换取「树更紧凑、范围扫描更顺」。没有绝对的好坏,取决于你的并发量和查询模式是否吃索引的紧凑度。


7. 线上碰到了,该怎么看和处理?

排查路径:

  1. 确认尖刺是否伴随大量INSERT,且当时没有大规模DELETE(排除页合并的干扰)。

  2. 使用SHOW ENGINE INNODB STATUS查看semaphore段。如果有大量线程等同一个index page,且行锁和MDL锁都很干净,基本就是最右页Latch争用。

  3. 检查单行数据的宽度(行越宽分裂越频繁),以及是否有另一个非常热的二级索引。

处理手段:

  • 保持自增,瘦身表结构:主键继续自增。把大JSON、大文本(TEXT/BLOB)拆分到旁路表,让主表叶子节点变窄。一页能放的行数翻倍,分裂频率和搬运代价就会减半。

  • 保持参数:确认innodb_autoinc_lock_mode=2,别乱改回1或0,避免引入不必要的表级自增锁。

  • 打散热点:如果写入并发实在太高,单页Latch成了绝对瓶颈,可以考虑按租户、时间或者Hash分表。让「1个最右页」变成「N个表的最右页」,把并发摊开。

  • 别白费力气:调innodb_fill_factor消除不了这个锯齿。该参数主要影响重建索引(DDL)时的填充率,无法阻止运行时为了预留Update空间而触发的1/16分裂机制。

总结:最右页满了会分裂,新行去右边,左页保持紧凑。所有并发写入都在同一页抢Latch,分裂那一下排队队伍变长,监控上就会出现一下一下的尖刺。理清了这个底层逻辑,你就能精准地决定是该“缩窄行宽”,还是该“打散热点”,而不是盲目地去重建索引。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询