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