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 不产生额外排序,三点都满足,性能自然就上去了。


如有问题欢迎留言交流 🙌

相关推荐
2301_805962932 小时前
ESP32远程OTA升级:从局域网到公网部署
网络·后端·http·esp32
cyforkk2 小时前
Spring Boot 3 集成 Swagger 踩坑实录:解决 doc.html 404 与 Unauthorized 拦截
spring boot·后端·html
小码哥_常2 小时前
当@RequestBody遇上Request:数据去哪儿了?
后端
yige452 小时前
SpringBoot 集成 Activiti 7 工作流引擎
java·spring boot·后端
人间打气筒(Ada)3 小时前
如何使用 Go 更好地开发并发程序?
开发语言·后端·golang
honor_zhang3 小时前
Spring Boot集成Websocket服务以及连接时需要注意的问题
spring boot·后端·websocket
陈随易3 小时前
深度拆解技术架构的三大鸿沟:企业级Claw vs OpenClaw的工程差异
前端·后端·程序员
qq_256247053 小时前
穿透网络壁垒:在 Docker 中配置 OpenClaw 实现带状态的网页自动化
后端
奕成则成3 小时前
Redis List 全面总结(含底层+命令+实战+调优)
后端