MySQL 单表 4200 万行,我没同意分表:分区、分表、归档四条路我自己压了一遍

🔑 关键词:MySQL分表,单表数据量上限,MySQL分区表,覆盖索引,数据归档

📖 摘要:一张 4200 万行的订单明细表,接口 P99 从 78ms 涨到 1.9s。我把单表加索引、RANGE 分区、哈希分表、历史归档四种做法在 4C8G 的机器上跑了一遍,点查、范围查、聚合、写入 TPS 的数字都记下来了,也说说为什么 2024 年分表被用滥了。

起因:一张 4200 万行的表,P99 从 78ms 涨到 1.9s

图片

去年年底帮一家做 B2B 建材的公司看问题。他们有张 order_item 表,4200 万行,data_length 6.8GB,跑在一台 4C8G 的阿里云 ECS 上,MySQL 8.0.32,ESSD PL1 云盘。有天早上监控报警,一个「查某客户近 30 天订单明细」的接口 P99 从 78ms 涨到 1.9s。会上同事直接说:分表吧,阿里手册上写着单表 500 万行就该分了。

我没同意。那句话出自《Java 开发手册》,原文是「单表行数超过 500 万行或者单表容量超过 2GB,才推荐进行分库分表」——注意是「才推荐」,而且它默认的语境是高并发交易系统。你一张 4200 万行的表,QPS 200,每天新增 3 万行,分表带来的收益很可能还不如加一个覆盖索引。

我先用 EXPLAIN ANALYZE(MySQL 8.0.18 之后才有,之前只能靠 EXPLAIN 加 SHOW PROFILE)看执行计划。索引是 (user_id, create_time),走的 range,预估扫 142 万行,filtered 只有 0.7%。问题不在行数,在于查询里还有 status IN (1,2) 和 shop_id = ?,MySQL 得拿这 142 万个主键去回表,再逐行过滤。1.9s 就是这么来的,全是随机 IO。

图片

加了联合覆盖索引 (user_id, create_time, status, shop_id, amount) 之后,Extra 里出现了 Using index,不用回表了,P99 掉到 12ms。整件事花了 40 分钟,其中 25 分钟在等索引建完。

真到了要选方案,我把四条路都压了一遍

为了给团队一个能落到纸面上的结论,我搭了台同配置的机器(4C8G、ESSD PL1、innodb_buffer_pool_size=4G),把四种做法都跑了一遍。表还是 4200 万行,四个字段两个索引,数据是内部导出脱敏的。

图片

  • A 方案:单表加覆盖索引,什么都不动
  • B 方案:按 create_time 做 RANGE 分区,12 个月分区
  • C 方案:按 user_id 哈希分 16 张表,应用层路由
  • D 方案:归档,两年前的 3800 万行挪到 order_item_history,热表剩下 400 万行

结果比我预想的更有意思。主键点查:A 0.8ms / B 0.7ms / C 0.9ms / D 0.6ms,基本没差,分区和分表反而多了一层路由开销。单用户 30 天窗口(约 4.2 万行):A 12ms / B 11ms / C 14ms / D 9ms,还是没拉开。真正拉开差距的是全表聚合,SELECT shop_id, SUM(amount) ... GROUP BY shop_id 扫 30 天数据:A 41s,B 3.1s(分区裁剪起作用了),C 2.6s,D 0.4s。

写入这边:A 8500 TPS,B 7200 TPS,C 9100 TPS,D 8800 TPS。分区表写入反而最慢,因为它每插一行都要算一遍分区函数,而且分区表的二级索引是本地索引,ORDER BY 不带分区键时要额外做归并排序。

顺带说个坑,我认识的三个人第一次上分区表都栽在同一个地方:MySQL 要求分区键必须包含在每个唯一索引里。你主键是 id,想按 create_time 分区,就必须把主键改成 (id, create_time)。改主键在线上表上意味着一次全表重建,这个成本很多人做方案评审的时候是没算进去的。

图片

我的偏心观点:分表在最该用的时候没用,在不该用的时候被用烂了

这几年我看到的实际情况是,真需要分表的场景(写入 QPS 过万、单表十亿行、纯 append-only)反而在天天抱怨跨分片查询和分布式事务;而那些一天几万写、P99 三位数毫秒的业务,天天在琢磨怎么分表。这个错位挺讽刺的。

我个人的粗暴经验值是这样:单表 2000 万行以内,只要你的查询是「高选择性条件 + 少量返回行」,InnoDB 完全扛得住。这个 2000 万不是拍脑袋,按 16KB 页、bigint 主键(8 字节)+ 页号(6 字节)算,非叶子节点一页能塞 1170 个指针;叶子节点假设一行 1KB 放 16 行,三层树能装 1170 × 1170 × 16 ≈ 2190 万行。超了之后树从 3 层变 4 层,多一次 IO——就一次,不是断崖。

图片

比行数更值得你花时间的,是另外三件事。第一,统计信息:innodb_stats_persistent_sample_pages 默认是 20,对大表来说采样太少,优化器经常选错索引,我把它调到 200 再跑一次 ANALYZE TABLE,见过查询从 3.2s 掉到 90ms 的。第二,索引是按 WHERE 写的还是按整个查询写的,回表才是真正的大头。第三,长事务,一个跑了两小时的定时任务能让 undo 表空间涨到 40GB,然后所有人开始怪「数据量太大了」。

真要动手,我的归档脚本大概长这样

先说工具。2024 年我不太推荐无脑上 pt-archiver 了,pt-toolkit 这两年维护很冷清,Percona 在 3.5.5 之后动作很少;gh-ost 的热度也在掉,作者 Shlomi Noach 去了 PlanetScale,现在在线 DDL 这块 Vitess 的 vreplication 反而更活跃。你要是本来就在用 Percona 全家桶,继续用没问题,但别指望它再有什么新特性。

图片

自己写归档脚本,我的参数是这样:每批 2000 行,批间 sleep 0.5,先 INSERT INTO order_item_history SELECT ... WHERE create_time < '2022-01-01' ORDER BY id LIMIT 2000,确认写入条数对得上再 DELETE。主从延迟超过 3 秒就暂停循环——注意别只用 Seconds_Behind_Master 判断,它在并行复制下不准,而且这个字段和 SHOW SLAVE STATUS 本身在 8.0.22 之后就已经被标记废弃了,8.4 里直接换成了 SHOW REPLICA STATUS,配合 performance_schema.replication_applier_status_by_worker 看会更靠谱。

还有一点,归档前先确认没有业务在读历史数据。我见过归档完三个月,财务系统才跑来问去年的对账单怎么查不到了。这种事在评审会上多问一句,比事后补数据省事得多。

至于什么时候我确实会建议分表:写入 QPS 稳定超过 8000 并且还在涨、单表超过 5 亿行、或者有明显冷热时间边界(只查最近 7 天)。这三个条件满足两个,再谈分表不迟。

🏷️ 标签: