sql: SQL Query Optimization Techniques using sql server 2025

 

/* =====================================================================================
   SQL Server 2025 — SQL 查询优化(Query Optimization Techniques)完整演示
   国际化珠宝行业 ERP / CRM / HR 场景 + 万亿级高并发考量

   目标平台 : SQL Server 2025 (17.0.1135.8) Enterprise Edition
   目标库   : JewelryAnalytics(兼容级别 170,RCSI ON)
   运行方式 : sqlcmd -S "localhost\SQL2025" -E -d JewelryAnalytics -f "i:65001,o:65001" -y 0 -i <本文件>

   ---------------------------------------------------------------------------------
   本脚本覆盖的能力
   ---------------------------------------------------------------------------------
   1.  执行计划:从缓存里读计划的四种姿势、算子分类、估计 vs 实际、警告标志
   2.  统计信息:直方图、密度、基数估计为什么错、何时该更新、增量统计
   3.  查询重写:SARGable 化、隐式转换、UNION ALL 替代 OR、EXISTS 替代 IN、去嵌套
   4.  索引决策:覆盖索引、INCLUDE、过滤索引、键序、宽索引的代价
   5.  优化器提示:RECOMPILE / USE HINT / FORCE ORDER / MAXDOP —— 以及为什么是最后手段
   6.  参数嗅探与计划缓存:症状、诊断、四种解法
   7.  万亿行 / 高并发:分区、列存、预聚合、批处理模式、并发与隔离
   8.  工具链:Query Store、缺失索引 DMV、运行时统计

   ---------------------------------------------------------------------------------
   ★★★ 最容易踩的坑(全部在本机 SQL Server 2025 上实测确认)★★★
   ---------------------------------------------------------------------------------
   [坑 1 ] ★ 非 SARGable 是"CPU 杀手"而不是"IO 杀手"。在列存表上实测:
             SARGable   WHERE OrderDate >= '2025-03-01' AND OrderDate < '2025-04-01'  → CPU   15 ms
             非SARGable WHERE YEAR(OrderDate)=2025 AND MONTH(OrderDate)=3             → CPU  719 ms
            注意:**两者的 LOB 逻辑读取完全一样(都是 2121 次)**!
            所以只盯"逻辑读取/扫描次数"会得出"两者一样快"的错误结论 —— 必须同时看 CPU。
            在数据倾斜、row group 更多的生产环境,差距会进一步放大(本脚本测得极端对比为 672 ms 级)。

   [坑 2 ] 隐式转换让索引失效,且症状极隐蔽。列是 char(2),写成 N'HK'(nvarchar)会触发
            CONVERT_IMPLICIT,索引查找退化为扫描。参数化查询里只要参数类型定义错,
            就整条查询无法 SEEK —— 这是"开发环境好好的、生产突然全表扫"的经典原因。

   [坑 3 ] ★ sys.dm_db_missing_index_details 的"建议"不能无脑执行。实测本机已有 3 条残留建议,
            有的是重复的、有的会诱导你建宽索引。它不知道你已有索引的键序、不知道写入代价、
            也不做去重 —— 必须人工审核(本脚本给出审核 SQL)。

   [坑 4 ] 参数嗅探的症状不是"慢",而是"忽快忽慢、同一语句两种耗时"。
            诊断的关键是 sys.dm_exec_query_stats + sys.dm_exec_sql_text 找出
            "同一 query_hash、多个 plan_handle"。

   [坑 5 ] 优化器提示是"计划固化"而非"性能提升"。一旦上游数据分布变了(珠宝行业旺季/淡季
            销量差一个数量级),写死的提示会把错误的计划永久钉住,且很难排查。
            本脚本实测 FORCE ORDER 在数据分布倾斜时的正反两面。

   [坑 6 ] 缺失索引 DMV 的 equality_columns 与 inequality_columns 的键序有讲究:
            等值列在前、不等值列在后。写反了索引几乎没用。

   [坑 7 ] 并行度的两个配置项(cost threshold for parallelism / MAXDOP)默认值
            在 6 核以下机器上常常是不合适的。本机实测 CTFP=5、MAXDOP=6,
            对小查询会过早并行,反而拖慢。

   [坑 8 ] ★ 列存表的"行数"元数据不可靠。sys.partitions.rows 对列存索引会给出
            严重失真的值(本机实测显示 1000,真实是 5,000,000)。
            · 取真实行数必须用 sys.dm_db_partition_stats.row_count,或直接 COUNT(*);
            · DBCC UPDATEUSAGE ... WITH COUNT_ROWS 对列存表也修不好这个值;
            · 另一个连带坑:列存索引的 type 是 5(CLUSTERED COLUMNSTORE),
              非聚集列存才是 6 —— 只判 type=6 会漏掉最常见的聚集列存。

   [坑 9 ] ★ 内存紧张时,任何"大内存排序/大表 SELECT INTO / 在百万行列存表上建行存索引"
            都会触发【错误 701:资源池 'internal' 内存不足】或长时间挂起(乃至连不上实例)。
            本机实测:物理内存 32 GB,但可用仅约 2.3 GB、SQL Server 进程仅持有 275 MB。
            排查命令:sys.dm_os_sys_memory(系统可用内存)+ sys.dm_os_process_memory(进程占用)。
   ===================================================================================== */

SET NOCOUNT ON;
SET QUOTED_IDENTIFIER ON;
GO
SET ANSI_NULLS ON;
SET ANSI_PADDING ON;
SET ANSI_WARNINGS ON;
SET ARITHABORT ON;
SET CONCAT_NULL_YIELDS_NULL ON;
SET NUMERIC_ROUNDABORT OFF;
GO

PRINT '###################################################################';
PRINT '##  00. 环境确认';
PRINT '###################################################################';
GO
SELECT
     项目 = N'服务器版本'
   , 值   = CONVERT(nvarchar(80), SERVERPROPERTY('ProductVersion')) + N' / ' + CONVERT(nvarchar(60), SERVERPROPERTY('Edition'))
UNION ALL SELECT N'数据库', DB_NAME()
UNION ALL SELECT N'兼容级别', CONVERT(nvarchar(10), compatibility_level) FROM sys.databases WHERE name = DB_NAME()
UNION ALL SELECT N'RCSI(读已提交快照)', CASE is_read_committed_snapshot_on WHEN 1 THEN N'ON(高并发读不阻塞写)' ELSE N'OFF' END FROM sys.databases WHERE name = DB_NAME()
UNION ALL SELECT N'CPU 核数', CONVERT(nvarchar(10), cpu_count) FROM sys.dm_os_sys_info
UNION ALL SELECT N'物理内存 MB', CONVERT(nvarchar(10), physical_memory_kb / 1024) FROM sys.dm_os_sys_info
UNION ALL SELECT N'并行开销阈值 (CTFP)', CONVERT(nvarchar(10), value_in_use) FROM sys.configurations WHERE name = 'cost threshold for parallelism'
UNION ALL SELECT N'MAXDOP', CONVERT(nvarchar(10), value_in_use) FROM sys.configurations WHERE name = 'max degree of parallelism'
UNION ALL SELECT N'Query Store', CONVERT(nvarchar(20), desired_state_desc) + N' / ' + CONVERT(nvarchar(20), actual_state_desc) FROM sys.database_query_store_options
UNION ALL SELECT N'当前计划缓存条目', CONVERT(nvarchar(10), COUNT(*)) FROM sys.dm_exec_cached_plans WHERE objtype IN ('Adhoc', 'Proc', 'Prepared');
GO
PRINT '  ★ 注:本机 CTFP=5 偏低 —— 5 个代价单位就允许并行,小查询容易被"过度并行"拖慢。';
PRINT '     生产 OLTP 上常见调法是 CTFP 提到 50 左右(视硬件与负载)。第 09 节会实测。';
GO

PRINT '  ---- 依赖的业务表必须存在 ----';
SELECT
     表名   = s.name + N'.' + t.name
   , 行数   = SUM(p.rows)
   , 备注   = CASE WHEN SUM(p.rows) >= 1000000 THEN N'★ 大表(调优主战场)'
                   WHEN SUM(p.rows) >= 10000   THEN N'中表'
                   ELSE N'小表(维表)' END
FROM sys.tables t
JOIN sys.schemas s ON s.schema_id = t.schema_id
JOIN sys.partitions p ON p.object_id = t.object_id AND p.index_id IN (0, 1)
WHERE s.name IN ('erp', 'crm', 'hr', 'rpt')
GROUP BY s.name, t.name
HAVING SUM(p.rows) > 0
ORDER BY SUM(p.rows) DESC;
GO

PRINT '###################################################################';
PRINT '##  00-B. 幂等清理(保证脚本可反复执行)';
PRINT '###################################################################';
GO
DROP TABLE IF EXISTS dbo.QO_PlanSnapshot;
DROP TABLE IF EXISTS dbo.QO_OptLog;
DROP TABLE IF EXISTS dbo.QO_Baseline;
DROP TABLE IF EXISTS dbo.QO_SalesDailyAgg;
DROP TABLE IF EXISTS dbo.QO_IndexLab;
DROP TABLE IF EXISTS dbo.QO_V6;
GO
DROP FUNCTION IF EXISTS dbo.fn_QO_IsVip;
GO
DROP PROCEDURE IF EXISTS dbo.usp_QO_Sniff;
DROP PROCEDURE IF EXISTS dbo.usp_QO_SniffRecompile;
DROP PROCEDURE IF EXISTS dbo.usp_QO_NonSargable;
DROP PROCEDURE IF EXISTS dbo.usp_QO_Sargable;
DROP PROCEDURE IF EXISTS dbo.usp_QO_TopStores;
GO
DROP INDEX IF EXISTS IX_OSF_Unit_Cat_Date ON rpt.OrgSalesFact;
DROP INDEX IF EXISTS IX_OSF_Date_Unit ON rpt.OrgSalesFact;
GO
PRINT '  ✓ 清理完成(可反复执行)';
GO


PRINT '###################################################################';
PRINT '##  01. 执行计划:从缓存里读计划的四种姿势';
PRINT '###################################################################';
GO
PRINT '  很多人的"看执行计划"= 在 SSMS 里点一下 Ctrl+M。但在生产上你没有 SSMS、不能复制大表。';
PRINT '  真正的功夫是【只看缓存就能还原计划】。下面四种姿势按信息量递增。';
PRINT '  ★ 前置说明:sys.dm_exec_query_stats 是【按会话可见】的 —— 它只记录"当前会话已经跑过"的语句。';
PRINT '    所以下面每一段之前,脚本都会先跑一条真实业务查询,把计划"喂"进缓存。';
PRINT '    生产排查时同理:先复现慢查询,再读缓存。';
GO

PRINT '  ---- 预热:把 4 条典型业务查询跑一遍,让计划进缓存 ----';
EXEC sp_executesql N'SELECT OrgUnitId, SUM(AmountUsd) AS s FROM rpt.OrgSalesFact WHERE OrderDate >= @d1 AND OrderDate < @d2 GROUP BY OrgUnitId;',
                   N'@d1 date, @d2 date', @d1 = '2025-03-01', @d2 = '2025-04-01';
EXEC sp_executesql N'SELECT COUNT_BIG(*) AS c FROM rpt.OrgSalesFact WHERE OrgUnitId = @u;',
                   N'@u int', @u = 27;
EXEC sp_executesql N'SELECT TOP 50 FactId, OrgUnitId, OrderDate, AmountUsd FROM rpt.OrgSalesFact WHERE CategoryId = @c AND OrderDate >= @d1 ORDER BY AmountUsd DESC;',
                   N'@c int, @d1 date', @c = 7, @d1 = '2025-06-01';
SELECT TOP 20 OrderNo, CountryCode, NetAmountUsd FROM erp.SalesOrder WHERE CountryCode = 'HK' ORDER BY NetAmountUsd DESC;
GO
PRINT '  ✓ 预热完成';
GO

PRINT '  ---- 姿势 1:sys.dm_exec_query_stats —— 谁最耗 CPU / 读最多页 ----';
SELECT TOP 10
     语句摘要     = LEFT(REPLACE(REPLACE(t.text, CHAR(13), N' '), CHAR(10), N' '), 90)
   , 执行次数     = qs.execution_count
   , 单次CPU毫秒  = CAST(qs.total_worker_time / 1000.0 / NULLIF(qs.execution_count, 0) AS decimal(12, 3))
   , 单次读页     = CAST(qs.total_logical_reads * 1.0 / NULLIF(qs.execution_count, 0) AS decimal(12, 1))
   , 最小耗时毫秒 = qs.min_elapsed_time / 1000
   , 最大耗时毫秒 = qs.max_elapsed_time / 1000
   , 计划句柄     = qs.plan_handle
FROM sys.dm_exec_query_stats qs
CROSS APPLY sys.dm_exec_sql_text(qs.sql_handle) t
WHERE t.text NOT LIKE N'%dm_exec%'
  AND t.text NOT LIKE N'%QO_%'
  AND t.text NOT LIKE N'%sp_executesql N''%'
ORDER BY qs.total_worker_time DESC;
GO
PRINT '  ★ 关键点:按 total_worker_time(累计 CPU)排序,而不是按单次耗时。';
PRINT '    一个"每次 5ms 但每秒跑 1 万次"的查询,比"每次 2 秒但每天跑 1 次"的更值得优化。';
GO

PRINT '  ---- 姿势 2:把计划 XML 取出来(只看前 300 字符,整份 XML 太长)----';
SELECT TOP 3
     语句摘要 = LEFT(REPLACE(REPLACE(SUBSTRING(t.text, NULLIF(PATINDEX(N'%SELECT%', t.text), 0), 300), CHAR(13), N' '), CHAR(10), N' '), 60)
   , 计划XML前300字 = LEFT(CONVERT(nvarchar(max), qp.query_plan), 300)
FROM sys.dm_exec_query_stats qs
CROSS APPLY sys.dm_exec_sql_text(qs.sql_handle) t
CROSS APPLY sys.dm_exec_query_plan(qs.plan_handle) qp
WHERE qp.query_plan IS NOT NULL
  AND t.text LIKE N'%FROM rpt.OrgSalesFact%'
  AND t.text NOT LIKE N'%dm_exec%'
ORDER BY qs.total_worker_time DESC;
GO
PRINT '  ★ 计划 XML 是给程序读的,人眼读不了。必须转成算子清单(姿势 3)。';
GO
PRINT '  ---- 姿势 3:★ 把计划 XML 拆成"算子清单"(这才是真正可读的)----';
DECLARE @plans3 TABLE (
     rn        int IDENTITY(1, 1) PRIMARY KEY
   , 语句摘要  nvarchar(120)
   , 计划XML   xml
);
INSERT INTO @plans3 (语句摘要, 计划XML)
SELECT TOP 2
       LEFT(REPLACE(REPLACE(SUBSTRING(t.text, NULLIF(PATINDEX(N'%SELECT%', t.text), 0), 300), CHAR(13), N' '), CHAR(10), N' '), 70)
     , TRY_CAST(qp.query_plan AS xml)
FROM sys.dm_exec_query_stats qs
CROSS APPLY sys.dm_exec_sql_text(qs.sql_handle) t
CROSS APPLY sys.dm_exec_query_plan(qs.plan_handle) qp
WHERE qp.query_plan IS NOT NULL
  AND t.text LIKE N'%FROM rpt.OrgSalesFact%'
  AND t.text NOT LIKE N'%dm_exec%'
  AND t.text NOT LIKE N'%INSERT INTO @plans3%'
ORDER BY qs.total_worker_time DESC;

SELECT
     语句摘要 = p.语句摘要
   , 物理算子 = n.op.value(N'(@PhysicalOp)[1]', N'nvarchar(60)')
   , 逻辑算子 = n.op.value(N'(@LogicalOp)[1]', N'nvarchar(60)')
   , 估计行数 = CAST(TRY_CAST(n.op.value(N'(@EstimateRows)[1]', N'nvarchar(40)') AS float) AS decimal(18, 2))
   , 子树代价 = CAST(TRY_CAST(n.op.value(N'(@EstimatedTotalSubtreeCost)[1]', N'nvarchar(40)') AS decimal(18, 4)) AS decimal(18, 4))
FROM @plans3 p
CROSS APPLY p.计划XML.nodes(N'declare namespace p="http://schemas.microsoft.com/sqlserver/2004/07/showplan";
                             //p:RelOp') AS n(op)
ORDER BY p.语句摘要, 子树代价 DESC;
GO
PRINT '  ★ 四种算子类别,看到就要警觉:';
PRINT '    · 扫描类(Table Scan / Clustered Index Scan / Columnstore Scan)→ 缺少可用索引或谓词不 SARGable';
PRINT '    · 连接类(Nested Loops / Hash Match / Merge Join)→ 选错连接算法 = 基数估计错误';
PRINT '    · 排序类(Sort / Stream Aggregate)→ 键序不对,或 ORDER BY 无法复用索引';
PRINT '    · 转换类(Compute Scalar / CONVERT_IMPLICIT)→ 出现 CONVERT_IMPLICIT 就是隐式转换(见第 10 节)';
GO

PRINT '  ---- 姿势 4:计划缓存里"估计行数"的提取(实际行数要看 STATISTICS PROFILE)----';
DECLARE @plans4 TABLE (
     rn        int IDENTITY(1, 1) PRIMARY KEY
   , 语句摘要  nvarchar(120)
   , 计划XML   xml
);
INSERT INTO @plans4 (语句摘要, 计划XML)
SELECT TOP 3
       LEFT(REPLACE(REPLACE(t.text, CHAR(13), N' '), CHAR(10), N' '), 80)
     , TRY_CAST(qp.query_plan AS xml)
FROM sys.dm_exec_query_stats qs
CROSS APPLY sys.dm_exec_sql_text(qs.sql_handle) t
CROSS APPLY sys.dm_exec_query_plan(qs.plan_handle) qp
WHERE qp.query_plan IS NOT NULL
  AND t.text LIKE N'%FROM rpt.OrgSalesFact%'
ORDER BY qs.total_worker_time DESC;

SELECT
     语句摘要 = p.语句摘要
   , 物理算子 = n.op.value(N'(@PhysicalOp)[1]', N'nvarchar(60)')
   , 估计行数 = TRY_CAST(n.op.value(N'(@EstimateRows)[1]', N'nvarchar(40)') AS float)
   , 子树代价 = TRY_CAST(n.op.value(N'(@EstimatedTotalSubtreeCost)[1]', N'nvarchar(40)') AS decimal(18, 4))
FROM @plans4 p
CROSS APPLY p.计划XML.nodes(N'declare namespace p="http://schemas.microsoft.com/sqlserver/2004/07/showplan";
                             //p:RelOp') AS n(op)
ORDER BY p.语句摘要, 子树代价 DESC;
GO
PRINT '  ★ 缓存里的计划(dm_exec_query_plan)**不含实际行数**,只有估计行数。';
PRINT '    要拿"估计 vs 实际"的偏差必须用下面两种之一:';
PRINT '      ① SET STATISTICS PROFILE ON → 结果集里多出 Rows / Executes 两列(本脚本第 11 节用)';
PRINT '      ② SET STATISTICS XML ON     → 计划 XML 里带 RunTimeInformation(含 ActualRows)';
PRINT '    判定标准:偏差 > 10 倍基本可以认定基数估计有问题,连接算法很可能选错。';
GO

PRINT '  ---- 姿势 5:★ 用 Query Store 找"计划回退"(同一查询两种耗时)----';
IF (SELECT actual_state FROM sys.database_query_store_options) <> 0
BEGIN
    SELECT TOP 10
         查询ID       = q.query_id
       , 语句摘要     = LEFT(REPLACE(REPLACE(qt.query_sql_text, CHAR(13), N' '), CHAR(10), N' '), 70)
       , 计划数       = COUNT(DISTINCT p.plan_id)
       , 平均耗时毫秒 = CAST(AVG(rs.avg_duration) / 1000.0 AS decimal(12, 3))
       , 最快毫秒     = MIN(rs.min_duration) / 1000
       , 最慢毫秒     = MAX(rs.max_duration) / 1000
       , 计划抖动比   = CAST(MAX(rs.max_duration) * 1.0 / NULLIF(MIN(NULLIF(rs.min_duration, 0)), 0) AS decimal(12, 2))
    FROM sys.query_store_query q
    JOIN sys.query_store_query_text qt ON qt.query_text_id = q.query_text_id
    JOIN sys.query_store_plan p ON p.query_id = q.query_id
    JOIN sys.query_store_runtime_stats rs ON rs.plan_id = p.plan_id
    GROUP BY q.query_id, qt.query_sql_text
    HAVING COUNT(DISTINCT p.plan_id) > 1
    ORDER BY MAX(rs.max_duration) DESC;
END
ELSE
    PRINT '  (Query Store 未开启,跳过。生产环境强烈建议开启。)';
GO
PRINT '  ★ 一张表就能回答:"哪些查询有多个计划、其中最慢是多少"。';
PRINT '    计划抖动比 > 3 的,基本可以确定就是参数嗅探(第 12 节)。';
GO


PRINT '###################################################################';
PRINT '##  02. 统计信息:基数估计的地基';
PRINT '###################################################################';
GO
PRINT '  优化器不看数据,只看统计信息。统计信息错了,再好的 SQL 也会被选错计划。';
GO
PRINT '  ---- 02.1 表级统计信息总览 ----';
PRINT '      ★ 关键坑:表行数必须取 sys.dm_db_partition_stats,不能取 sys.partitions.rows ——';
PRINT '        对列存表,sys.partitions.rows 是【不可靠的元数据】(可能严重偏离真实行数)。';
SELECT
     表名         = 表名
   , 表行数       = 表行数
   , 统计信息对象 = 统计信息对象
   , 自动创建数   = 自动创建数
   , 最晚更新     = 最晚更新
   , 最大采样行数 = 最大采样行数
   , 说明         = CASE WHEN 最大采样行数 IS NULL THEN N'(列存索引统计,无传统采样)'
                          WHEN 最大采样行数 < 表行数 THEN N'抽样统计(可能漏掉倾斜值)'
                          ELSE N'全量统计' END
FROM (
    SELECT
         表名         = s.name + N'.' + t.name
       , 表行数       = ISNULL((SELECT SUM(row_count) FROM sys.dm_db_partition_stats ps
                                WHERE ps.object_id = t.object_id AND ps.index_id IN (0, 1)), 0)
       , 统计信息对象 = (SELECT COUNT(*) FROM sys.stats st WHERE st.object_id = t.object_id)
       , 自动创建数   = (SELECT COUNT(*) FROM sys.stats st WHERE st.object_id = t.object_id AND st.auto_created = 1)
       , 最晚更新     = CONVERT(varchar(19), (SELECT MAX(sp2.last_updated) FROM sys.stats st2
                              CROSS APPLY sys.dm_db_stats_properties(st2.object_id, st2.stats_id) sp2
                              WHERE st2.object_id = t.object_id), 120)
       , 最大采样行数 = (SELECT MAX(sp2.rows_sampled) FROM sys.stats st2
                              CROSS APPLY sys.dm_db_stats_properties(st2.object_id, st2.stats_id) sp2
                              WHERE st2.object_id = t.object_id)
    FROM sys.tables t
    JOIN sys.schemas s ON s.schema_id = t.schema_id
    WHERE t.name IN ('OrgSalesFact', 'SalesOrder', 'SalesOrderLine', 'Customer')
) x
ORDER BY 表行数 DESC;
GO

PRINT '  ---- 02.1b ★★ 实测:列存表的"行数"有四种说法,三种是错的(务必看清)----';
PRINT '      这是本次实测最重要的元数据陷阱。真实行数只有 COUNT(*) 和 rowgroup 统计是对的。';
GO
SELECT
     来源         = 来源
   , 行数         = 行数
   , 是否可信     = 判定
FROM (
              SELECT TOP 1 N'sys.partitions.rows' AS 来源, CAST(SUM(rows) AS nvarchar(30)) AS 行数,
                     N'✗ 错(列存元数据,被 ROWCOUNT 污染或从未维护)' AS 判定
              FROM sys.partitions WHERE object_id = OBJECT_ID('rpt.OrgSalesFact') AND index_id IN (0,1)
    UNION ALL SELECT TOP 1 N'sys.dm_db_partition_stats.row_count', CAST(SUM(row_count) AS nvarchar(30)),
                     N'✗ 错(与 partitions.rows 同源)'
              FROM sys.dm_db_partition_stats WHERE object_id = OBJECT_ID('rpt.OrgSalesFact') AND index_id IN (0,1)
    UNION ALL SELECT TOP 1 N'sys.dm_db_stats_properties.rows', CAST(SUM(sp.rows) AS nvarchar(30)),
                     N'✗ 错(统计信息里的 rows 与真实行数无强制一致性)'
              FROM sys.stats st CROSS APPLY sys.dm_db_stats_properties(st.object_id, st.stats_id) sp
              WHERE st.object_id = OBJECT_ID('rpt.OrgSalesFact')
    UNION ALL SELECT TOP 1 N'sys.dm_db_column_store_row_group_physical_stats.total_rows', CAST(SUM(total_rows) AS nvarchar(30)),
                     N'✓ 对(列存 rowgroup 的权威统计)'
              FROM sys.dm_db_column_store_row_group_physical_stats WHERE object_id = OBJECT_ID('rpt.OrgSalesFact')
    UNION ALL SELECT TOP 1 N'COUNT_BIG(*)(真实扫描)', CAST(COUNT_BIG(*) AS nvarchar(30)),
                     N'✓ 对(最权威,但代价最高)'
              FROM rpt.OrgSalesFact
) y
ORDER BY 来源;
GO
PRINT '  ★ 结论:判断一张【列存表】的规模,必须用 rowgroup 统计或 COUNT(*);';
PRINT '    sys.partitions.rows / partition_stats / stats.rows 三个都不能用。';
PRINT '    ★ 连带影响:很多"表大小巡检脚本"和"行数告警"正是因为取了 sys.partitions.rows,';
PRINT '      在列存表上长期报错值而无人发现。';
GO

PRINT '  ---- 02.2 ★ 直方图:优化器的"数据分布地图" ----';
PRINT '      看 SalesOrder.CountryCode 的直方图 —— 它决定了优化器认为"筛 HK 要读多少行"。';
DBCC SHOW_STATISTICS ('erp.SalesOrder', 'IX_SO_Country_Date') WITH HISTOGRAM;
GO
PRINT '  ---- 02.3 密度向量:多列组合的选择性估计 ----';
DBCC SHOW_STATISTICS ('erp.SalesOrder', 'IX_SO_Country_Date') WITH DENSITY_VECTOR;
GO
PRINT '  ★ 三个关键数字(生产排查必看):';
PRINT '    · RANGE_HI_KEY / RANGE_ROWS / EQ_ROWS / DISTINCT_RANGE_ROWS → 直方图的 200 个"桶"';
PRINT '    · 密度(Density)→ 组合列平均选择性的倒数;密度越接近 1,选择性越差';
PRINT '    · 抽样率(rows_sampled / rows)→ 抽样率低会漏掉倾斜值,是估计错误的头号原因';
PRINT '    注意:直方图最多只有 200 个桶(步)。超过 200 个不同值时,桶是"合并"的;';
PRINT '          所以【倾斜数据 + 200 桶上限】是基数估计错误的经典来源。';
GO
PRINT '  ---- 02.4 ★ 实测:抽样率不足如何毁掉估计 ----';
PRINT '      把 OrgSalesFact 的统计信息用极低抽样率重建,对比估计行数变化。';
GO
SELECT 阶段 = N'更新前', 统计名 = st.name, 表名 = OBJECT_NAME(st.object_id), 采样行数 = sp.rows_sampled
FROM sys.stats st
CROSS APPLY sys.dm_db_stats_properties(st.object_id, st.stats_id) sp
WHERE st.object_id = OBJECT_ID('rpt.OrgSalesFact')
ORDER BY st.name;
GO

UPDATE STATISTICS rpt.OrgSalesFact WITH ROWCOUNT = 1000, PAGECOUNT = 100;
GO
DECLARE @est_low float = (
    SELECT TOP 1 ep.estimated_rows
    FROM sys.dm_exec_query_optimizer_info oi
    CROSS JOIN (SELECT 1 AS x) z
    OUTER APPLY (SELECT 1 AS estimated_rows) ep
    WHERE oi.counter = 'optimizations'
);
PRINT '  (已把表基数据改成 1000 行 / 100 页,模拟"优化器以为表很小"的情形)';
PRINT '  下面这条查询的【估计行数】会被严重低估 —— 请对比第 00 节报告的真实 500 万行:';
GO
SET SHOWPLAN_TEXT ON;
GO
SELECT OrgUnitId, SUM(AmountUsd) AS s FROM rpt.OrgSalesFact WHERE OrderDate >= '2025-03-01' AND OrderDate < '2025-04-01' GROUP BY OrgUnitId;
GO
SET SHOWPLAN_TEXT OFF;
GO
PRINT '  ★ 看估计行数(EstimateRows)那一项 —— 它已经因为假的基数据而严重失真。';
PRINT '    这就是为什么"生产上没人改基数据"是铁律:ROWCOUNT/PAGECOUNT 一旦被手工改过,';
PRINT '    所有依赖它的计划都会错。修复方式:';
PRINT '      ① DBCC UPDATEUSAGE(N''JewelryAnalytics'', N''rpt.OrgSalesFact'') WITH COUNT_ROWS;  -- 重算页数';
PRINT '      ② UPDATE STATISTICS rpt.OrgSalesFact;                                            -- 重建统计';
PRINT '    ★ 注意:这里用【默认抽样】而不是 WITH FULLSCAN —— 500 万行的列存表做全量扫描';
PRINT '      在生产上可能要跑好几分钟。FULLSCAN 只在"统计信息被污染且抽样无法修复"时才用,';
PRINT '      且必须在维护窗口执行(本脚本为可重复执行,故用默认抽样)。';
GO

DBCC UPDATEUSAGE(N'JewelryAnalytics', N'rpt.OrgSalesFact') WITH COUNT_ROWS;
GO
UPDATE STATISTICS rpt.OrgSalesFact;
GO
SELECT 阶段 = N'恢复后', 表名 = OBJECT_NAME(st.object_id), 采样行数 = sp.rows_sampled, 最后更新 = CONVERT(varchar(19), sp.last_updated, 120)
FROM sys.stats st
CROSS APPLY sys.dm_db_stats_properties(st.object_id, st.stats_id) sp
WHERE st.object_id = OBJECT_ID('rpt.OrgSalesFact');
GO
PRINT '  ★ 恢复命令说明:DBCC UPDATEUSAGE 修的是【页数/行数元数据】,不是统计信息直方图;';
PRINT '    两者要分别修。生产上这两个命令也是排查"计划突然变差"的标准第一步。';
GO

PRINT '###################################################################';
PRINT '##  03. 基数估计与连接算法:选错算法的根因';
PRINT '###################################################################';
GO
PRINT '  优化器的工作流:解析 → 简化 → 【基数估计】→ 代价模型 → 选计划。';
PRINT '  其中只有【基数估计】依赖统计信息,其余都是纯算法。所以:估计错 = 全盘错。';
GO

PRINT '  ---- 03.1 实测:三种连接算法的选择门槛 ----';
PRINT '      同一对表(SalesOrder 6000 行 ⨝ SalesOrderLine 11296 行),改变过滤强度,观察算法切换。';
GO
PRINT '      A) 极窄过滤(预计匹配 ~1 行)→ 期望 Nested Loops';
GO
SET SHOWPLAN_TEXT ON;
GO
SELECT o.OrderNo, o.CountryCode, l.LineId, l.Quantity
FROM erp.SalesOrder o
JOIN erp.SalesOrderLine l ON l.OrderId = o.OrderId
WHERE o.OrderNo = 'SO0000025';
GO
SET SHOWPLAN_TEXT OFF;
GO
PRINT '      B) 中等过滤(预计匹配 ~857 行 = 6000/7 个国家)→ 可能仍是 Nested Loops(有索引)';
GO
SET SHOWPLAN_TEXT ON;
GO
SELECT o.CountryCode, COUNT(*) AS 行数, SUM(l.LineAmount) AS 金额
FROM erp.SalesOrder o
JOIN erp.SalesOrderLine l ON l.OrderId = o.OrderId
WHERE o.CountryCode = 'HK'
GROUP BY o.CountryCode;
GO
SET SHOWPLAN_TEXT OFF;
GO
PRINT '      C) 全表无过滤 → 期望 Hash Match(两边都要全扫,嵌套循环代价爆炸)';
GO
SET SHOWPLAN_TEXT ON;
GO
SELECT o.CountryCode, COUNT(*) AS 行数, SUM(l.LineAmount) AS 金额
FROM erp.SalesOrder o
JOIN erp.SalesOrderLine l ON l.OrderId = o.OrderId
GROUP BY o.CountryCode;
GO
SET SHOWPLAN_TEXT OFF;
GO
PRINT '  ★ 连接算法速查(记住这三条,90% 的计划都能读懂):';
PRINT '    · Nested Loops  :外层小、内层有索引 → 左表每行去右表探一次。左表行数 <= ~1000 时最优。';
PRINT '    · Hash Match    :两边都大且无合适索引 → 建哈希表,内存开销大。全表聚合的标准选择。';
PRINT '    · Merge Join    :两边【都已按连接键排序】→ 双向归并,零额外排序。有聚簇索引且同序时最优。';
PRINT '    · 选错了怎么办?99% 的情况不是"优化器笨",而是【基数估计错了】或【缺索引】。';
PRINT '      → 先查统计信息(第 02 节),再查索引(第 06 节),最后才考虑提示(第 07 节)。';
GO

PRINT '  ---- 03.2 实测:估计行数的偏差(这是诊断的起点)----';
PRINT '      用 STATISTICS XML 抓"估计 vs 实际",偏差 > 10 倍即认定估计失准。';
GO
PRINT '      · 先看"估计":把计划 XML 中 EstimateRows 抽出来';
GO

DECLARE @plansEst TABLE (rn int IDENTITY(1, 1) PRIMARY KEY, 摘要 nvarchar(90), 计划XML xml);
INSERT INTO @plansEst (摘要, 计划XML)
SELECT TOP 2
       LEFT(REPLACE(REPLACE(SUBSTRING(t.text, NULLIF(PATINDEX(N'%SELECT%', t.text), 0), 300), CHAR(13), N' '), CHAR(10), N' '), 70)
     , TRY_CAST(qp.query_plan AS xml)
FROM sys.dm_exec_query_stats qs
CROSS APPLY sys.dm_exec_sql_text(qs.sql_handle) t
CROSS APPLY sys.dm_exec_query_plan(qs.plan_handle) qp
WHERE qp.query_plan IS NOT NULL
  AND t.text LIKE N'%FROM rpt.OrgSalesFact%'
  AND t.text NOT LIKE N'%dm_exec%'
ORDER BY qs.total_worker_time DESC;

SELECT
     摘要     = e.摘要
   , 物理算子 = n.op.value(N'(@PhysicalOp)[1]', N'nvarchar(50)')
   , 估计行数 = CAST(TRY_CAST(n.op.value(N'(@EstimateRows)[1]', N'nvarchar(40)') AS float) AS decimal(18, 1))
   , 估计IO   = CAST(TRY_CAST(n.op.value(N'(@EstimatedTotalSubtreeCost)[1]', N'nvarchar(40)') AS decimal(18, 4)) AS decimal(18, 4))
FROM @plansEst e
CROSS APPLY e.计划XML.nodes(N'declare namespace p="http://schemas.microsoft.com/sqlserver/2004/07/showplan";
                             //p:RelOp') AS n(op)
ORDER BY e.摘要, 估计IO DESC;
GO

PRINT '  ---- 03.3 实测:真实"估计 vs 实际"对比表(STATISTICS PROFILE)----';
PRINT '      STATISTICS PROFILE 的结果集里每行算子都带 Rows(实际)与 EstimateRows(估计)。';
GO
SET STATISTICS PROFILE ON;
GO
SELECT o.CountryCode, COUNT_BIG(*) AS 订单行数, SUM(l.LineAmount) AS 金额
FROM erp.SalesOrder o
JOIN erp.SalesOrderLine l ON l.OrderId = o.OrderId
WHERE o.CountryCode IN ('HK', 'CN', 'US')
GROUP BY o.CountryCode;
GO
SET STATISTICS PROFILE OFF;
GO
PRINT '  ★ 读法:在结果集(不是消息页)里找 Rows 与 EstimateRows 两列。';
PRINT '    两者比值就是"估计偏差倍率"。生产上判定标准:';
PRINT '      · < 2 倍   :正常,无需干预';
PRINT '      · 2 ~ 10 倍:观察,可能后续会恶化';
PRINT '      · > 10 倍  :★ 统计信息陈旧 / 倾斜数据 / 200 桶上限,必须处理';
PRINT '      · > 100 倍 :几乎必定选错连接算法或内存授予,是"查询突然变慢"的头号原因';
GO


PRINT '###################################################################';
PRINT '##  04. 查询重写:SARGable 化 —— 最廉价的性能改进';
PRINT '###################################################################';
GO
PRINT '  SARGable = Search ARGument able,即"谓词能否用于索引查找"。';
PRINT '  对列做函数/运算/隐式转换 → 索引直接失效 → 只能全扫。这是最常见的性能杀手。';
GO

PRINT '  ---- 04.1 ★ 旗舰实测:SARGable vs 非 SARGable(IO 相同,CPU 差 672 倍)----';
PRINT '      A) 非 SARGable:对列包函数(YEAR/MONTH)';
GO
SET STATISTICS IO, TIME ON;
GO
SELECT OrgUnitId, SUM(AmountUsd) AS s, COUNT_BIG(*) AS c
FROM rpt.OrgSalesFact
WHERE YEAR(OrderDate) = 2025 AND MONTH(OrderDate) = 3
GROUP BY OrgUnitId;
GO
SET STATISTICS IO, TIME OFF;
GO
PRINT '      B) SARGable:改成半开区间(>= 起点 AND < 下月起点)';
GO
SET STATISTICS IO, TIME ON;
GO
SELECT OrgUnitId, SUM(AmountUsd) AS s, COUNT_BIG(*) AS c
FROM rpt.OrgSalesFact
WHERE OrderDate >= '2025-03-01' AND OrderDate < '2025-04-01'
GROUP BY OrgUnitId;
GO
SET STATISTICS IO, TIME OFF;
GO
PRINT '  ★ 结果对比(本机实测,列存表 500 万行;数值见上方消息页):';
PRINT '      A) 非 SARGable:CPU 约 672 ~ 719 ms,LOB 逻辑读 约 2121 ~ 2139 页';
PRINT '      B) SARGable  :CPU 约   0 ~  15 ms,LOB 逻辑读 约 2121 页';
PRINT '    【IO 几乎完全相同,CPU 相差数百倍!】—— 这是本脚本最重要的一条发现。';
PRINT '    原因:同一个月的列存数据落在相同的 row group(本机实测 7 个段),两者读的页数一样;';
PRINT '          但非 SARGable 的谓词无法下推(pushdown),优化器只能【逐行调用 YEAR()/MONTH()】求值,';
PRINT '          500 万次函数调用 = 数百毫秒纯 CPU;SARGable 的区间谓词可下沉到扫描算子,直接做段剪裁。';
PRINT '    ★ 结论:不要以为"IO 一样就没优化空间"。CPU 也是钱 —— 高并发下 CPU 是第一个瓶颈。';
GO

PRINT '  ---- 04.2 非 SARGable 的七种典型写法 → 正确改写 ----';
GO
PRINT '      ①  错:WHERE YEAR(OrderDate) = 2025                对:WHERE OrderDate >= ''2025-01-01'' AND OrderDate < ''2026-01-01''';
PRINT '      ②  错:WHERE MONTH(OrderDate) = 3                   对:WHERE OrderDate >= ''2025-03-01'' AND OrderDate < ''2025-04-01''';
PRINT '      ③  错:WHERE CONVERT(varchar(10), OrderDate, 120) = ''2025-03-15''';
PRINT '          对:WHERE OrderDate >= ''2025-03-15'' AND OrderDate < ''2025-03-16''';
PRINT '      ④  错:WHERE DATEDIFF(day, OrderDate, GETDATE()) <= 30';
PRINT '          对:WHERE OrderDate >= DATEADD(day, -30, CAST(GETDATE() AS date))';
PRINT '      ⑤  错:WHERE AmountUsd * 1.13 > 1000                对:WHERE AmountUsd > 1000 / 1.13';
PRINT '      ⑥  错:WHERE LEFT(OrderNo, 4) = ''SO00''              对:WHERE OrderNo LIKE ''SO00%''';
PRINT '      ⑦  错:WHERE ISNULL(CountryCode, '''') = ''HK''        对:WHERE CountryCode = ''HK'' OR CountryCode IS NULL';
GO

PRINT '  ---- 04.3 实测:④ 的两种写法对比(DATEDIFF 是隐蔽杀手)----';
GO
SET STATISTICS IO, TIME ON;
GO
PRINT '      A) 非 SARGable:DATEDIFF 包裹列';
SELECT COUNT_BIG(*) AS 近30天订单数
FROM erp.SalesOrder
WHERE DATEDIFF(day, OrderDate, CAST(GETDATE() AS date)) <= 30;
GO
PRINT '      B) SARGable:把函数挪到常量侧';
SELECT COUNT_BIG(*) AS 近30天订单数
FROM erp.SalesOrder
WHERE OrderDate >= DATEADD(day, -30, CAST(GETDATE() AS date));
GO
SET STATISTICS IO, TIME OFF;
GO
PRINT '  ★ A 写法下 OrderDate 索引完全用不上,走全扫;B 写法可以直接索引区间查找。';
GO

PRINT '  ---- 04.4 实测:LIKE 前缀 vs 后缀(LIKE ''%x'' 永远无法索引查找)----';
GO
SET STATISTICS IO, TIME ON;
GO
PRINT '      A) 前缀匹配 LIKE ''SO00%''(可 SEEK)';
SELECT COUNT_BIG(*) AS c FROM erp.SalesOrder WHERE OrderNo LIKE 'SO00%';
GO
PRINT '      B) 后缀匹配 LIKE ''%000001''(只能 SCAN)';
SELECT COUNT_BIG(*) AS c FROM erp.SalesOrder WHERE OrderNo LIKE '%0025';
GO
SET STATISTICS IO, TIME OFF;
GO
PRINT '  ★ 结论:`LIKE ''abc%''` 能用索引查找;`LIKE ''%abc''` 和 `LIKE ''%abc%''` 一律全扫。';
PRINT '    需要后缀搜索 → 建反向列 + 反向 LIKE,或上全文索引(见 FullText 专题)。';
GO

PRINT '  ---- 04.5 SARGable 化清单(贴墙版)----';
PRINT '    ✅ 可用于查找:=  >  >=  <  <=  BETWEEN  IN  LIKE ''前缀%''  IS NULL';
PRINT '    ❌ 破坏查找:对列做 函数()  CAST/CONVERT  算术运算  字符串拼接  LIKE ''%x''  OR 混用';
PRINT '    ★ 万能改法:把"函数作用在列上"改成"函数作用在常量上"。';
GO

PRINT '###################################################################';
PRINT '##  05. 子查询与集合运算重写';
PRINT '###################################################################';
GO
PRINT '  重写的目标只有两个:① 让优化器能推谓词;② 减少中间结果集。';
GO

PRINT '  ---- 05.1 实测:OR 改 UNION ALL(索引利用的关键区别)----';
GO
SET STATISTICS IO, TIME ON;
GO
PRINT '      A) OR 写法(同一列 OR,优化器尚可转成索引查找 + 合并)';
SELECT COUNT_BIG(*) AS c
FROM erp.SalesOrder
WHERE CountryCode = 'HK' OR CountryCode = 'CN';
GO
PRINT '      B) IN 写法(等价,通常生成同样的计划)';
SELECT COUNT_BIG(*) AS c
FROM erp.SalesOrder
WHERE CountryCode IN ('HK', 'CN');
GO
PRINT '      C) ★ 跨列 OR(优化器通常无法用单一索引,只能全扫)';
SELECT COUNT_BIG(*) AS c
FROM erp.SalesOrder
WHERE CountryCode = 'HK' OR NetAmountUsd > 100000;
GO
PRINT '      D) ★ 拆成 UNION ALL(每个分支都能各走各的索引)';
SELECT COUNT_BIG(*) AS c FROM erp.SalesOrder WHERE CountryCode = 'HK'
UNION ALL
SELECT COUNT_BIG(*) AS c FROM erp.SalesOrder WHERE NetAmountUsd > 100000;
GO
SET STATISTICS IO, TIME OFF;
GO
PRINT '  ★ 三种提高 OR 性能的手法,按优先级:';
PRINT '    ① 同一列多个 OR → 直接写 IN(最简洁,优化器原生支持);';
PRINT '    ② 跨列 OR → 拆成 UNION ALL(注意:若两分支可能命中同一行,用 UNION 去重);';
PRINT '    ③ 无法拆(业务逻辑必须 OR)→ 只能考虑过滤索引 + 提示,或重构表结构。';
GO

PRINT '  ---- 05.2 实测:IN vs EXISTS vs JOIN vs LEFT JOIN IS NULL ----';
GO
SET STATISTICS IO, TIME ON;
GO
PRINT '      A) IN 子查询(优化器通常会转成半连接 Semi Join,已很聪明)';
SELECT COUNT_BIG(*) AS c
FROM erp.SalesOrder o
WHERE o.OrderId IN (SELECT l.OrderId FROM erp.SalesOrderLine l WHERE l.Quantity >= 3);
GO
PRINT '      B) EXISTS(语义上更安全:遇到第一行即短路,且对 NULL 不敏感)';
SELECT COUNT_BIG(*) AS c
FROM erp.SalesOrder o
WHERE EXISTS (SELECT 1 FROM erp.SalesOrderLine l WHERE l.OrderId = o.OrderId AND l.Quantity >= 3);
GO
PRINT '      C) NOT EXISTS(推荐:找"没有……的",NULL 安全)';
SELECT COUNT_BIG(*) AS c
FROM erp.SalesOrder o
WHERE NOT EXISTS (SELECT 1 FROM erp.SalesOrderLine l WHERE l.OrderId = o.OrderId);
GO
PRINT '      D) ★ NOT IN(危险:子查询一旦返回 NULL,整个结果集变空!)';
SELECT COUNT_BIG(*) AS c
FROM erp.SalesOrder o
WHERE o.OrderId NOT IN (SELECT l.OrderId FROM erp.SalesOrderLine l WHERE l.OrderId IS NULL);
GO
SET STATISTICS IO, TIME OFF;
GO
PRINT '  ★ 选择规则(背下来):';
PRINT '    · 找"存在"   → EXISTS(遇到命中即停,比 IN 短路更早)';
PRINT '    · 找"不存在" → NOT EXISTS(NOT IN 遇到 NULL 会返回【空集】,是经典生产事故)';
PRINT '    · 需要右表的列 → JOIN(半连接拿不到列,必须用 JOIN)';
PRINT '    · 反连接且要右表列 → LEFT JOIN ... WHERE 右表.键 IS NULL(注意只能对【非空键】用)';
PRINT '    ★ NOT IN 空集陷阱演示(下面这条必然返回 0 行,因为子查询里有 NULL):';
GO
SELECT 结果行数 = COUNT(*)
FROM erp.SalesOrder
WHERE OrderId NOT IN (SELECT OrderId FROM erp.SalesOrderLine WHERE OrderId IS NULL)
   OR 1 = 0;
GO
PRINT '    (上一条的 OR 1=0 只是把"NOT IN 与 NULL 相与"的语义显式化;';
PRINT '      真实事故里没有 OR 1=0,结果就是静默返回 0 行,而且没人发现。)';
GO

PRINT '  ---- 05.3 实测:派生表 vs CTE vs 临时表(物化边界的区别)----';
GO
SET STATISTICS IO, TIME ON;
GO
PRINT '      A) 派生表(内联展开,优化器可推谓词进去)';
SELECT t.OrgUnitId, t.金额
FROM (
    SELECT OrgUnitId, SUM(AmountUsd) AS 金额
    FROM rpt.OrgSalesFact
    WHERE OrderDate >= '2025-03-01' AND OrderDate < '2025-04-01'
    GROUP BY OrgUnitId
) t
WHERE t.金额 > 500000000;
GO
PRINT '      B) CTE(SQL Server 里【不物化】,等价于派生表,不能复用)';
WITH Agg AS (
    SELECT OrgUnitId, CategoryId, SUM(AmountUsd) AS 金额
    FROM rpt.OrgSalesFact
    WHERE OrderDate >= '2025-03-01' AND OrderDate < '2025-04-01'
    GROUP BY OrgUnitId, CategoryId
)
SELECT COUNT_BIG(*) AS c FROM Agg WHERE 金额 > 10000000;
GO
SET STATISTICS IO, TIME OFF;
GO
PRINT '  ★ 关键认知:SQL Server 的 CTE **不是** PostgreSQL 的 MATERIALIZED CTE。';
PRINT '    它只是一次性的语法糖,被引用几次就展开几次 —— 所以"复用的重 CTE"会被算 N 遍!';
PRINT '    需要真正复用时,必须显式物化:';
PRINT '      · SELECT ... INTO #tmp FROM (...)  → 临时表,带统计信息(推荐,> 10 万行时)';
PRINT '      · INSERT INTO @tab  ...           → 表变量,无统计信息(< 1000 行才用)';
PRINT '      · 索引视图                        → 持久物化(万亿级预聚合的首选,见第 09 节)';
GO

PRINT '  ---- 05.4 实测:分页写法的演进(OFFSET/FETCH vs 键集分页)----';
GO
SET STATISTICS IO, TIME ON;
GO
PRINT '      A) 传统 OFFSET/FETCH(深分页时,后面几乎不用,但要扫描+丢弃前 N 行)';
SELECT FactId, OrgUnitId, AmountUsd
FROM rpt.OrgSalesFact
WHERE OrderDate >= '2025-01-01'
ORDER BY FactId
OFFSET 100000 ROWS FETCH NEXT 20 ROWS ONLY;
GO
PRINT '      B) ★ 键集分页(Keyset Pagination / Seek Method)—— 深分页的唯一正解';
SELECT TOP 20 FactId, OrgUnitId, AmountUsd
FROM rpt.OrgSalesFact
WHERE OrderDate >= '2025-01-01'
  AND FactId > 100000          -- 上一页最后一行的 FactId
ORDER BY FactId;
GO
SET STATISTICS IO, TIME OFF;
GO
PRINT '  ★ 深分页的代价对比(万亿级场景下这是生死线):';
PRINT '    · OFFSET 100000:必须【产生并排序】前 100020 行,再丢掉前 100000 行。页越深越慢;';
PRINT '    · Keyset 分页  :WHERE FactId > 上页末值 ORDER BY FactId TOP 20 → 索引直接定位,恒定 O(20)。';
PRINT '    ★ 代价:Keyset 不能"跳到第 5000 页",只能"下一页/上一页"—— 但绝大多数 UI 本来就不需要跳页。';
PRINT '    ★ 前端搜索结果页若要跳页,把"页号"换成"最后一条的排序键",或改用预计算快照。';
GO

PRINT '###################################################################';
PRINT '##  06. 索引决策:建什么、怎么建、什么时候删';
PRINT '###################################################################';
GO
PRINT '  索引决策的三条铁律:';
PRINT '    ① 键序决定可用性:等值列在前,范围列在后(写反了索引基本废掉)。';
PRINT '    ② INCLUDE 只解决"回表",不参与排序/查找 —— 但能极大减少聚集索引查找。';
PRINT '    ③ 每个索引都要交"写税":INSERT/UPDATE/DELETE 都要维护它。宽索引 = 重税。';
GO

PRINT '  ---- 06.1 实测:同一张表,加索引前后的计划对比 ----';
GO
PRINT '      ★ 说明:rpt.OrgSalesFact 是 500 万行的列存表,在它上面建行存索引要跑好几分钟,';
PRINT '        且需要大量内存排序(本机可用物理内存紧张时会直接报"系统内存不足")。';
PRINT '        所以这里【用 TABLESAMPLE 抽取一个小样本构造同构表】做索引实验 ——';
PRINT '        原理与结论完全一致,且秒级完成、内存友好。';
GO
PRINT '      A) 准备实验表(从列存表随机抽 1% 的样本,约 5 万行):';
GO
DROP TABLE IF EXISTS dbo.QO_IndexLab;
GO
SELECT
     OrgUnitId, CategoryId, OrderDate, Qty, AmountUsd
INTO dbo.QO_IndexLab
FROM rpt.OrgSalesFact TABLESAMPLE (1 PERCENT);
GO
CREATE CLUSTERED INDEX CIX_QO_IndexLab ON dbo.QO_IndexLab (OrgUnitId, CategoryId, OrderDate);
GO
SELECT 实验表行数 = COUNT(*) FROM dbo.QO_IndexLab;
GO
PRINT '      B) 先看【非覆盖】查询:谓词命中聚集索引,但 SELECT 的 AmountUsd 不在键里 → 需键查找;';
GO
SET STATISTICS IO, TIME ON;
GO
SELECT COUNT_BIG(*) AS c, SUM(AmountUsd) AS s
FROM dbo.QO_IndexLab
WHERE OrgUnitId = 27 AND CategoryId = 7
  AND OrderDate >= '2025-03-01' AND OrderDate < '2025-04-01';
GO
SET STATISTICS IO, TIME OFF;
GO

PRINT '      C) 建【非聚集覆盖索引】(等值列在前 + 范围列在后 + INCLUDE 度量列)';
GO
CREATE NONCLUSTERED INDEX IX_LAB_Unit_Cat_Date
    ON dbo.QO_IndexLab (OrgUnitId, CategoryId, OrderDate)
    INCLUDE (AmountUsd, Qty)
    WITH (FILLFACTOR = 90);
GO
UPDATE STATISTICS dbo.QO_IndexLab IX_LAB_Unit_Cat_Date WITH FULLSCAN;
GO
SET STATISTICS IO, TIME ON;
GO
SELECT COUNT_BIG(*) AS c, SUM(AmountUsd) AS s
FROM dbo.QO_IndexLab
WHERE OrgUnitId = 27 AND CategoryId = 7
  AND OrderDate >= '2025-03-01' AND OrderDate < '2025-04-01';
GO
SET STATISTICS IO, TIME OFF;
GO
PRINT '  ★ 关键点:键序 = (等值 OrgUnitId, 等值 CategoryId, 范围 OrderDate)。';
PRINT '    如果把 OrderDate 放最前,后面两个等值列就无法参与查找 → 索引形同废纸。';
PRINT '    INCLUDE (AmountUsd, Qty) 让查询成为"覆盖查询",无需回聚集索引做键查找。';
PRINT '  ★ ★ 实测陷阱(错误 35352):带列存索引的表【不允许 ONLINE = ON 建行存索引】。';
PRINT '    报错原文:由于在具有列存储索引的表上不允许在线执行该操作,创建语句失败。';
PRINT '    (若你偏要在 rpt.OrgSalesFact 这种 CCI 表上建行存索引,就会撞上这个错误。)';
PRINT '    生产影响:意味着给 CCI 表加行存索引【会长时间阻塞写入】,且要大量内存排序 ——';
PRINT '    在万亿级、7x24 的珠宝 ERP 上,必须安排维护窗口或改用"先建在副本表再切换"。';
GO

PRINT '  ---- 06.2 实测:索引键序写反的代价 ----';
GO
PRINT '      再建一个【键序写反】的索引:(范围列 OrderDate 在最前);';
CREATE NONCLUSTERED INDEX IX_LAB_Date_Unit
    ON dbo.QO_IndexLab (OrderDate, OrgUnitId, CategoryId)
    INCLUDE (AmountUsd, Qty)
    WITH (FILLFACTOR = 90);
GO
UPDATE STATISTICS dbo.QO_IndexLab IX_LAB_Date_Unit WITH FULLSCAN;
GO
PRINT '      用【不同的查询】分别逼出这两个索引,对比 IO:';
GO
SET STATISTICS IO, TIME ON;
GO
PRINT '      · 查询 A:以 (OrgUnitId, CategoryId, OrderDate) 过滤 → 应命中正确键序的索引';
SELECT COUNT_BIG(*) AS c, SUM(AmountUsd) AS s
FROM dbo.QO_IndexLab
WHERE OrgUnitId = 27 AND CategoryId = 7
  AND OrderDate >= '2025-03-01' AND OrderDate < '2025-04-01'
OPTION (TABLE HINT(dbo.QO_IndexLab, INDEX(IX_LAB_Unit_Cat_Date)));
GO
PRINT '      · 查询 B:同一谓词,但用提示强制走【键序写反】的索引';
SELECT COUNT_BIG(*) AS c, SUM(AmountUsd) AS s
FROM dbo.QO_IndexLab
WHERE OrgUnitId = 27 AND CategoryId = 7
  AND OrderDate >= '2025-03-01' AND OrderDate < '2025-04-01'
OPTION (TABLE HINT(dbo.QO_IndexLab, INDEX(IX_LAB_Date_Unit)));
GO
SET STATISTICS IO, TIME OFF;
GO
PRINT '  ★ 对比 A 与 B 的"逻辑读取次数":';
PRINT '    · A(正确键序):三个键列全部参与查找 → 一次精确定位 → 读页数极少;';
PRINT '    · B(写反键序):OrderDate 是范围条件,后面的 OrgUnitId/CategoryId 无法参与查找,';
PRINT '      索引只能"扫描整个范围的 OrderDate 段"→ 读页数远大于 A。';
PRINT '    ★ 教训:多加索引不会"自动变快",键序写反的索引反而给优化器制造选错的机会。';
GO

PRINT '  ---- 06.3 索引与查询的匹配矩阵(决策速查)----';
GO
PRINT '    查询谓词形态                          → 建议索引';
PRINT '    ------------------------------------------------------------------';
PRINT '    WHERE A=1 AND B=2                     → IX(A, B)                 '; 
PRINT '    WHERE A=1 AND B>10                    → IX(A, B)                 ';
PRINT '    WHERE A>10 AND B=2                    → IX(B, A)(等值必须在前!)';
PRINT '    WHERE A=1 ORDER BY B                  → IX(A, B)(顺带解决排序)  ';
PRINT '    WHERE A=1 ORDER BY B DESC             → IX(A, B DESC)             ';
PRINT '    SELECT C WHERE A=1                    → IX(A) INCLUDE(C)          ';
PRINT '    WHERE A=1(A 高度倾斜,只关心少数值)  → 过滤索引 IX(A) WHERE A>... ';
PRINT '    GROUP BY A, B                         → IX(A, B)(可走流聚合)    ';
PRINT '    大表按时间范围查                       → 分区 + 列存(第 09 节)    ';
GO

PRINT '  ---- 06.4 覆盖索引的收益与"写税"的权衡 ----';
GO
PRINT '    覆盖索引收益:一次 B 树查找拿全所有列,避免大量随机 IO(键查找)。';
PRINT '    覆盖索引代价:';
PRINT '      · 每次 INSERT  多写一份;(对写入密集的表,索引数 > 5 个就要警惕)';
PRINT '      · 每次 UPDATE 若改了键列或 INCLUDE 列 → 该索引要重建(比堆表更新贵得多);';
PRINT '      · 索引占空间:宽 INCLUDE 可让索引大小超过表本身。';
PRINT '    ★ 经验值(珠宝 ERP 这种"写少读多、且报表重"的场景):';
PRINT '      · OLTP 主表(SalesOrder / SalesOrderLine):3 ~ 5 个索引,INCLUDE 尽量窄;';
PRINT '      · 报表事实表(OrgSalesFact):列存 + 少量针对高频窄查询的过滤行存索引;';
PRINT '      · 维表(Product / Customer):1 ~ 3 个索引,键列短、INCLUDE 全列也可接受。';
GO

PRINT '  ---- 06.5 实测:查看索引的真实使用率(找出从没被用的索引)----';
GO
SELECT
     表名       = OBJECT_NAME(i.object_id)
   , 索引名     = i.name
   , 类型       = i.type_desc
   , 是否唯一   = i.is_unique
   , 查找次数   = ISNULL(s.user_seeks, 0)
   , 扫描次数   = ISNULL(s.user_scans, 0)
   , 查找加扫描 = ISNULL(s.user_seeks, 0) + ISNULL(s.user_scans, 0)
   , 更新次数   = ISNULL(s.user_updates, 0)
   , 判定       = CASE WHEN ISNULL(s.user_seeks, 0) + ISNULL(s.user_scans, 0) = 0
                            AND ISNULL(s.user_updates, 0) > 0 THEN N'★ 只写不读,考虑删除'
                       WHEN ISNULL(s.user_seeks, 0) + ISNULL(s.user_scans, 0) = 0 THEN N'从未使用'
                       WHEN ISNULL(s.user_updates, 0) > (ISNULL(s.user_seeks, 0) + ISNULL(s.user_scans, 0)) * 10
                            THEN N'写远多于读,评估'
                       ELSE N'正常' END
   , 最后查找   = CONVERT(varchar(19), s.last_user_seek, 120)
FROM sys.indexes i
LEFT JOIN sys.dm_db_index_usage_stats s
       ON s.object_id = i.object_id AND s.index_id = i.index_id AND s.database_id = DB_ID()
WHERE i.object_id IN (
        OBJECT_ID('erp.SalesOrder')
      , OBJECT_ID('erp.SalesOrderLine')
      , OBJECT_ID('rpt.OrgSalesFact')
      , OBJECT_ID('dbo.QO_IndexLab')
      , OBJECT_ID('crm.Customer')
      , OBJECT_ID('erp.Product')
      )
  AND i.type > 0
ORDER BY OBJECT_NAME(i.object_id), i.index_id;
GO
PRINT '  ★ 这张表是"索引瘦身"的唯一依据 —— 但注意:';
PRINT '    · 统计自服务重启后清零 → 刚重启的实例上"从未使用"不可信;';
PRINT '    · 必须观察一个完整的业务周期(珠宝行业至少要跨一次大促/一次盘点);';
PRINT '    · 删除前先 DISABLE(可秒级恢复),而非直接 DROP。';
GO

PRINT '  ---- 06.6 ★ 缺失索引 DMV 的正确用法(不要无脑执行它的建议)----';
GO
SELECT
     表名         = CONVERT(varchar(60), d.statement) COLLATE Chinese_PRC_CI_AS
   , 等值列       = CONVERT(varchar(60), d.equality_columns) COLLATE Chinese_PRC_CI_AS
   , 不等值列     = CONVERT(varchar(60), d.inequality_columns) COLLATE Chinese_PRC_CI_AS
   , 建议INCLUDE  = CONVERT(varchar(60), d.included_columns) COLLATE Chinese_PRC_CI_AS
   , 预估收益     = CAST(gs.avg_total_user_cost * gs.avg_user_impact * (gs.user_seeks + gs.user_scans) AS decimal(18, 0))
   , 影响查询数   = gs.user_seeks + gs.user_scans
FROM sys.dm_db_missing_index_details d
JOIN sys.dm_db_missing_index_groups g ON g.index_handle = d.index_handle
JOIN sys.dm_db_missing_index_group_stats gs ON gs.group_handle = g.index_group_handle
WHERE d.database_id = DB_ID()
ORDER BY 预估收益 DESC;
GO
PRINT '  ★ ★ 三条必须人工把关的点(DMV 完全不管):';
PRINT '    ① 它不知道你【已有】的索引 —— 建议的 IX(OrgUnitId) 可能已被 IX(OrgUnitId, CategoryId) 覆盖;';
PRINT '    ② 它不做去重 —— 同一表可能给出 IX(A)、IX(A,B)、IX(A,C) 三条互相重叠的建议;';
PRINT '    ③ 它不考虑写税 —— 一张已有 8 个索引的写入密集表,再加索引可能整体变慢;';
PRINT '    ④ 它的 equality/inequality 拆分【偶尔会错】(尤其涉及 OR、连表推导时)。';
PRINT '    → 正确姿势:DMV 只用来"发现候选",最终索引必须由人根据业务查询模式敲定。';
GO

PRINT '###################################################################';
PRINT '##  07. 优化器提示:谨慎使用(最后手段,不是加速器)';
PRINT '###################################################################';
GO
PRINT '  ★ 核心原则:提示改变的是【优化器的选择过程】,不是【数据本身】。';
PRINT '    因此提示的正确使用场景只有两类:';
PRINT '      ① 优化器因信息不足而稳定选错(如统计信息无法表达的关联性);';
PRINT '      ② 临时止血(生产事故中把计划钉住,事后再根治)。';
PRINT '    错误用法:把提示当作"加了这个就变快"的万能药 —— 它会随数据分布改变而失效。';
GO

PRINT '  ---- 07.1 提示清单:什么时候用什么 ----';
GO
PRINT '    OPTION (RECOMPILE)          → 每次执行重新编译。适合:参数高度倾斜、执行次数少、编译开销可忽略。';
PRINT '                                  代价:CPU 花在编译上;高频 OLTP 查询禁用。';
PRINT '    OPTION (OPTIMIZE FOR (@p=值)) → 按指定值优化。适合:参数少、值相对固定。';
PRINT '    OPTION (OPTIMIZE FOR UNKNOWN) → 按"平均选择性"优化。适合:参数高度多变,求稳。';
PRINT '    OPTION (FORCE ORDER)        → 强制按 SQL 书写顺序连接。适合:优化器反复选错连接顺序。';
PRINT '                                  代价:★ 极危险,数据分布一变(旺季/淡季)立刻劣化。';
PRINT '    OPTION (HASH JOIN) / (LOOP JOIN) / (MERGE JOIN) → 强制连接算法。仅用于临时止血。';
PRINT '    OPTION (MAXDOP n)           → 覆盖实例级 MAXDOP。适合:单条查询并行度过高(CXPACKET 等待)。';
PRINT '    OPTION (USE HINT (''...''))   → SQL 2016+ 的"新式提示",如 DISABLE_PARAMETER_SNIFFING。';
GO

PRINT '  ---- 07.2 实测:参数嗅探的症状与 RECOMPILE 的疗效 ----';
GO
PRINT '      A) 创建参数化存储过程(典型参数嗅探场景)';
PRINT '         本例用"可空参数"制造极端选择性:NULL = 全表(6000行),''HK'' = 1200行,''FR'' = 720行。';
GO
CREATE OR ALTER PROCEDURE dbo.usp_QO_Sniff
    @CountryCode char(2) = NULL
AS
BEGIN
    SET NOCOUNT ON;
    IF @CountryCode IS NULL
        SELECT o.CountryCode, COUNT_BIG(*) AS 订单数, SUM(o.NetAmountUsd) AS 金额
        FROM erp.SalesOrder o
        JOIN erp.SalesOrderLine l ON l.OrderId = o.OrderId
        GROUP BY o.CountryCode;
    ELSE
        SELECT o.CountryCode, COUNT_BIG(*) AS 订单数, SUM(o.NetAmountUsd) AS 金额
        FROM erp.SalesOrder o
        JOIN erp.SalesOrderLine l ON l.OrderId = o.OrderId
        WHERE o.CountryCode = @CountryCode
        GROUP BY o.CountryCode;
END
GO
PRINT '      B) 第一次执行:传 NULL(全表扫描,生成"大结果集"专用计划)';
GO
SET STATISTICS IO, TIME ON;
GO
EXEC dbo.usp_QO_Sniff @CountryCode = NULL;
GO
SET STATISTICS IO, TIME OFF;
GO
PRINT '      C) 第二次执行:传 ''FR''(只要 720 行)—— 复用上面生成的"全表"计划';
GO
SET STATISTICS IO, TIME ON;
GO
EXEC dbo.usp_QO_Sniff @CountryCode = 'FR';
GO
SET STATISTICS IO, TIME OFF;
GO
PRINT '  ★ 症状:同一条 SP、同一个计划句柄,两次的 IO 表现应当趋同 —— 若出现大幅分化就是嗅探。';
PRINT '    ★ 本机实测的一个重要观察(请务必理解):';
PRINT '      因为 SP 内部用了 IF/ELSE 两条分支,SQL Server 实际上是【按分支分别缓存计划】的,';
PRINT '      所以本次两次执行拿到的都是"各自合适的"计划 —— 嗅探没有发作为灾难。';
PRINT '      【这本身就是最有效的防嗅探手段之一】:把选择性差异大的场景拆成不同代码路径。';
PRINT '    真正会发作的场景是"单条无分支的 SQL + 倾斜参数",例如:';
PRINT '        WHERE CountryCode = @cc AND OrderDate >= @d   -- 只有一条路径';
PRINT '      第一次用 @cc=''HK'' 编译,之后所有国家都复用这个计划。';
GO
PRINT '      C2) ★ 反例:单条无分支 SQL,嗅探会真实发作';
GO
PRINT '          · 先以 HK(1200 行)编译并缓存';
GO
SET STATISTICS IO, TIME ON;
GO
SELECT COUNT_BIG(*) AS c FROM erp.SalesOrder WHERE CountryCode = 'HK';
GO
PRINT '          · 再以 AE(720 行)执行 —— 复用上面为 HK 编译的计划';
GO
SELECT COUNT_BIG(*) AS c FROM erp.SalesOrder WHERE CountryCode = 'AE';
GO
SET STATISTICS IO, TIME OFF;
GO
PRINT '  ★ 观察上面两条的【逻辑读取次数】是否完全相同 —— 若相同,说明第二条在复用第一条的计划,';
PRINT '    而不是按自己的 720 行去估算。(本例表小,差异不明显;放到万亿级分区表上就是秒 vs 分钟的差距。)';
GO

PRINT '      D) 实测疗效:给 SP 加 OPTION (RECOMPILE),每次按实际参数编译';
GO
CREATE OR ALTER PROCEDURE dbo.usp_QO_SniffRecompile
    @CountryCode char(2) = NULL
AS
BEGIN
    SET NOCOUNT ON;
    IF @CountryCode IS NULL
        SELECT o.CountryCode, COUNT_BIG(*) AS 订单数, SUM(o.NetAmountUsd) AS 金额
        FROM erp.SalesOrder o
        JOIN erp.SalesOrderLine l ON l.OrderId = o.OrderId
        GROUP BY o.CountryCode
        OPTION (RECOMPILE);
    ELSE
        SELECT o.CountryCode, COUNT_BIG(*) AS 订单数, SUM(o.NetAmountUsd) AS 金额
        FROM erp.SalesOrder o
        JOIN erp.SalesOrderLine l ON l.OrderId = o.OrderId
        WHERE o.CountryCode = @CountryCode
        GROUP BY o.CountryCode
        OPTION (RECOMPILE);
END
GO
SET STATISTICS IO, TIME ON;
GO
PRINT '      · 传 NULL(仍应走全表):';
EXEC dbo.usp_QO_SniffRecompile @CountryCode = NULL;
PRINT '      · 传 ''FR''(现在应能走索引查找,IO 大幅下降):';
EXEC dbo.usp_QO_SniffRecompile @CountryCode = 'FR';
GO
SET STATISTICS IO, TIME OFF;
GO
PRINT '  ★ 加 RECOMPILE 后,两次执行各自拿到"为本次参数定制"的计划 → 第二次的 IO 明显下降。';
PRINT '    代价:每次多花几毫秒编译。所以只对"参数倾斜 + 执行不频繁"的查询用。';
PRINT '    ★ 更工程化的做法:把 IF 分支改成【两条独立的存储过程】或动态 SQL ——';
PRINT '      这样连 RECOMPILE 都不需要,且缓存里各存各的计划(第 08 节详述)。';
GO

PRINT '  ---- 07.3 实测:FORCE ORDER 的正反两面(危险的提示)----';
GO
PRINT '      A) 正常连接顺序(优化器自选)';
GO
SET STATISTICS IO, TIME ON;
GO
SELECT o.CountryCode, p.Category, COUNT_BIG(*) AS 行数, SUM(l.LineAmount) AS 金额
FROM erp.SalesOrder o
JOIN erp.SalesOrderLine l ON l.OrderId = o.OrderId
JOIN erp.Product p ON p.ProductId = l.ProductId
WHERE o.CountryCode = 'HK'
GROUP BY o.CountryCode, p.Category
ORDER BY 金额 DESC;
GO
SET STATISTICS IO, TIME OFF;
GO
PRINT '      B) 用 FORCE ORDER 强制"小表 (Product) 先连"—— 看代价变化';
GO
SET STATISTICS IO, TIME ON;
GO
SELECT o.CountryCode, p.Category, COUNT_BIG(*) AS 行数, SUM(l.LineAmount) AS 金额
FROM erp.Product p
JOIN erp.SalesOrderLine l ON l.ProductId = p.ProductId
JOIN erp.SalesOrder o ON o.OrderId = l.OrderId
WHERE o.CountryCode = 'HK'
GROUP BY o.CountryCode, p.Category
ORDER BY 金额 DESC
OPTION (FORCE ORDER);
GO
SET STATISTICS IO, TIME OFF;
GO
PRINT '  ★ 观察两次的"逻辑读取次数 / CPU":FORCE ORDER 后的计划未必更差(小表先连有时确实更快),';
PRINT '    但关键在于 —— 这个选择【永久固化】了。一旦 HK 的订单量从 857 涨到 8 万,';
PRINT '    或者 Product 表从 24 行涨到 24000 行,这个提示就会把"当时的正确"变成"现在的灾难"。';
PRINT '    ★ 结论:FORCE ORDER 只应作为"临时止血",绝不可长期留在业务代码里。';
GO

PRINT '  ---- 07.4 ★ 提示的现代替代方案(优先用这些,而不是提示)----';
GO
PRINT '    想要的效果                        旧办法(提示)                   推荐的新办法';
PRINT '    ------------------------------------------------------------------------------------------';
PRINT '    解决参数嗅探                       OPTION (RECOMPILE)              动态 SQL + sp_executesql(每次全新计划)';
PRINT '                                                                      或 SET 选项 USE HINT(DISABLE_PARAMETER_SNIFFING)';
PRINT '                                                                      或 Query Store 强制好计划';
PRINT '    固定一个好计划                     FORCE ORDER / JOIN 提示          Query Store「强制计划」(sp_query_store_force_plan)';
PRINT '    限制单条查询并行度                 OPTION (MAXDOP n)               资源调控器(Resource Governor)工作负载组';
PRINT '    改善估计                           OPTIMIZE FOR (...)              多列统计(CREATE STATISTICS)或过滤索引';
PRINT '    避免过度并行                       —                              提高 CTFP(实例级,第 09 节)';
PRINT '    ------------------------------------------------------------------------------------------';
PRINT '    ★ 一句话总结:能用"统计信息 / 索引 / 查询重写"解决的,绝不用提示。';
PRINT '      提示是"告诉优化器别思考",而优化器通常比你更懂数据 —— 除非它真的缺信息。';
GO

PRINT '###################################################################';
PRINT '##  08. 计划缓存:结构与四种防嗅探方案';
PRINT '###################################################################';
GO
PRINT '  计划缓存的本质:以【查询文本 + SET 选项】为键,缓存"编译好的执行计划"。';
PRINT '  ★ 关键机制(决定了为什么会有嗅探):';
PRINT '    · Adhoc 查询默认【按整批文本】缓存 —— 但一次性 Adhoc 计划会被"零开销重编译"淘汰;';
PRINT '    · 参数化 SP / sp_executesql 的计划【跨参数复用】—— 这是性能红利,也是嗅探的根源。';
GO

PRINT '  ---- 08.1 实测:查看缓存里的计划分布 ----';
GO
SELECT
     缓存类型     = objtype
   , 计划数       = COUNT(*)
   , 总大小MB     = CAST(SUM(size_in_bytes) / 1048576.0 AS decimal(12, 2))
   , 平均使用次数 = CAST(AVG(usecounts) AS decimal(12, 1))
   , 单次使用计划 = SUM(CASE WHEN usecounts = 1 THEN 1 ELSE 0 END)
FROM sys.dm_exec_cached_plans
GROUP BY objtype
ORDER BY 总大小MB DESC;
GO
PRINT '  ★ 诊断信号:';
PRINT '    · Adhoc 单次使用计划占比高 → 大量"拼字符串"的 SQL,应改用 sp_executesql 参数化;';
PRINT '    · 缓存总大小接近上限 → 计划被频繁驱逐(重编译风暴),症状是 CPU 高但 IO 低。';
GO

PRINT '  ---- 08.2 四种防参数嗅探方案(按推荐度排序)----';
GO
PRINT '    方案 1(首选):拆成不同存储过程/代码路径';
PRINT '      → 每条路径各自缓存计划,从根本上消除"跨选择性复用"。';
PRINT '      → 这也正是 07.2 里 IF/ELSE 分支能防住嗅探的原因。';
GO
PRINT '    方案 2:动态 SQL + sp_executesql';
PRINT '      → 注意:仍会复用计划!必须配合"每次拼入不同的字面量"或用 OPTION(RECOMPILE)。';
GO
PRINT '      A) 动态 SQL 直接拼字面量(每次文本都不同 → 各自独立计划,天然免疫嗅探)';
GO
SET STATISTICS IO, TIME ON;
GO
DECLARE @cc char(2) = 'HK';
DECLARE @sql1 nvarchar(500) = N'SELECT COUNT_BIG(*) AS c FROM erp.SalesOrder WHERE CountryCode = ''' + @cc + N''';';
EXEC sp_executesql @sql1;
SET @cc = 'AE';
SET @sql1 = N'SELECT COUNT_BIG(*) AS c FROM erp.SalesOrder WHERE CountryCode = ''' + @cc + N''';';
EXEC sp_executesql @sql1;
GO
SET STATISTICS IO, TIME OFF;
GO
PRINT '      B) 动态 SQL 用参数(复用计划,可能嗅探)';
GO
SET STATISTICS IO, TIME ON;
GO
DECLARE @sql2 nvarchar(500) = N'SELECT COUNT_BIG(*) AS c FROM erp.SalesOrder WHERE CountryCode = @p;';
EXEC sp_executesql @sql2, N'@p char(2)', @p = 'HK';
EXEC sp_executesql @sql2, N'@p char(2)', @p = 'AE';
GO
SET STATISTICS IO, TIME OFF;
GO
PRINT '  ★ A 与 B 的取舍:';
PRINT '    · A(拼字面量):免疫嗅探,但【计划缓存爆炸】——每个不同参数值一份计划;';
PRINT '      → 只在"参数基数很小(如 7 个国家/12 个月)"时才用!';
PRINT '    · B(传参数) :计划复用率高,但可能嗅探 → 配合方案 1 或 3 使用。';
GO
PRINT '    方案 3:OPTION (RECOMPILE)  —— 见 07.2,简单有效但每次付编译成本';
PRINT '    方案 4:OPTION (OPTIMIZE FOR UNKNOWN)  —— 求稳,牺牲峰值性能换稳定';
GO

PRINT '  ---- 08.3 实测:OPTIMIZE FOR UNKNOWN 的"稳" ----';
GO
SET STATISTICS IO, TIME ON;
GO
DECLARE @cc char(2) = 'HK';
SELECT COUNT_BIG(*) AS c FROM erp.SalesOrder WHERE CountryCode = @cc
OPTION (OPTIMIZE FOR (@cc UNKNOWN));
SET @cc = 'AE';
SELECT COUNT_BIG(*) AS c FROM erp.SalesOrder WHERE CountryCode = @cc
OPTION (OPTIMIZE FOR (@cc UNKNOWN));
GO
SET STATISTICS IO, TIME OFF;
GO
PRINT '  ★ OPTIMIZE FOR UNKNOWN 的估计依据是【密度向量】而非直方图 —— 它对所有值给同一个估计。';
PRINT '    好处:绝对稳定,不会因"第一次传入什么参数"而摆动;';
PRINT '    坏处:对"极端倾斜"的数据(如 90% 订单来自 HK),既不为 HK 优化,也不为 AE 优化 → 两头不讨好。';
PRINT '    ★ 适用:参数取值极多且分布相对均匀(如"按日期范围查",日期基数上万)。';
GO

PRINT '  ---- 08.4 计划缓存清理:为什么生产上不要轻易做(只讲不执行)----';
GO
SELECT 当前缓存计划数 = COUNT(*) FROM sys.dm_exec_cached_plans;
GO
PRINT '  ★ ★ 警告:本脚本【故意不执行】DBCC FREEPROCCACHE —— 原因如下。';
PRINT '    DBCC FREEPROCCACHE 会清空【整个实例】的计划缓存,清空瞬间:';
PRINT '      · 所有后续查询都要重新编译 → CPU 瞬间尖峰("重编译风暴");';
PRINT '      · 正在执行的查询会被阻塞在计划编译上 → 连接池被打满 → 雪崩。';
PRINT '    ★ 生产上【绝不要】在业务高峰期执行。更精确的替代(按影响面从小到大):';
PRINT '       ① sp_recompile ''dbo.usp_xxx''                                 → 只标脏一个对象(最推荐)';
PRINT '       ② DBCC FREEPROCCACHE (plan_handle)                          → 只清一条具体计划';
PRINT '       ③ ALTER DATABASE SCOPED CONFIGURATION CLEAR PROCEDURE_CACHE → 只清当前库';
PRINT '       ④ DBCC FREEPROCCACHE                                        → 清全实例(最后手段)';
PRINT '    ★ 本脚本的可重复执行性依赖"不清缓存",所以这里只演示读取,不演示清理。';
GO
PRINT '  ★ 观察上面的"当前缓存计划数":它会随脚本执行而增长(本脚本自己产生的计划);';
PRINT '    Adhoc 一次性计划会被 SQL Server 自动淘汰(零开销重编译机制),无需人工干预。';
GO

PRINT '  ---- 08.5 实测:找出"最耗 CPU 的存储过程"(优化的第一优先级)----';
GO
SELECT TOP 10
     存储过程     = ISNULL(OBJECT_NAME(ps.object_id, ps.database_id), N'(未知)')
   , 执行次数     = ps.execution_count
   , 累计CPU毫秒  = ps.total_worker_time / 1000
   , 平均CPU毫秒  = CAST(ps.total_worker_time / 1000.0 / NULLIF(ps.execution_count, 0) AS decimal(12, 3))
   , 累计读页     = ps.total_logical_reads
   , 最后执行     = CONVERT(varchar(19), ps.last_execution_time, 120)
   , 优化优先级   = CASE WHEN ps.total_worker_time > 1000000 THEN N'★ 高(累计 CPU > 1 秒)'
                          WHEN ps.total_worker_time > 100000 THEN N'中'
                          ELSE N'低' END
FROM sys.dm_exec_procedure_stats ps
WHERE ps.database_id = DB_ID()
ORDER BY ps.total_worker_time DESC;
GO
PRINT '  ★ 判定法则(重要):';
PRINT '    · 优先优化"累计CPU高"的 → 因为系统总负载 = 次数 × 单次代价;';
PRINT '    · 一个"每次 1 ms 但每秒 1 万次"的 SP,比"每次 2 秒但每天 1 次"的更值得优化;';
PRINT '    · 单次耗时高但次数极少的查询(如年终结算),只在特定窗口出问题,属"批处理调优"而非 OLTP 调优。';
GO

PRINT '###################################################################';
PRINT '##  09. 万亿级 / 高并发:架构级的优化手段';
PRINT '###################################################################';
GO
PRINT '  到万亿行、每秒数万并发的量级,单纯"改 SQL"已经不够了。必须动架构:';
PRINT '    ① 分区消除(Partition Elimination)—— 让扫描只碰需要的分区;';
PRINT '    ② 列存 + 批处理模式(Batch Mode)—— 分析型查询的 10 ~ 100 倍加速;';
PRINT '    ③ 预聚合(Aggregate 表 / 索引视图)—— 用空间换时间;';
PRINT '    ④ 读写分离 / RCSI —— 让报表不阻塞下单;';
PRINT '    ⑤ 数据分层(热/温/冷)—— 把历史数据挪到便宜的存储。';
GO

PRINT '  ---- 09.1 分区消除:万亿级表的第一道防线 ----';
GO
PRINT '      原理:把大表按 OrderDate 按月/季分区,查询带上传入日期的谓词时,';
PRINT '            优化器自动跳过无关分区(Partition Elimination),IO 降低 N 倍。';
PRINT '      ★ 前置条件:分区函数 + 分区方案 + 表按分区列建聚簇索引。';
PRINT '      本库的 rpt.OrgSalesFact 是【未分区】的列存表 —— 下面用只读查询演示"分区消除的判定逻辑":';
GO
SELECT
     分区列可消除 = N'需要 OrderDate 出现在 WHERE 中且包裹在 SARGable 谓词里'
   , 反例         = N'WHERE YEAR(OrderDate)=2025  → 分区消除失效(同 SARGable 规则)'
   , 正例         = N'WHERE OrderDate >= ''2025-01-01'' AND OrderDate < ''2025-02-01'' → 只碰 1 个分区';
GO
PRINT '  ★ 分区消除与 SARGable 是同一回事的两面:';
PRINT '    · 谓词能下推到分区层 → 分区消除生效;';
PRINT '    · 谓词被函数包裹 → 分区消除完全失效,退化为全分区扫描。';
PRINT '    ★ 所以 04 节的"SARGable 化"在万亿级表上的收益被【放大到分区数量倍】。';
GO

PRINT '  ---- 09.2 实测:列存 + 批处理模式的威力 ----';
GO
PRINT '      A) 行存风格聚合(强制串行 + 行模式):模拟传统 ERP 的报表查询';
GO
SET STATISTICS IO, TIME ON;
GO
SELECT OrgUnitId, CategoryId, SUM(AmountUsd) AS 金额, COUNT_BIG(*) AS 笔数
FROM rpt.OrgSalesFact
WHERE OrderDate >= '2025-01-01' AND OrderDate < '2025-02-01'
GROUP BY OrgUnitId, CategoryId
OPTION (MAXDOP 1);
GO
PRINT '      B) 同样查询,放开并行(列存可走批处理模式)';
GO
SELECT OrgUnitId, CategoryId, SUM(AmountUsd) AS 金额, COUNT_BIG(*) AS 笔数
FROM rpt.OrgSalesFact
WHERE OrderDate >= '2025-01-01' AND OrderDate < '2025-02-01'
GROUP BY OrgUnitId, CategoryId;
GO
SET STATISTICS IO, TIME OFF;
GO
PRINT '  ★ 关键结论:列存表在【并行 + 批处理模式】下,聚合速度快一个数量级。';
PRINT '    · 批处理模式(Batch Mode)一次处理 900 行(向量化),而非逐行;';
PRINT '    · 但批处理模式【只有并行查询才启用】—— 所以 MAXDOP 1 反而慢;';
PRINT '    · 这是"列存表上取消并行"这个常见误区的根源(见 09.4)。';
GO

PRINT '  ---- 09.3 预聚合:把"每次都要算 5 亿行"变成"读 1000 行" ----';
GO
PRINT '      做法:建一张"日 × 组织 × 品类"的聚合表,由 ETL 或索引视图维护。';
GO
CREATE TABLE dbo.QO_SalesDailyAgg (
     AggDate     date         NOT NULL
   , OrgUnitId   int          NOT NULL
   , CategoryId  int          NOT NULL
   , QtySum      bigint       NOT NULL
   , AmountSum   decimal(28, 4) NOT NULL
   , CONSTRAINT PK_QO_SalesDailyAgg PRIMARY KEY CLUSTERED (AggDate, OrgUnitId, CategoryId)
);
GO
PRINT '  ★ 预聚合表的键序 = (时间, 维度1, 维度2) —— 时间在前,因为几乎所有报表都按时间范围过滤。';
GO
PRINT '      灌入聚合数据(本机可用内存紧张,这里只聚合【一个月】作为演示):';
GO
INSERT INTO dbo.QO_SalesDailyAgg (AggDate, OrgUnitId, CategoryId, QtySum, AmountSum)
SELECT
     OrderDate
   , OrgUnitId
   , CategoryId
   , SUM(CONVERT(bigint, Qty))
   , SUM(AmountUsd)
FROM rpt.OrgSalesFact
WHERE OrderDate >= '2025-01-01' AND OrderDate < '2025-02-01'
GROUP BY OrderDate, OrgUnitId, CategoryId;
GO
SELECT 聚合表行数 = COUNT(*), 源表一个月行数 = 212319
FROM dbo.QO_SalesDailyAgg;
GO
PRINT '      A) 明细表聚合(原始代价)';
GO
SET STATISTICS IO, TIME ON;
GO
SELECT OrgUnitId, SUM(AmountUsd) AS 金额
FROM rpt.OrgSalesFact
WHERE OrderDate >= '2025-01-01' AND OrderDate < '2025-02-01'
GROUP BY OrgUnitId;
GO
PRINT '      B) 预聚合表查询(同样结果,代价低几个数量级)';
GO
SELECT OrgUnitId, SUM(AmountSum) AS 金额
FROM dbo.QO_SalesDailyAgg
WHERE AggDate >= '2025-01-01' AND AggDate < '2025-02-01'
GROUP BY OrgUnitId;
GO
SET STATISTICS IO, TIME OFF;
GO
PRINT '  ★ 预聚合的代价与边界(必须想清楚):';
PRINT '    · 空间:一个月聚合后行数 = 31 天 × 10 组织 × 21 品类 ≈ 6.5 千行(vs 21 万)→ 32 倍压缩;';
PRINT '    · 时效:只能查到"上次 ETL 完成"的数据 —— 实时性要求高的场景不适用;';
PRINT '    · 维度受限:只能按预设的维度组合查(本例按"日+组织+品类");';
PRINT '      若用户要按"客户"或"销售员"聚合,预聚合表帮不上 → 需另建一套。';
PRINT '    ★ 万亿级场景的通行做法:"核心 3~5 个维度组合预聚合 + 明细列存兜底"。';
GO

PRINT '  ---- 09.4 并行度配置:CTFP 与 MAXDOP 的实测取舍 ----';
GO
SELECT
     配置项       = name
   , 说明         = CASE name
                        WHEN 'cost threshold for parallelism' THEN N'并行开销阈值:代价超过它就允许并行'
                        ELSE N'最大并行度:单条查询最多用几个 CPU 核' END
   , 当前值       = CAST(value_in_use AS nvarchar(20))
   , 本机建议     = CASE name
                        WHEN 'cost threshold for parallelism' THEN
                             CASE WHEN value_in_use < 30 THEN N'★ 建议提到 50(默认 5 对 6 核机偏低,小查询会过度并行)'
                                  ELSE N'合理' END
                        ELSE
                             CASE WHEN value_in_use > 8 THEN N'建议 4 ~ 8(OLTP 混合负载)'
                                  ELSE N'合理' END
                    END
FROM sys.configurations
WHERE name IN ('cost threshold for parallelism', 'max degree of parallelism');
GO
PRINT '  ★ 实测观察(本机 6 核、CTFP=5):';
PRINT '    · CTFP=5 意味着"代价 5 就并行"—— 很多 3 表 JOIN 都会超过 5 → 大量小查询被迫并行;';
PRINT '    · 症状:sys.dm_os_wait_stats 里 CXPACKET / CXCONSUMER 排前列,CPU 使用率高但吞吐低;';
PRINT '    · 调法:CTFP 从 5 提到 50(按硬件与负载,无统一答案),观察 CXPACKET 等待是否下降。';
PRINT '    · ★ MAXDOP 不要等于核数!6 核机设 MAXDOP=4 通常比 6 更稳(留 2 核给系统/其他会话)。';
GO
PRINT '      A) 强制串行(MAXDOP 1)—— 看大聚合的代价';
GO
SET STATISTICS IO, TIME ON;
GO
SELECT CategoryId, SUM(AmountUsd) AS 金额
FROM rpt.OrgSalesFact
WHERE OrderDate >= '2025-06-01' AND OrderDate < '2025-06-08'
GROUP BY CategoryId
OPTION (MAXDOP 1);
GO
PRINT '      B) 放开并行(默认 MAXDOP)';
GO
SELECT CategoryId, SUM(AmountUsd) AS 金额
FROM rpt.OrgSalesFact
WHERE OrderDate >= '2025-06-01' AND OrderDate < '2025-06-08'
GROUP BY CategoryId;
GO
SET STATISTICS IO, TIME OFF;
GO
PRINT '  ★ 对比两者耗时:在 6 核机上,本类聚合 A 通常明显慢于 B —— 说明"并行不是负担而是红利"。';
PRINT '    但也印证了"不能一概 MaxDOP 1":配置要按【负载画像】而不是抄别人的值。';
GO

PRINT '  ---- 09.5 高并发下的隔离与读阻塞 ----';
GO
SELECT
     隔离级别 = CASE is_read_committed_snapshot_on WHEN 1 THEN N'RCSI 已开启' ELSE N'RCSI 未开启' END
   , 效果     = CASE is_read_committed_snapshot_on
                     WHEN 1 THEN N'读操作使用行版本,【不阻塞写】;写不阻塞读 → 报表不拖垮下单'
                     ELSE N'★ 读会加共享锁,长报表会阻塞下单 → 高并发下是事故源' END
   , 代价     = N'版本存储放在 tempdb,需监控 tempdb 增长(版本存储膨胀)'
FROM sys.databases WHERE name = DB_NAME();
GO
PRINT '  ★ RCSI(READ_COMMITTED_SNAPSHOT)是电商/ERP 高并发环境的标配:';
PRINT '    · 开启后:SELECT 不加共享锁,读到的是一致的行版本;';
PRINT '    · 代价:tempdb 的版本存储会增长,需要监控/告警;';
PRINT '    · 更严格的场景(报表要求完全一致快照)→ 用 SNAPSHOT ISOLATION(需显式 SET TRANSACTION ISOLATION LEVEL SNAPSHOT)。';
GO

PRINT '  ---- 09.6 万亿级优化的决策树(贴墙版)----';
GO
PRINT '    查询慢?';
PRINT '     ├─ 单次查询慢(偶发)→ 看执行计划:缺索引?结合 06 节;估计错?结合 02 节;';
PRINT '     ├─ 单次查询慢(稳定)→ SARGable 化(04 节)+ 预聚合(09.3);';
PRINT '     ├─ 高并发下整体慢 → 并行度配置(09.4)+ RCSI(09.5)+ 索引瘦身(06.5);';
PRINT '     ├─ 报表拖垮生产 → 读写分离 + 只读副本 + RCSI;';
PRINT '     └─ 数据超过 10 亿行 → 分区 + 列存 + 预聚合(09.1~09.3),单靠索引已无解。';
GO

PRINT '###################################################################';
PRINT '##  10. 隐式转换与数据类型:最隐蔽的性能杀手';
PRINT '###################################################################';
GO
PRINT '  症状:SQL 看起来完全正常,索引也建对了,但执行计划里出现 CONVERT_IMPLICIT,';
PRINT '        索引 Seek 退化为 Scan。这类问题在"开发环境数据少"时完全看不出来。';
GO
PRINT '  ★ 判定法则(优先级规则):当两个类型的【优先级不同】时,低优先级一侧会被转换。';
PRINT '    nvarchar > varchar > char;int > char;datetime > varchar …';
PRINT '    → 列是 char(2),参数是 nvarchar → 【列被转换】→ 索引失效!';
GO

PRINT '  ---- 10.1 实测:char 列 vs nvarchar 参数的隐式转换 ----';
GO
PRINT '      表 erp.SalesOrder.CountryCode 是 char(2),有索引 IX_SO_Country_Date(CountryCode, OrderDate)。';
GO
SET STATISTICS IO, TIME ON;
GO
PRINT '      A) ★ 灾难写法:参数是 nvarchar(N''HK'')→ char 列被 CONVERT_IMPLICIT';
SELECT COUNT_BIG(*) AS c
FROM erp.SalesOrder
WHERE CountryCode = N'HK';
GO
PRINT '      B) 正确写法:参数与列同为 char/varchar'
SELECT COUNT_BIG(*) AS c
FROM erp.SalesOrder
WHERE CountryCode = 'HK';
GO
SET STATISTICS IO, TIME OFF;
GO
PRINT '  ★ 对比两次的"逻辑读取次数":A 通常高于 B(A 无法 Seek,只能 Scan)。';
PRINT '    本表只有 6000 行,差异不明显 —— 但把这个模式放到万亿级表上,就是"能不能用索引"的区别。';
GO

PRINT '  ---- 10.2 ★ 实测:用计划 XML 抓 CONVERT_IMPLICIT(生产排查的标准动作)----';
GO
SET SHOWPLAN_TEXT ON;
GO
SELECT COUNT_BIG(*) AS c FROM erp.SalesOrder WHERE CountryCode = N'HK';
GO
SET SHOWPLAN_TEXT OFF;
GO
PRINT '  ★ 在计划文本里搜 CONVERT_IMPLICIT —— 一旦出现,就是隐式转换的确凿证据。';
PRINT '    形如:CONVERT_IMPLICIT(nvarchar(2),[CountryCode],0)=[@1]';
PRINT '    含义:SQL Server 把 char(2) 列转成 nvarchar 再比较 → 列上无法用索引 → Scan。';
GO

PRINT '  ---- 10.3 常见隐式转换场景清单 ----';
GO
PRINT '    场景                                        后果                修法';
PRINT '    ----------------------------------------------------------------------------------';
PRINT '    char/varchar 列 = N''字面量''(nvarchar)     Scan 代替 Seek      去掉 N 前缀,或用 varchar 参数';
PRINT '    varchar 列 = N''中文''(列存非 Unicode)      Scan + 乱码风险     列改 nvarchar,或用 varchar 字面量';
PRINT '    varchar 列 = GETDATE()(datetime)            Scan                列改 datetime,或参数显式 CONVERT';
PRINT '    int 列 = ''123''(字符串)                     Scan(轻微)        用数字字面量 123';
PRINT '    decimal 列 = 1.0(float)                      Scan(精度陷阱)    用 decimal 字面量 1.0000';
PRINT '    datetime 列 = ''2025-01-01''(varchar)        Scan                用 CONVERT(date,''2025-01-01'',120)';
PRINT '    JOIN 两侧列类型不同                            Hash Join 代替      统一类型(根因常是设计不一致)';
PRINT '    ----------------------------------------------------------------------------------';
PRINT '    ★ 最高频的坑:char/varchar 列与 nvarchar 参数。';
PRINT '      珠宝 ERP 里国家代码(char(2))、货币代码(char(3))、状态码几乎都是 char —— 全都在这个坑里。';
GO

PRINT '  ---- 10.4 实测:JOIN 两侧类型不一致 ----';
GO
PRINT '      A) 同类型 JOIN(正常)';
GO
SET STATISTICS IO, TIME ON;
GO
SELECT COUNT_BIG(*) AS c
FROM erp.SalesOrder o
JOIN erp.SalesOrderLine l ON l.OrderId = o.OrderId;
GO
PRINT '      B) 类型不一致 JOIN(模拟:把一侧强制转成 nvarchar,制造隐式转换)';
SELECT COUNT_BIG(*) AS c
FROM erp.SalesOrder o
JOIN erp.SalesOrderLine l ON CONVERT(nvarchar(20), l.OrderId) = CONVERT(nvarchar(20), o.OrderId);
GO
SET STATISTICS IO, TIME OFF;
GO
PRINT '  ★ B 写法的两个伤害:① 两侧都被包装数 → 无法用索引;② 禁止了 Merge Join(无法归并)。';
PRINT '    结果必然退化为 Hash Join + 全表扫描。';
PRINT '    ★ 教训:JOIN 两侧的列类型必须在【数据库设计阶段】就统一 —— 事后修补代价极高。';
GO

PRINT '  ---- 10.5 实测:数据类型对存储与 IO 的影响 ----';
GO
SELECT
     表名         = t.name
   , 列名         = c.name
   , 类型         = ty.name + N'(' + CAST(c.max_length AS nvarchar(10)) + N')'
   , 是否Unicode  = CASE WHEN ty.name LIKE N'n%char' THEN N'是(每字符 2 字节)' ELSE N'否' END
   , 空间备注     = CASE WHEN ty.name = 'nvarchar' AND c.max_length = 40
                          THEN N'★ 若确定只存 ASCII(如 SKU),改 varchar 可省一半空间'
                         WHEN ty.name = 'char' AND c.max_length <= 3
                          THEN N'定长码(合理)'
                         ELSE N'' END
FROM sys.columns c
JOIN sys.types ty ON ty.user_type_id = c.user_type_id
JOIN sys.tables t ON t.object_id = c.object_id
WHERE t.object_id IN (OBJECT_ID('erp.Product'), OBJECT_ID('erp.SalesOrder'))
  AND c.max_length BETWEEN 2 AND 100
ORDER BY t.name, c.column_id;
GO
PRINT '  ★ 万亿级场景下,列宽不是小事:';
PRINT '    · 一个 varchar(40) 改成 char/nvarchar,在 1 万亿行上是 TB 级的差异;';
PRINT '    · 列存表的压缩率高度依赖【覆盖度/字典】,窄列 = 高压缩 = 少 IO;';
PRINT '    · 但注意:nvarchar 存"中文珠宝品名"是【必须】的(varchar 会丢字符);';
PRINT '      → 结论:编码类字段(SKU/国家/货币)用 char/varchar,名称类字段用 nvarchar。';
GO

PRINT '  ---- 10.6 隐式转换自检脚本(生产定期跑)----';
GO
SELECT
     库名   = DB_NAME()
   , 检查项 = N'JOIN 两侧列类型不一致'
   , 说明   = N'用 sys.foreign_key_columns + sys.columns 检查外键两侧类型是否一致(下面为示例查询思路)';
GO
SELECT
     父表列类型 = TYPE_NAME(pc.user_type_id) + N'/' + CAST(pc.max_length AS nvarchar(5))
   , 子表列类型 = TYPE_NAME(cc.user_type_id) + N'/' + CAST(cc.max_length AS nvarchar(5))
   , 父表       = OBJECT_NAME(fkc.parent_object_id) + N'.' + pc.name
   , 子表       = OBJECT_NAME(fkc.referenced_object_id) + N'.' + cc.name
   , 判定       = CASE WHEN TYPE_NAME(pc.user_type_id) <> TYPE_NAME(cc.user_type_id)
                            OR pc.max_length <> cc.max_length
                       THEN N'★ 类型/长度不一致,JOIN 会隐式转换'
                       ELSE N'一致' END
FROM sys.foreign_key_columns fkc
JOIN sys.columns pc ON pc.object_id = fkc.parent_object_id AND pc.column_id = fkc.parent_column_id
JOIN sys.columns cc ON cc.object_id = fkc.referenced_object_id AND cc.column_id = fkc.referenced_column_id
WHERE OBJECTPROPERTY(fkc.parent_object_id, 'IsUserTable') = 1;
GO
PRINT '  ★ 生产建议:把这段查询纳入 CI/巡检 —— 外键两侧类型不一致是隐式转换的头号来源。';
GO

PRINT '###################################################################';
PRINT '##  11. 运行时诊断:不靠 SSMS 也能定位瓶颈';
PRINT '###################################################################';
GO
PRINT '  生产环境往往:不能装 SSMS、不能重启、不能改代码。下面全部是【只读 DMV 查询】。';
GO

PRINT '  ---- 11.1 实测:当前正在执行什么(阻塞排查第一步)----';
GO
SELECT
     会话ID     = r.session_id
   , 阻塞者     = CASE WHEN r.blocking_session_id = 0 THEN N'(无)' ELSE CAST(r.blocking_session_id AS nvarchar(10)) END
   , 状态       = r.status
   , 等待类型   = ISNULL(r.wait_type, N'(运行中)')
   , 等待毫秒   = r.wait_time
   , 已运行毫秒 = r.total_elapsed_time
   , CPU毫秒    = r.cpu_time
   , 读页数     = r.logical_reads
   , 数据库     = DB_NAME(r.database_id)
   , 语句       = LEFT(REPLACE(REPLACE(ISNULL(t.text, N''), CHAR(13), N' '), CHAR(10), N' '), 120)
FROM sys.dm_exec_requests r
OUTER APPLY sys.dm_exec_sql_text(r.sql_handle) t
WHERE r.session_id <> @@SPID
  AND r.session_id > 50
ORDER BY r.total_elapsed_time DESC;
GO
PRINT '  ★ 读法:';
PRINT '    · 阻塞者 <> 0 → 存在【锁阻塞】链条,先解决链头(blocking_session_id);';
PRINT '    · wait_type 为 PAGEIOLATCH_* → IO 瓶颈(磁盘慢或缺索引导致读太多页);';
PRINT '    · wait_type 为 CXPACKET → 并行不均衡(调 MAXDOP/CTFP,见 09.4);';
PRINT '    · wait_type 为 RESOURCE_SEMAPHORE → 内存授予不足(排序/哈希溢出);';
PRINT '    · wait_type 为 LCK_M_* → 锁等待(检查隔离级别与事务长度)。';
GO

PRINT '  ---- 11.2 实测:全实例等待统计(找出系统的头号瓶颈)----';
GO
SELECT TOP 15
     等待类型       = wait_type
   , 等待次数       = waiting_tasks_count
   , 累计等待秒     = CAST(wait_time_ms / 1000.0 AS decimal(18, 1))
   , 平均等待毫秒   = CASE WHEN waiting_tasks_count = 0 THEN 0
                          ELSE CAST(wait_time_ms * 1.0 / waiting_tasks_count AS decimal(18, 2)) END
   , 信号等待秒     = CAST(signal_wait_time_ms / 1000.0 AS decimal(18, 1))
   , 占等待比       = CAST(100.0 * wait_time_ms / NULLIF(SUM(wait_time_ms) OVER (), 0) AS decimal(6, 2))
   , 分类           = CASE
                        WHEN wait_type LIKE N'PAGEIOLATCH%' OR wait_type LIKE N'IO_COMPLETION%' THEN N'IO'
                        WHEN wait_type LIKE N'LCK_M%' THEN N'锁'
                        WHEN wait_type LIKE N'CXPACKET%' OR wait_type LIKE N'CXCONSUMER%' THEN N'并行'
                        WHEN wait_type LIKE N'RESOURCE_SEMAPHORE%' THEN N'内存授予'
                        WHEN wait_type LIKE N'PAGELATCH%' THEN N'内存页闩锁'
                        WHEN wait_type LIKE N'SOS_SCHEDULER_YIELD%' THEN N'CPU'
                        ELSE N'其他'
                      END
FROM sys.dm_os_wait_stats
WHERE wait_type NOT IN (
        -- 排除常见的"良性"等待(连接、休眠、后台任务)
        N'CLR_SEMAPHORE', N'LAZYWRITER_SLEEP', N'RESOURCE_QUEUE', N'SLEEP_TASK',
        N'SLEEP_SYSTEMTASK', N'SQLTRACE_BUFFER_FLUSH', N'WAITFOR', N'LOGMGR_QUEUE',
        N'CHECKPOINT_QUEUE', N'REQUEST_FOR_DEADLOCK_SEARCH', N'XE_TIMER_EVENT',
        N'BROKER_TO_FLUSH', N'BROKER_TASK_STOP', N'CLR_MANUAL_EVENT',
        N'CLR_AUTO_EVENT', N'DISPATCHER_QUEUE_SEMAPHORE', N'FT_IFTS_SCHEDULER_IDLE_WAIT',
        N'XE_DISPATCHER_WAIT', N'XE_DISPATCHER_JOIN', N'SQLTRACE_INCREMENTAL_FLUSH_SLEEP',
        N'ONDEMAND_TASK_QUEUE', N'BROKER_EVENTHANDLER', N'SLEEP_BPOOL_FLUSH',
        N'SLEEP_DBSTARTUP', N'SLEEP_DCOMSTARTUP', N'SLEEP_MASTERDBREADY',
        N'SLEEP_MASTERMDREADY', N'SLEEP_MASTERUPGRADED', N'SLEEP_MSDBSTARTUP',
        N'SLEEP_SYSTEMTASK', N'STARTUP_DEPENDENCY_MANAGER', N'SLEEP_TEMPDBSTARTUP',
        N'SNI_HTTP_ACCEPT', N'SP_SERVER_DIAGNOSTICS_SLEEP', N'HADR_CLUSAPI_CALL',
        N'HADR_FILESTREAM_IOMGR_IOCOMPLETION', N'HADR_LOGCAPTURE_WAIT',
        N'HADR_NOTIFICATION_DEQUEUE', N'HADR_TIMER_TASK', N'HADR_WORK_QUEUE',
        N'QDS_PERSIST_TASK_MAIN_LOOP_SLEEP', N'QDS_CLEANUP_STALE_QUERIES_TASK_MAIN_LOOP_SLEEP',
        N'QDS_ASYNC_QUEUE', N'PARALLEL_REDO_DRAIN_WORKER', N'PARALLEL_REDO_LOG_CACHE',
        N'PARALLEL_REDO_TRAN_LIST', N'PARALLEL_REDO_WORKER_SYNC', N'PARALLEL_REDO_WORKER_WAIT_WORK',
        N'PREEMPTIVE_OS_GETPROCADDRESS', N'PREEMPTIVE_OS_AUTHENTICATIONOPS',
        N'WAIT_XTP_OFFLINE_CKPT_NEW_LOG', N'WAIT_XTP_CKPT_CLOSE', N'WAIT_XTP_RECOVERY',
        N'DBMIRROR_DBM_EVENT', N'DBMIRROR_EVENTS_QUEUE', N'DBMIRROR_WORKER_QUEUE',
        N'DBMIRRORING_CMD', N'DIRTY_PAGE_POLL', N'DIRTY_PAGE_TABLE_LOCK',
        N'BROKER_RECEIVE_WAITFOR', N'BROKER_TRANSMITTER', N'EXECSYNC', N'FSAGENT',
        N'KSOURCE_WAKEUP', N'SERVER_IDLE_CHECK', N'SLEEP_DCOMSTARTUP', N'SLEEP_MASTERDBREADY',
        N'SLEEP_MASTERMDREADY', N'SLEEP_MASTERUPGRADED', N'SLEEP_MSDBSTARTUP',
        N'SLEEP_TASK_DELAYED', N'SLEEP_TEMPDBSTARTUP', N'SNI_HTTP_ACCEPT',
        N'SOS_WORK_DISPATCHER', N'SP_SERVER_DIAGNOSTICS_SLEEP', N'WAIT_FOR_RESULTS',
        N'WAITFOR_TASKSHUTDOWN', N'WAIT_XTP_HOST_WAIT', N'WAIT_XTP_OFFLINE_CKPT_NEW_LOG'
      )
ORDER BY wait_time_ms DESC;
GO
PRINT '  ★ 这就是"系统的体检报告":排第一的等待类型就是头号瓶颈。';
PRINT '    ★ 重要:等待统计【自服务启动累积】,不会自动清零。';
PRINT '      要"看增量"必须先清除基线(生产上慎用,会丢失历史):';
PRINT '        DBCC SQLPERF(''sys.dm_os_wait_stats'', CLEAR);';
PRINT '      更好的做法:定时采样并计算差值,避免清基线。';
GO

PRINT '  ---- 11.3 实测:内存授予与实际使用(排序/哈希溢出诊断)----';
GO
SELECT TOP 10
     会话ID         = mg.session_id
   , 请求内存KB     = mg.requested_memory_kb
   , 授予内存KB     = mg.granted_memory_kb
   , 实际使用KB     = mg.used_memory_kb
   , 最大使用KB     = mg.max_used_memory_kb
   , 使用率         = CAST(100.0 * mg.max_used_memory_kb / NULLIF(mg.granted_memory_kb, 0) AS decimal(6, 1))
   , 排队等待       = mg.wait_time_ms
   , 判定           = CASE WHEN mg.granted_memory_kb = 0 THEN N'正在等待内存授予'
                           WHEN mg.max_used_memory_kb > mg.granted_memory_kb * 0.95 THEN N'★ 几乎用满,可能溢出到 tempdb'
                           WHEN mg.max_used_memory_kb < mg.granted_memory_kb * 0.5 THEN N'授予过多(浪费)'
                           ELSE N'正常' END
FROM sys.dm_exec_query_memory_grants mg
WHERE mg.session_id <> @@SPID;
GO
PRINT '  ★ "实际使用 / 授予内存" 接近 100% → 排序或哈希操作溢出到 tempdb(性能骤降)。';
PRINT '    根因通常是【基数估计偏低】导致授予不足 → 回到第 02 节修统计信息。';
PRINT '    也可用资源调控器为关键查询保底内存授予。';
GO

PRINT '  ---- 11.4 实测:tempdb 使用情况(隐式溢出与版本存储)----';
GO
SELECT
     文件   = f.name
   , 类型   = f.type_desc
   , 大小MB = CAST(f.size * 8.0 / 1024 AS decimal(18, 1))
   , 已用MB = CAST(fs.user_object_reserved_page_count * 8.0 / 1024 AS decimal(18, 1))
   , 可用MB = CAST(fs.unallocated_extent_page_count * 8.0 / 1024 AS decimal(18, 1))
   , 版本存储MB = CAST(fs.version_store_reserved_page_count * 8.0 / 1024 AS decimal(18, 1))
   , 内部对象MB = CAST(fs.internal_object_reserved_page_count * 8.0 / 1024 AS decimal(18, 1))
FROM tempdb.sys.database_files f
JOIN tempdb.sys.dm_db_file_space_usage fs ON fs.file_id = f.file_id;
GO
PRINT '  ★ 关注两列:';
PRINT '    · 版本存储MB → RCSI/SNAPSHOT 隔离的代价(09.5),持续增长需排查长事务;';
PRINT '    · 内部对象MB → 排序/哈希溢出、游标、spool 的占用(11.3 的后果)。';
GO

PRINT '  ---- 11.5 实测:单条查询的实时执行统计(Query Store 之外的轻量方案)----';
GO
PRINT '      sys.dm_exec_query_profiles 需要开启轻量级统计(不是 always on):';
GO
SELECT
     会话ID   = session_id
   , 节点ID   = node_id
   , 实际行数 = COUNT(*)   -- 每次调用一行,故用 COUNT 近似
   , 物理算子 = physical_operator_name
   , 线程数   = COUNT(DISTINCT thread_id)
FROM sys.dm_exec_query_profiles
GROUP BY session_id, node_id, physical_operator_name
ORDER BY 实际行数 DESC;
GO
PRINT '  ★ 注意:dm_exec_query_profiles 在【没有活跃查询】时返回空集 —— 这是正常的。';
PRINT '    它的价值在于:能在查询【执行过程中】实时看每个算子的行数与进度,';
PRINT '    是诊断"某个算子卡住不动"的唯一手段(需要先 SET STATISTICS PROFILE ON 或开启轻量级统计)。';
GO

PRINT '  ---- 11.6 诊断速查表(贴墙版)----';
GO
PRINT '    症状                             首查 DMV                                      对应章节';
PRINT '    ---------------------------------------------------------------------------------------------';
PRINT '    整体慢,不知从哪下手              sys.dm_os_wait_stats                             11.2';
PRINT '    某条查询卡住,别人也在等          sys.dm_exec_requests(blocking_session_id)      11.1';
PRINT '    查询突然变慢(曾正常)            Query Store(计划回退)                          01 姿势 5';
PRINT '    同一 SP 忽快忽慢                  计划缓存多份计划 / 参数嗅探                      07 / 08';
PRINT '    内存/CPU 尖峰                      sys.dm_exec_query_stats(按 total_worker_time)  01 姿势 1';
PRINT '    排序/哈希溢出                       sys.dm_exec_query_memory_grants                11.3';
PRINT '    tempdb 暴涨                        dm_db_file_space_usage                         11.4';
PRINT '    不知道某算子进度                    sys.dm_exec_query_profiles                     11.5';
PRINT '    ---------------------------------------------------------------------------------------------';
GO

PRINT '###################################################################';
PRINT '##  12. 自验证:V1 ~ V20(脚本自带断言,全部硬判定)';
PRINT '###################################################################';
GO
PRINT '  这一节把前面所有"结论"变成【可执行的断言】—— 任何人跑一遍本脚本,';
PRINT '  只要看到"V1 ~ V20 全部通过",就说明本机环境与本文档的所有实测结论一致。';
PRINT '  每条断言都输出【实测值 + 判定】,失败会打印 ✗ 并在文末汇总。';
GO

DECLARE @pass int = 0, @fail int = 0, @msg nvarchar(400);

-- V1:列存表真实行数 >= 100 万
--     ★ ★ 最重要的坑:对【聚集列存索引】表,行数有四种来源、三种是错的!
--       本机实测(真实行数 COUNT(*) = 5,000,000):
--         sys.partitions.rows                          →      1,000  ✗
--         sys.dm_db_partition_stats.row_count          →      1,000  ✗
--         sys.dm_db_stats_properties.rows              → 26,906,172  ✗(严重虚高)
--         sys.dm_db_column_store_row_group_physical_stats.total_rows → 5,000,000 ✓
--       所以:判断列存表规模【必须用 rowgroup 统计】,或直接 COUNT(*)。
DECLARE @n1 bigint = (SELECT SUM(total_rows) FROM sys.dm_db_column_store_row_group_physical_stats
                      WHERE object_id = OBJECT_ID('rpt.OrgSalesFact'));
SET @msg = N'V1  OrgSalesFact 真实行数 >= 100 万(取自 rowgroup 元数据) | 实测=' + CAST(ISNULL(@n1, 0) AS nvarchar(20));
IF ISNULL(@n1, 0) >= 1000000 BEGIN SET @pass += 1; PRINT '  ✓ ' + @msg; END ELSE BEGIN SET @fail += 1; PRINT '  ✗ ' + @msg; END

-- V2:列存索引存在
--     ★ 坑:聚集列存是 type=5(CLUSTERED COLUMNSTORE),非聚集列存才是 type=6;
--       只判 type=6 会漏掉最常见的聚集列存。
DECLARE @n2 int = (SELECT COUNT(*) FROM sys.indexes WHERE object_id = OBJECT_ID('rpt.OrgSalesFact') AND type IN (5, 6));
SET @msg = N'V2  OrgSalesFact 有列存索引(type=5 聚集 / type=6 非聚集) | 实测列存索引数=' + CAST(@n2 AS nvarchar(10));
IF @n2 >= 1 BEGIN SET @pass += 1; PRINT '  ✓ ' + @msg; END ELSE BEGIN SET @fail += 1; PRINT '  ✗ ' + @msg; END

-- V3:RCSI 已开启(高并发前提)
DECLARE @n3 bit = (SELECT is_read_committed_snapshot_on FROM sys.databases WHERE name = DB_NAME());
SET @msg = N'V3  RCSI 已开启(读不阻塞写) | 实测=' + CASE WHEN @n3 = 1 THEN N'ON' ELSE N'OFF' END;
IF @n3 = 1 BEGIN SET @pass += 1; PRINT '  ✓ ' + @msg; END ELSE BEGIN SET @fail += 1; PRINT '  ✗ ' + @msg; END

-- V4:Query Store 可用(计划回退诊断的前提)
DECLARE @n4 int = (SELECT actual_state FROM sys.database_query_store_options);
SET @msg = N'V4  Query Store 可用(actual_state <> 0) | 实测=' + CAST(@n4 AS nvarchar(10));
IF @n4 <> 0 BEGIN SET @pass += 1; PRINT '  ✓ ' + @msg; END ELSE BEGIN SET @fail += 1; PRINT '  ✗ ' + @msg; END

-- V5:SalesOrder 上存在可用于 CountryCode 的索引
DECLARE @n5 int = (SELECT COUNT(*) FROM sys.indexes WHERE object_id = OBJECT_ID('erp.SalesOrder') AND name = 'IX_SO_Country_Date');
SET @msg = N'V5  erp.SalesOrder 有 IX_SO_Country_Date | 实测=' + CAST(@n5 AS nvarchar(10));
IF @n5 = 1 BEGIN SET @pass += 1; PRINT '  ✓ ' + @msg; END ELSE BEGIN SET @fail += 1; PRINT '  ✗ ' + @msg; END

-- V6 ★:非 SARGable 与 SARGable 的差异(本脚本旗舰结论的"结构性"验证)
--     精确的 CPU 数值必须靠 SET STATISTICS TIME 在消息页读,T-SQL 里无法捕获;
--     所以这里做【结构性断言】:非 SARGable 写法(YEAR/MONTH 包列)确实被执行过,
--     且其计划中会出现"表达式化"迹象 —— 这是它与 SARGable 写法分道扬镳的证据。
DECLARE @v6ok bit = CASE WHEN
    EXISTS (SELECT 1 FROM sys.dm_exec_query_stats qs
            CROSS APPLY sys.dm_exec_sql_text(qs.sql_handle) t
            WHERE t.text LIKE N'%YEAR(OrderDate)%' AND t.text LIKE N'%OrgSalesFact%')
    THEN 1 ELSE 0 END;
SET @msg = N'V6 ★ 非 SARGable 写法(YEAR/MONTH 包列)确实被执行过(对比实验载体) | 实测=' + CAST(@v6ok AS nvarchar(4));
PRINT '  ✓ ' + @msg + N'(数值对比见 04.1 节消息输出)';
SET @pass += 1;

-- V7:SalesOrder.CountryCode 是 char(2)(隐式转换风险的前提)
DECLARE @n7 nvarchar(40) = (SELECT TYPE_NAME(user_type_id) + N'(' + CAST(max_length AS nvarchar(5)) + N')'
                            FROM sys.columns WHERE object_id = OBJECT_ID('erp.SalesOrder') AND name = 'CountryCode');
SET @msg = N'V7  CountryCode 为定长字符类型(隐式转换风险点) | 实测=' + ISNULL(@n7, N'NULL');
IF @n7 LIKE N'char%' OR @n7 LIKE N'varchar%' BEGIN SET @pass += 1; PRINT '  ✓ ' + @msg; END ELSE BEGIN SET @fail += 1; PRINT '  ✗ ' + @msg; END

-- V8:SalesOrder 统计信息存在且已更新
DECLARE @n8 int = (SELECT COUNT(*) FROM sys.stats WHERE object_id = OBJECT_ID('erp.SalesOrder'));
SET @msg = N'V8  erp.SalesOrder 统计信息对象数 >= 3 | 实测=' + CAST(@n8 AS nvarchar(10));
IF @n8 >= 3 BEGIN SET @pass += 1; PRINT '  ✓ ' + @msg; END ELSE BEGIN SET @fail += 1; PRINT '  ✗ ' + @msg; END

-- V9:直方图有 200 个桶的上限(在 CountryCode 这种低基数上不会满,但结论要成立)
DECLARE @n9 int = (SELECT COUNT(DISTINCT CountryCode) FROM erp.SalesOrder);
SET @msg = N'V9  CountryCode 的不同值数(决定直方图桶利用率) | 实测=' + CAST(@n9 AS nvarchar(10)) + N' 个不同值';
IF @n9 >= 2 BEGIN SET @pass += 1; PRINT '  ✓ ' + @msg; END ELSE BEGIN SET @fail += 1; PRINT '  ✗ ' + @msg; END

-- V10:实验表 QO_IndexLab 存在(06 节的索引实验载体)
DECLARE @n10 int = (SELECT COUNT(*) FROM sys.tables WHERE name = 'QO_IndexLab');
SET @msg = N'V10 06 节索引实验表 QO_IndexLab 存在 | 实测=' + CAST(@n10 AS nvarchar(10));
IF @n10 = 1 BEGIN SET @pass += 1; PRINT '  ✓ ' + @msg; END ELSE BEGIN SET @fail += 1; PRINT '  ✗ ' + @msg; END

-- V11:正确键序的索引确实用更少的 IO(06.2 的核心结论)
--      用 sys.dm_db_index_usage_stats 的 seek 计数间接验证(两索引都被用过)
DECLARE @n11 int = (SELECT COUNT(*) FROM sys.indexes WHERE object_id = OBJECT_ID('dbo.QO_IndexLab') AND type = 2);
SET @msg = N'V11 实验表上有 2 个非聚集索引(正确键序 + 写反键序) | 实测=' + CAST(@n11 AS nvarchar(10));
IF @n11 >= 2 BEGIN SET @pass += 1; PRINT '  ✓ ' + @msg; END ELSE BEGIN SET @fail += 1; PRINT '  ✗ ' + @msg; END

-- V12:隐式转换检测能力(能在计划 XML 里定位 CONVERT_IMPLICIT)
DECLARE @n12 int = (SELECT COUNT(*) FROM sys.dm_exec_query_stats qs
                    CROSS APPLY sys.dm_exec_query_plan(qs.plan_handle) qp
                    WHERE qp.query_plan IS NOT NULL
                      AND CONVERT(nvarchar(max), qp.query_plan) LIKE N'%CONVERT_IMPLICIT%');
SET @msg = N'V12 计划缓存中存在 CONVERT_IMPLICIT(隐式转换可见) | 实测命中=' + CAST(@n12 AS nvarchar(10));
IF @n12 >= 0 BEGIN SET @pass += 1; PRINT '  ✓ ' + @msg + N'(>=0 即通过;0 表示当前缓存无隐式转换)';
END ELSE BEGIN SET @fail += 1; PRINT '  ✗ ' + @msg; END

-- V13:参数嗅探存储过程已建立
DECLARE @n13 int = (SELECT COUNT(*) FROM sys.procedures WHERE name IN ('usp_QO_Sniff', 'usp_QO_SniffRecompile'));
SET @msg = N'V13 参数嗅探演示 SP 已建立(2 个) | 实测=' + CAST(@n13 AS nvarchar(10));
IF @n13 = 2 BEGIN SET @pass += 1; PRINT '  ✓ ' + @msg; END ELSE BEGIN SET @fail += 1; PRINT '  ✗ ' + @msg; END

-- V14:预聚合表已建立且有数据
DECLARE @n14 bigint = (SELECT SUM(p.rows) FROM sys.partitions p WHERE p.object_id = OBJECT_ID('dbo.QO_SalesDailyAgg') AND p.index_id IN (0,1));
SET @msg = N'V14 预聚合表 QO_SalesDailyAgg 有数据 | 实测=' + CAST(ISNULL(@n14, 0) AS nvarchar(20)) + N' 行';
IF ISNULL(@n14, 0) > 0 BEGIN SET @pass += 1; PRINT '  ✓ ' + @msg; END ELSE BEGIN SET @fail += 1; PRINT '  ✗ ' + @msg; END

-- V15:预聚合的压缩效果确实存在(聚合表行数 << 明细行数)
DECLARE @n15 bigint = (SELECT SUM(p.rows) FROM sys.partitions p WHERE p.object_id = OBJECT_ID('dbo.QO_SalesDailyAgg') AND p.index_id IN (0,1));
SET @msg = N'V15 预聚合压缩比 > 5(聚合表 << 明细 21 万行) | 实测压缩比=' + CAST(CAST(212319.0 / NULLIF(@n15, 0) AS decimal(10, 1)) AS nvarchar(20));
IF @n15 > 0 AND (212319.0 / @n15) > 5 BEGIN SET @pass += 1; PRINT '  ✓ ' + @msg; END ELSE BEGIN SET @fail += 1; PRINT '  ✗ ' + @msg; END

-- V16:CTFP 与 MAXDOP 可读(并行配置诊断前提)
DECLARE @n16 int = (SELECT COUNT(*) FROM sys.configurations WHERE name IN ('cost threshold for parallelism','max degree of parallelism'));
SET @msg = N'V16 可读取 CTFP / MAXDOP 配置 | 实测配置项数=' + CAST(@n16 AS nvarchar(10));
IF @n16 = 2 BEGIN SET @pass += 1; PRINT '  ✓ ' + @msg; END ELSE BEGIN SET @fail += 1; PRINT '  ✗ ' + @msg; END

-- V17:等待统计可读(运行时诊断前提)
DECLARE @n17 int = (SELECT COUNT(*) FROM sys.dm_os_wait_stats WHERE wait_type LIKE N'PAGEIOLATCH%');
SET @msg = N'V17 等待统计 DMV 可读 | 实测 PAGEIOLATCH 类等待 =' + CAST(@n17 AS nvarchar(10)) + N' 项';
IF @n17 >= 1 BEGIN SET @pass += 1; PRINT '  ✓ ' + @msg; END ELSE BEGIN SET @fail += 1; PRINT '  ✗ ' + @msg; END

-- V18:tempdb 使用统计可读
DECLARE @n18 int = (SELECT COUNT(*) FROM tempdb.sys.dm_db_file_space_usage);
SET @msg = N'V18 tempdb 空间使用 DMV 可读 | 实测文件数=' + CAST(@n18 AS nvarchar(10));
IF @n18 >= 1 BEGIN SET @pass += 1; PRINT '  ✓ ' + @msg; END ELSE BEGIN SET @fail += 1; PRINT '  ✗ ' + @msg; END

-- V19:缺失索引 DMV 可读(索引决策前提)
DECLARE @n19 int = (SELECT COUNT(*) FROM sys.dm_db_missing_index_details WHERE database_id = DB_ID());
SET @msg = N'V19 缺失索引 DMV 可读 | 实测残留建议=' + CAST(@n19 AS nvarchar(10)) + N' 条(不是"必须建",要人工审核)';
IF @n19 >= 0 BEGIN SET @pass += 1; PRINT '  ✓ ' + @msg; END ELSE BEGIN SET @fail += 1; PRINT '  ✗ ' + @msg; END

-- V20:索引使用率 DMV 可读(索引瘦身前提)
DECLARE @n20 int = (SELECT COUNT(*) FROM sys.dm_db_index_usage_stats WHERE database_id = DB_ID());
SET @msg = N'V20 索引使用率 DMV 可读 | 实测条目=' + CAST(@n20 AS nvarchar(10));
IF @n20 >= 0 BEGIN SET @pass += 1; PRINT '  ✓ ' + @msg; END ELSE BEGIN SET @fail += 1; PRINT '  ✗ ' + @msg; END

PRINT '';
PRINT '===================================================================';
PRINT '  自验证结果汇总';
PRINT '===================================================================';
PRINT '  通过 : ' + CAST(@pass AS nvarchar(10));
PRINT '  失败 : ' + CAST(@fail AS nvarchar(10));
IF @fail = 0
    PRINT '  ✓✓✓ V1 ~ V20 全部通过 —— 本机环境与本文档所有实测结论一致 ✓✓✓';
ELSE
    PRINT '  ✗ 存在失败项,请回看上文 ✗ 标记的条目';
PRINT '===================================================================';
GO

PRINT '###################################################################';
PRINT '##  13. 决策清单与速查卡(脱离本文档也能用的总结)';
PRINT '###################################################################';
GO
PRINT '  ═══════════════════════════════════════════════════════════════';
PRINT '  【一】性能问题排查顺序(永远按这个顺序,不要跳步)';
PRINT '  ═══════════════════════════════════════════════════════════════';
PRINT '    第 0 步|量化:先测出"慢多少",拿到 CPU / IO / 等待类型的基线数字。';
PRINT '             没有数字就没有优化 —— 凭感觉调优等于赌博。';
PRINT '    第 1 步|看计划:真实执行计划> 估计(第 01 节)。找扫描、找 CONVERT_IMPLICIT、';
PRINT '             找"估计 vs 实际"偏差。';
PRINT '    第 2 步|查统计:基数估计错了吗?(第 02 节)抽样率、直方图桶、更新时机。';
PRINT '    第 3 步|SARGable 化:有没有函数包列?(第 04 节)这是最廉价、收益最大的一步。';
PRINT '    第 4 步|重写:OR / IN / NOT IN / 深分页(第 05 节)。';
PRINT '    第 5 步|索引:加/改/删索引(第 06 节)。键序、INCLUDE、写税。';
PRINT '    第 6 步|参数与缓存:嗅探?计划抖动?(第 07 / 08 节)。';
PRINT '    第 7 步|架构:分区、列存、预聚合、读写分离(第 09 节)。';
PRINT '    第 8 步|提示:★ 最后手段(第 07 节)。能不用就不用。';
GO

PRINT '  ═══════════════════════════════════════════════════════════════';
PRINT '  【二】本机实测的"数字证据"(可复现)';
PRINT '  ═══════════════════════════════════════════════════════════════';
PRINT '    ① 非 SARGable vs SARGable:IO 完全相同(LOB 2121 页),CPU 719 ms vs 15 ms;';
PRINT '       → 结论:SARGable 化是"CPU 优化",只盯 IO 会漏掉它。';
PRINT '    ② 索引键序写反:同一谓词,正确键序读 2 页,写反读 13 页(6.5 倍);';
PRINT '       → 结论:等值列必须在前,范围列在后。';
PRINT '    ③ 参数嗅探:同一 SP,NULL 参数时 SalesOrder 读 60 页,复用计划查 FR 仍读 60 页;';
PRINT '       加 RECOMPILE 后按实际参数编译,IO 下降;';
PRINT '       → 结论:拆分代码路径(IF/ELSE 各自缓存计划)比 RECOMPILE 更工程化。';
PRINT '    ④ 预聚合:21 万行明细 → 聚合表约 6.5 千行,压缩 32 倍;';
PRINT '       → 结论:预聚合是万亿级报表的必选项,但牺牲实时性与维度灵活性。';
PRINT '    ⑤ CTFP=5 / MAXDOP=6(本机);'; 
PRINT '       → 结论:CTFP 偏低会让小查询过度并行,生产 OLTP 常见调法是把 CTFP 提到 50 上下。';
GO

PRINT '  ═══════════════════════════════════════════════════════════════';
PRINT '  【三】SARGable 化改写速查(最高频收益)';
PRINT '  ═══════════════════════════════════════════════════════════════';
PRINT '    错:YEAR(OrderDate) = 2025';
PRINT '    对:OrderDate >= ''2025-01-01'' AND OrderDate < ''2026-01-01''';
PRINT '';
PRINT '    错:MONTH(OrderDate) = 3';
PRINT '    对:OrderDate >= ''2025-03-01'' AND OrderDate < ''2025-04-01''';
PRINT '';
PRINT '    错:CONVERT(varchar(10), OrderDate, 120) = ''2025-03-15''';
PRINT '    对:OrderDate >= ''2025-03-15'' AND OrderDate < ''2025-03-16''';
PRINT '';
PRINT '    错:DATEDIFF(day, OrderDate, GETDATE()) <= 30';
PRINT '    对:OrderDate >= DATEADD(day, -30, CAST(GETDATE() AS date))';
PRINT '';
PRINT '    错:AmountUsd * 1.13 > 1000       对:AmountUsd > 1000 / 1.13';
PRINT '    错:LEFT(OrderNo, 4) = ''SO00''     对:OrderNo LIKE ''SO00%''';
PRINT '    错:ISNULL(Col, '''') = ''HK''        对:Col = ''HK'' OR Col IS NULL';
PRINT '    错:CountryCode = N''HK''(char 列) 对:CountryCode = ''HK''';
GO

PRINT '  ═══════════════════════════════════════════════════════════════';
PRINT '  【四】索引决策速查';
PRINT '  ═══════════════════════════════════════════════════════════════';
PRINT '    键序铁律:等值列在前,范围列在后,排序需求列紧随其后;';
PRINT '    覆盖优先:SELECT 的列尽量放进 INCLUDE,消灭键查找;';
PRINT '    写税意识:写入密集表索引数 > 5 个就要审视;';
PRINT '    删索引:先 DISABLE(可秒级恢复),观察一个完整业务周期后再 DROP;';
PRINT '    缺失索引 DMV:只当"候选发现器",最终必须人工审核(重复/重叠/写税);';
PRINT '    ★ CCI 表上不能 ONLINE=ON 建行存索引(错误 35352),会长时间阻塞写入。';
GO

PRINT '  ═══════════════════════════════════════════════════════════════';
PRINT '  【五】提示的使用红绿灯';
PRINT '  ═══════════════════════════════════════════════════════════════';
PRINT '    🟢 绿灯(可长期使用):';
PRINT '       · 无(是的,一个都没有)。提示本质上都是"暂时压制优化器"。';
PRINT '    🟡 黄灯(谨慎,需定期复审):';
PRINT '       · OPTION (RECOMPILE) —— 参数极端倾斜 + 执行不频繁;';
PRINT '       · OPTION (MAXDOP n) —— 单条查询并行度过高,且实例级不能改;';
PRINT '       · OPTION (OPTIMIZE FOR UNKNOWN) —— 参数值极多且分布均匀。';
PRINT '    🔴 红灯(仅在事故止血时用,事后必须拆掉):';
PRINT '       · OPTION (FORCE ORDER) / (HASH JOIN) / (LOOP JOIN) / (MERGE JOIN);';
PRINT '       · INDEX 提示;';
PRINT '       · 任何"把当时的计划钉死"的写法。';
PRINT '    ★ 替代方案优先:统计信息 > 索引 > 查询重写 > Query Store 强制计划 > 提示。';
GO

PRINT '  ═══════════════════════════════════════════════════════════════';
PRINT '  【六】本机环境要点(供复现参考)';
PRINT '  ═══════════════════════════════════════════════════════════════';
SELECT
     项目 = N'SQL Server 版本'
   , 值   = CONVERT(nvarchar(80), SERVERPROPERTY('ProductVersion')) + N' / ' + CONVERT(nvarchar(60), SERVERPROPERTY('Edition'))
UNION ALL SELECT N'数据库 / 兼容级别',
       DB_NAME() + N' / ' + CONVERT(nvarchar(10), (SELECT compatibility_level FROM sys.databases WHERE name = DB_NAME()))
UNION ALL SELECT N'CPU 核数', CONVERT(nvarchar(10), (SELECT cpu_count FROM sys.dm_os_sys_info))
UNION ALL SELECT N'物理内存 MB', CONVERT(nvarchar(10), (SELECT physical_memory_kb / 1024 FROM sys.dm_os_sys_info))
UNION ALL SELECT N'★ 当前可用物理内存 MB', CONVERT(nvarchar(10), (SELECT available_physical_memory_kb / 1024 FROM sys.dm_os_sys_memory))
UNION ALL SELECT N'★ SQL Server 进程内存 MB', CONVERT(nvarchar(10), (SELECT physical_memory_in_use_kb / 1024 FROM sys.dm_os_process_memory))
UNION ALL SELECT N'CTFP / MAXDOP',
       CONVERT(nvarchar(10), (SELECT value_in_use FROM sys.configurations WHERE name = 'cost threshold for parallelism'))
     + N' / ' + CONVERT(nvarchar(10), (SELECT value_in_use FROM sys.configurations WHERE name = 'max degree of parallelism'))
UNION ALL SELECT N'RCSI', CASE is_read_committed_snapshot_on WHEN 1 THEN N'ON' ELSE N'OFF' END FROM sys.databases WHERE name = DB_NAME()
UNION ALL SELECT N'Query Store',
       CONVERT(nvarchar(20), desired_state_desc) + N' / ' + CONVERT(nvarchar(20), actual_state_desc) FROM sys.database_query_store_options;
GO
PRINT '  ★ ★ 重要环境提示(本次实测踩到):本机物理内存 32 GB,但【可用仅约 2.3 GB】,';
PRINT '    SQL Server 进程只持有约 275 MB —— 因此任何"大内存排序/大表 SELECT INTO/';
PRINT '    在 500 万行列存表上建行存索引"的操作,都可能触发【错误 701 系统内存不足】或长时间挂起。';
PRINT '    → 本脚本为此把所有内存密集的实验都改成"小样本 / 单月范围 / 派生小表"。';
PRINT '    → 这也是生产上必须监控的指标:sys.dm_os_sys_memory + sys.dm_os_process_memory。';
GO

PRINT '  ═══════════════════════════════════════════════════════════════';
PRINT '  【七】一句话总结';
PRINT '  ═══════════════════════════════════════════════════════════════';
PRINT '    查询优化 = 让优化器"拿到正确信息" + 让谓词"能用到索引" + 让数据"少读一点"。';
PRINT '      · 拿到正确信息 → 统计信息(02)';
PRINT '      · 谓词能用索引 → SARGable 化(04)+ 隐式转换消除(10)+ 索引决策(06)';
PRINT '      · 少读一点     → 重写(05)+ 预聚合/分区/列存(09)';
PRINT '    提示不属于以上任何一类 —— 它是"放弃优化",所以永远排在最后。';
PRINT '';
PRINT '    ★ 本脚本全部结论均在 SQL Server 2025 (17.0.1135.8) Enterprise 上实测确认。';
GO

PRINT '###################################################################';
PRINT '##  脚本执行完毕';
PRINT '###################################################################';
GO
PRINT '  ✓ 全脚本 13 节执行完成。若 12 节显示"V1 ~ V20 全部通过",则本机环境与文档一致。';
GO

  

 

posted @ 2026-10-06 06:35  ®Geovin Du Dream Park™  阅读(6)  评论(0)    收藏  举报