3 倍吞吐提升,40 倍锁等待下降—TaurusDB LOB 并发性能跃升之路
设想这样一个场景:业务里存着大量图片、富文本正文、文档附件——它们在数据库里都是 BLOB/TEXT/JSON 这类大对象(LOB)。平时相安无事,可一旦并发上来(大促、流量高峰、批量导入),写入吞吐就莫名上不去:CPU 没打满、IO 没到瓶颈、连接数也正常,可 QPS 就是卡在一个天花板下,延迟还跟着抖。扩规格也不见好——问题不在资源,在内核。
本文讲的就是这类“LOB 并发写入”的隐形瓶颈:TaurusDB 沿 LOB 写入的完整链路,把原生 MySQL 实现里一处处串行化的 latch 瓶颈解开,让 LOB 并发吞吐直接提升 3 倍,latch 争用下降 40 倍。
1 LOB 写入瓶颈与优化
先补一点背景:LOB 在内核里到底长什么样?MySQL 8.0 引入 LOB index 结构后,非压缩 LOB(除 SDI 外)都用 LOB index 结构存储。聚簇索引 leaf page 的记录里只存一个 LOB ref(引用),LOB 的实际数据被拆成三类页——first page(存 LOB 元数据、index entry 链头和本页 inline 数据,约 15 KB)、node pages(存 index entry,是 LOB 自索引树的节点)、data pages(存实际 LOB 数据),由 index entry 链串成一棵小型树状结构,这个结构即 LOB index。

为简化示意,JSON 类型 partial update 产生的 data page 的 MVCC 版本链未在图中体现
这个结构是 LOB 读写的核心,也是并发写入瓶颈的根源:每次写入、更新、清理 LOB,都要在这套结构上分配/修改/释放页、维护 index entry 链,而对这些结构的操作,会导致多个较大粒度的 latch 被长时间持有,正是后面要讲的并发写入瓶颈所在。
一条带 LOB 的记录,从产生到被淘汰,可能会涉及三类操作 —— LOB insert、LOB update、purge 旧 LOB,如下图所示:
对于上图中三类操作相互关联解释如下:

- LOB update 产生 待 purge 的旧 LOB:除了 JSON 类型 partial update 外,对于溢出字段的更新会使得整个 LOB index 变为旧版本,在更新完成后,需要由后台 purge 线程负责对旧 LOB index 进行清理回收。
- LOB update 依赖 LOB insert:对于溢出字段的更新会使得整个 LOB index 变为旧版本,也就意味着要写入新的 LOB index,走的是 lob::insert,也就是说 lob::insert 同时服务于针对溢出字段的 update 和 insert 操作。
- 后台 purge 旧 LOB 影响 前台的 LOB update/insert:purge 清理旧 LOB 时会持有 index tree sx-latch 和 root page sx-latch 的临界区,会和同一个表上并发的 LOB update/insert 争用 latch,互相拖慢。
每个阶段都有自己的卡点,TaurusDB 针对 LOB 写入链路各阶段的瓶颈逐一进行了优化,下面按阶段展开。
1.1 LOB insert
LOB insert 有两个独立的瓶颈,分别由①批量预分配和②索引锁降级解决:
瓶颈 1:逐页分配导致全程持有大粒度锁
分配页面需要持有 root page sx-latch和fsp x-latch,原生InnoDB是在分配 LOB page 时采用逐页分配、逐页写入的模式,导致全周期需要持有 root page sx-latch 和表空间 fsp x-latch,LOB 并发写入会在这2把锁上冲突,导致被串行化。
对应优化:批量预分配(优化①)
把逐页分配改成一次性批量分配所有页,只在分配段持一次 root page sx-latch。一个 LOB 写入被拆成三个阶段:
- 阶段 1(批量分配段):一次拿全页、初始化 LOB 元数据(索引链头、长度等),持有表空间锁和 root sx,仅此一段。
- 阶段 2(逐页写入段):用独立短 mtr 逐页填数据,不再持表空间锁和 root sx,只持当前 LOB 数据页锁。锁持有时间从“整个 LOB 写完”缩短到“单页写完”。
- 阶段 3(收尾段):回到聚簇索引 leaf page 检测是否存在并发修改冲突、把最终长度写回引用,短暂持 leaf page 锁。 批量分配还顺带保证崩溃安全:分配段结束时先把 LOB 引用长度置零、打“正在修改”标记再提交,任何阶段崩溃都能顺着元数据链释放已分配的页,不泄漏空间。它也有降级条件:buffer pool 太小、LOB 页数占 buffer pool 比例过高、预期产生 redo record 超过上限、分配失败或收尾段冲突时,回退原生逐页路径。
瓶颈 2:锁模式强度过高
整条写入路径的 latch mode 是 BTR_MODIFY_TREE,导致持有 index tree sx-latch(仅与 s-latch 兼容,与 sx/x-latch 互斥)。高并发 INSERT 时,LOB 并发写入会在这把锁上冲突,导致被串行化。
对应优化:索引锁降级(优化②)
把latch mode 从 BTR_MODIFY_TREE 降为 BTR_MODIFY_LEAF,从持有index tree sx-latch变成持有 index tree s-latch,并发写入不再互相阻塞。索引锁降级后暴露了一些潜在死锁。最关键的例子——原路径分配 LOB 页时先拿 fsp x-latch、后拿 root page sx-latch;但释放 LOB 页的路径顺序相反。原生下两条路径都持index tree sx-latch导致互斥,所以不会有并发的反序锁,导致死锁;但是索引锁降级后,就有可能会产生并发的反序锁,导致死锁。所以需要统一所有 LOB 相关路径的锁序,这是在实现上较为复杂的一部分。
下面两张时序图对比优化前后 LOB insert 流程的 latch 交互情况:


优化总结:
优化①和优化②优化的是不同维度的latch瓶颈:优化①是对于无法降低锁强度的两把大锁:root page sx-latch和fsp x-latch,降低 LOB insert流程中持有这两把大锁的时间;优化②是降低索引锁的强度,即 index tree sx-latch降级为 index tree s-latch;
从上述时序图可以看出,优化①、②必须配合,否则 LOB insert流程会被上述3把大锁中的任意一把互斥,导致被串行化,这也是后文“协同效应”的第一层体现。
- 优化前:全程持有排他索引锁:index tree sx-latch;全程持有root page sx-latch ,fsp x-latch,这3把大锁导致并发的 LOB insert 流程因为锁互斥导致被串行化;
- 优化后:索引锁强度降低为共享锁:index tree s-latch,且非全程持有;root page sx-latch 和 fsp x-latch 这两把大锁仅仅阶段 1 短时间持有,并发的 LOB insert 流程锁冲突降低,并发度提高。
1.2 LOB update
瓶颈:一刀切回退悲观,触发 BTR_MODIFY_TREE
UPDATE 替换带溢出字段的记录时,InnoDB 原生实质上是一刀切:只要满足以下 3 个条件之一,就回退到悲观路径:
1) 更新前的旧记录包含溢出字段;
2) 待更新的字段的新值是溢出字段:此场景仅出现在 rollback 流程中,基于 undo record 进行反向更新时,undo record 中记录的被更新的字段的旧值已经是一个溢出字段。
3) 乐观更新执行过程中发现页面剩余空间不足以 inline 存放更新后的新行:作为对比,悲观路径会尝试将行中字段进行溢出存放;
可以看到,上述回退条件存在一个核心弊端:是否需要回退到悲观路径,本质上取决于是否需要 B-tree SMO(结构变更操作);而这只与 leaf page 的剩余空间和 inline 记录的大小有关。但上述 3 个条件使得判断退化为——只要记录包含溢出字段,就一律回退到最重的悲观路径,使整条更新路径的 B-tree latch mode 变为 BTR_MODIFY_TREE(排他),高并发下整个聚簇索引串行写入。
对应优化:乐观更新(优化③)
优化的本质是:对于一个不会触发 SMO 的 LOB 更新,支持走乐观更新路径,不再一刀切回退悲观,省去悲观路径沉重的 latch 代价。设计是两阶段 mtr(mini-transaction):
- 阶段 1:在 leaf page 就地改inline记录(把溢出字段提取成 big_rec,行里留LOB ref占位,删旧记录、插新记录);
- 阶段 2:独立写 LOB 数据、回填LOB ref 到 inline 记录。
下面两张时序图对比优化③前后,LOB update 路径与 latch 的交互情况:


优化总结:
优化③和优化①、②解决的是不同阶段的瓶颈,需要配合:优化③只让 LOB update 在阶段1走乐观路径,但阶段 2 的执行路径就是LOB insert路径,而优化①、②优化的正是 LOB insert路径。如果仅仅单独开启其中的一项优化,整个 LOB update流程还是会在某个阶段因锁互斥导致串行化,这也是后文“协同效应”的第二层体现。对于优化③的行为总结如下:
- 优化前:一定回退悲观 update,导致全程持有 index tree sx-latch + root sx-latch,这些大粒度的锁冲突导致并发 LOB update 操作被串行化。
- 优化后:支持乐观 update,流程拆为2个阶段:阶段 1 仅持 leaf page x,和普通的inline记录的乐观update一致;阶段 2可以享受到之前优化①、②带来的收益,并发 LOB update 流程的锁冲突降低。
1.3 purge 旧 LOB
一行带 LOB 的记录被 DELETE 或者 UPDATE,MVCC 不可见后,后台 purge 线程要把这条记录占用的旧 LOB 页回收掉,原生 InnoDB 对此的处理中有两个瓶颈——一个把临界区拉长,一个产生了不必要的 IO,以下展开:
瓶颈 1:持锁后产生同步 IO
purge 释放旧 LOB 页时,是在“持有 index tree sx-latch + root page sx-latch”的临界区里干的,并发的 LOB 写入可能被这个临界区的锁阻塞。而 purge 线程进入临界区后,会依次同步读取待 purge 的 LOB index 涉及的所有页面,读一页释放一页;若页面在 buffer pool 中未命中,需要从底层存储中读上来,会大幅增加持锁时间。
对应优化:purge 预读(优化④)
把 purge 可能产生的读页面 IO 提前到临界区之外——在持有锁之前,先异步把这些页预读到 buffer pool,进入临界区后真正执行 purge 时会命中 buffer pool,持锁期间不再有同步 IO。预读本身不持锁,只对页面做个 buf-fix(防驱逐)就放手,不会阻挡其他对页面的并发操作。
瓶颈 2:不必要的页面 IO
原生 InnoDB 在释放一个 LOB data page 之前,需要先把它加载进 buffer pool,但实际上这个开销可以省去:释放页面的操作其实只更新分配位图(XDES bitmap)把页标记成“空闲”,并不需要查看页面内容。而且 LOB data page 通常较大,这种大数据量载入 buffer pool 在产生 IO 开销的同时,也会造成一定程度的 buffer pool 污染。
对应优化:purge 免加载(优化⑤)
让 purge 释放 LOB data page 时直接跳过读取这些页面,只加载 LOB first page 和 LOB node page,以获取 LOB data page 的页号,通过页号直接更新分配位图来释放页面,无需加载页面内容,避免了额外的 IO 和 buffer pool 污染。
优化总结:
优化④和⑤不重叠:优化④预读的是 LOB first page 和 LOB node page 以获取 LOB data page 的页号,使 purge 线程在临界区内避免因加载这些页面未命中 buffer pool 而产生同步 IO;优化⑤省去的是加载 LOB data page 产生的 IO 和 buffer pool 污染。
2 测试数据
2.1 优化项“协同效应”
上述 5 个优化项都设置了动态开关,因此测试了依次单独打开每个优化项的性能,但比较反直觉的是:单独开启任意一个优化项,QPS 几乎纹丝不动;但当所有优化项打开后,性能明显提升。以下为 UPDATE 16 KB 场景下的性能数据,可以明显看到这种“协同效应”:

产生上述“协同效应”的原因,在之前的优化项讲解中已经解释过:LOB 写入链路上的性能瓶颈也符合“木桶理论”。一次 LOB 写入的代价,由 latch 的级别与粒度、latch 的持有时间、乐观/悲观路径选择、purge 后台影响、IO 负载等多个因素叠加决定。每个优化只解开链路中某一段流程的一个瓶颈,解开的瓶颈让出了位置,下一个瓶颈立刻顶上来阻塞整个链路的并发度。
2.2 优化效果
将优化项的开关全部打开后,LOB 并发写入的吞吐会真正跃升,各尺寸 UPDATE 优化前后对比如下:

UPDATE 三档尺寸提升都很显著,其中 16 KB 最突出——吞吐从约 4.7K 涨到 14.7K,提升约 3.1 倍;64 KB、256 KB 分别有约 2.2 倍、1.8 倍的提升。背后的原因要分两层看:上述优化通过降低 latch 级别和 latch 的持有时间,带来的并发收益对所有 LOB 尺寸都生效;但 LOB 越大,写 data page 产生的绝对 IO 耗时在负载里占比越高,锁层面省下的时间被 IO 摊薄,提升幅度反而略低。16 KB 处在最优收益区间——它足够大、必然走 LOB 溢出路径,负载开销又没被 data page 的 IO 主导,latch 争用是首要瓶颈,所以 latch 优化的收益最能体现在吞吐上。
上述 latch 争用的下降可以从 InnoDB 的 ENGINE STATUS 监控中得到更直接的印证:优化后相同负载压力下的 rw-sx 锁等待从每秒近 2 万次降到约 500 次——下降约 40 倍,这正是上述优化项的主要目标。
INSERT 负载同样受益(16 KB 约 +50%、64 KB 约 +35%),量级比 UPDATE 小一些——因为 INSERT 路径不涉及乐观更新,少了优化项③的收益来源,但预分配 + 锁降级 + 清理阶段两处优化(④⑤)依然有效。
3 总结
回到开头那个场景:LOB 并发写入场景下,“资源都没满、吞吐就是上不去”的现象,是数据库内核中典型的因内部 latch 冲突造成的并发性能屏障。这次沿 LOB 的完整生命周期,把LOB insert、LOB update、LOB purge三个阶段上的串行化瓶颈均解开,实现了 LOB 并发写入吞吐 3 倍的跃升。本次优化工作也体现了内核优化中的常见思路:瓶颈是网状的,对应的优化也是成体系的。这要求优化工作对整条链路有全局视角,而不是只盯一段。
- 点赞
- 收藏
- 关注作者
评论(0)