SQL 性能优化实战:一次压测 404 的根因追查与解决

大家好,我是G探险者!

今天聊一聊sql优化的一则案例分析。

适用数据库:达梦(DM8)/ MySQL

场景:任务列表查询接口 work_hour_task 压力测试


一、背景

测试人员对任务列表查询接口进行并发压测时,出现大量 404 响应错误。经初步排查,这些 404 并非业务逻辑主动返回,而是接口响应超时后由网关/Nginx 抛出的超时错误。

触发链路如下:

sql 复制代码
SQL 慢查询(全表扫描 + N+1 子查询 + filesort)
  → 接口响应时间飙升
  → 压测并发请求大量堆积
  → 网关请求排队超时
  → 返回 404

二、问题 SQL

原始 SQL(MySQL 版本)

sql 复制代码
EXPLAIN SELECT
    t.id, t.tenant_id, t.task_name, t.is_project_task, t.team_code, t.project_code,
    t.task_type, t.task_sub_type, t.responsible_person_code, t.work_hours, t.estimate_work_hours,
    t.task_desc, t.process, t.plan_start_date, t.plan_end_date, t.task_source,
    t.actual_start_time, t.actual_end_time, t.related_team_code, t.rel_id,
    t.STATUS, t.ext_info, t.version, t.deleted,
    t.created_by, t.created_time, t.updated_by, t.updated_time
FROM work_hour_task t
WHERE t.deleted = 0
  AND (
    t.team_code IN ('webDev')
    OR EXISTS (
      SELECT 1 FROM work_hour_task_related_team tr
      WHERE tr.task_rel_id = t.rel_id
        AND tr.deleted = 0
        AND tr.team_code IN ('webDev')
        AND tr.TENANT_ID = 'hkbank'
    )
  )
  AND t.TENANT_ID = 'hkbank'
ORDER BY t.created_time DESC
LIMIT 15

达梦版本 结构基本一致,WHERE 条件中额外使用了 FIND_IN_SET 函数代替 EXISTS 子查询。


三、执行计划解读

MySQL 版本执行计划

id select_type type key rows Extra
1 PRIMARY ALL NULL 1233 Using where; Using filesort
2 DEPENDENT SUBQUERY eq_ref uk_task_team_del 1 Using index condition; Using where

达梦版本执行计划

节点 类型 描述
NSET2 → PRJT2 → SORT3 结果集 → 投影 → 排序 存在额外排序开销
UNION FOR OR2 OR 条件拆成两路扫描 两路各自回表,代价翻倍
BLKUP2 × 2 BOOKMARK LOOKUP 两路均存在回表
SSEK2 × 2 二级索引 seek 其中一路因 FIND_IN_SET 索引失效

四、三大核心问题

🚨 问题一:全表扫描(type = ALL)

主表 t 有 3 个候选索引(idx_task_teamidx_task_page_coreidx_task_count_core),但优化器最终 key = NULL,全部放弃,被迫全表扫描 1233 行

根本原因: WHERE 条件中对列使用了函数(FIND_IN_SET)或存在关联子查询(EXISTS),优化器无法利用索引进行范围扫描。

🚨 问题二:DEPENDENT SUBQUERY(N+1 问题)

EXISTS 子查询的 select_typeDEPENDENT SUBQUERY,意味着它依赖外层主表的每一行逐行触发执行

yaml 复制代码
主表扫描 1233 行 × 子查询执行 1233 次 = I/O 实际放大 1233 倍

子查询虽然单次走了唯一索引(eq_refrows=1),但积累后总代价极高,这是典型的 N+1 问题

🚨 问题三:Using filesort(额外排序)

ORDER BY t.created_time DESC 无法利用现有索引完成排序,数据库须在内存或磁盘中对全量结果集做额外排序。高并发压测时,排序操作大量占用 CPU 和内存,进一步拖慢响应时间。


五、优化方案

改写思路:两段式查询

将原来「一条复杂 SQL 承包所有逻辑」的写法,拆分为两步:

  • 第一步(ID 收集层) :轻量查询先收集符合条件的 task id 列表,用 UNION ALL 分别处理两种匹配条件,各自加 ORDER BY + LIMIT,合并后取 Top N。
  • 第二步(明细查询层) :用主键 IN (id1, id2, ...) 查询完整字段,走主键索引,无子查询,无 filesort

这种「分步查询」模式是处理 OR + 子查询 + 排序分页 组合场景的标准实践,彻底解耦了「找哪些记录」和「取这些记录的字段」两个问题。

优化后 SQL(MySQL 版本)

sql 复制代码
-- 第二步:用主键 IN 查询明细,消除子查询和 filesort
SELECT
    id, tenant_id, task_name, is_project_task, team_code, project_code,
    task_type, task_sub_type, responsible_person_code, work_hours, estimate_work_hours,
    task_desc, process, plan_start_date, plan_end_date, task_source,
    actual_start_time, actual_end_time, related_team_code, rel_id,
    STATUS, ext_info, version, deleted,
    created_by, created_time, updated_by, updated_time
FROM work_hour_task
WHERE id IN (1942, 1941, 1940, 1939, 1938, 1937, 1936, 1935,
             1934, 1933, 1932, 1931, 1930, 1929, 1928)
  AND deleted = 0
  AND TENANT_ID = 'hkbank'

优化后执行计划

id select_type type key rows Extra
1 SIMPLE range PRIMARY 15 Using where

达梦版本额外改写

达梦原 SQL 中 FIND_IN_SET 将函数施加于列上导致索引失效,需在应用层将参数预先拆分:

sql 复制代码
-- 改前(索引失效)
OR FIND_IN_SET(?, RELATED_TEAM_CODE) > 0

-- 改后(应用层拆分参数后传入,索引可正常使用)
OR t.related_team_code IN (?, ?, ?)

推荐覆盖索引

sql 复制代码
-- MySQL
ALTER TABLE work_hour_task
ADD INDEX idx_covering (TENANT_ID, deleted, team_code, created_time DESC);

-- 达梦
CREATE INDEX idx_covering
ON work_hour_task (TENANT_ID, deleted, team_code, created_time DESC);

六、优化前后对比

指标 优化前 优化后
select_type PRIMARY + DEPENDENT SUBQUERY SIMPLE
type(扫描类型) ALL(全表扫描)❌ range(索引范围扫描)✅
key(命中索引) NULL(全部放弃)❌ PRIMARY(主键)✅
rows(扫描行数) 1233 行 × 子查询 1233 次 ❌ 15 行 ✅
Extra Using filesort ❌ Using where ✅
行数降幅 --- 下降 98.8%

优化前执行路径:

yaml 复制代码
全表扫描 1233 行 → 逐行触发子查询(× 1233 次)→ filesort 排序 → 取 Top 15

优化后执行路径:

go 复制代码
主键 range 扫描 15 行 → 直接返回(无子查询,无排序)

七、经验总结与规范建议

本次问题根因清单

# 根因 影响 解决方式
1 FIND_IN_SET / EXISTS 对列施函数 索引全部失效,退化为全表扫描 改为 IN (?) 或应用层预处理
2 DEPENDENT SUBQUERY(N+1) 子查询随主表每行触发,I/O 放大 N 倍 分步查询或改写为 JOIN
3 Using filesort 全量结果集额外排序,高并发时 CPU 飙升 建包含 ORDER BY 字段的覆盖索引
4 缺少覆盖索引 索引命中后仍大量回表取字段 建联合覆盖索引,字段顺序:过滤列 + 排序列

SQL 开发规范建议

  1. 禁止在 WHERE 条件的列上直接使用函数FIND_IN_SETDATE()YEAR() 等),改为在参数侧做处理,保持列的"裸露"。

  2. 慎用 EXISTS / IN 关联子查询 ,考虑改写为 JOIN 或分步查询,避免产生 DEPENDENT SUBQUERY

  3. 分页列表接口推荐「两段式查询」 :先查 id 列表(轻查询,走索引),再用主键 IN 查完整字段,两步走比一步复杂查询更可控。

  4. 新建索引需覆盖 WHERE 过滤字段 + ORDER BY 字段 ,减少回表和 filesort,字段顺序按选择性从高到低排列。

  5. 上线前必须通过 EXPLAIN 验证执行计划,重点关注:

    • type 不得为 ALL
    • Extra 不得出现 Using filesort / Using temporary
  6. 压测出现大量非业务 404 时,优先排查接口响应时间和数据库慢查询日志,而非只看应用层错误日志。

执行计划关键字速查

字段 危险值(需优化) 目标值
type ALL(全表) range / ref / eq_ref / const
key NULL(未用索引) 命中具体索引名
rows 远大于实际返回行数 接近实际返回行数
Extra Using filesort / Using temporary Using index(覆盖索引最佳)
select_type DEPENDENT SUBQUERY SIMPLE / PRIMARY

总结

这次优化的核心收获是:慢不一定在业务代码里,404 也不一定是路由问题。当压测出现大量超时类 404 时,第一步应该打开慢查询日志,把 EXPLAIN 拿出来看。

记住三个关键词:全表扫描、N+1、filesort。这三者任意一个在高并发下都足以拖垮接口,三个叠加则必然超时。

优化的本质不是"加索引"这么简单,而是要理解优化器的决策逻辑------让 WHERE 条件能走索引,让子查询不随主表行数膨胀,让 ORDER BY 不产生额外排序,三点都满足,性能自然就上去了。


如有问题欢迎留言交流 🙌

相关推荐
码事漫谈5 分钟前
2026软考高级·系统架构设计师备考指南
后端
AI茶水间管理员1 小时前
如何让LLM稳定输出 JSON 格式结果?
前端·人工智能·后端
其实是白羊2 小时前
我用 Vibe Coding 搓了一个 IDEA 插件,复制URI 再也不用手动拼了
后端·intellij idea
用户8356290780512 小时前
Python 操作 Word 文档节与页面设置
后端·python
酒後少女的夢2 小时前
设计模式教程
后端·架构
凌览2 小时前
别再手搓 Skill 了,用这个工具 5 分钟搞定
前端·后端
weixin_408099673 小时前
python请求文字识别ocr api
开发语言·人工智能·后端·python·ocr·api·ocr文字识别
weixin_408099673 小时前
【组合实战】OCR + 图片去水印 API:自动清洗图片再识别文字(完整方案 + 代码示例)
图像处理·后端·ocr·api·文字识别·去水印·ocr识别优化
gelald3 小时前
SpringBoot - Actuator与监控
java·spring boot·后端