一次线上慢查询,竟然查出了 MySQL 溢出页问题
有些线上问题表面上看只是 SQL 变慢实际上背后却隐藏着数据库底层存储结构的问题。前段时间我们在生产环境就遇到过一次典型案例一张访问量并不算高的业务表查询RT响应时间却突然飙升甚至出现了大量 IO 等待。最开始大家都以为是索引失效、慢查询或者buffer pool不够但最终定位下来真正的问题居然是 ——InnoDB 溢出页Overflow Page。这篇文章通过真实排查思路聊聊这个问题是怎么出现的又是如何一步步被定位出来的。线上问题查询越来越慢但 SQL 看起来没问题业务里有一张订单扩展表大概结构如下CREATE TABLE order_ext ( id BIGINT PRIMARY KEY, order_id BIGINT, ext_info LONGTEXT, create_time DATETIME, KEY idx_order_id(order_id) );其中ext_info用来存储订单扩展信息包括用户提交的 JSON第三方返回报文风控字段审计信息上线初期数据量不大一切正常。但随着业务增长问题开始逐渐暴露根据order_id查询越来越慢buffer pool命中率下降磁盘IO持续升高即使只查少量数据响应时间依然不稳定更奇怪的是SELECT id, order_id FROM order_ext WHERE order_id ?;这种只查普通字段、不查LONGTEXT的 SQL也开始变慢。这就有点反常了。第一步排查索引没问题执行计划也正常首先查看执行计划EXPLAIN SELECT id, order_id FROM order_ext WHERE order_id 10001;结果显示命中了二级索引rows很小type ref看起来没任何异常。接着查看慢日志buffer pool使用情况磁盘IOundo / redo 状态依旧没有发现明显问题。直到后来我们注意到一个现象这张表的单行记录异常大。真正的问题大量字段进入了“溢出页”继续分析表结构后发现LONGTEXT字段里存储了大量 JSON 数据。有些记录甚至超过了几十 KB。而 InnoDB 的数据页默认只有16KB也就是说一行数据根本塞不进一个普通数据页。这时候InnoDB 就会启动一种机制溢出页Overflow Page什么是溢出页简单来说当一行数据太大普通页放不下时InnoDB 会把超长字段拆出去单独存储到其他页中。原始数据页里只保留前缀数据指针信息真正的大字段内容则放在“溢出页”里。大概可以理解成这样为什么溢出页会拖慢性能很多人会有一个误区“我又没查询 TEXT 字段为什么也会慢”原因在于虽然 SQL 没查大字段但行记录本身仍然更大页能容纳的记录数变少buffer pool缓存效率下降页分裂概率提升回表成本增加更关键的是某些场景下InnoDB仍然需要访问溢出页。比如MVCC版本读取行完整性校验回表读取行迁移一旦大量随机 IO 打到磁盘性能就会迅速恶化。InnoDB 是如何决定使用溢出页的这部分稍微深入一点。InnoDB 并不是所有 TEXT/BLOB 都直接放溢出页。它会根据行格式ROW_FORMAT字段大小页剩余空间综合决定。常见行格式包括CompactDynamicCompressed其中Compact 行格式会在数据页中保留768 字节前缀剩余部分放到溢出页。因此即使字段非常大主页里仍然会保留部分内容。Dynamic 行格式这是 MySQL 5.7/8.0 更推荐的方式。它会尽量只保留 20 字节指针大字段完整放入溢出页这样能让主页更“轻”。很多线上库升级后性能改善其实就和这个有关。为什么我们的线上问题越来越严重后来继续分析发现业务表存在几个典型问题。1. JSON 数据持续膨胀最初{status:ok}后来逐渐变成{ risk: {...}, audit: {...}, third_response: {...}, ... }单条数据越来越大。2. 热数据和冷大字段混在一起实际上业务查询只关心order_idstatuscreate_time但每次回表时大字段依然跟着存储在同一行逻辑结构里。导致热点数据缓存效率越来越差。3. 页利用率急剧下降正常情况下16KB 页可以放很多记录。但有大量大字段后一个页只能放几条记录。buffer pool很快被“低效占用”。最终解决方案最后我们用了几个手段解决问题。方案一拆分大字段这是最有效的方法。把ext_info LONGTEXT拆到独立表order_ext_detail主表只保留核心查询字段。效果非常明显buffer pool命中率提升IO明显下降查询RT恢复正常这也是线上最推荐的方案。方案二避免无意义的大 JSON很多业务喜欢把所有数据直接塞JSON。但实际上很多字段不会查询很多字段不会回看很多字段只是日志性质这类内容可以归档可以压缩可以放对象存储不要全部堆进MySQL。方案三使用 Dynamic 行格式检查表SHOW TABLE STATUS LIKE order_ext;如果不是ROW_FORMATDYNAMIC建议调整ALTER TABLE order_ext ROW_FORMATDYNAMIC;这样可以减少主页占用。方案四避免频繁回表比如SELECT *非常容易把大字段一起带出来。线上最好明确字段列表使用覆盖索引避免无意义读取否则即使SQL看起来简单也可能触发大量IO。一个容易被忽视的底层细节很多MySQL性能问题本质并不是SQL写得差索引没建好而是数据组织方式已经不适合当前业务规模。尤其在业务快速发展后JSONTEXTBLOB这些字段非常容易成为隐藏炸弹。平时数据量小时没感觉。一旦数据规模上来buffer poolIO页分裂回表成本问题会集中爆发。总结溢出页本身并不是问题。真正的问题是大字段被无节制地放进核心业务表。线上排查MySQL性能问题时如果出现IO 异常升高查询越来越慢buffer pool命中率下降单表数据膨胀明显除了索引和SQL之外也建议关注行大小TEXT/BLOB字段ROW_FORMATOverflow Page使用情况很多“查不出原因”的慢查询最后根因都藏在这里。