慢查询猎杀日:一天三个性能案、一个「按钮消失」案与统一的方法论
导读
9 月 14 日接连猎杀三个”慢”:巡更任务分页 5 秒卡死(三层根因叠加)、统一通知列表线上 1s+(真凶不在 SQL 层)、动环 CO 查询 21 秒(203 万行全表扫描)。外加一个番外:停车位”视频预览按钮消失”,看起来像后端挂了,其实是前端
null.length把请求拦在了发出之前。四个案子共用同一套方法论,文末汇总。
前言
性能问题有个共同点:用户只会说”卡”,而”卡”可以被翻译成非常具体的证据——哪条 SQL、跑了多久、扫了多少行。这一天的三个慢查询案和一个前端案,每个都从”卡”开始,以数字收尾:5s→300ms、1s+→预期 200ms、21s→0.14ms。
案例一:巡更任务分页——三层根因叠加,少修一层都白搭
现象:巡更任务列表(51 万行表)首次进入尚可,来回切页就卡死。前端两个页面共用同一接口,都慢。
第一层:delete_flag = 0 废掉联合索引。 09-12 已经加过 idx_patrol_task_list(task_type, delete_flag, create_time),但接口照慢。根因:delete_flag 是 varchar(1),XML 里硬编码 delete_flag = 0(数字)→ 列侧隐式类型转换 → 联合索引第二列断链,EXPLAIN type=ALL。改成 '0' 后 42 万行实测:主查询 1060ms → 2.3ms。⚠️ 同款写法在 property 20 个 mapper 还有 60+ 处——以后给任何带 delete_flag 的表加索引,必须连条件一起改。
第二层:分页拦截器的包裹式 count——“越刷越卡”的真凶。 修好第一层部署后仍”反复切换就卡”。processlist 现场抓到唯一在跑的 SQL:
修复:手写与 listByPage 条件逐字一致的裸 count(注意 mapper 里原有的 count 查询缺条件,不能直接用),Service 预填 totalCount 跳过拦截器。放大器在前端:request.js 切页不取消旧请求,5s 的 count 并发堆积——“越刷越卡”就是这么来的。
第三层:零选择性 IN + 覆盖索引。 线上 status 分布 98.99% 是已归档,Controller 默认塞的 statusList(1,2,3) 是全字典值、零选择性——任何形态的 count 都要数 51 万行。新增 count 专用覆盖索引 idx_patrol_task_count(task_type, delete_flag, status):count 从 20s → 154ms(纯覆盖不回表)。

图 1:三层叠加——废索引的写法、拦截器包裹 count、零选择性 IN,每层独立修复
验证矩阵(本地 41.9 万行同构表实测)最能说明”少修一层都白搭”:
| 场景 | count | 主查询 LIMIT 10 |
|---|---|---|
| 无索引 | 943ms | 1060ms(filesort) |
list 索引 + = 0(09-12 的实际效果) |
1027ms | 1155ms(索引不生效) |
list 索引 + = '0'(第一层修复) |
~1s | 2.3ms |
| + 预填跳过拦截器(第二层) | 裸 count ~1s | 2.3ms |
| + count 覆盖索引(第三层) | 154ms | 2.3ms |
案例二:unifiedPage 线上 1s+——真凶不在 SQL 层
延续 9 月 11 日的统一通知功能。SQL 层前两轮优化(延迟关联、分支候选封顶)经复查确认到位,但线上接口仍 1s+。按排查顺序:
- 查索引:information_schema 确认四个索引都在,排除漏执行;
- buffer pool:
@@innodb_buffer_pool_size= 128M——docker 默认值,跑在 30G 内存的机器上。SET PERSIST在线调到 512M; - 分段慢日志立功:之前埋的
permissionMs/countMs/listMs/receiverMs分段日志直接把嫌疑钉在 countMs/listMs; - 真凶:
totalCount=348877− pushing 104,732 = message_notice 24.4 万行(2023 年至今真实业务积累)。count 每条回表查 delete_flag,list 分支 24 万行收集 + filesort 取 10 行。
修复:覆盖索引 idx_mn_unified_page(delete_flag, create_time, id, biz_type)——列序有讲究:delete_flag 等值前缀 + create_time/id 让列表倒序扫 10 行即停,biz_type 放末尾走 ICP 让 count 全程不回表。
这个案子最贵的教训:验证数据要生产保真。本地 message_notice 只有 74 行,同结构的查询在小数据上什么索引缺失都暴露不出来。性能验证第一步,先拉生产行数和分布。
案例三:动环 CO 查询——21 秒到 0.14 毫秒
停车场 CO 概况接口,页面卡片加载慢。原查询按 monitoring_time DESC 取最新记录,但表只有主键索引,且接口只用 ddc_co1 一个字段却 SELECT 整行几十个字段。测试库 203 万行实测:全表扫描 → 过滤 37 万条再排序 → 单次约 21 秒。
修复三件套:
索引后执行计划变为覆盖索引读取,约扫 49 个索引项,EXPLAIN ANALYZE 约 0.14ms;新旧 SQL 返回记录与 CO 平均值(17.38)完全一致。21 秒 → 0.14 毫秒,五个数量级——这不是调参,是把”全表扫描 + 排序”换成”覆盖索引直读”。
番外:停车位”按钮消失”——请求根本没发出去
停车位绑定摄像头后”视频预览”按钮不显示,看起来像后端或 IoT 保存失败。沿 updateSelective 链路排查,真因在前端空值处理:
异常发生在提交前——updateSelective 请求根本没发出,所以后端日志干干净净,看起来像”后端没保存”。这个案子配了一个分层确认清单,值得所有前后端联调通用:请求没发出 → 看浏览器 Console;发出但失败 → 看服务日志;成功但字段丢 → 查数据库三列值;只有保存成功且按钮出现后拉流失败,才轮到 IoT 排查。

图 2:分层定位——Console、网络面板、服务日志、数据库,各管一段
方法论汇总
四个案子收尾,沉淀下来的通用动作:
- processlist 是最好的分层证据:把”页面卡”翻译成”哪条 SQL 跑了多久”;要抓”正在卡”的瞬间(空闲时抓必扑空);
- 无源码框架反编译字节码:
javap -c看 String 常量(SQL 模板的真实拼接方式)+ 关键跳转(totalCount == -1逃生口),比猜快一个数量级; - 验证数据生产保真:本地 74 行 vs 线上 24.4 万,小数据上什么索引缺失都看不出来;
- 分段慢日志让定位从猜变成看:permissionMs/countMs/listMs/receiverMs 提前埋好,出事就是几条命令的事;
- 本地同构表做相对对比:buffer pool 小时绝对值会失真(FORCE INDEX 曾测出 27s 的假象),同条件下的相对对比才可信;
- 前后端联调先分层:请求发没发出、响应成没成功、字段落没落库,各段有各段的工具。
结语
一天的猎杀清单:三个索引/配置修复、一个前端空值修复、五个提交。但比修复更重要的是证据链——每个案子都留下了”从现象到数字”的完整路径。下一次再有用户说”卡”,这套动作可以直接复用。