慢查询猎杀日:一天三个性能案、一个「按钮消失」案与统一的方法论

导读

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_flagvarchar(1),XML 里硬编码 delete_flag = 0(数字)→ 列侧隐式类型转换 → 联合索引第二列断链,EXPLAIN type=ALL。改成 '0' 后 42 万行实测:主查询 1060ms → 2.3ms。⚠️ 同款写法在 property 20 个 mapper 还有 60+ 处——以后给任何带 delete_flag 的表加索引,必须连条件一起改

第二层:分页拦截器的包裹式 count——“越刷越卡”的真凶。 修好第一层部署后仍”反复切换就卡”。processlist 现场抓到唯一在跑的 SQL:

拦截器包裹式 count(反编译框架 jar 确认)
select count(1) from (SELECT ... ORDER BY create_time DESC ...) t
-- ORDER BY 不剥离:51 万行物化 + filesort ≈ 5s,每个分页请求固定跑一次
-- 逃生口(字节码 497-511):totalCount == -1 && autoCount 才执行 count
-- → 外部预填 totalCount 即跳过

修复:手写与 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+。按排查顺序:

  1. 查索引:information_schema 确认四个索引都在,排除漏执行;
  2. buffer pool@@innodb_buffer_pool_size = 128M——docker 默认值,跑在 30G 内存的机器上。SET PERSIST 在线调到 512M;
  3. 分段慢日志立功:之前埋的 permissionMs/countMs/listMs/receiverMs 分段日志直接把嫌疑钉在 countMs/listMs;
  4. 真凶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 秒

修复三件套:

CO 查询修复(V20260914.11.00)
-- ① 只 SELECT id, ddc_co1(减少读取与传输)
-- ② 排序加决胜列:ORDER BY monitoring_time DESC, id DESC(同时间下 LIMIT 边界稳定)
-- ③ 覆盖索引:
ALTER TABLE building_monitoring_log
  ADD INDEX idx_bml_co_recent (delete_flag, monitoring_time, id, device_no, ddc_co1);

索引后执行计划变为覆盖索引读取,约扫 49 个索引项,EXPLAIN ANALYZE0.14ms;新旧 SQL 返回记录与 CO 平均值(17.38)完全一致。21 秒 → 0.14 毫秒,五个数量级——这不是调参,是把”全表扫描 + 排序”换成”覆盖索引直读”。

番外:停车位”按钮消失”——请求根本没发出去

停车位绑定摄像头后”视频预览”按钮不显示,看起来像后端或 IoT 保存失败。沿 updateSelective 链路排查,真因在前端空值处理:

confirmCamera 空值坑(修复前后)
// ❌ 旧代码:null !== undefined 为 true,随后读 null.length 抛异常
if (this.parkingLotForm.associatedCameraInfo !== undefined) {
  for (let i = 0; i < this.parkingLotForm.associatedCameraInfo.length; i++) {

// ✅ 修复:只对数组执行重复检查,null 直接进新绑定分支
if (Array.isArray(this.parkingLotForm.associatedCameraInfo)) {

异常发生在提交前——updateSelective 请求根本没发出,所以后端日志干干净净,看起来像”后端没保存”。这个案子配了一个分层确认清单,值得所有前后端联调通用:请求没发出 → 看浏览器 Console;发出但失败 → 看服务日志;成功但字段丢 → 查数据库三列值;只有保存成功且按钮出现后拉流失败,才轮到 IoT 排查。

按钮消失案:请求被异常拦在发出前
图 2:分层定位——Console、网络面板、服务日志、数据库,各管一段

方法论汇总

四个案子收尾,沉淀下来的通用动作:

  1. processlist 是最好的分层证据:把”页面卡”翻译成”哪条 SQL 跑了多久”;要抓”正在卡”的瞬间(空闲时抓必扑空);
  2. 无源码框架反编译字节码javap -c 看 String 常量(SQL 模板的真实拼接方式)+ 关键跳转(totalCount == -1 逃生口),比猜快一个数量级;
  3. 验证数据生产保真:本地 74 行 vs 线上 24.4 万,小数据上什么索引缺失都看不出来;
  4. 分段慢日志让定位从猜变成看:permissionMs/countMs/listMs/receiverMs 提前埋好,出事就是几条命令的事;
  5. 本地同构表做相对对比:buffer pool 小时绝对值会失真(FORCE INDEX 曾测出 27s 的假象),同条件下的相对对比才可信;
  6. 前后端联调先分层:请求发没发出、响应成没成功、字段落没落库,各段有各段的工具。

结语

一天的猎杀清单:三个索引/配置修复、一个前端空值修复、五个提交。但比修复更重要的是证据链——每个案子都留下了”从现象到数字”的完整路径。下一次再有用户说”卡”,这套动作可以直接复用。