两年前我接手过一个电商订单系统,订单表在业务增长期几乎每天都新增三四十万行,半年不到就冲上了千万级。某天凌晨收到告警,一条用来做后台统计的 SQL 跑了接近 40 秒,直接把一个核心查询接口拖到超时。从那天开始,我算是被 MySQL 大表优化正式“教育”了一遍:所谓千万级数据的性能瓶颈,极少是单个问题,而是一串被忽略的细节——索引没覆盖、SQL 写得糙、表结构埋雷、数据越堆越多。这篇文章不打算讲理论大纲,而是围绕我实际踩过的坑,把排查路径、索引设计、SQL 改写、表结构取舍和架构升级这几块挨个说透。无论你的表现在只有几百万行,还是已经涨到几千万行,这套打法都能直接落地。
1. 千万级表的“卡顿”要从哪里开始排查
1.1 慢日志和 EXPLAIN 是定位慢 SQL 的第一站
很多人一听到“大表慢”就想着加配置、换机器,实际上先搞清楚慢在哪一步才是关键。我接手项目第一天,第一件事就是把慢查询日志打开。MySQL 5.7 里可以直接动态开启:
set global slow_query_log = 'ON'; set global long_query_time = 1; set global log_queries_not_using_indexes = 'ON';long_query_time 设成 1 秒,意思是超过 1 秒的 SQL 才会落日志。然后让系统跑上一两个小时,看看哪些 SQL 频繁出现在日志里。这样做往往比你自己猜“哪几条 SQL 比较慢”要可靠得多,因为真实业务里占大头的慢查询,经常是你完全没注意到的边缘接口。
拿到慢 SQL 之后,下一步不是急着改,而是用 EXPLAIN 看执行计划。重点看三样东西:type 列、rows 列和 Extra 列。
- type 列如果出现 ALL,说明这条 SQL 在做全表扫描。千万级表里全表扫描意味着几千万行数据要从磁盘刷到内存,几乎等于自杀式查询。
- rows 列是优化器预估扫描的行数,它直接告诉你这条查询的“胃口”有多大。rows 一旦到 100 万以上,哪怕 SQL 本身不长,也基本可以断定索引没设计好。
- Extra 列如果出现 Using filesort、Using temporary,说明查询里隐含排序或者临时表操作。这类操作在千万级表上的代价,往往比索引扫描还要大得多。
我习惯把慢日志里的 TOP SQL 再用 pt-query-digest 汇总一遍,看看哪些 SQL 的总执行时间占比最高。注意,不一定执行次数最多的就是最需要优化的,有时候一条每天只跑几次、但一次就 30 秒的统计 SQL,对系统的伤害远大于一万条 200 毫秒的查询。
1.2 先分清慢的根源:磁盘 IO、锁等待,还是 SQL 本身
拿到慢 SQL 之后,还得判断瓶颈到底落在哪一层。同一个“大表慢”,背后的原因可能完全不同,乱动结构反而浪费时间。我常用的方法是先看两个指标。
第一个是 InnoDB Buffer Pool 的命中率。Buffer Pool 是 MySQL 在内存里缓存表数据和索引的地方,命中率越高,说明查询越靠内存;命中率低了,说明大量查询在真实读磁盘,问题往往出在索引设计不够好,或者内存分配不足。
SHOW GLOBAL STATUS LIKE 'Innodb_buffer_pool_read_requests'; SHOW GLOBAL STATUS LIKE 'Innodb_buffer_pool_reads';命中率 = read_requests / (read_requests + reads)。千万级表上这个值如果低于 95%,甚至掉到 90% 以下,就要非常小心了。内存面是有上限的,如果一条 SQL 需要扫描 500 万行,而 Buffer Pool 里只有最近热门的 2000 万行索引页,那它必然会把大量旧页挤出缓存,进而引发连锁的磁盘 IO。这也是为什么“加内存”有时候有效,但治标不治本——真正的解法通常是让 SQL 扫更少的行。
第二个是锁等待。大表上最常见的一个坑就是长事务。某个连接开启事务后执行了很长时间的 UPDATE,却一直没提交,它持有的行锁会挡住所有其他操作。这时候从慢日志里看到的现象是:一条看似很简单的 INSERT 都执行了几秒钟,但它本身并不慢,而是被前面的长事务卡住了。排查锁等待可以直接查两个视图:
SELECT * FROM sys.innodb_lock_waits\G SELECT * FROM information_schema.innodb_trx\G我把常见瓶颈的特征整理成一个简单的判断表,排查的时候按表对照比较快:
| 瓶颈类型 | 典型现象 | 优先检查项 |
|---|---|---|
| 磁盘 IO 占比高 | 慢 SQL 集中在大量扫描,Buffer Pool 命中率偏低 | EXPLAIN rows、Buffer Pool 命中率、索引覆盖情况 |
| 锁等待 | 简单的 INSERT/UPDATE 也慢,阻塞时间不固定 | sys.innodb_lock_waits、innodb_trx 长事务 |
| SQL 自身低效 | 只有特定几条 SQL 慢,系统整体状态稳定 | 慢日志、执行计划、索引是否失效 |
1.3 建立容量基线:量化你的表到底“重”不“重”
我见过不少团队,一说大表优化就凭感觉说“这表有几千万行”,但具体多大、索引多大、平均一行多少字节,一概不知。没有基线,后面所有优化都是猜。要量化,一条 SQL 就够了:
SELECT table_name, table_rows, data_length, index_length, ROUND((data_length + index_length) / 1024 / 1024, 2) AS total_mb FROM information_schema.tables WHERE table_schema = 'your_db' AND table_name = 'order_info';以我当时那个订单表为例,2600 万行,data_length 大概 28GB,index_length 大概 12GB。除以行数算出来,平均一行数据大约 1.1KB,加上索引整体接近 40GB。这个数字什么概念呢?InnoDB 默认页大小 16KB,一页大概能装 14 行;如果做一次全表扫描,就是大约 180 万个页要读,而 Buffer Pool 当时只有 8GB,连这份表的索引都塞不下。所以那个跑了 40 秒的统计 SQL,本质上不是 SQL 语法问题,是它选择了全表扫,而全表扫在这个量级下根本没有活路。
基线还能帮你想清楚另一个问题:怎么让全表扫描尽量不出现?只要业务查询不总是要求返回整张表,就一定有办法用更小的数据集合代替全表扫。
2. 索引设计:先想清楚查询路径再建索引
2.1 覆盖索引和回表的血泪账
大表优化的第一战场永远在索引。建索引不是“哪个字段查得多就给哪个字段加”,而是先看清执行计划里有没有欠下“回表”这笔账。
所谓回表,就是 InnoDB 在主键索引上存了完整数据行,而二级索引只存了索引字段和主键值。你查询的字段如果不在二级索引里,InnoDB 就得拿着主键值去主键索引里再找一次完整行。听起来没什么,但在千万级表上,一次回表就是一次随机读。如果某个用户的查询命中了 1000 行订单,那就是 1000 次随机读;如果这些行分散在几十个数据文件块里,Buffer Pool 又没缓存,延迟会成倍放大。
我那个订单表早期有一个统计查询,逻辑不复杂:
SELECT order_id, user_id, status, pay_time FROM order_info WHERE user_id = 123456 AND status IN (1, 2, 3) ORDER BY pay_time DESC LIMIT 20;当时的索引只有 idx_user_id(user_id),EXPLAIN 出来 type=ref,看着挺正常,但 Extra 里没有 Using index,意味着每一条命中的订单都要回表。用户订单多的时候,一次查询就是几百次随机 IO。后来我把索引改成联合索引:
ALTER TABLE order_info ADD INDEX idx_user_status_pay (user_id, status, pay_time);再跑 EXPLAIN,Extra 里出现了 Using index,说明这个查询要的所有字段都能从二级索引上直接拿到,一次回表都不需要。查询耗时从 300 毫秒级别降到了 20 毫秒级别。这就是覆盖索引的威力:让查询所需字段“锁”在索引里,不给随机读留机会。
2.2 联合索引字段顺序怎么排才能不浪费
联合索引的顺序不能拍脑袋决定。索引本质上是一棵多列排序的树,排在后面的列只有在前面列相等时才能发挥作用。比如索引 (user_id, status, pay_time),它支持“user_id 等值 + status 等值 + pay_time 范围”这种条件完全走索引;但如果查询只带 status 不带 user_id,这个索引基本帮不上忙,因为最左边的 user_id 没有被约束住。
我的排法是遵循三条原则:第一,等值条件字段放最前面;第二,范围条件字段放在等值字段之后;第三,排序字段尽量放在索引的尾部,这样 ORDER BY 可以直接顺着索引顺序读,避免 filesort。
字段内部也有优先级。同一个联合索引里,如果两个字段都是等值查询条件,把区分度高的放前面。区分度可以用这个公式粗略估计:
SELECT COUNT(DISTINCT user_id) / COUNT(*) FROM order_info;比如 user_id 的区分度接近 0.8,status 只有 0.01,那显然 user_id 应排在前面,因为它能把扫描范围快速缩小。但如果业务查询永远带 user_id,status 在不在前面对单次查询影响不大,只是会影响索引的“复用价值”。
2.3 索引不是免费午餐:写入放大和冗余索引
索引越多查询越爽,这是新手阶段最容易掉的坑。我见过一张表被加上将近 20 个索引,结果每条 INSERT 都要往 20 棵索引树里各插一条记录。千万级数据量的表,写入路径本身就重,再把索引堆上去,高峰期插入直接卡到怀疑人生。
InnoDB 的二级索引插入还可能触发页分裂。索引页塞满后,新插入一条记录就要申请新页,把旧页的数据拆过去。表越大、索引越碎,页分裂越频繁。这就是为什么业务上“要给一个字段加索引”的时候,我总会要求先把已有索引列出来检查一遍。经常出现的情况是:之前为了 A 查询建了 (a, b),后来为了 B 查询又建了 (a, b, c) 或者 (a, c),实际上前者有很多场景是冗余的。判断冗余最直接的办法是查 sys.schema_unused_indexes,把长时间没人用的索引记录下来,再结合 EXPLAIN 的 key 列确认没有 SQL 误用,然后才敢删。
我的习惯是单表二级索引数量控制在 5~6 个以内,宁缺毋滥。这个数字不是硬标准,但它会逼着你认真思考每个索引的服务对象,而不是每遇到一条慢 SQL 就加一个新索引。
2.4 索引下推:一个容易被忽略的免费“截流”机制
MySQL 5.6 开始支持的索引下推(Index Condition Pushdown,ICP),在大表查询里经常被低估。简单说,在没有 ICP 之前,InnoDB 根据索引只能定位到最左边的一批记录,然后回表读取完整行,再在 Server 层过滤后面的条件。有了 ICP,引擎会在索引扫描阶段就把不满足条件的记录剔除掉,真正需要回表的行数大幅减少。
还是拿订单表举例,索引是 (user_id, status, pay_time),查询条件是 user_id = 123456 AND status IN (1,2,3) AND pay_time > '2024-01-01'。如果没有 ICP,引擎会把 user_id=123456 的所有订单都从二级索引找出来,再回表逐条看 status 是否符合;有 ICP 之后,status 和 pay_time 的过滤直接发生在索引扫描阶段,回表次数少了一个数量级。
这个机制不需要你写特殊 SQL,只要版本满足、优化器认为可行就会自动用。真正需要做的是:把可以下推的过滤条件尽量写成普通等值或范围条件,而不是用函数包裹字段,因为函数会打破 ICP 能利用的范围。
3. 慢 SQL 改写与深分页的重构实践
3.1 深分页为什么越翻越慢:offset 的代价
后端接口分页可以说是大表性能最集中的“翻车现场”。前端要第 50 万页,后端就写:
SELECT * FROM order_info WHERE status = 1 ORDER BY id LIMIT 1000000, 20;这条 SQL 看起来没什么问题,但道理很扎心:InnoDB 要从满足条件的记录里从第 1 行开始数,一直数到第 1000020 行,才能取出最后 20 行。前面那 100 万行虽然不会返回给客户端,但引擎仍然要扫描它们。等于你只是想看 20 条数据,数据库却先翻了 100 万条“废行”。
我优化这类分页,最优先的方案是改掉接口的翻页协议,用游标分页替代 offset 分页。把上一页最后一条记录的 id 作为下一页的起点:
SELECT * FROM order_info WHERE status = 1 AND id > 1280000 ORDER BY id LIMIT 20;这样引擎直接从 id=1280000 往后扫 20 行,复杂度从 O(offset) 降为 O(pageSize)。代价是跳页体验变差,用户不能直接点最后一页;但很多业务场景根本不需要无限制的深跳转,配合“上一页/下一页”按钮完全够用。
3.2 延迟关联:把回表和工作量一起降下来
如果产品就是要求支持任意页跳转,offset 分页不能彻底消失,那就要用延迟关联来改写。思路是先在一个覆盖索引上完成分页定位,只取出少量主键,再拿这些主键去关联原表取完整字段:
SELECT t.* FROM order_info t JOIN (SELECT id FROM order_info WHERE status = 1 ORDER BY id LIMIT 1000000, 20) tmp ON t.id = tmp.id;子查询的 SELECT id 和 WHERE status=1 都能用二级索引 idx_status_id 直接覆盖,不需要回表;它在 100 万行的索引扫描后只产出 20 个 id。外层 JOIN 再根据这 20 个主键去原表取完整行,回表次数只有 20 次。
对比原来的写法:原 SQL 是边扫边回表 100 万行,延迟关联是“索引扫 100 万行 + 20 次回表”。同样是扫 100 万行,但少了 100 万次随机读,效果是天壤之别。这个模式非常好用,尤其是在 MySQL 5.6 以上版本,子查询还能配合 ICP,进一步降低扫描成本。
3.3 ORDER BY、GROUP BY 的排序陷阱:filesort 和临时表
千万级表上做排序和聚合,是最容易让内存被打爆的场景。EXPLAIN 里 Extra 一旦出现 Using filesort,就意味着 MySQL 要额外开辟一块内存(或者超出内存后落到磁盘临时文件)来排序。排序的数据量一大,性能直接崩。
让排序走索引是最干净的解法。比如 WHERE user_id=? AND ORDER BY pay_time,索引 (user_id, pay_time) 就能让排序直接顺着索引顺序输出,Extra 里不会再出现 filesort。反过来,如果索引是 (user_id, status),ORDER BY pay_time,排序字段不在索引的有效序列里,MySQL 只能自己排。所以前面说的“排序字段放索引尾部”不是洁癖,而是实打实的性能策略。
GROUP BY 的本质也是先排序再分组,所以同样的思路适用。但坦白说,千万级表上频繁做 GROUP BY 聚合,无论怎么优化索引,都很难比“提前算好”更快。我习惯在业务层维护一张按天/按小时的汇总表,或者用一个计数器表保存热门维度的统计值。实时报表入口往往只查汇总表,几十毫秒出结果,把千万级表留给低频的对账任务去跑。
3.4 别让“小动作”变成全表扫描:隐式转换、函数包裹和锁放大
大表优化的很多收益,不是说换个大招,而是把那些看着人畜无害的“小动作”揪出来。隐式类型转换是我最常遇到的。比如 user_id 是 varchar 类型,SQL 里写 WHERE user_id = 123456,MySQL 会把字段转换成数字再去比较。对索引而言,字段本身被“处理”过了,快速定位能力就失效了,优化器只能老老实实全表扫。解决方法是让字段类型和查询参数类型完全一致,或者把查询参数写成字符串并确保没有转义问题。
函数包裹字段也类似。写 WHERE DATE(pay_time) = CURDATE() 看着很直观,但 pay_time 字段被 DATE 函数包住之后,索引上的有序性就用不上了。改成范围条件:
WHERE pay_time >= '2024-01-01 00:00:00' AND pay_time < '2024-01-02 00:00:00'才是让索引发挥作用的正确姿势。范围条件还能触发 ICP,进一步减少回表。
还有一个大坑是大范围的 UPDATE 和 DELETE。千万级表上执行 UPDATE order_info SET status=5 WHERE status=1,如果 status=1 的数据有几百万行,这条 SQL 不只是更新数据本身,还会因为二级索引维护而锁住大量间隙,产生 Gap Lock / Next-Key Lock,把周边写入全部堵死。我的处理方案是分批处理:先用主键 id 圈定一个小范围,比如每次取 5000 个 id,更新完提交后再取下一批。这样每条 SQL 锁的行数有限,主从延迟也更好控制。这个过程用 pt-archiver 或者自己写存储过程都可以,核心思路是“化整为零”。
4. 表结构与存储策略:字段、行格式和冷热分离
4.1 字段设计是把双刃剑:从主键到 varchar 都要较真
到了一千万行这个级别,字段设计已经没有“将就”的余地。主键我建议直接用 bigint 自增,或者业务 id 生成器生成的 bigint,而不是 varchar。为什么?因为 InnoDB 二级索引的叶子节点里都带着主键值,主键越长,每个二级索引页能容纳的记录越少,所有索引都会跟着膨胀。32 位 uuid 字符串做主键的订单表,索引体积可以轻松比普通 bigint 多出一倍以上。同理,普通字段也要克制:用户昵称设 varchar(64) 就够,别图省事给 255。varchar 的“最大长度”会影响索引记录在页内的占用,字段定多 3 倍,索引页装下的行数就少几成,查询扫描页数随之增加。
另外一个常见的雷是 NULL。建表时尽量就给 NOT NULL DEFAULT 值。NULL 字段在索引里处理起来更笨重,统计估算也容易偏掉,排序扫描都不太友好。更重要的是,业务上的 NULL 往往最后都会变成一堆“空值判断”的查询条件,这些条件天然不利于索引选择。
text、blob 这种大字段一定要慎防。如果订单备注必须存几十上百行文字,建议把它拆到独立的附件表/备注表,用 order_id 关联。主表查询的字段集合越短,单行数据越紧凑,回表和扫描成本都更低。这条规则在千万级表上尤其重要。
4.2 表分区对千万级数据大概率不是银弹
MySQL 的分区表经常被当成大表优化的首选,但我的真实体验是:分区表只在特定场景下才有价值,别把它当默认方案。分区最擅长的是时间序列数据,比如订单表按 pay_time 按月分区。这样业务查询如果总是带上“这个月”“上个月”这样的过滤条件,MySQL 可以做分区裁剪,只扫描对应分区;更关键的是,历史数据清理可以直接 DROP PARTITION,秒级完成,根本不用走 DELETE 再触发大量 binlog 和锁。
但如果你把千万级订单表按时间分区,而业务查询清一色按 user_id 先过滤,那么每次查询都需要去每个分区里跑一遍匹配,最后再把多个分区的结果合并。这比普通单表查询更慢,因为你把“一个有序结构”拆成了“多个无序结构”。更麻烦的是,分区键和索引的关系很微妙,主键要么包含分区键,要么很多操作会受限,维护起来比普通表难得多。
我现在的判断标准是:如果数据有明确的时间生命周期,且清理是刚需,分区值得考虑;如果表只是“大”而没有清晰的分区键,那就先做索引、SQL、归档,不要被 8192 个分区上限之类的话题带跑。
4.3 冷热分离是性价比最高的“优化”
在千万级表优化的所有手段里,冷热分离是投入产出比最高的。绝大多数业务表并不是所有数据都被频繁访问,比如订单表,真正高频查询的是最近一年甚至最近三个月的订单。几年前的历史订单,一个月可能才被翻一次,而且通常来自后台管理端。
我的做法是把订单表按时间切成两张物理表:一张是热表 order_info,只保留最近一年的数据;另一张是 order_info_archive 归档表,放更早的历史数据。迁移用 pt-archiver 分批搬,比如:
pt-archiver --source h=127.0.0.1,D=your_db,t=order_info \ --where "pay_time < '2023-01-01'" --limit 2000 --bulk-delete \ --dest h=127.0.0.1,D=his_db,t=order_info_archivelimit 2000 的意思是每批只搬 2000 行,搬完一批自动提交。这样每一条迁移 SQL 都是小事务,不会像一次性 DELETE 百万行那样把 undo 撑爆、把主从延迟干到几分钟。迁移之后,热表行数从 2600 万降到了 800 万,索引体积和缓冲池压力一下子小了很多。热查询的响应时间基本回到“秒回”状态。
应用层这边要跟着改。查询接口必须带时间范围,程序根据时间判断走热表还是归档表;如果实在带不了时间,可以在上层维护一个“是否热数据”的标记。千万级数据上我不推荐用视图跨两张表查,UNION ALL 的开销在量级上去之后反而更难受,应用层显式路由最可控。
4.4 在线 DDL 和元数据锁:动大表结构的正确姿势
索引、归档方案都定下来后,最终要执行 ALTER TABLE。千万别小看这一步,在千万级表上,一次不加参数的 ALTER 可能直接把业务写 hang 住。MySQL 5.7 里 ADD INDEX 默认是 INPLACE 算法,但是否允许并发 DML,取决于操作类型。最稳妥的是显式声明:
ALTER TABLE order_info ADD INDEX idx_pay_time(pay_time), ALGORITHM=INPLACE, LOCK=NONE;ALGORITHM=INPLACE 表示不复制整表数据,LOCK=NONE 表示执行过程中允许并发读写。即便如此,执行之前还要查一下有没有长事务在跑。因为 ALTER 在某个阶段必须拿元数据锁(MDL),如果前面有一个事务迟迟不提交,ALTER 就会排队等锁,后续所有查询和写入都会被挡在 MDL 之外。看起来像是“一条 ALTER 把库卡死了”,其实根源是长事务。
8.0 里有些加列操作可以用 INSTANT 算法,秒级完成,但并非所有 DDL 都支持。如果是超大表要改主键、改字段类型这类必须 rebuild 的操作,我建议用 pt-online-schema-change 或 gh-ost。它们的基本原理都是建一张新表,通过触发器或 binlog 同步增量,追平增量后做原子切换。以前我用 gh-ost 给 5000 万行的业务表改过一列字符集,全程没有出现锁等待,线上流量完全没受影响。把这个工具放进工具箱,是真的能救命。
5. 规模再往上走的架构取舍:读写分离与分库分表
5.1 别急着拆:先确认压力到底在哪一层
有一个现象我见过很多次:单表数据量涨到千万级、接口开始变慢,团队第一反应就是“上分库分表”。结果半年后发现,架构复杂度上去了,分布式事务一堆坑,问题却没解决多少。为什么?因为原表的问题是慢 SQL 扫全表、索引不合理、大表 JOIN 扫文件,这些东西拆成十张表也一样存在。
正确的顺序是先回答几个问题:QPS 和 TPS 是多少?读写比是什么?慢查询日志里占大头的是哪些 SQL?Buffer Pool 命中率多少?如果高并发读是主要矛盾,且所有慢点都能通过索引和 SQL 改写解决,那架构上一动不如一静。真正该去看读写分离和分库分表的信号是:单实例 CPU 已经持续 80% 以上,或者热点表的写入已经成为系统瓶颈,单纯优化 SQL 再怎么压都压不下来了。
5.2 读写分离不是无脑开:延迟和路由要处理
读写分离是千万级读多写少场景下的首选扩展方案。主库负责写入,一两个从库用 MySQL 原生复制同步数据,后台查询、报表、统计全打到从库去。这个方案成本低、收益明显,但也有坑。
最大的坑是主从延迟。MySQL 默认异步复制,主库提交完事务、从库可能还没执行完对应的 binlog。如果你的应用刚写完订单就去从库读,很可能读不到。规避办法有三种:一是对强一致性读的接口做强制主库路由;二是把关键写入之后的“立即读”改成小延迟等待;三是通过半同步复制或增强半同步把主从延迟压到极低。半同步也不是银弹,网络抖动时照样可能退化,所以我的原则是:能承受延迟的就走从库,不能承受的坚决走主库。
另一个细节是让从库专门为报表场景建更宽的索引,甚至建主库没有的复合索引。反正从库不承担写入压力,查询模式固定,索引怎么利于报表就怎么来。
5.3 分库分表:做之前先把分片键想明白
当单库单表确实顶不住,考虑分库分表时,我会先做三件功课,缺一件都别动手。第一件,确定分片键。分片键必须来自绝大多数查询的必经字段。订单表通常按 user_id 分或者按 order_id 分。按 user_id 分,查询必须带 user_id 才能定位到具体分片;不带 user_id 的查询就是强制全 shard 扫描,中间层把所有分片结果合并,复杂度直线上升。
第二件,设计全局唯一 id。分片之后自增 id 不能再用,常见方案是雪花算法或者号段生成器,保证各分片生成的 id 全局唯一且趋势递增。第三件,想清楚交易型数据怎么处理跨分片事务。两个用户不在同一个分片,一笔订单涉及双方账务变动,就会变成分布式事务。分布式事务的成本不是“多写两行代码”,而是引入协调器、补偿逻辑和一致性兜底,复杂度陡增。
我的建议是:分库分表是最后手段,是“优化型手术”。做之前,先把单表内部的索引、SQL、归档、读写分离全部榨干。一个千万级表用这些手段往往已经能顶住相当高的并发,真正需要分片的,通常是数据量大到一两天就产生千万行,或者单表索引已经无法支撑业务查询模式的极端场景。
5.4 优化不能一劳永逸:监控、容量预测和备份恢复
大表优化是持续的过程,做完一轮不等于以后都不用管。我最后总会把监控搭起来,至少保证慢查询日志不断采集、pt-query-digest 定期汇总、锁等待有告警。毕竟业务增长不会停,这周 2000 万行,半年后就可能是 5000 万行,性能基线随时会变。
容量预测也很简单,用前面建过的基线就能算:订单表日增 30 万行,平均行大小 1.1KB,一天约 330MB,一年约 120GB。那就要提前规划磁盘、Buffer Pool 和冷数据迁移周期。经验值是让 Buffer Pool 能装下热表全部索引和部分数据,至少 70% 以上,否则查询迟早要开始打磁盘。
最后提醒一句:备份恢复演练别省略。大表优化过程中,误删数据、迁移失败、gh-ost 切换异常,这些都属于“没遇到是侥幸,遇到就要命”的范畴。定期从备份库完整恢复一次,确认可用性,比任何性能指标都重要。
我在实际做这些优化时,最深的体会是把优先级排对:先看慢日志,再改 SQL,再调索引,再动表结构,最后才轮到读写分离和分库分表。这个顺序陪我从 2600 万行的订单表一路把接口压回秒级,也帮我躲过了很多次没必要的架构“手术”。如果你手头也有一张正在逼近千万级的表,不妨从今天把慢日志打开,先看清病根,再决定要不要动刀。