|
|
马上注册,结交更多好友,享用更多功能,让你轻松玩转社区。
您需要 登录 才可以下载或查看,没有账号?用户注册
×
一、具体的问题
某业务库有张流水表 t_order_flow,按天追加,现 3.2 亿行,flow_date 上有一个非聚集索引。月初报表跑一次 4 分钟,某天忽然变成 41 分钟,没有发版、没加索引、没改 SQL,CPU 不高,磁盘队列一直顶着。
看实际执行计划,三段典型症状:
- 「聚集索引查找 + 键查找」算子上预估 1 行、实际 3 104 872 行;
- 嵌套循环被按「1 行驱动」排在最内层,实际执行了 310 万次;
- 排序算子挂着黄色感叹号,Operator used tempdb to spill data,内存授予只有 1024 KB,实际需要约 260 MB。
大多数人到这一步会直接往「参数嗅探」上想,但这次不是。把 Query Store 的历史计划拉出来,每条计划里这个算子的预估值都是 1 行,一年没变过——优化器不是在猜错某个参数,而是它手里那份分布认知本身就是错的。
查统计信息:
- SELECT s.name AS stat_name, sp.last_updated, sp.rows,
- sp.rows_sampled, sp.steps, sp.modification_counter
- FROM sys.stats AS s
- CROSS APPLY sys.dm_db_stats_properties(s.object_id, s.stats_id) AS sp
- WHERE s.object_id = OBJECT_ID('dbo.t_order_flow');
复制代码
last_updated 停在 11 个月前,modification_counter 高达 2.9 亿。也就是说,这张表在统计信息眼里还是「一年前那 4000 万行的样子」,最近十一个月新增的数据对优化器等于不存在。
这类问题最麻烦的地方在于不报错、不告警、不写日志,只是安静地让计划一天天变差。
二、核心原理
1. 基数估算(CE)是整条计划的地基
优化器挑计划的依据是「估计成本最低」,而所有成本都建立在一个数字上:每个算子会输出多少行。这个数字叫基数估算(Cardinality Estimation)。它一错,下游全错:
- 估 1 行 → 认为嵌套循环最便宜 → 实际循环 310 万次;
- 估 1 行 → 认为排序只要几 KB 内存 → 只授予 1 MB → 溢出 tempdb;
- 估 1 行 → 认为不必并行 → 单线程跑 40 分钟,16 个核闲着。
所以「优化器选错了计划」,九成应该翻译成「优化器对行数的猜测错了」。
2. 统计信息对象里装了什么
DBCC SHOW_STATISTICS('dbo.t_order_flow', 'IX_flow_date') 返回三块:
| 部分 | 内容 | 用途 | | 头部 | 表总行数、采样行数、更新日期、抽样百分比、步数 | 判断这份统计有多旧、多准 | | 直方图 | 最多 200 步,每步记 RANGE_ROWS / EQ_ROWS / DISTINCT_RANGE_ROWS / AVG_RANGE_ROWS | 估算等值、范围谓词 | | 密度向量 | 每个索引列前缀组合的平均选择性(1/去重值数) | 多列等值、命中不了直方图时兜底 |
三个要点:
- 直方图只有 200 步。3.2 亿行、跨 1000 个 flow_date 值,200 步意味着大量日期被合并成区间,只能按 AVG_RANGE_ROWS 平摊。分布一旦倾斜(工作日单量是周末 6 倍),平均值两头不靠。
- 密度向量假设列独立。WHERE city='上海' AND channel='APP' 被算成「总行数 × 1/城市去重数 × 1/渠道去重数」,而现实里两者强相关,于是系统性低估。
- 直方图右边有边界。超出最大 RANGE_HI_KEY 的谓词无步可查,只能退回平均估算——这就是自增键问题。
3. 自增键(ascending key)为什么必然估错
流水表的日期或主键几乎单调递增,今天新写入的数据永远落在直方图最右一步之外。这类值走平均逻辑:估算行数 ≈ 总行数 ÷ 该列去重值数。
全表 3.2 亿、flow_date 有 1000 个不同值,「今天」会被估成约 32 万行——还能接受。但统计信息停在 11 个月前时,去重值只有 330 天、总行数只有 4000 万,谓词直接落进密度向量的平均估算,被估成几十行。
2014 之后的新版 CE(兼容级别 120+)对超范围值有修正,但修正有前提、不保证生效,靠它兜底等于赌运气。
4. 自动更新的阈值,才是根因
AUTO_UPDATE_STATISTICS 默认开着,但「自动」不等于「及时」。触发条件是变更行数超过阈值:
| 版本 / 兼容级别 | 阈值规则 | | ≤ 120(2014 及更早行为) | 表行数 > 500 时:500 + 20% × 表行数 | | ≥ 130(2016+ 行为) | 表行数 < 25000:500 行;≥ 25000:SQRT(1000 × 表行数),上限 20% |
对 3.2 亿行的表:旧阈值要变更 3200 万行才触发,一天 30 万要等一百多天,且阈值随表变大水涨船高;新阈值 SQRT(1000 × 3.2 亿) ≈ 56.6 万,两天就能触发一次。
- SELECT name, compatibility_level FROM sys.databases;
复制代码
还停在 100/110/120 的库,大表统计信息基本处于「事实冻结」。另外两个隐性前提:默认按页采样(大表常常只采样几千页),且只在编译计划时顺带检查。
三、实例参考(动手步骤)
步骤 0:确认现状基线
先量化「差多少倍」,别凭感觉。计划缓存侧快速判断:
- SELECT TOP 10 qs.execution_count,
- qs.total_elapsed_time / qs.execution_count / 1000.0 AS avg_ms,
- qs.last_grant_kb,
- SUBSTRING(st.text, qs.statement_start_offset / 2 + 1, 200) AS stmt
- FROM sys.dm_exec_query_stats AS qs
- CROSS APPLY sys.dm_exec_sql_text(qs.sql_handle) AS st
- ORDER BY qs.total_elapsed_time DESC;
复制代码
last_grant_kb 只有几百 KB、语句里又带 ORDER BY / GROUP BY 的,基本可锁定「低估 → 授予不足 → 溢出」。
步骤 1:定位到具体哪个统计对象过期
- SELECT OBJECT_NAME(s.object_id) AS tbl, s.name AS stat_name, s.auto_created,
- sp.last_updated, sp.rows, sp.rows_sampled, sp.steps, sp.modification_counter,
- CAST(sp.rows_sampled * 100.0 / NULLIF(sp.rows,0) AS DECIMAL(5,2)) AS sample_pct
- FROM sys.stats AS s
- CROSS APPLY sys.dm_db_stats_properties(s.object_id, s.stats_id) AS sp
- WHERE OBJECTPROPERTY(s.object_id,'IsUserTable') = 1 AND sp.rows > 1000000
- ORDER BY sp.modification_counter DESC;
复制代码
判读三列:modification_counter 远大于 0 → 早该刷没刷;last_updated 超一个月 → 陈旧;sample_pct 个位数 → 刷过也不够准。
步骤 2:证明是统计问题,不是索引问题
把谓词值拿去和直方图对照:
- SELECT step_number, range_high_key, range_rows, equal_rows,
- distinct_range_rows, average_range_rows
- FROM sys.dm_db_stats_histogram(OBJECT_ID('dbo.t_order_flow'), 2)
- ORDER BY step_number DESC;
复制代码
报表查 flow_date = '2026-10-31',直方图最大 range_high_key 只到 2026-09-12——谓词值根本不在描述范围里。索引没问题,问题在「地图」过期了。
步骤 3:立刻止血
先手动把当前分布拍进去,别等自动触发:
- UPDATE STATISTICS dbo.t_order_flow WITH FULLSCAN; -- 全表(含列统计)
- UPDATE STATISTICS dbo.t_order_flow IX_flow_date WITH FULLSCAN; -- 只刷索引统计
复制代码
刷新完别急着收工:旧计划还在缓存里,不会自动变好。让这条语句重新编译来验证:
- SELECT ... FROM dbo.t_order_flow WHERE flow_date = '2026-10-31'
- OPTION (RECOMPILE);
复制代码
看到预估行数从 1 变成 30 万那一档、内存授予从 1 MB 变成 260 MB,止血成功。
步骤 4:把阈值修对,这才是根治
「每周手动刷一次」是拿人力补配置缺陷,不是方案:
- ALTER DATABASE YourDB SET COMPATIBILITY_LEVEL = 130; -- 启用动态阈值
- DBCC TRACEON (2371, -1); -- 无法升级时的老库兜底
复制代码
代价必须说清:兼容级别提升会启用新版 CE,可能让一批原本恰好跑得好的语句变差。上线前把 Query Store 的 QUERY_CAPTURE_MODE 设为 ALL,跑一轮核心业务对比 avg_duration,再决定是否保留。
步骤 5:按表分级维护
超大表和分区表即使阈值修对了仍要显式维护:
- -- 分区表:增量统计,只刷变化分区
- ALTER STATISTICS st_flow_date ON dbo.t_order_flow SET INCREMENTAL = ON;
- -- 固定抽样比例,让准确度可复现(2016 SP1 CU4+)
- UPDATE STATISTICS dbo.t_order_flow IX_flow_date
- WITH FULLSCAN, PERSIST_SAMPLE_PERCENT = 100;
- -- 多列相关性补一份统计,治组合低估
- CREATE STATISTICS st_city_channel ON dbo.t_order_flow (city, channel)
- WITH FULLSCAN, NORECOMPUTE;
复制代码
多列统计只在同一批列组合被反复查询时才值得建;筛选统计在参数化查询里容易失效,不能滥建。
治理前后对比
| 指标 | 治理前 | 治理后 | | 关键算子预估 / 实际 | 1 / 3,104,872 | 31.2 万 / 30.8 万 | | 连接方式 | 嵌套循环(310 万次查找) | 哈希匹配 | | 内存授予 | 1,024 KB(溢出) | 268 MB(无溢出) | | 报表 P95 | 41 分钟 | 3 分 10 秒 | | tempdb 排序溢出 | 每轮 42 GB | 0 | | last_updated | 11 个月前 | 2 天内 | | 大表触发阈值 | 3200 万行变更 | 56 万行变更 | | 人工干预 | 每月手动刷一次 | 无需干预 |
四、实操检查清单
- [ ] 查 sys.databases.compatibility_level,低于 130 的库一律列入「统计可能冻结」清单。
- [ ] 全库跑一次 sys.dm_db_stats_properties,按 modification_counter 降序取前 20 张表存档做基线。
- [ ] 亿级表记录 rows_sampled / rows,低于 5% 的标记为「样本不足」。
- [ ] 抓实际执行计划,找出「预估 / 实际」比值 > 100 或 < 0.01 的算子,逐个回溯统计对象。
- [ ] 用 sys.dm_db_stats_histogram 确认热点谓词是否落在直方图范围内,落不进去的即为自增键盲区。
- [ ] 改兼容级别前先抓全量 Query Store,改后对比 avg_duration 与 avg_logical_io_reads。
- [ ] 分区表统一 INCREMENTAL = ON,并确认 sys.stats.is_incremental = 1。
- [ ] 关键大表刷新统一带 PERSIST_SAMPLE_PERCENT,别靠抽样运气。
- [ ] 维护作业放低峰、并发限 1,避免多个 FULLSCAN 同时打满 IO 并引发重编译风暴。
- [ ] 治理后连续观察 5 个工作日,确认 modification_counter 回落、计划稳定,再撤掉应急的 OPTION (RECOMPILE)。
几个容易踩的坑
- 以为「自动更新开着」就等于「统计是新的」。开着的只是机制,阈值才是开关。3 亿行表在老兼容级别下要变更 3200 万行才触发,等于实质关闭。
- 把统计问题和参数嗅探混为一谈。区分很干脆:参数嗅探是「不同参数值计划不同、预估值随参数变」;统计陈旧是「不管什么参数,预估值都停在同一个荒谬数字」。
- 只刷索引统计,漏掉列统计。_WA_Sys_ 开头的自动列统计同样参与估算,WHERE 用到非索引列时尤其关键。刷表时用 UPDATE STATISTICS 表名 不加限定才会连列统计一起刷。
- 业务高峰跑全库 FULLSCAN。统计更新会让引用它的计划失效并重编译,大量并发同时重编译会打满 CPU,症状是「维护一跑业务就抖」。
- 升了兼容级别不做回归。新版 CE 对多表连接、OR 展开、表变量的估算与旧版差异很大,不对比就上线等于拿生产做实验。
- 拿 OPTION (RECOMPILE) 当长期方案。它只解决这一次,高频短查询每次重编译本身就有 CPU 成本。
- 忽略分区切换。SWITCH PARTITION 搬进来的数据不会自动刷新统计,必须增量统计 + 定期刷,否则「新分区不参与估算」会周期性复发。
|
|