1.1 需求拆解:单库瓶颈与查询热点

下单、支付、查询、对账,四条业务线都压在同一张订单表上。拆分之前,我们花了两周做压力测试和链路追踪,只为回答一个问题:这张表到底是被「写」压垮的,还是被「查」压垮的。结论决定了后面所有的选型方向,这一节把观测过程和结论都摆出来。

先说结论:不是写崩的,是读崩的。写平均耗时 8 毫秒一直很稳,读却随着数据量增长越来越慢。核心原因在于 InnoDB 的读依赖 buffer pool 热数据,表越大热数据占比越低,磁盘随机读越多。理解这一点很重要——如果系统是写瓶颈(比如行锁过高、IO 写满),解法完全不同,盲目分库只会放大问题。

场景:一张表扛起四条业务线

订单主表 t_order 的流量画像非常典型:写多读也多,而且读法五花八门。C 端用户按订单号查详情、按用户 ID 查最近订单列表;B 端商家按店铺 ID 查待发货订单、按天统计 GMV;内部财务按支付时间范围拉流水、按渠道对账。高峰期 QPS 约 4200,其中写 1500、读 2700。

单库实例是 16 核 64G 的主从架构,表面上看 CPU 只到 65%,似乎还有余量。真正先撑不住的是三个地方:磁盘 IO、连接数和行锁等待。这三个指标不看监控根本发现不了,CPU 利用率是最容易误导人的数字。

观测到的瓶颈分布如下:

指标 峰值 告警阈值 瓶颈原因
磁盘 IO 利用率 97% 80% 冷数据多,buffer pool 命中率只有 82%
活跃连接数 2100 / 3000 2500 大量查询排队,慢查询挤占连接
行锁等待平均时长 480ms 100ms 高并发下单同用户锁竞争
慢查询(超 1s) 每分钟 340 条 50 范围查询扫行过多
主从延迟 最高 22s 3s 从库 apply 速度跟不上

观测手段上我们走过弯路。最初拿云监控的 CPU 曲线当主视图,后来才发现真正有信息量的是三个细粒度视图:IO 等待采样、行锁等待明细、慢查询排行榜。行锁明细要开 performance_schema 才能看到具体是哪些行在争抢,开启后会消耗约 3% 的性能,建议只在排查期临时打开。

观测手段 看什么 采样频率 开启代价
云监控大盘 QPS、连接数、IO 利用率 10 秒 无侵入
performance_schema 行锁等待与等待对象 按需开启 约 3% 性能损耗
慢查询日志 慢 SQL 明细与扫描行数 实时 IO 与磁盘占用
explain 加 trace 单条 SQL 的行扫描成本 手工执行 无
全链路追踪 一次接口背后的 SQL 条数 实时 约 2% 性能损耗

最有价值的发现来自慢查询排行榜:top10 里有 7 条是「按用户查最近订单列表」,扫描行数都在 3000 行以上。根因之一是深分页——用户翻到第 5000 页时,MySQL 要先扫过前 10 万行再丢弃,分库之后这个问题依然存在,治理办法放在 4.4 节索引优化。

方案:先治标,再决定拆不拆

拿到这组数据后,团队先做了一轮「不拆库能做什么」的评估,这很重要:如果优化就能扛住,就不要急着上分库分表。我们做了四件事,每件都记录了实测效果。

手段 实施内容 实测效果 结论
索引治理 补建 6 个缺失联合索引,删 3 个冗余索引 慢查询降 55% 见效快但治标
冷数据归档 把 1 年前订单迁到历史表 主表降到 9000 万行 有效,需要常态化
读写分离 报表与对账流量切从库 主库连接降 30% 从库延迟拖后腿
分库分表 按订单号哈希分 16 片 慢查询降到每分钟 6 条 最终方案

前三步都只是把危机推后。主表降到 9000 万行之后,按月增量仍有 2400 万行,半年就会回到原点。而且商家的范围统计在任何单库上都不会变快——这是数据量本身的问题,必须靠拆分解决。更多数据量级的治理思路可以参考 Java 实战:订单系统 中的压测章节,那本书记录了同一业务在单体架构下的极限。

配置:瓶颈观测的最小化脚本

判断瓶颈类型不需要复杂工具,一段持续十分钟的采样脚本就够了。核心是看 IO 等待和行锁,而不是 CPU:

-- 每隔 10 秒采一次,连续采样 60 组
SHOW GLOBAL STATUS LIKE 'Innodb_buffer_pool_read_requests';
SHOW GLOBAL STATUS LIKE 'Innodb_buffer_pool_reads';
SHOW GLOBAL STATUS LIKE 'Threads_running';
SHOW GLOBAL STATUS LIKE 'Innodb_row_lock_time_avg';

buffer pool 命中率用这个公式算:1 减去 Innodb_buffer_pool_reads 与 Innodb_buffer_pool_read_requests 的比值。我们第一次采样算出来 82%,远低于健康线 95%,说明大量读请求落在磁盘上——这是表太大、热数据放不进内存的直接证据。

锁等待则直接看 Innodb_row_lock_time_avg,480 毫秒意味着同一用户的并发下单在排队。如果是这个数字偏高,分库分表之前先考虑把同一用户的写串行化,否则拆了也白拆。

下面是我们用来定位「到底哪条 SQL 吃掉了 IO」的查询,按扫描行数倒序取 top10,比单纯看执行时间更准:

SELECT LEFT(argument, 120) AS sql_head,
       rows_examined,
       rows_sent,
       query_time
FROM mysql.slow_log
WHERE start_time > NOW() - INTERVAL 10 MINUTE
ORDER BY rows_examined DESC
LIMIT 10;

用扫描行数而不是耗时排序,是因为 IO 瓶颈期的慢 SQL 往往不是最久的(锁等待才最久),但扫行最多的那条一定在持续吃 IO。排第一的永远是「按用户 ID 查最近订单列表」,这也直接印证了后面按订单号做分片键的决定。

踩坑:被 CPU 利用率骗了半年

最大的坑是只盯 CPU。我们曾经认为「CPU 才 65% 说明没到瓶颈」,于是继续往单库上加业务。实际上 MySQL 的很多等待并不消耗 CPU:IO 等待、锁等待、网络等待都让线程处于休眠状态,CPU 自然不高。后来我们把告警维度改成 IO 利用率加行锁时长加慢查询条数三件套,才真正看清系统水位。

第二个坑是低估了范围查询的破坏力。WHERE pay_time BETWEEN ... AND ... 这类语句在 2 亿行的表上,即使有索引,也要回表扫几十万行。这类查询是后来决定「冷热分离必须做」的直接原因,也就是为什么 4.2 节要专门写归档到 COS。

第三个坑是压测环境与生产环境的数据分布不同。压测库里数据是均匀生成的,没有「大商家」和「爆款商品」,上线后真实流量一过来,某几个商家的订单占了单日订单量的 15%,单表上的热点比压测严重得多。后来压测脚本专门加入了帕累托分布的热点数据模拟,压测结论才真正可用。

小结

瓶颈观测先于拆分方案:IO 利用率、行锁等待、慢查询条数三个指标比 CPU 更能说明问题。治标手段(索引、归档、读写分离)应当先做完,既验证了优化空间,也给拆分争取了时间窗口。确认数据量是根因后,再进入下一步——选型对比。一句话总结这一节的判断标准:如果你的慢查询 top10 全是范围统计、加索引只能管一两个月,说明数据量已经是根因,分库分表提上日程;如果加一条索引慢查询就消失,别折腾。

照着做的操作清单:

步骤 动作 完成标志
1 采集 IO、行锁、慢查询三组指标一周 三条曲线都有完整峰值
2 按扫描行数排慢查询 top10 找出占比最高的查询模式
3 做一轮索引治理加冷数据归档 慢查询降幅不足 50% 才继续
4 汇总结论进选型评审 输出瓶颈类型与根因判断

延伸阅读