主键自增明明是顺序插入,为什么还会偶尔“卡”一下?
高并发往一张 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()里挨个执行:
定切点:往左、往右,还是从中间切。
要新页:调用
btr_page_alloc()申请新页,并尽量要求物理上靠近当前页,减少随机IO。申请不到直接报错返回。挂链表:调用
btr_attach_half_pages()把新页挂进B+树,修改前后页的指针,并在父节点加上指向新页的记录(如果父页也满了,分裂会向上传导,极端情况下根节点分裂,树高加一)。释放树锁:如果新行放得下,InnoDB会尽早释放整棵索引树的Latch。如果树锁抓太久,别的插入连其他无关的页都进不去。
搬数据:最后才是把该搬的记录搬过去,把新行写进去。
一个小细节:预留的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. 线上碰到了,该怎么看和处理?
排查路径:
确认尖刺是否伴随大量
INSERT,且当时没有大规模DELETE(排除页合并的干扰)。使用
SHOW ENGINE INNODB STATUS查看semaphore段。如果有大量线程等同一个index page,且行锁和MDL锁都很干净,基本就是最右页Latch争用。检查单行数据的宽度(行越宽分裂越频繁),以及是否有另一个非常热的二级索引。
处理手段:
保持自增,瘦身表结构:主键继续自增。把大JSON、大文本(TEXT/BLOB)拆分到旁路表,让主表叶子节点变窄。一页能放的行数翻倍,分裂频率和搬运代价就会减半。
保持参数:确认
innodb_autoinc_lock_mode=2,别乱改回1或0,避免引入不必要的表级自增锁。打散热点:如果写入并发实在太高,单页Latch成了绝对瓶颈,可以考虑按租户、时间或者Hash分表。让「1个最右页」变成「N个表的最右页」,把并发摊开。
别白费力气:调
innodb_fill_factor消除不了这个锯齿。该参数主要影响重建索引(DDL)时的填充率,无法阻止运行时为了预留Update空间而触发的1/16分裂机制。
总结:最右页满了会分裂,新行去右边,左页保持紧凑。所有并发写入都在同一页抢Latch,分裂那一下排队队伍变长,监控上就会出现一下一下的尖刺。理清了这个底层逻辑,你就能精准地决定是该“缩窄行宽”,还是该“打散热点”,而不是盲目地去重建索引。