MySQL大字段治理:行溢出机制、覆盖索引与拆表

大家好,我是数据库小学妹👋 我踩过的坑,你别再踩。

上个月改了个功能,工单表加了个"处理备注",类型 TEXT,平均一条20来KB。上线第二天,工单列表接口从30毫秒涨到400毫秒。我先看索引,全在;再看数据量,没涨多少;看慢日志,那句列表 SQL 赫然在列,WHERE status = 0 ORDER BY updated_at DESC LIMIT 20。SQL 一个字没改,索引一个没动,慢下来的原因是它现在要把 20KB 的备注一起搬出来。以前做设计的时候我天天跟图层打交道,图层多会卡,这个道理我懂;加字段这件事,我却一直当"加一列"。

一、行溢出到底发生了什么

InnoDB 的一个数据页是 16KB,一行数据原则上要能放进一个页里。可 TEXT、BLOB 这种变长列上限很大,一行塞不下怎么办?答案是把超出的部分挪到别的地方,行内只留一个指针。这个动作就叫行溢出。

挪出去的部分叫溢出页,是独立的 16KB 页。一条 20KB的备注,至少要占两个溢出页,行内留20个字节的指针指过去。读这行数据的时候,引擎要先拿到行,再顺着指针去把溢出页读上来。也就是说,它从"一次页访问"变成了"三次页访问",而这三次里,溢出页那两次最不容易被缓存捂热。

行内留多少,取决于行格式。REDUNDANT 和 COMPACT 会在行内留 768 个字节的前缀,剩下的才溢出;DYNAMIC 和 COMPRESSED 更彻底,超限的整段搬走,行内只留一个20字节的指针,一个字节都不留。8.0 的默认行格式是 DYNAMIC,所以你现在新建的表,超长的大字段基本都是"整段搬走"。

行格式 行内留什么 大字段读取代价 备注
REDUNDANT 768 字节前缀 + 指针 前 768 字节可以直接读到 老格式,基本不用了
COMPACT 768 字节前缀 + 指针 同上 5.7 时代的默认
DYNAMIC 20 字节指针 超限部分整段跳溢出页 8.0 默认,主流
COMPRESSED 20 字节指针 + 页压缩 多一层解压 CPU 省空间,费 CPU

DYNAMIC 的好处是行内干净,主键和常用列挤得更紧,一个页能放更多行;放得下的短值仍然留在行内,只有超限的才搬走。坏处是大字段一旦被 SELECT 到,就是实打实的一次额外 IO。你只读 id 和 status 时它完全不参与,可你一旦写了 SELECT *,它就跟着来了。

二、四层代价,一层比一层难发现

第一层是读放大,最容易理解也最容易撞。列表接口 SELECT *,一页20行,20行乘20KB,就是400KB。看着不多,可接口 QPS 一上来,网络带宽和应用的堆内存一起涨。我那次就是这样,接口本身只慢在"取数据",CPU 一点没动。

第二层是覆盖索引失效。二级索引里只有索引列和主键,不带你那个大字段。所以只要查询里出现 content,引擎就必须回表,把整行读上来。原本能靠覆盖索引一条索引扫完的查询,因为多带了一个字段,变成"索引扫一遍 + 每行回表一次"。

第三层是临时表和排序。ORDER BY、GROUP BY、DISTINCT 用到带大字段的列时,中间结果要落进临时表。内存临时表放不下,就写磁盘。Created_tmp_disk_tables 这个状态值开始涨,查询耗时从毫秒级跳到百毫秒级。这条最容易被误判成"排序慢",其实是"要搬的字节太多"。

第四层是缓冲池污染和碎片。大字段占的是缓冲池的页,它把热数据挤出去。一个 32GB 的缓冲池,如果都是 20KB的行,理论上 160万行就占满了,而热数据可能只有几十万行。反复更新长字段还会在页里留下空洞,空间回收的问题就跟着来了,那是另一件事,今天不展开。

代价 表现 怎么发现 怎么处置
读放大 列表接口纯取数变慢,CPU 不高 抓包看返回字节数,对比只取必要列 列白名单,禁止 SELECT *
覆盖索引失效 EXPLAIN 的 Using index 消失 对比同一查询带/不带大字段的 Extra 大字段拆表,索引查询走主表
临时表写盘 排序、分组变慢 Created_tmp_disk_tables 增长 排序键与展示分离
缓冲池污染 命中率下降,物理读上升 命中率曲线与新增字段时间点对齐 拆表 + 缩短字段长度

三、把证据抓出来

先看表的平均行长。加了大字段之后,Avg_row_length 通常会跳一档,可以当作第一条线索。不跳也别奇怪,InnoDB 的这个数是估算,溢出页算不算进去,各版本不完全一致,最后还是要落到查询上的对照。

sql 复制代码
SHOW TABLE STATUS LIKE 't_ticket'\G
-- 关注 Avg_row_length、Data_length 与新增字段上线时间的关系

再做一次对照查询。同一张表、同一个条件,一次只取必要列,一次 SELECT *,把耗时和返回字节数都记下来。这一步用 mysql 客户端直接跑就能看到返回行数和耗时,返回字节数在应用侧打点更准。我当时的数是 32 毫秒对 410 毫秒,返回字节数差了 40倍。

sql 复制代码
-- 列表页需要的列,明确写出来
SELECT id, ticket_no, status, updated_at
FROM t_ticket WHERE status = 0 ORDER BY updated_at DESC LIMIT 20;

-- 错误示范:多带了 content 这一个 TEXT
SELECT id, ticket_no, status, updated_at, content
FROM t_ticket WHERE status = 0 ORDER BY updated_at DESC LIMIT 20;

最后确认覆盖索引还在不在。覆盖索引要把查询里出现的列都装进去,所以得建成 (status, updated_at, ticket_no),只建 (status, updated_at) 是不够的,ticket_no 还得回表。索引建对了,第一条查询的 Extra 里应该有 Using index,第二条一定没有。

vbnet 复制代码
EXPLAIN SELECT id, ticket_no, status, updated_at
FROM t_ticket WHERE status = 0 ORDER BY updated_at DESC LIMIT 20\G
-- 期望:Extra: Using where; Using index

EXPLAIN SELECT id, ticket_no, status, updated_at, content
FROM t_ticket WHERE status = 0 ORDER BY updated_at DESC LIMIT 20\G
-- 现实:Extra: Using where(回表)

四、改的顺序

第一件事永远是列白名单。列表、搜索、导出,每个接口写清需要哪些列,不许 SELECT *。这条改动最小,收益最大,我那次就是这么把接口救回来的,30毫秒回来了,一行 DDL 都没动。

第二件事是拆表。正文、快照、附件描述这类"只在详情页出现"的字段,单独放一张 1:1 的附表,主键跟主表一致。主表保持瘦,索引和列表查询全在主表上跑,详情页才去附表取。拆完之后主表的平均行长会掉回几十字节,缓冲池能装的行数翻好几倍。

sql 复制代码
-- 附表:只在详情页读
CREATE TABLE t_ticket_content (
  ticket_id BIGINT UNSIGNED NOT NULL PRIMARY KEY,
  content   MEDIUMTEXT,
  updated_at DATETIME NOT NULL
) ENGINE=InnoDB;

-- 存量先 INSERT ... SELECT 灌进附表,再删主表列
ALTER TABLE t_ticket DROP COLUMN content;

第三件事是延迟加载。详情页也不一定需要立刻把正文拉出来,前端点了"查看全文"再取。这是接口设计层面的取舍,但对数据库的压力影响很直接。

第四件事才是压缩。InnoDB 有两档:表压缩(ROW_FORMAT=COMPRESSED)和透明页压缩,对大字段为主的表效果明显,代价是多一层 CPU。这条路适合"存多读少"的归档表,不适合高频读的业务表。顺序别搞反,先做前三条,压缩放最后。

五、什么时候大字段无害

不是所有大字段都要拆。判断标准只有一条:它会不会在列表类查询里被读出来。日志表、归档表、只写不读的快照表,大字段放主表完全没问题,因为没人扫它。真要存图片、视频、附件,正确做法是存对象存储,库里只放路径,这一点不用我多说。

还有一个中间地带:确实需要按正文检索的场景。这种别指望 LIKE '%关键词%',它一定全表扫。要么上全文索引,要么把正文同步到独立的检索里。这条和"大字段查询慢"是两个问题,别混在一起解。

六、避坑清单

列表、搜索、导出三类接口,一律写列白名单。这条不只是性能,也是安全,SELECT * 会把不该出的字段一起返回。

大字段尽量别参与排序和分组。需要按更新时间排,就把排序键放在主表,正文放附表。带正文的 ORDER BY,临时表最容易被撑到磁盘。8.0 的临时表能装 VARCHAR/BLOB,小结果集还留在内存,字段一大就往外写。

最后一条是我自己踩的。有个字段原本是 VARCHAR(2000),实际平均只存两三百字节,一直待在行内。业务说不够用,我顺手改成了 TEXT。改完不报错了,第二周业务真开始往里贴长文,平均长度从300字节涨到12 KB。行溢出一出现,凡是带这一列的查询,IO 都翻了一倍。我当时以为是数据量涨了,查了半天才发现是这一列的形态变了。后来我记住两件事:改列类型前先看这一列的取值分布,改完必须重跑执行计划。现在我改列类型,一定在改之前把关键 SQL 的 EXPLAIN 存一份,改完再对比。

写在最后

大字段的成本不在存储费,在它被读出来的次数。存起来便宜,读起来贵,而且贵在你没注意的地方:一次 SELECT *、一个排序、一次导出。

如果你也遇到过"加了字段就变慢",先做一件事:把接口里的 SELECT * 换成明确的列。八成的问题到这一步就解决了,剩下的才需要拆表。

你们的库里,最大的那张表有没有 TEXT 字段?它在不在列表查询里?评论区说说,我帮你判断该不该拆。

我是数据库小学妹,帮你少走弯路少踩坑,咱们下篇见👋

相关推荐
爱吃奥利奥_wen1 小时前
SQL Server 基础语法详解:从建库建表到约束与增删改查
服务器·数据库·oracle
贾伟康2 小时前
【共创稿事节】HarmonyOS 7.0 Progress进度体系:从接入到生产验证
性能优化·实战·harmonyos·arkts·arkui
这个DBA有点耶2 小时前
MySQL 8.0不可见索引:删索引怕出事,不删又拖慢写入——中间态怎么用?
数据库·mysql·代码规范
测试界的小码农4 小时前
【图数据库实战进阶(01):从零认识 Neo4j,搭建第一个图数据库项目】
数据库·neo4j
x_SpiderMan4 小时前
搭建Oracle RAC 集群
数据库·oracle
测试界的小码农4 小时前
【图数据库实战进阶(03):Neo4j 图数据建模实战——节点、关系与属性应该怎么设计?】
数据库·oracle·neo4j
瑞克-桑切斯4 小时前
redis
数据库·redis·缓存
噜哥带你玩转 AI 大模型5 小时前
面试加分项!CASE WHEN 两种写法、CTE 公共表表达式、四大类窗口函数,老哥一次讲透
mysql·窗口函数·cte·sql 进阶
freejackman5 小时前
【工程化】从“单兵作战”到“多引擎架构”:基于门面模式的重构实战
大数据·数据库·hive·sql·测试工具·设计模式·架构