内容管理系统的文章表,正文 content TEXT(平均 5KB)和标题、作者等元数据混在同一张表。列表页只需要标题和摘要,查询已走索引、也不查 content 字段,但响应仍要 45ms。
-- 列表页:不需要 content,但表里有 TEXT
SELECT id, title, author, category, views, created_at
FROM t_article_bad
WHERE category = '技术'
ORDER BY created_at DESC
LIMIT 20;案例 32 演示了用"只查必要列"避免读取 TEXT 溢出页。但如果 TEXT 字段就在表里,即使不查它,InnoDB 数据页结构仍被 TEXT 拖累--每页只能放 3 行,Buffer Pool 被冷数据挤占。根本解法是把大字段拆到扩展表。
::: warning 真实场景 任何把大字段和元数据混在一张表的场景:文章正文、商品详情富文本、日志原文、JSON 报文体。只要列表/统计查询访问这张表,大字段就会拖慢整体性能--不是因为你查了它,而是因为它让每页能放的行变少了。 :::
-- 正文和元数据混在一张表
CREATE TABLE t_article_bad (
id BIGINT NOT NULL AUTO_INCREMENT,
title VARCHAR(200) NOT NULL,
author VARCHAR(50) NOT NULL,
category VARCHAR(20) NOT NULL,
views INT NOT NULL DEFAULT 0,
content TEXT NOT NULL, -- 平均 5KB
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (id),
KEY idx_category_created (category, created_at)
) ENGINE=InnoDB;
-- 列表查询:不查 content,但表结构有 TEXT
SELECT id, title, author, category, views, created_at
FROM t_article_bad
WHERE category = '技术'
ORDER BY created_at DESC
LIMIT 20;+----------------+------------+---------+
| TABLE_NAME | TABLE_ROWS | data_mb |
+----------------+------------+---------+
| t_article_bad | 100000 | 512.34 | -- 10 万行占 512 MB
+----------------+------------+---------+
问题不在 SQL 写法(已走索引、未查 TEXT),而在表物理结构:
InnoDB 页大小 16KB
bad 表每行约 5KB(含 TEXT 指针 + 行内数据)
→ 每页只能放 3 行
→ 取 20 行需读取约 7 个数据页
→ 512 MB 表大量占用 Buffer Pool
→ 列表查询的元数据页被 TEXT 冷数据挤走
→ Buffer Pool 命中率低,磁盘 I/O 增加
即使不查 content 字段,回表到聚簇索引时仍需加载包含 TEXT 相关数据的完整数据页。TEXT 的存在让数据页变得"稀疏",同样数量的行占用更多页。
::: tip 核心认知 大字段的问题不在"被查询时慢",而在"存在于表中就慢"。它让每页可容纳的行数急剧减少,影响所有访问该表的查询。拆表是把"热数据"(元数据)和"冷数据"(正文)物理隔离,让热数据页更紧凑。 :::
-- 1. 主表:只存元数据(热数据)
CREATE TABLE t_article_good (
id BIGINT NOT NULL AUTO_INCREMENT,
title VARCHAR(200) NOT NULL,
author VARCHAR(50) NOT NULL,
category VARCHAR(20) NOT NULL,
views INT NOT NULL DEFAULT 0,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (id),
KEY idx_category_created (category, created_at)
) ENGINE=InnoDB;
-- 2. 扩展表:单独存正文(冷数据)
CREATE TABLE t_article_content (
article_id BIGINT NOT NULL,
content MEDIUMTEXT NOT NULL,
PRIMARY KEY (article_id)
) ENGINE=InnoDB;
-- 列表查询:只查主表,每页 80 行,28.5 MB 可常驻 Buffer Pool
SELECT id, title, author, category, views, created_at
FROM t_article_good
WHERE category = '技术'
ORDER BY created_at DESC
LIMIT 20;
-- 详情查询:JOIN 扩展表取正文
SELECT a.id, a.title, a.author, a.category, a.views, c.content, a.created_at
FROM t_article_good a
LEFT JOIN t_article_content c ON a.id = c.article_id
WHERE a.id = 1;拆表后,主表每行约 0.2KB(无 TEXT),数据页结构发生根本变化:
| bad(混合表) | good(拆表后) | |
|---|---|---|
| 每行大小 | ~5 KB | ~0.2 KB |
| 每页行数 | ~3 行 | ~80 行 |
| 主表总大小 | 512 MB | 28.5 MB |
| 取 20 行扫描页数 | ~7 页 | ~1 页 |
| Buffer Pool 命中率 | ~70% | ~99% |
28.5 MB 的主表可以完全常驻 Buffer Pool,列表查询几乎纯内存操作。正文(485 MB)只在详情页按主键精确读取,不污染列表查询的缓存。
| bad (混合表) | good (拆表后) | |
|---|---|---|
| 主表大小 | 512 MB | 28.5 MB |
| 每页行数 | ~3 | ~80 |
| 列表查询耗时 | ~45 ms | ~8 ms |
| 详情页耗时 | ~5 ms | ~6 ms(多一次 JOIN) |
| Buffer Pool 命中率 | ~70% | ~99% |
::: warning 注意事项
-
什么字段该拆。TEXT、BLOB、MEDIUMTEXT、LONGTEXT 以及超长 VARCHAR(如 4000+ 字符的 JSON 字段)。判断标准:字段平均大小远大于其他字段,且不是每次查询都需要。
-
详情页的 JOIN 代价。拆表后详情查询多一次 JOIN,但
type=eq_ref(主键关联)代价极小(1 次 B+ 树查找)。如果详情页访问频率很高,可考虑缓存正文到 Redis。 -
不要拆得太碎。垂直拆表一般拆成 2 张(主表 + 扩展表)即可。拆成 3 张以上会增加 JOIN 复杂度,得不偿失。
-
迁移现有表的方案。用
pt-online-schema-change创建新表 + 同步数据 + 原子切换。不要直接ALTER TABLE,大表会锁很久。 -
与案例 32 的关系。案例 32 解决"已经有大字段表,如何避免 SELECT * 读溢出页"的问题(不改表结构);本案例解决"从根本上把大字段拆出去"的问题(改表结构)。如果表已经存在 TEXT 且无法改结构,先用案例 32 的方案;新表设计或可重构时,用本案例的方案。 :::
| 特性 | 5.7 | 8.0 |
|---|---|---|
| 默认行格式 | DYNAMIC | DYNAMIC |
| TEXT 存储机制 | 溢出页 | 溢出页(一致) |
| 垂直拆表效果 | ✅ 有效 | ✅ 有效 |
| Buffer Pool 管理 | 基础 LRU | 改进版 LRU(更智能) |
::: tip 两版通用 垂直拆表是物理设计层面的优化,与 MySQL 版本无关。5.7 和 8.0 的 DYNAMIC 行格式下,TEXT 都存溢出页,拆表后主表紧凑度提升一致。8.0 的 Buffer Pool 管理更智能,拆表后命中率提升略明显。 :::
# 默认在 MySQL 8.0 上运行
./scripts/run-case.sh 50-vertical-split-text
# 在 MySQL 5.7 上运行(对比)
./scripts/run-case.sh 50-vertical-split-text --ver 5.7
# 跳过造数据重跑
./scripts/run-case.sh 50-vertical-split-text --no-seed