事情经过
SELECT @@VERSION 返回的是 SQL Server 2019 (RTM-CU18),15.0.4261.1,企业版。库是从 2008 R2 升上来的,跳过 2012、2014、2016、2017 直接到 2019。这种跳版本升级在制造业 ERP 环境里特别常见,实施商图省事,备份还原加上升级向导点下一步就完事了。
现象很具体:按物料编码加日期区间查出入库流水,以前 0.4~0.6 秒,现在 32 秒。七八个人同时点,CPU 直接顶到 100%,然后整个系统跟着卡。用户说的是「系统崩了」,实际上就一张表的问题。
表叫 dbo.t_StockFlow,7824 万行,日增 25 到 30 万行。聚集索引在自增 ID 上,另外有 (MaterialCode, FlowDate) 和 (FlowDate, MaterialCode) 两个非聚集索引。维护计划每天凌晨 2 点重组索引,3 点更新统计信息,全是默认选项,多年没人动过。
第一反应是统计信息过期,查完发现不是
我第一反应就是统计信息过期,症状太像了:一个报表突然慢下来,索引扫描变键查找,基本就是这个套路。于是查 DMV:
SELECT s.name AS stat_name, s.auto_created, s.no_recompute,
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_StockFlow')
ORDER BY sp.last_updated DESC;
关键的那几个统计对象((MaterialCode, FlowDate) 索引统计,还有自动创建的 _WASys),last_updated 全是当天凌晨 03:11,modification_counter 才 24 万,rows_sampled 大约 219 万——7800 万行的表采样率不到 3%,但这是默认行为,本身没问题。直方图 steps 是 200,也是默认上限。
结论很干脆:统计信息是新的,没毛病。
顺便说一句,很多人看到大表采样率只有 3% 就坐不住,非要 WITH FULLSCAN。真没必要,200 个 step 的直方图,采样 3% 和全扫往往给出一样的执行计划,代价却是几分钟和几十分钟的差别。
统计阈值这块得单独讲,因为太多人还在用老经验
SQL Server 2016 之前,自动更新统计信息的触发阈值是 20%(表超过 500 行时)。一张 7824 万行的表,得被改掉 1560 万行左右,SQL Server 才觉得「值得重新采样」。按日增 30 万算,要攒 52 天才触发一次,中间这段时间优化器一直拿着快两个月的直方图干活。
2016 起改成了动态阈值,表行数超过 25000 时大约是 √(1000 × 行数)。7824 万行代进去,√(1000 × 78,240,000) ≈ 279,700。同样日增 30 万,基本每天都触发。两个数字差了 55 倍以上。
| 引擎版本 | 触发阈值(表 > 25000 行) | 7824 万行的实际阈值 |
|---|---|---|
| SQL Server 2014 及更早 | 500 + 20% × 行数 | 约 15,640,000 |
| SQL Server 2014 SP1 + TF 2371 | √(1000 × 行数) | 约 279,700 |
| SQL Server 2016 及以后 | √(1000 × 行数)(默认) | 约 279,700 |
如果还在 2012 或 2014 上,可以开 TF 2371 把这个公式提前拿来用:启动参数加 -T2371,或者 DBCC TRACEON(2371, -1)。这是全局跟踪标志,不是查询级的,加完记得写进文档,不然几年后没人知道这台机器为什么跟别的不一样。
这部分跟本文案例其实没关系,那台是 2019,动态阈值早就默认生效了。我把它单独写出来,是因为排查过程中我在这个方向上花了大半天,最后发现是白忙。留个记录。
真正的原因:兼容级别 100 等于把优化器锁在了 CE 70
第一天下午我其实就查过兼容级别,只是当时没往这个方向想:
SELECT name, compatibility_level, collation_name
FROM sys.databases
WHERE name = 'ERPDB';
-- ERPDB 100 Chinese_PRC_CI_AS
引擎是 2019(内部版本 15.x),数据库兼容级别却是 100。兼容级别决定优化器用哪一版基数估算器(Cardinality Estimator):
| 数据库兼容级别 | 基数估算器 |
|---|---|
| 80 / 90 / 100 / 110 | CE 70(2005 年那套) |
| 120 | CE 120(SQL Server 2014 引入的新 CE) |
| 130 | CE 130(2016) |
| 140 | CE 140(2017) |
| 150 | CE 150(2019) |
| 160 | CE 160(2022) |
也就是说,一台 2019 的引擎,被一个兼容级别 100 的库按 2005 年的方式在做估算。升级向导在还原或者附加数据库时,默认是把兼容级别原样保留的,它不会自动帮你升。
慢的那个查询谓词是三列:MaterialCode = '...' AND FlowDate >= '...' AND FlowDate < '...'。CE 70 假设各列之间完全独立,把两个选择率直接相乘;CE 120 之后引入了指数退避(Exponential Backoff),对多列相关性做了修正。这个查询在 CE 70 下估出来 1200 行,实际返回 41000 行,差了 34 倍。
估算少了会怎样?优化器觉得这活儿小,就选了 Nested Loops 加 Key Lookup:先走 (MaterialCode, FlowDate) 索引定位主键,再回聚集索引取剩下的字段。它以为要回表 1200 次,实际回了 41000 次,而且每次都是随机 IO,32 秒就是这么来的。
验证只用十分钟,两个跟踪标志的事
不用改任何配置,会话级跑一次对比就行:
-- 强制用最新 CE(2019 上是 CE 150)
SELECT ...
FROM dbo.t_StockFlow
WHERE MaterialCode = 'M-88012'
AND FlowDate >= '2024-03-01' AND FlowDate < '2024-04-01'
OPTION (QUERYTRACEON 2312);
-- 反过来,强制用老 CE 70
SELECT ...
OPTION (QUERYTRACEON 9481);
加 2312 那次,执行计划里的 Estimated Number of Rows 从 1200 变成 38500,算子从 Nested Loops 变成 Hash Match 加聚集索引扫描,逻辑读从 41 万页降到 8000 多页,耗时 0.68 秒。加 9481 那次跟原计划一模一样——这很正常,它本来就是 CE 70。
这两个标志在微软文档里都写着,不是我编的。但 QUERYTRACEON 需要 sysadmin 权限,生产库上别随手用,先在只读副本或者测试库验证。
改兼容级别的顺序,别一上来就 ALTER
- 先看 Query Store 开没开:
SELECT actual_state_desc, current_storage_size_mb FROM sys.database_query_store_options。SQL Server 2016 之后新建的库默认是开的(master 模板里就是 ON),但从老版本还原过来的库默认是 OFF,这点很多人不知道。要开的话记得给空间,默认 100MB 撑不了几天:
ALTER DATABASE ERPDB SET QUERY_STORE = ON
(OPERATION_MODE = READ_WRITE, MAX_STORAGE_SIZE_MB = 2048,
INTERVAL_LENGTH_MINUTES = 15, QUERY_CAPTURE_MODE = AUTO);
-
让它跑够一个完整业务周期(月末月结那次一定要覆盖),把基线收齐。
-
测试库上先升:
ALTER DATABASE ERPDB SET COMPATIBILITY_LEVEL = 150;,然后按 query_id 对比升级前后的 duration 和 cpu_time,回归的查询单独拎出来看。SSMS 里右键数据库,Tasks 下面有个兼容级别升级报告,可以参考但别全信,直接查 Query Store 的 DMV 更实在。 -
真有回归的,用
sp_query_store_force_plan把老计划钉住,比整个库回退兼容级别精细得多。 -
回退成本极低,一条 ALTER 语句,改完该库的缓存计划会被标脏重新编译,不用重启实例。
我们是周五晚上 8 点动的手,改完全量报表跑一遍,32 秒变 0.6 秒,CPU 峰值从 100% 掉到 20% 出头。整个过程不到二十分钟,其中十五分钟是在等备份。
最后说点不太一样的
「升级 SQL Server 版本」和「升级执行计划质量」是两件事。引擎装的是 2019,数据库脑子还停在 2005 年,这在跳版本升级里是常态。大家平时聊的都是统计信息过期、索引碎片、缺失索引,这些反而好排查,因为 DMV 一眼能看出来。兼容级别藏在 sys.databases 的一个小字段里,没人提醒的话根本想不起来看。
我见过太多升级后的验收:SSMS 能连上、能查数据、备份能跑,就算成功。兼容级别、Query Store 状态、并行度阈值(cost threshold for parallelism,还在用默认的 5)、MAXDOP,一个都没人看。
如果你手上也有从 2008 或者 2012 升上来的库,先花十分钟跑这两句:
SELECT name, compatibility_level, is_read_committed_snapshot_on,
snapshot_isolation_state_desc, is_query_store_on
FROM sys.databases
WHERE database_id > 4;
SELECT actual_state_desc, current_storage_size_mb,
max_storage_size_mb, readonly_reason
FROM sys.database_query_store_options;
大概率会看到一两个让你想骂人的结果。
不过话说回来,如果那套系统真的老了,2012 甚至 2008 还撑着核心业务,别急着升兼容级别。有些库就是靠老 CE 那套「错误但稳定」的估算跑得挺好,升完反而一堆回归。这种情况我一般建议先开 Query Store 观察两周,把慢查询和计划变化摸清楚再动。那是另一个话题了。