慢查询治理最常见的失败形态不是"SQL 改不动",而是"没人认领"。日志每周导出一次,Top 20 贴进群里,下周同一批语句还在榜上;三次之后连导出都停了。技术手段都是现成的,真正决定能否收敛的是组织安排:谁改、什么时候改、改到什么程度算完。这篇只谈这三个问题的判据与代价。
1. 归属判定先于技术判定
一条慢 SQL 的修改方,等于持有该 SQL 代码的团队∩承担该接口 SLO 的团队。两者是同一个团队时,治理几乎自动发生;两者分离时,任何优化方案都会卡在"这不是我的排期"上。
| 现象 | 表面归因 | 真实原因 |
|---|---|---|
| 日志导出后无人跟进 | 大家都很忙 | 清单没有责任人字段,天然无人接 |
| 每次都说"下个版本改" | 排期紧 | 慢查询没有进入需求池,永远排在功能后面 |
| 改完一周又出现在 Top 20 | 索引没加上 | 没有验证环节,改动是否生效无人确认 |
| DBA 反复提同一张表 | 业务不配合 | 缺跨团队的成本共识,只有技术诉求 |
判据很简单:影响面跨团队时,先落 owner,再谈方案。没有 owner 的优化,做完也不会有人验收。
2. 三类慢查询,处置路径完全不同
把 Top 20 当同质问题处理,效率最低。按时间形态分三类,处置手段差别很大:
| 类型 | 识别特征 | 典型成因 | 首选处置 | 明确不适合 |
|---|---|---|---|---|
| 偶发抖动 | 均值正常、P99 尖刺,与时刻相关 | 批量任务、缓存集中失效、并发突增 | 定位触发源、限流、资源隔离 | 加索引(无稳定执行形态) |
| 稳定偏慢 | 每次都慢,与数据量无关 | 缺索引、函数包裹、N+1、多余 JOIN | 改写、补索引 | 扩容(不解决单语句代价) |
| 随增长变慢 | 上线时快,按月变慢 | 表变大、深分页、统计信息陈旧 | 索引调整、改分页、归档/分区 | 只调参数 |
第三类最容易被误判为"机器不够"。判据:同一条 SQL 在历史数据量的备份上跑一次,耗时随行数近似线性上升即为增长型,扩容只能推迟。
3. 责任划分的三种模式与可持续性
| 模式 | 谁定优先级 | 优势 | 失效点 | 可持续的前提 |
|---|---|---|---|---|
| DBA 主导 | DBA 出清单,业务排期 | 全局视角、清单出得快 | 不懂业务语义,改法保守;业务侧无强制力 | 有固定排期通道 + DBA 对上线有否决权 |
| 业务主导 | 各团队自定 | 语义最准、改法最贴需求 | 看不到全局、重复投入、无外部压力就不做 | 慢查询进入发布门禁,而非靠自觉 |
| 平台托管 | 平台统一拦截/改写 | 覆盖面最大、可强制 | 改写风险集中、易误伤、责任模糊 | 白名单 + 可回滚 + 改写命中可观测 |
能长期跑下去的多是混合式:平台负责发现与拦截(不漏),业务负责语义判定(改对),DBA 负责索引与表变更(安全)。交接物不是会议,而是清单、责任字段、验证记录三张表。
4. 止损手段的代价阶梯
五种手段成本严格递增。先做便宜的,不是因为它更好,而是它能立刻止住影响面,为贵的手段争取时间。
| 顺序 | 手段 | 见效 | 主要成本 | 副作用 | 适用信号 |
|---|---|---|---|---|---|
| 1 | 限流/降级 | 分钟级 | 一个配置项 | 部分请求失败 | 抖动型、影响面大 |
| 2 | 加缓存 | 小时级 | 一致性设计 | 数据陈旧 | 读多写少、可容忍延迟 |
| 3 | 改写 SQL | 天级 | 开发 + 回归 | 语义漂移 | 稳定偏慢 |
| 4 | 加索引 | 天级(含变更窗口) | 写放大、变更风险 | 写入变慢、空间增长 | 选择性好的过滤条件 |
| 5 | 改表结构/归档 | 周级 | 迁移、双写、回滚 | 停机或长事务 | 增长型 |
限流的代价要提前说清:它把"慢"换成"错",必须配明确降级语义,否则数据库问题会变成体验问题。
# 止损第一层:按接口维度限流,而不是按 SQL 维度(SQL 维度拦不住并发来源)
rate_limit:
endpoint: /api/order/list
qps: 120 # 按压测出的单实例安全水位打折
burst: 40
fallback: cache_stale # 明确降级语义:返回陈旧缓存 + 标记
observe: [latency_p99, db_qps, reject_ratio]
5. "加索引"的边界
索引最容易被滥用,因为它看起来零风险。三个真实约束:
写放大:每多一个二级索引,每次写入要多维护一棵 B+ 树。写多读少的表上,加索引可能净亏。判据是写读比,不是"这条查询快了多少"。
优化器可能不用:区分度低的索引,优化器评估后可能直接放弃,实际执行计划不变,白加一次变更。
区分度:count(distinct col)/count(*) 是粗判据,典型量级低于 0.1 的列单独建索引收益有限(按 MySQL 8.0 口径核对,版本间采样有差异)。
-- 区分度:低于 ~0.1 的列,单列索引通常被优化器跳过
SELECT COUNT(DISTINCT status) / COUNT(*) AS sel_status,
COUNT(DISTINCT user_id) / COUNT(*) AS sel_user
FROM orders WHERE created_at >= '2026-01-01';
-- 冗余索引:前导列被别的索引覆盖时,这条是纯写放大
SELECT a.TABLE_NAME, a.INDEX_NAME, a.COLUMN_NAME, b.INDEX_NAME AS covered_by
FROM information_schema.STATISTICS a
JOIN information_schema.STATISTICS b
ON a.TABLE_NAME = b.TABLE_NAME AND b.SEQ_IN_INDEX = 1
AND a.SEQ_IN_INDEX > 1 AND a.COLUMN_NAME = b.COLUMN_NAME
WHERE a.TABLE_SCHEMA = DATABASE();
还有一点常被忽略:索引变更也是变更,大表加索引要走在线 DDL,有锁与主从延迟风险,需与发版一样有窗口和回滚。
6. 改写 SQL 的四类
| 类别 | 典型形态 | 收益来源 | 风险 |
|---|---|---|---|
| 拆查询 | 一次 JOIN 五张表、一条 SQL 拿全量 | 减少中间结果与排序 | 多次往返、一致性窗口 |
| 改 JOIN | 驱动表选错、被驱动表无索引 | 改变驱动顺序 | 语义随数据分布变化 |
| 换分页 | LIMIT 100000, 20 深分页 |
消除大偏移扫描 | 游标需稳定排序键 |
| 去函数包裹 | WHERE date(created_at) = ? |
恢复索引可用 | 时区与边界语义 |
-- 深分页:偏移扫描改为游标(排序键必须唯一且稳定)
SELECT id, amount FROM orders
WHERE (created_at, id) < (:last_created_at, :last_id)
ORDER BY created_at DESC, id DESC LIMIT 20;
-- 函数包裹:改为范围条件,让索引可用
-- 差:WHERE DATE(created_at) = '2026-03-01'
SELECT id FROM orders
WHERE created_at >= '2026-03-01 00:00:00'
AND created_at < '2026-03-02 00:00:00';
改写最贵的部分不是写新 SQL,而是证明语义没变:对同一批输入比对新旧结果集,行数与关键字段一致才算完成。
7. 阈值:响应时间 × 调用量
单一响应时间阈值("超过 1s 算慢")会把低频语句拉进清单,稀释真正的问题。改用二维判据:
| 调用量 \ 响应时间 | < 200ms | 200ms–1s | > 1s |
|---|---|---|---|
| > 100 QPS | 记录 | P1:进入本周排期 | P0:当日止损 |
| 10–100 QPS | 忽略 | P2:下个迭代 | P1:本周排期 |
| < 10 QPS | 忽略 | 忽略 | 观察(多为报表/后台) |
slow_query_policy:
window: 7d
dimensions: [query_digest, endpoint, team]
priority:
p0: {rps: ">=100", p99_ms: ">=1000"}
p1: {rps: ">=100", p99_ms: ">=200"}
p2: {rps: ">=10", p99_ms: ">=1000"}
exclude: [batch_nightly, admin_report] # 显式排除,避免清单失真
owner_required: true # 无 owner 的条目不计入闭环率
"改到哪一步算完"要写进策略,常见口径是 P0 的 P99 回到阈值内并稳定 7 天,而不是"加了个索引"。缺稳定期,改动是否生效无从区分。
8. 闭环:五个环节各有产物
| 环节 | 动作 | 必须留下的产物 | 常见断点 |
|---|---|---|---|
| 发现 | 日志聚合、按 digest 归并 | 清单 + 调用量 + 影响接口 | 按原文聚合会把参数拆散 |
| 归属 | 按 endpoint 映射到团队 | owner 字段、SLO 归属 | 映射表缺失 |
| 修复 | 按阶梯选手段 | 变更记录 + 回滚方式 | 只记录"已优化" |
| 验证 | 新旧结果比对 + 指标回落 | 对比结果、7 天曲线 | 看一眼截图就结案 |
| 防复发 | 评审、门禁、压测 | 规则、基线、告警 | 靠人记 |
防复发三个抓手,成本递增:
# 1) SQL 评审:在 CI 拦截已知模式,比人工 review 稳定
sql_review_rules:
- no_function_on_indexed_column
- no_select_star
- join_table_limit: 3
- require_limit_on_list_api
# 2) 压测门禁:新接口上生产前给出 P99 与 DB QPS 基线
# 3) 索引变更流程:走变更单 + 窗口 + 主从延迟观察
度量只留四个:慢查询语句数占比(digest 维度)、最长 P99、治理周期天数(发现到验证通过的中位数)、回潮率(修复后 30 天内重新入榜比例)。回潮率信息量最大,直接暴露"改法是否对症"。
9. 排错:治理推不动时的对照表
| 现象 | 判断 | 处理 |
|---|---|---|
| 清单每周一样 | 归属缺失 | 补 owner,无 owner 条目不进讨论 |
| 业务说"这不是我的 SQL" | 代码与调用归属分离 | 以 endpoint 归属为准,落到调用方 |
| 改了索引但仍慢 | 优化器未选或区分度低 | 看执行计划确认,别只看 DDL 成功 |
| 优化后 P99 反升 | 写放大或索引维护 | 对比写读比,删冗余索引 |
| 治理一阵后停摆 | 靠人推动,无门禁 | 规则进 CI,阈值进发布检查 |
10. 边界:这些情况治理回报低于投入
| 场景 | 判断依据 | 更合适的做法 |
|---|---|---|
| 数据量小、低频 | 全表扫描也在毫秒级 | 不治理,保留观察 |
| 系统 6 个月内下线 | 投入回收不了 | 只做限流兜底 |
| 后台报表/离线 | 不影响在线 SLO | 显式排除,免得稀释清单 |
| 慢因外部依赖 | 慢查询只是表象 | 先治下游,再回看 SQL |
一句话选择:影响面跨团队、且随数据增长恶化,才值得建治理机制;其余用限流+缓存兜底。
11. 自测
- 一条慢 SQL 的代码归属与 SLO 归属分属两个团队,先解决什么?
- 偶发抖动型慢查询,为什么加索引通常是无效动作?
- 限流作为第一层止损,代价是什么,需要配合哪件东西才成立?
- 区分度低的列建了索引却没被使用,怎么确认是优化器放弃了它?
- "改到哪一步算完"应该写成什么样的验收口径?
- 回潮率高,说明治理链条上哪个环节最可能有问题?