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 | 汇总结论进选型评审 | 输出瓶颈类型与根因判断 |
延伸阅读
- Java 实战:订单系统:单体架构下同一业务的压力测试记录
- Go 实战:IM 系统:长连接服务的连接数治理与分库前的系统降压
- MySQL 实战:分库分表订单系统:回到本书目录,继续 1.2 选型对比
