先说背景,不看背景的优化都是耍流氓
表叫 t_order,2021年上线的订单中心主表,到今年3月我接手的时候是 2.73亿行,数据文件186GB,算上索引总共240GB。机器是阿里云 ecs.g7.8xlarge,32核64G,ESSD PL2 云盘,标称5万随机IOPS。MySQL 8.0.32,InnoDB,之前有人把 innodb_buffer_pool_size 调到了 32G,其他基本默认。
业务是典型的订单查询:按用户ID查订单列表,按状态过滤,按时间倒序。读写比大概 8:2,写主要是状态流转的 UPDATE,一次改一行。压力最大的那个接口,平均响应 340ms,P99 直接干到 2.1 秒,监控每天凌晨都报警,产品经理在群里 @ 我三次。团队当时的结论很统一:分库分表,上 ShardingSphere,按 user_id 哈希拆 16 片。
我没同意。不是因为分表不好,是因为我翻了三天慢查询日志之后,觉得这个锅分表背不动。
第一步:先算一下 B+ 树到底能装多少行
"单表超过500万就要分"这句话,我大概在2013年的技术博客上就见过,它对应的场景是机械硬盘、7200转、寻道时间8ms的时代。放在NVMe SSD上,这个数字没有意义。
InnoDB 的页默认 16KB,扣掉页头页尾和一些保留字段,实际可用大概 16200 字节。非叶子节点存的是「主键 + 页号」,我们的主键是 bigint,8字节,页号在 InnoDB 里占 6 字节(源码里是 FIL_PAGE_OFFSET,4字节的 space id 加上 4字节的 page no,实际压缩后差不多),一条记录 14 字节左右。16200 / 14 ≈ 1150。也就是说两层的 B+ 树能索引 1150 行,三层能索引 1150 × 1150 × 1150 ≈ 15.2 亿行。
2.73亿行,B+ 树高度还是 3。走主键查一行,还是 3 次页访问,前两层几乎必然在 buffer pool 里。所以真正的问题从来不是"行数",而是:
- 二级索引回表产生的随机 IO
- 排序、临时表、filesort
- buffer pool 命中率不够导致的物理读
行数只是一个替罪羊。
第二步:把那个慢查询扒干净
核心 SQL 长这样,这是压测里跑得最多的:
SELECT order_id, order_no, amount, status, created_at
FROM t_order
WHERE user_id = 12345678
AND status = 1
ORDER BY created_at DESC
LIMIT 20;
当时表上只有两个索引:主键 id,和一个 idx_user(user_id)。EXPLAIN 的输出我记得很清楚:
type: ref
key: idx_user
rows: 8462
Extra: Using where; Using filesort
翻译成人话:这个用户历史上一共有一万八千多单,优化器估计要扫 8462 行。执行过程是——从 idx_user 里把这八千多个主键全捞出来,挨个回表读聚簇索引,拿到 status 和 created_at 之后过滤、排序,最后取前 20 条。
八千多次随机回表,加上一次 filesort。这就是那 2.1 秒的来源。
我加了新索引:
ALTER TABLE t_order
ADD INDEX idx_user_status_ct (user_id, status, created_at, order_no, amount);
这里有个细节值得说一下。字段顺序是精心排的:user_id 等值、status 等值、created_at 排序,前三个决定了能不能用索引顺序直接满足 ORDER BY,避免 filesort。后面两个 order_no 和 amount 纯粹是为了覆盖——让查询完全走二级索引,不回表。
为什么没显式加 order_id?因为 InnoDB 的二级索引叶子节点本身就带主键,SELECT order_id 可以直接拿到。这是个很多人不知道的点,白白浪费索引空间。
加完之后 EXPLAIN 变成 Extra: Using index,rows 从 8462 掉到 21(用户实际只查了20条),P99 从 2100ms 掉到 18ms。
第三步:线上加索引这件事,比想象中刺激
2.73亿行的表,直接 ALTER 是要命的。MySQL 8.0 虽然支持在线 DDL,但加二级索引这个操作,InnoDB 内部还是要做一次全表扫描 + 排序建索引,期间会占大量 IO 和临时空间。
我们第一次直接在从库上试,跑了 47分钟,产生了大概 60GB 的临时文件。中间 IO 打满,从库延迟涨到 800 秒以上。这个不能上主库。
后来用的 gh-ost,加 --max-load=Threads_running=40 和 --critical-load=Threads_running=80,把速度压下来,跑了 2小时15分钟 才切完。切表那一下大概有 1.2 秒的短暂阻塞,我们在凌晨2点做的,业务无感知。
顺带一提,加完索引别忘了 ANALYZE TABLE。InnoDB 的统计信息默认是持久化的(innodb_stats_persistent=ON),抽样页数是 innodb_stats_persistent_sample_pages=20。对2.7亿行的表,20页抽样太少了,优化器很容易选错索引。我们把它调到 200,ANALYZE 跑了大概 90 秒,之后执行计划才稳定下来。
第四步:数据库改完了,QPS 只涨到 2400,因为锅在应用层
索引上线之后单条 SQL 是快了,但压测 QPS 卡在 2400 上不去。CPU 使用率只有 40%,磁盘 IO 也不高。这种"哪里都不忙但就是上不去"的情况,八成是连接池。
查了一圈,HikariCP 的 maximumPoolSize 配的是 20。对,20个连接。32核的数据库,被20个连接卡住了。这里有个常见的误区:很多人以为连接数越多越好,其实不是。HikariCP 官方给的经验公式是 connections = ((core_count * 2) + effective_spindle_count),我们是32核,SSD 当作1个盘算,理论值65左右。我调到 64,QPS 直接翻到 5800。
同时改了 MySQL 侧的几个参数:
innodb_buffer_pool_size:32G → 40G。别给太多,操作系统还要留内存做文件缓存和连接开销,64G的机器给到40G是安全线,超过45G风险就大了。innodb_buffer_pool_instances:默认8,保持不动。每个实例1G以上是合理的。innodb_io_capacity:默认200,这是给机械盘准备的。ESSD PL2 我们设成 4000,innodb_io_capacity_max=8000。innodb_flush_neighbors:默认是1,把它设成 0。SSD 上根本不需要把相邻页一起刷,这个参数在机械盘时代才有意义。
改完这些,QPS 到了 7800。
第五步:深分页这个老妖怪
还有一个问题是翻页。运营后台要导出订单,SQL 是 LIMIT 1000000, 20 这种。这种写法 MySQL 会先扫 1000020 行,丢掉前100万行再返回。别说 2.7亿行,200万行的表都能给你跑挂。
两种改法。第一种是延迟关联:
SELECT t.* FROM t_order t
JOIN (SELECT id FROM t_order
WHERE user_id = ? AND status = 1
ORDER BY created_at DESC
LIMIT 1000000, 20) x
ON t.id = x.id;
让子查询走覆盖索引,不回表,只取20个 id 再去回表。
第二种更彻底,游标分页,用行值比较:
SELECT * FROM t_order
WHERE user_id = ? AND status = 1
AND (created_at, order_id) < ('2024-03-01 10:00:00', 88776655)
ORDER BY created_at DESC, order_id DESC
LIMIT 20;
注意这里排序字段要加 order_id 保证唯一,否则同一时间戳的订单会漏。这个细节我在第一次上线时忘了,被测试同学抓出来一个重复订单的 bug,挺丢人的。
改完深分页,QPS 稳定在 11200,P99 稳定在 18ms 以内。
最后:为什么我还是没上分库分表
坦白讲,分库分表解决的是「单机写能力上限」和「单机存储容量上限」这两个问题,不是「单表行数」问题。这张表一天新增订单大概80万行,写入 TPS 峰值不到 900,离单机 MySQL 的写入瓶颈(NVMe SSD 上跑到 1.5万 TPS 不成问题)差得远。存储 240GB,单盘随便放。
如果我当时按 user_id 拆了16个片,会发生什么?
跨片查询全部要改写。运营要按时间范围统计订单量,ShardingSphere 会广播到16个分片再聚合,16次查询串起来,延迟反而比不解剖的单表扫还高。原本一条 GROUP BY status 的 SQL,现在要写成一堆 UNION ALL。扩容的时候要 resharding,2.7亿行重新分布,那才是真的灾难。
分表不是免费的。它把数据库的问题,变成了应用层的问题、运维的问题、还有半夜三点数据对不上的问题。
顺便说说另外两个选项。PostgreSQL 在这类场景有个杀招叫部分索引:CREATE INDEX idx ON t_order(user_id, created_at) WHERE status = 1,索引体积能小一大截。但它的 autovacuum 在2.7亿行 + 高频 UPDATE 的表上是个长期麻烦,dead tuple 膨胀起来要你不断调 autovacuum_vacuum_scale_factor,运维心智负担不低。TiDB 我也评估过,三节点起步的机器成本先不说,点查延迟在 5-8ms 量级,比 MySQL 的 0.5ms 高一个数量级,我们的场景是高频点查,不划算。
什么情况下我才会真的去分表?三个条件同时满足:单机写入 IOPS 长期打满(不是峰值,是长期)、单表数据文件超过单盘容量、业务上确实能把分片键设计成永远不跨片。这三条我们一条都不占。
所以我的结论可能有点反共识:大多数喊着要分库分表的团队,其实是索引没建对、连接池没调好、分页没写对。 先把这三件事做完,再谈分表。
(后来那张表的 idx_user_status_ct 索引占了 42GB 空间,比原来大了一倍多,但我觉得值。)