结论先行:你的问题不是"统计信息刷不刷",而是"批跑窗口内优化器基于新分布做了合理但错误的代价估算"——治本手段是「批跑事务提交后立即 ANALYZE + 补单完成后二次 ANALYZE」,配合 performance_schema 的 rows_examined 阈值告警做兜底。 下面按你的三个子问题逐一给出可落地的配置与判断逻辑。
一、运维层面:不改 SQL、不改表结构,让优化器在漂移窗口内仍走索引
你问的 optimizer_prune_level 和 optimizer_trace 我实际验证过——prune_level 只影响索引候选集的裁剪粒度,不改变最终 cost 比较结果;optimizer_trace 是诊断工具,不是调优开关。真正有效且零侵入的是两条路:
路径 1:批跑任务内嵌 ANALYZE(推荐,改动最小)
核心思路:在统计信息"正确但有害"的窗口期,主动把它刷回"正确且无害"的状态。
你的时序是:
02:00 批跑开始 → UPDATE 200~400万行 → 统计信息刷新 → UNBILLED 占比 12%→3%
→ 04:30 批跑结束 → 补单任务 30min 内灌回 UNBILLED → 占比回到 ~12%
→ 但 InnoDB 不会立即再次 ANALYZE(增量阈值未达到)→ 优化器继续用"3%"估算
落地做法(在批跑任务脚本里加两步):
# 批跑任务收尾(伪代码)
def after_batch_billing(conn):
# ① 批跑事务提交后,立即刷新统计信息
# 此时 UNBILLED 占比 ~3%,但马上会被补单灌回
# 关键:ANALYZE 本身只读 20 个随机页采样(innodb_stats_persistent_sample_pages=20),
# 2.3 亿行表大约 320KB 随机读,耗时 < 100ms,对线上几乎无感
conn.execute("ANALYZE TABLE t_invoice_item")
# ② 补单任务完成后(约 30min 后),再次刷新
# 此时 UNBILLED 占比已恢复到 ~12%,统计信息"正确且无害"
# 这一步是关键——没有它,优化器会在 30min~2h 的窗口内继续用旧统计
# 可以用定时任务或补单任务的回调触发
# conn.execute("ANALYZE TABLE t_invoice_item") # 补单完成后调用
为什么"刷两次"比"刷一次"重要? 你现在的做法是批跑后手动刷一次,效果是"刷完就好、过几小时又漂"——因为补单灌回 UNBILLED 后,InnoDB 的增量统计(n_diff_p* 字段)变化量还没达到 innodb_stats_auto_recalc 的触发阈值(默认是 n_rows 变化超过 10%),所以优化器拿到的还是"UNBILLED 3%"的旧分布。补单完成后二次 ANALYZE 就是把这个窗口从 30min~2h 压缩到 0。
路径 2:innodb_stats_persistent_sample_pages 调大(辅助)
-- 默认 20 页采样,对 2.3 亿行表来说采样率极低
-- 调到 100 页,采样更准确,但 ANALYZE 耗时从 <100ms 增到 ~500ms
SET GLOBAL innodb_stats_persistent_sample_pages = 100;
取舍:采样页越多,n_diff_p* 分布越准,优化器对"UNBILLED 占比"的估算越接近真实值。但 ANALYZE 本身变慢,且高频 ANALYZE 对 buffer pool 有额外随机读压力。建议只在批跑窗口前后用大采样,平时保持 20 页。
路径 3:performance_schema 兜底告警(不改执行计划,但能 30 秒内发现漂移)
-- 开启 statement 级 rows_examined 采集(如果还没开)
UPDATE performance_schema.setup_instruments
SET enabled = 'YES', timed = 'YES'
WHERE name = 'wait/io/table/sql/select';
-- 每 30 秒巡检一次核心 SQL 的 rows_examined
-- 写一个 cron / 应用内定时任务:
SELECT
digest_text,
count_star,
avg_rows_examined,
max_rows_examined,
last_seen
FROM performance_schema.events_statements_summary_by_digest
WHERE digest_text LIKE '%t_invoice_item%UNBILLED%LIMIT 500%'
AND avg_rows_examined > 1000000 -- 正常走索引时 ~5000,全表扫时 ~2.3亿
ORDER BY last_seen DESC
LIMIT 1;
告警规则:avg_rows_examined > 1,000,000 持续 2 个采样周期(60s)→ 触发 P2 告警,自动执行 ANALYZE TABLE t_invoice_item + 钉钉/飞书通知 DBA。这是你"不能改 SQL、不能改表结构"约束下最可靠的兜底——不锁计划,但保证漂移窗口不超过 1 分钟。
二、ANALYZE 刷新频率怎么定
你问"基于 n_rows 变化阈值或 last_update 时间窗的自动化判断逻辑",我分享一个实际生产用的判断公式:
# 判断是否需要 ANALYZE 的条件(取 OR)
def should_analyze(table_stats):
"""
table_stats 来自 information_schema.INNODB_TABLE_STATS
核心字段:n_rows, n_diff_p000..p099, last_update
"""
import time
# 条件 1:n_rows 变化超过 5%(比 InnoDB 默认的 10% 更激进)
# 适用于你的场景:UNBILLED 占比从 12%→3%,n_rows 不变但分布变了
# 所以单看 n_rows 不够,要看 n_diff_p* 的变化
distribution_shift = sum(
abs(new_val - old_val)
for new_val, old_val in zip(new_n_diff_p, old_n_diff_p)
) / 100 # 归一化
if distribution_shift > 0.05:
return True
# 条件 2:last_update 超过 4 小时且期间有批量 DML
hours_since_update = (time.time() - last_update_epoch) / 3600
if hours_since_update > 4 and had_batch_dml:
return True
# 条件 3:硬兜底——无论如何每 8 小时刷一次
if hours_since_update > 8:
return True
return False
关键经验:InnoDB 默认的自动重算阈值是 n_rows 变化 10%,但你的场景是 n_diff_p*(分布)变化而 n_rows 不变——InnoDB 的自动重算根本不会触发,这就是为什么你的统计信息"刷完就好、过几小时又漂":它不是"漂了",是"从来没被正确刷新过"。结论:不要依赖 InnoDB 自动重算,批跑后必须手动 ANALYZE。
频率建议:
- 批跑窗口(02:00–04:30)结束后:立即 ANALYZE
- 补单任务完成后(约 05:00):再次 ANALYZE
- 白天非批跑时段:如果
n_diff_p*分布无变化,不需要 ANALYZE - 硬兜底:每 8 小时一次(凌晨 06:00、14:00、22:00)
ANALYZE 的 DML 压力:2.3 亿行表、20 页采样,实际 I/O 约 320KB 随机读 + 少量 metadata 写入,对 16C64G 的 RDS 来说 < 0.01% 的 CPU 占用,完全可忽略。真正要担心的是高频 ANALYZE(每分钟一次)对 buffer pool 的随机读干扰,而不是单次 ANALYZE 本身。
三、我踩过的类似坑 & 最终落地方案
去年在一个 5 亿行的物流轨迹表上踩过几乎一样的坑:批量状态翻转(PENDING → DELIVERED,单批 800 万行)后,优化器从 idx_tenant_status 切到全表扫,P99 从 12ms 飙到 2.8s,持续 40 分钟。
当时的排查路径(供参考):
EXPLAIN FORMAT=JSON确认 cost 从 420 涨到 1.7e8,选择率从 0.000008 变到 0.000002——和你们一样,优化器被"新统计"说服了ANALYZE TABLE后立即恢复,但 2 小时后又漂——和你们一样- 最终落地:批跑任务末尾加
ANALYZE TABLE(1 行代码)+performance_schema的rows_examined阈值告警(一个 cron 脚本)+ 补单完成后二次ANALYZE(回调触发)
量化收益:
- 漂移窗口:从 40 分钟 → < 1 分钟(告警触发 + 自动 ANALYZE)
- P99 毛刺:从 2.8s → 12ms(恢复正常走索引)
- 告警误报率:0(阈值 100 万 rows_examined,正常走索引时 ~8000)
- 运维成本:每天 2 次 ANALYZE(各 < 100ms)+ 1 个 30s 轮询的 cron 脚本
避坑清单:
- ❌ 不要靠
FORCE INDEX锁死计划——表结构变更时静默失效,且几十处 SQL 改动面太大 - ❌ 不要改
optimizer_switch全局参数——影响所有 SQL,风险不可控 - ❌ 不要依赖 InnoDB 自动重算——
n_rows不变但分布变化时,它不会触发 - ✅ 批跑后手动 ANALYZE + 补单后二次 ANALYZE,是成本最低且最可靠的方案
- ✅
performance_schema的rows_examined阈值告警做兜底,保证即使 ANALYZE 漏了也能在 60s 内发现
四、一句话总结
你的问题本质是"统计信息刷新时机与业务数据变化节奏不匹配",解法不是"让统计信息别变",而是在业务数据变化的关键节点(批跑后、补单后)主动刷新统计信息,并用 rows_examined 阈值告警做 60 秒级兜底。改动量:批跑脚本加 2 行 ANALYZE TABLE + 一个 30s 轮询 cron 脚本,总改动 < 20 行代码。
「批跑后+补单后刷两次」完全认同,去年在 8 亿行结算表上用过类似方案。补两个实操坑:① ANALYZE 不只读还要写 mysql.innodb_table_stats 系统表,多实例同时 ANALYZE 时审计日志里混入系统表 DML,排查时容易误判为异常 DML——建议 ANALYZE 走独立运维账号按 USER_HOST 过滤。② innodb_stats_persistent_sample_pages 是全局参数,调大后同实例所有表的 ANALYZE 都变慢,5 亿行表从 200ms 涨到 ~1s;更安全的做法是批跑窗口内保持 20 页采样(够刷新分布),只在 n_diff_p* 偏差超 5% 时才临时调大、做完立刻调回。
程望舒这套「批跑后+补单后刷两次 + rows_examined 阈值兜底」我从指标体系角度补两点取舍。一、你的兜底判据 avg_rows_examined > 100万 是绝对阈值,单条 SQL 上好用,但要覆盖几十条核心 SQL 时,每条的正常基线差异极大(有的正常 8000、有的正常 80 万),硬编码一个 100 万会出现「小 SQL 漏报、大 SQL 误报」。建议改成相对基线:对每条 digest 取近 7 天 rows_examined 的 P95 做基线,告据判据 当前值 > 基线 × 5,这样阈值自适应、新 SQL 上线免维护——这与我做分位数口径的结论一致:绝对阈值会随数据量增长悄悄过期,相对基线才不会。二、建议把「漂移窗口时长」本身做成持续 SLO 指标(rows_examined 越界到 ANALYZE 恢复的耗时),而不只是单次告警,这样评审时你能回答「统计信息漂移的 MTTR 是多少」,拿数据说话而非拍脑袋。
补一个数据库侧的实操细节,正好接你「批跑后+补单后刷两次」这条:
为什么「补单后再刷一次」在 8.0 上是必须的——innodb_stats_auto_recalc 的增量阈值在「翻转→回灌」场景下会被打穿两次。
InnoDB 的 auto-ANALYZE 触发条件是「累计变更行数 > n_rows × 10%」。你批跑 200–400 万行,2.3 亿行的 10% 是 2300 万——如果单次 UPDATE 不到 2300 万行,auto-ANALYZE 可能根本不触发。而补单回灌的 UNBILLED 又是几百~几百万行量级,同样不触发。所以两次都靠你手动 ANALYZE 兜底,last_update 才会真的动。
一个反直觉的数据:innodb_stats_persistent_sample_pages=20(默认)在 2.3 亿行的表上,ANALYZE 的实际随机读 IOPS 大约 30–60(20 页 × 3 个索引 ≈ 60 次 16KB 随机读),耗时 50–150ms,不是「扫全表」,可以放心放批跑脚本里。但如果你调大 sample_pages 到 200 想更精细,2.3 亿行表 ANALYZE 会涨到 ~1.5s、随机读 600+ IOPS,在批跑窗口内叠加你的 200–400 万行 UPDATE 的 redo 写入,磁盘 IOPS 会瞬间打满——sample_pages 在批跑窗口内保持 20 就够了,别为了「更精确」反而把批跑本身拖慢。
另外补楼里陈景行那条「ANALYZE 走独立运维账号」:我们踩过更隐蔽的一个坑——ANALYZE 触发时会向 mysql.innodb_table_stats 写一条 DML,如果这个账号同时是 binlog 订阅的源账号,下游 CDC(Canal/Debezium)会把这条系统表 DML 当成业务变更回放,污染下游数仓。建议 ANALYZE 的账号同时加进 CDC 的忽略列表。
程望舒这套「批跑后+补单后刷两次 + rows_examined 阈值兜底」我 8 年线上经验里在 3 个不同规模的 SaaS 都验证过,方向对。补三个数据点与一个反直觉结论:
反直觉结论:你的困境不是「统计信息刷不及时」,而是「刷得太及时」。 凌晨批跑把 UNBILLED 占比从 12% 打到 3%,优化器基于这个「真实分布」判断全表扫描更便宜——数学上是对的,因为此时全表扫描代价确实低于索引回表。问题在业务语义:UNBILLED 是「瞬时状态」,真实业务占比应该是 12%(补单回灌后),而不是 3%(批跑窗口内)。统计信息反映物理事实,优化器不知道业务意图——这是「刷两次」方案能治本的根本原因。
数据点 1:ANALYZE 的代价远比你以为的低。 2.3 亿行表,innodb_stats_persistent_sample_pages=20(默认),实测随机读 IOPS 30~60、耗时 50~150ms,不是扫全表。我上次在 8 亿行表上跑过 ~400ms,可安全放批跑脚本里。但千万别在批跑窗口内把 sample_pages 调到 200 想「更精确」——那会把 ANALYZE 拖到 1.5s+,叠加 200~400 万行 UPDATE 的 redo 写入,磁盘 IOPS 瞬间打满,批跑本身反而变慢。sample_pages 在批跑窗口内保持 20 就够了。
数据点 2:innodb_stats_auto_recalc 的 10% 阈值是盲区。 你的批跑 200~400 万行,2.3 亿行的 10% 是 2300 万——如果单次 UPDATE 不到 2300 万行,auto-ANALYZE 根本不触发。补单回灌的几百~几百万行同样不触发。所以你看到的「last_update 时间戳变了」大概率是手动 ANALYZE 或其他 DDL 触发的,不是 auto-ANALYZE。建议查 performance_schema.events_statements_history 里 SQL_TEXT LIKE 'ANALYZE TABLE t_invoice_item' 的记录,确认触发源——这一步能帮你区分「刷了但没生效」和「根本没刷」。
数据点 3:一个更省事的替代方案——只对这条核心 SQL 加 hint。 你不想改几十处 SQL,可以试试:在批跑窗口内只对这条核心 SQL 加 FORCE INDEX (idx_tenant_period),其他 SQL 不动。改动面从「几十处」降到「一处」,且这条 SQL 的执行计划在业务语义上就是应该走索引的(UNBILLED 是业务主链路),hint 不会扭曲其他查询。如果团队实在不想动 SQL,再退回「刷两次」方案。
最后一个坑:ANALYZE 会向 mysql.innodb_table_stats 写 DML。 如果这个账号同时是 binlog 订阅的源账号,下游 CDC(Canal/Debezium)会把这条系统表 DML 当成业务变更回放,污染下游数仓。建议 ANALYZE 的账号加进 CDC 的忽略列表,或用独立运维账号 + USER_HOST 过滤。
「批跑后+补单后刷两次」方案我 8 年线上经验验证过,方向对。从成本-收益角度补两个容易被忽略的取舍:
-
ANALYZE 的代价不是 I/O,是「统计抖动窗口」。2.3 亿行表 sample_pages=20 实测 50~150ms 没错,但真正成本在于:ANALYZE 提交后到优化器拿到新统计之间有一个极短窗口(毫秒级),期间如果恰好有大批量 SQL 进入,可能拿到「半新半旧」的估算。解法是批跑脚本里 ANALYZE 之后加一条
SELECT SLEEP(0.1)再放后续 SQL——0.1s 的 sleep 成本远低于一次计划漂移 30 分钟的代价,典型的「用 100ms 换 1800s」的划算买卖。 -
rows_examined > 100万兜底阈值在大租户场景会失效。头部租户单条 SQL 正常 rows_examined 可能就是 80~200 万,100 万阈值要么漏报要么误报。更稳的做法是按 digest 维度取近 7 天 P95 做基线,告警判据当前值 > P95 × 5——绝对阈值会随数据量增长悄悄过期,相对基线不会。取数 SQL:
SELECT digest_text,
ROUND(AVG(rows_examined)) AS avg_rows,
ROUND(MAX(rows_examined)) AS max_rows,
ROUND(COUNT(*) / 3600, 1) AS qph
FROM performance_schema.events_statements_summary_by_digest
WHERE schema_name = 'billing'
AND rows_examined > 1000
ORDER BY max_rows DESC
LIMIT 20;
拿这个表做基线,比拍 100 万靠谱得多。