现象或背景:3000+ 查询模板多租户库,执行计划漂移如何定义成可计算、可基线、能事前拦截的指标?
我是一名技术文档工程师,日常帮 30 人数据团队搭建指标体系。现在接手一个新课题:给一个 MySQL 8.0.36 / InnoDB 多租户核心库(单主 32C128G,核心表 2.8 亿行,头部租户占 41%,慢日志日均 1500+ 条)搭建一套**「执行计划稳定性」度量体系**。
站内的讨论基本都读过了:2216 问「怎么监测计划突变」、2258 问「四条固化路线怎么取舍」、1551/1486 问「告警阈值静态还是动态」、208 给了统计信息漂移的根因案例。但这些都默认「计划稳定性」已经被定义成指标了——没人正面回答:这个指标本身怎么算。
原理或根因分析:为什么「执行计划稳定性」难以度量?
我试过三种朴素的「稳定性」定义,全部踩坑:
-
plan_change_rate = 计划变更次数 / 总查询次数。问题:分母是查询次数,一条一天跑 10 万次的模板和一条一天跑 3 次的模板权重差 5 万倍;而且「变更」怎么算——EXPLAIN 输出里
rows估算值每次跑都漂,直接 diff 全是噪声。 -
per-template 计划签名集合的基数。问题:签名归一化怎么做?MySQL 的 EXPLAIN 里
type、key、Extra里Using temporary; Using filesort的出现顺序会随优化器版本变,直接 hash 整个 EXPLAIN JSON 会假阳性爆炸。 -
P99 延迟的环比。问题:这是结果指标不是原因指标——延迟涨了 3 倍可能是索引没选对,也可能是 buffer pool 被 ETL 冲掉、也可能是上游连接池打满,三个根因对应完全不同的动作,用同一个指标分不清。
具体做法:5 个可复现方案
1. 指标定义:分子分母
PlanDriftScore(t) = Σ_w(T) · drift_events(T, t) / Σ_w(T) · baseline_events(T, t)
- 权重
w(T)用慢日志占比 × 业务关键度,不用查询次数。理由:你的问题本质是"慢的模板别变",一天跑 3 次但每次都 800ms 的模板权重应该高于一天 10 万次但都 <5ms 的模板。具体取法:w = 0.6 × slow_log_share + 0.4 × criticality,criticality 是人工维护的 0/1/2 三档(P0 核心链路 / P1 常规 / P2 后台),3000 条模板里人工标 P0/P1 也就 200 条以内,成本可控。 drift_events:该模板在该时间桶内出现的非白名单新签名首次出现次数(不是出现次数,一次只计 1)。baseline_events:基线窗口内该模板的总执行桶数。- 分母用时间桶不用次数,直接解决你说的"10 万次 vs 3 次"权重差。
2. 签名归一化:参与 hash 的字段
从 EXPLAIN FORMAT=JSON 提取后只保留结构性字段:
| 参与 hash | 排除 |
|---|---|
| type(access type) | rows(估算值,每次漂) |
| key(实际走的索引名) | filtered(估算值) |
| Extra 中 Using temporary / Using filesort / Using index 的布尔集合(不保序) | Extra 中的 Using where 顺序、Impossible WHERE 之外的动态文本 |
| 表连接顺序(JOIN 顺序 + 内/外) | cost 全部 |
| 是否走了覆盖索引(Using index 且无 Using where 回表) | 任何数字型估算 |
hash 前对 Extra 做集合化(sorted(set(flags))),连接顺序做规范化(内层优先序列)。这样 Extra 顺序变化、rows 漂移全部被过滤,只剩真正的结构变更。
伪代码:
def plan_signature(explain_json):
nodes = walk(explain_json)
parts = []
for n in nodes:
parts.append(n['type'])
parts.append(n.get('key', ''))
parts.append('|'.join(sorted(n.get('extra_flags', []))))
parts.append('join_order:' + canonical_join_order(nodes))
return sha1('|'.join(parts))
3. 基线策略
- 窗口:头部租户(P0 模板)用 14 天滚动,长尾用 30 天滚动。理由:头部模板的执行特征受 ETL 窗口影响大,14 天能覆盖 2 个完整业务周期;长尾模板样本稀疏,30 天才够覆盖度。
- 排除 ETL 窗口:把凌晨 ETL 时段(你这里应该是 01:00–05:00)从基线计算里显式排除,单独建一个
etl_window基线。原因:ETL 期间的 buffer pool 命中率、锁竞争与业务时段完全不同,混在一起会把基线拉飞。 - 多租户:头部租户(占 41% 的那个)单独基线,长尾租户合并成一个
long_tail基线。不要 per-tenant 建 3000 个基线,成本不可控。
4. 事前拦截
发布前跑计划签名回归:
# 1. 从 baseline 表取 3000 条模板的当前签名
# 2. 在 staging 库上对每条模板跑 EXPLAIN FORMAT=JSON,提取签名
# 3. diff:new_signature != baseline_signature 的模板列表
# 4. 对 diff 超阈值的模板,输出 EXPLAIN 对比 + 建议动作
阈值建议:P0 模板 diff > 0 就 block(一条都不能变),P1 模板 diff > 5% 告警,P2 不拦。CI 里跑这个脚本大概 3000 条 × 100ms ≈ 5 分钟,可接受。
5. 真实案例(我踩过的)
帮一个电商团队做慢查询治理时,一次 MySQL 8.0.34 → 8.0.36 的 minor 升级后,orders 表的一条 P0 查询(日均 12 万次,P99 从 8ms 飙到 210ms)的执行计划从 ref 访问变成了 index 全扫。根因是 8.0.36 优化器对 WHERE tenant_id = ? AND status IN (...) 的索引选择策略变了——统计信息没变,是优化器内部 cost model 的权重调整。
如果当时只有 P99 告警,要等 2 小时才能发现(因为 P99 是结果指标,涨 3 倍才触发)。用计划签名 diff,升级前在 staging 上跑回归就能拦住——那条模板的签名变了,直接 block,升级回滚。
事前拦截比事后复盘便宜 10 倍,这句话在你那个 30 人数据团队里应该是共识了。
踩过的坑或反直觉之处
- 「签名归一化」不是一劳永逸:MySQL 的
EXPLAIN输出结构会随着版本迭代变化,例如 8.0.34 和 8.0.36 的Extra字段内容会有差异,需要持续更新归一化规则。 - 「基线窗口」不能太短:头部租户的基线窗口若选 7 天,可能无法覆盖完整业务周期,导致误判;长尾租户若选 7 天,则样本稀疏度会更高。
- 「多租户基线」的代价:头部租户单独建基线虽然精准,但管理成本高;长尾租户合并基线虽成本低,但可能掩盖部分模板的问题。
结论与可复用清单
1. 指标定义优先级
- 避免比率指标:用「每个模板一个的有序状态序列」代替比率,保留定位信息。
- 权重计算:用「慢日志占比 × 业务关键度」代替查询次数,确保权重合理。
- 基线窗口:头部租户选 14 天滚动,长尾租户选 30 天滚动。
2. 签名归一化规则
- 保留结构性字段,过滤估算值和动态文本。
- 对
Extra字段做集合化,连接顺序做规范化。
3. 事前拦截策略
- 在 staging 库上跑计划签名回归,阈值建议:P0 模板 diff > 0 就 block,P1 模板 diff > 5% 告警。
- CI 集成回归脚本,约 5 分钟可完成 3000 条模板检查。
4. 实践建议
- 持续更新归一化规则,适应 MySQL 版本迭代。
- 多租户基线策略需权衡精准度和成本。
- 通过实际案例验证方案,例如通过计划签名 diff 拦住 MySQL 升级时的执行计划变更。
最后取舍:
你的方案 2(签名集合基数)其实是最接近正确的方向,问题只在"签名归一化怎么做"。把归一化规则定死(上面第 2 节),方案 2 就能用。不要追求"一个指标解决所有问题",per-template 签名序列 + 慢日志权重的组合,比任何单一比率都更接近"可计算、可基线、能事前拦截"的目标。