1. 巡检事件 dballgts01e19-2 的第一个线索:慢查询不是偶发而是规则性翻车
晚上 22 点 17 分,告警平台弹出一条由巡检模块自动生成的事件编号:dballgts01e19-2。这种编号第一眼像乱码,但读多了就有规律:db 表示数据库组件,all 表示全链路检查,后面的 gts01e19-2 是时间窗口加批次号。它没有对应的用户投诉工单,说明不是交易主链路炸了,而是某个数据库请求已经连续一段时间超过慢查询红线。
再往前翻监控,业务侧的现象很清楚:商家后台里“订单查询”页接口的 P99 从平时的 600ms 涨到了 3.2s,单量没有明显波动,数据库 CPU 也不高,没有锁等待,没有连接数打满。这种表现最容易让人误判成“网络问题”或者“第三方服务抖动”,但巡检模块既然把前缀打成了 db,问题大概率还是在 MySQL 这一层。
我在慢查询日志里找到了对应的语句,形态大概是这样:
sql复制SELECT id, merchant_id, order_no, pay_amount, order_status, create_time
FROM t_order
WHERE merchant_id = 899001
AND order_status = 2
AND create_time >= '2024-06-01 00:00:00'
AND create_time < '2024-06-30 00:00:00'
ORDER BY create_time DESC
LIMIT 20 OFFSET 0;
这是一条很典型的“商户端订单列表”查询:商户登录后只看自己的订单,按时间倒序分页。order_status = 2 表示只查已支付状态,create_time 限制在一个月内。从业务逻辑看,它不复杂。
表 t_order 当时的规模是 3180 万行,InnoDB 存储引擎,单表数据量在 70GB 左右。表上已有的索引包括主键 id、idx_merchant_id(merchant_id) 和 idx_create_time(create_time)。看到这里,很多人第一反应就是加索引,但我建议先把“为什么这么慢”查清楚,再动手。
1.1 巡检编号为什么能直接定位到数据库
dballgts01e19-2 中的 gts 不是某个中间件名称,而是巡检平台自动生成的时间戳序列。它出现一次,意味着巡检任务在 1 到 1.5 秒的采样窗口里发现这条 SQL 执行了比较长的时间。
第一次定位时,不要只盯一条 SQL。要先问三个问题:慢查询是不是只在高峰期出现?是不是某个商户的订单量异常大?是不是最近上线改过代码或者索引?这三个问题决定了后续优化的方向。
回到这个 case:巡检开始时,慢查询日志里每分钟有 20 到 40 条类似 SQL,而且都集中在 merchant_id = 899001 和几个大商户上。显然不是偶发,是同一类查询在高峰期稳定变慢。
1.2 查询代码里的“分页参数”成了噪音
这里有个容易被忽略的细节:代码里实际传入的 create_time 范围是根据页面上的默认筛选条件拼出来的。如果用户没有手动选时间,前端会默认传最近一个月。一个月对中小商户来说可能只有几千条订单,但对 899001 这种高量级商户来说,单月订单量接近 6 万多条,再加上 order_status 过滤,一次查询要处理的数据量完全不同。
也就是说,这条 SQL 不是“每页都慢”,而是“大商户 + 默认大时间范围”才慢。优化前必须把这个条件固定下来,否则你在测试环境只拿一个 1000 行的小表验证,永远复现不了生产问题。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 先别急着加索引:我是按这四步把问题现场还原出来的
一旦确认是慢查询,很多同学会直接执行 EXPLAIN,看一眼 possible_keys 和 key 就开始写 ALTER TABLE。但执行计划只能反映优化器对当前统计信息的判断,不能反映真实扫描行数、回表次数和排序成本。我这次没有急着加索引,而是先按下面的顺序把现场还原完整。
2.1 先看慢日志中的 rows_examined,而不是只看耗时
慢查询日志里最关键的字段不是 Query_time,而是 Rows_examined 和 Rows_sent。如果 Rows_sent 只有 20,但 Rows_examined 有几十万,说明大部分成本花在“找数据”上;如果两个值都很大,说明业务取数逻辑有问题,可能需要改成批量查询。
日志里这组值让我警觉:Rows_examined: 286142,Rows_sent: 20。也就是说,为了返回第一页 20 条数据,MySQL 扫描了 28 万行。这个比例非常不健康,几乎可以确定是索引选择出了问题。
2.2 确认是否有锁等待和长事务干扰
在优化索引之前,先排除锁的因素。当时查了 SHOW ENGINE INNODB STATUS,没有看到明显的锁等待;information_schema.innodb_trx 里也没有长时间未提交的事务。但我不建议只看一次,慢查询问题经常是偶发的,要多看几个时间点,尤其要看是否存在“批量更新任务与查询叠加”的情况。
我顺手看了数据库的 CPU、IOPS 和活跃会话数。CPU 只有 30%,IO 也不高,说明问题不是硬件瓶颈,而是单条 SQL 的扫描路径太长。这种场景下加一个合适的索引是最直接的解法。
2.3 用真实参数跑一遍 EXPLAIN,而不是用变量名跑
优化 SQL 前有一个非常重要但总被忽略的原则:要让优化器拿到真实的字段值。很多 ORM 日志里的 SQL 是 WHERE merchant_id = ? AND order_status = ?,你直接拿带 ? 的语句去 EXPLAIN,得到的 rows 是估算值,甚至可能因为不知道具体参数而放弃某个索引。
正确的做法是把慢日志里那几条实际 SQL 补上真实参数,再执行:
sql复制EXPLAIN
SELECT id, merchant_id, order_no, pay_amount, order_status, create_time
FROM t_order
WHERE merchant_id = 899001
AND order_status = 2
AND create_time >= '2024-06-01 00:00:00'
AND create_time < '2024-06-30 00:00:00'
ORDER BY create_time DESC
LIMIT 20 OFFSET 0;
当时的执行计划简化如下:
| 列名 | 值 |
|---|---|
| type | range |
| possible_keys | idx_create_time, idx_merchant_id |
| key | idx_create_time |
| rows | 284235 |
| filtered | 0.03 |
| Extra | Using index condition; Using where |
注意 Extra 里没有 Using filesort,这一点非常迷惑人。它之所以选 idx_create_time,是因为查询需要 ORDER BY create_time DESC,而 idx_create_time 天然有序,优化器认为可以避免排序。但这个选择付出的代价是:它把整个 6 月里所有商户的订单都当成扫描范围,再逐条过滤出 merchant_id = 899001 和 order_status = 2 的数据。
filtered 0.03 代表优化器认为最终返回的数据只占扫描结果的极小比例。实际情况也确实如此,等于用 28 万次索引扫描换 20 条结果,当然慢。
2.4 再对比另一个可能索引:为什么不走 idx_merchant_id
如果走 idx_merchant_id,先按 merchant_id = 899001 找到目标商户的所有订单,再把 order_status = 2 和 create_time 范围过滤掉,最后排序。问题是,899001 这个商户在一个月内的订单量本身就有 6 万多条,过滤完之后也有几万条需要临时排序。优化器在成本估算里认为 filesort 的成本更高,所以放弃了这条路径。
这个选择本身没有绝对的对错,但在分页查询场景下,真正的痛点不是“排序本身”,而是“为了排序把过滤条件放到了太后面”。理想状态是:先用等值条件把范围缩得很小,再在缩小的范围里按时间倒序取前 20 条,而不是全局按时间扫。
3. 看懂执行计划里的排序黑洞:为什么旧索引看着能用,实际却在拖垮接口
很多人看到 EXPLAIN 里 type=range、key=idx_create_time、Extra 里没有 filesort,会觉得这条 SQL 没什么问题。这是最大的误区。
3.1 排序没有消失,只是被“索引顺序”替代了
当 ORDER BY create_time DESC 和索引 idx_create_time 的顺序一致时,MySQL 不需要在内存或磁盘里额外做 filesort,因为从 idx_create_time 的叶子节点一路往回读,拿到的数据已经是有序的。
但这不代表排序成本消失了,它只是被转移成了一长串索引扫描。类似你去图书馆按楼层找一本特定作者的书,如果所有书都按时间顺序排列,你可以从最新日期开始一本一本翻;翻到第 20 本目标作者的书之前,可能已经把几十本无关的书都过了一遍。如果这个作者的书分布很散,翻的册数会非常惊人。
merchant_id = 899001 在整张表里占比并不高。优化器选择按时间倒序扫描时,为了凑齐这个商户最近一个月的第一页 20 条订单,可能需要扫描大量其他商户的订单。慢日志里的 Rows_examined: 286142 已经说明实际情况确实如此。
3.2 如果强制走 idx_merchant_id 会怎样
有时候为了快速验证,可以在 SQL 里加 FORCE INDEX(idx_merchant_id) 再跑一次。但注意,FORCE INDEX 只能在排障时用来验证猜想,不能直接作为长期方案。
加上之后,执行计划的 key 变成 idx_merchant_id,rows 降到了 6.1 万左右,但 Extra 里出现了 Using filesort。实际执行耗时并没有显著下降,主要是因为 899001 这个商户一个月内的订单量太大,即使过滤完还要对一个较大的结果集做降序排序。
所以这个 case 不是“一个索引能解决”的简单问题,而是要设计一个能同时满足过滤、排序、分页三个条件的复合索引。
3.3 到底为什么不能只加一个普通单列索引
单列索引进不了联合索引的“组合优势”:
idx_create_time:解决排序,但过滤 merchant_id/order_status 不够高效。idx_merchant_id:解决商户过滤,但排序成本高。idx_order_status:状态字段区分度太低,基本没有优化价值。
设计复合索引的顺序时,通常把等值查询字段放前面,把范围字段和排序字段放在后面。这里等值字段是 merchant_id 和 order_status,范围/排序字段是 create_time,所以最终索引设计是:
sql复制ALTER TABLE t_order
ADD INDEX idx_merchant_status_time (merchant_id, order_status, create_time);
加完索引后再看执行计划:
| 列名 | 值 |
|---|---|
| type | range |
| key | idx_merchant_status_time |
| rows | 3200 |
| Extra | Using index condition |
rows 从 28 万降到了 3000 多,实际查询耗时从 2.6s 降到了 50ms 以内。原因是 MySQL 可以先用 merchant_id = 899001 AND order_status = 2 定位到商户的已支付订单段,再在这个段内按 create_time 做范围扫描。因为复合索引的叶子节点已经按 (merchant_id, order_status, create_time) 排序,所以倒序取前 20 条同样不需要额外 filesort。
4. 根治方案:复合索引的列顺序怎么排,以及上线前后到底快了多少
索引上线不能直接在生产库上执行一条 ALTER TABLE 就完事。3000 万行的表加索引,即使 MySQL 支持在线 DDL,也要考虑排序资源、磁盘 I/O 和主从延迟。我对这个 case 的处理流程如下。
4.1 先用小表验证索引顺序,再讨论是否拆旧索引
当时我先在一台从库上基于一份脱敏数据建了一个 1/100 大小的表,模拟加索引前后的执行计划,确认 idx_merchant_status_time 的顺序是 (merchant_id, order_status, create_time),而不是 (order_status, merchant_id, create_time)。
这里有一个很实用的经验:等值条件越能缩小数据范围,越应该放在索引前面。merchant_id 的区分度远高于 order_status,所以它必须在 order_status 前面。如果把 order_status 放在第一个,MySQL 会先撞上一个区分度很低的状态值,扫描范围明显变大,性能不会达到预期。
至于旧索引要不要删,要看还有没有其他查询依赖。idx_create_time 可能被别的统计任务使用,idx_merchant_id 也可能被其他按商户查询且不含状态的场景使用。生产环境保留一个多余索引的成本不只是存储,还有每次写入时的索引维护开销。我的建议是分两次变更:先把新索引加上,观察两周,再和业务方确认旧索引是否无人使用,最后再删除。
4.2 大表加索引的在线变更方式
3000 万行的表直接用 ALTER TABLE ADD INDEX,在 MySQL 8.0 里虽然可以 ALGORITHM=INPLACE,但仍然会在构建索引期间占用不小的 I/O 和临时空间。更稳妥的做法是使用 pt-online-schema-change 这类工具,避免长时间锁写。
我这次用的是低峰期执行,并把 lock_wait_timeout 调成了 15 秒,避免因为等待 MDL 锁直接把业务请求阻塞住。加索引预计用时 18 分钟,但实际线上执行了 24 分钟。原因是这个表当天正好有一个批量更新任务在扫数据,虽然没阻塞,但额外占用了 I/O。
如果你们的表更大,建议分几步:先在从库加索引,确认没有主从延迟,再切主;或者在业务低峰期用 gh-ost 这类在线工具,分批拷贝数据,尽量缩短不可用窗口。
4.3 上线前后对比数据
优化后我在第二天同一时间窗口重新看了监控,数据对比如下:
| 指标 | 优化前 | 优化后 |
|---|---|---|
| P99 耗时 | 3.2s | 68ms |
| 每秒调用次数 | 42 | 41 |
| 单次扫描行数 | 28.6 万 | 3210 |
| 是否出现 filesort | 否 | 否 |
| 回表次数 | 高 | 低,基本无额外回表 |
这里还有个小提示:新索引并不是所有场景都比旧索引快。比如某些后台分析师会按 create_time 做全表统计,不走商户条件,这时 idx_create_time 反而是更好的选择。所以不要为了这一个 case 把其他索引全删了。
4.4 分页深翻页问题仍然要处理
加复合索引解决了前几十页的查询,但如果有人翻了 1000 页,LIMIT 20000 OFFSET 20 依然要扫描 20020 行才能丢掉前 20000 行。对于商家后台这种查询,更好的方案是改成 keyset 分页:
sql复制SELECT id, merchant_id, order_no, pay_amount, order_status, create_time
FROM t_order
WHERE merchant_id = 899001
AND order_status = 2
AND create_time >= '2024-06-01 00:00:00'
AND create_time < '2024-06-30 00:00:00'
AND (create_time < '2024-06-28 10:00:00'
OR (create_time = '2024-06-28 10:00:00' AND id < 1234567))
ORDER BY create_time DESC
LIMIT 20;
这种方案把“翻页”变成了“向后取数”,每一页都只扫描目标页附近的索引记录,不会随着页码增大而越来越慢。不过 keyset 分页对前端跳页逻辑不友好,如果不是深度分页场景,保持 LIMIT OFFSET 也可以接受。
5. 复盘 dballgts01e19-2:慢查询根因之外的三个工程教训
这条巡检事件最后被定位为“复合索引缺失导致的大范围扫描”。如果把问题只归结为一句“加个索引就好了”,那下次换个 SQL 还是会在同一个地方跌倒。我更想记录的是排障过程中的几个工程判断。
5.1 慢查询采集阈值设得太粗糙,等于没设
这次巡检能发现问题,是因为平台对 P99 耗时设置了多级阈值。但很多团队的慢查询日志阈值是默认的 10 秒,等到业务已经卡到用户投诉才有人去看。实际上,不同业务模块应该有不同的慢查询阈值:交易核心链路超过 200ms 就要告警,后台导出任务跑 5 秒反而正常。
如果能提前把 t_order 这类大表上的查询单独设置监控,dballgts01e19-2 可能早在 P99 刚过 1 秒时就暴露出来,而不是等到 3.2s 才被巡检抓到。
5.2 EXPLAIN 的 rows 是估算值,不能当成真实扫描行数
我在整个排障过程中始终把 rows 当作参考,而不是精确数字。由于 MySQL 优化器用采样统计估算行数,rows=284235 可能与实际扫描行数有偏差,尤其当数据分布不均匀时,偏差会很大。
要拿到更真实的数据,可以在测试环境或从库上使用 EXPLAIN ANALYZE。它能显示每条执行步骤的实际耗时和实际行数,比纯 EXPLAIN 直观得多。不过它真的会执行 SQL,生产环境不建议直接跑,尤其是大查询,否则可能给库上添一把火。
5.3 优化 SQL 前先确认业务是否接受降级
我当时的经验和教训是:不要只盯着优化索引,先和业务方确认一个关键问题——商户能不能接受默认查 7 天而不是 30 天?如果能,哪怕一个索引都不加,把时间范围缩小也能明显改善。
这个 case 里,最终产品同学确认可以把默认筛选时间从 30 天改成 15 天,同时配合新索引,效果更加稳定。很多时候,SQL 优化到最后不光是数据库问题,而是业务查询边界问题。把不必要的数据范围缩小,比任何索引都更便宜。
最后再分享一个我自己的习惯:每次处理完一类慢查询,我会把对应的 SQL 模板、表结构、索引方案和执行计划存成一个 markdown 文件,放在团队知识库里。后面再遇到 dballgts01e19-2 这种编号时,不用重新从慢日志开始翻,直接搜索表名或商户 ID,就能找到上次的完整排障记录。这种“排障资产化”带来的收益,一次两次不明显,时间久了就会非常可观。
