SQL Server 升级到 2019 后查询从 0.5 秒变成 32 秒,最后发现是兼容级别卡在 100

🔑 关键词:SQL Server 兼容级别, 统计信息阈值, TF 2371, 基数估算 CE 70, 查询变慢

📖 摘要:一个从 SQL Server 2008 R2 跳到 2019 的 ERP 库,报表查询从 0.5 秒掉到 32 秒。统计信息是最新的,索引也正常,真正的问题是数据库兼容级别还停在 100,优化器走的是 2005 年的 CE 70。附统计阈值 20% 与 SQRT 公式对比、DMV 排查语句、TF 2312/9481 验证方法和升兼容级别的安全步骤。

事情经过

图片

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

图片

  1. 先看 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);
  1. 让它跑够一个完整业务周期(月末月结那次一定要覆盖),把基线收齐。

  2. 测试库上先升:ALTER DATABASE ERPDB SET COMPATIBILITY_LEVEL = 150;,然后按 query_id 对比升级前后的 duration 和 cpu_time,回归的查询单独拎出来看。SSMS 里右键数据库,Tasks 下面有个兼容级别升级报告,可以参考但别全信,直接查 Query Store 的 DMV 更实在。

  3. 真有回归的,用 sp_query_store_force_plan 把老计划钉住,比整个库回退兼容级别精细得多。

  4. 回退成本极低,一条 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 观察两周,把慢查询和计划变化摸清楚再动。那是另一个话题了。

🏷️ 标签: