当优化器决定用串行扫描处理一张大表,而查询本身并没有指定 MAXDOP 1,问题通常不在扫描本身,而在于扫描被嵌套查询中的某个迭代器从并行区域中拉了出来。SQL Server 的并行执行不是对整个语句统一开启或关闭,而是把执行计划划分成若干区域,只有位于并行区域内部的扫描、过滤、连接等运算符才会被多个线程同时处理。嵌套查询、相关子查询和部分表值函数容易生成串行区域,最终导致外层大表也无法使用并行扫描。

一、并行扫描不是全局开关,而是执行计划里的区域属性
并行扫描有一个重要前提:表扫描运算符必须处在执行计划中的并行区域。这个区域由 Gather Streams 等并行相关运算符包围,扫描节点下方会有多个线程共同读取数据页。如果优化器没有为查询生成并行区域,或者扫描节点被移动到了串行区域,那么即使服务器配置允许 16 个逻辑处理器同时工作,实际也只会有一个线程去扫描整张表。这就是为什么很多时候我们看到 MAXDOP 配置正常、CPU 核心也不少,但执行计划里仍然是一个串行表扫描。
SQL Server 是否生成并行计划,主要受 cost threshold for parallelism 和 max degree of parallelism 两个配置影响。查询串行执行成本超过阈值时,优化器会尝试生成并行计划。但这个尝试并不是针对整条语句统一决策,而是按执行计划片段进行成本估算。如果嵌套结构中的某个片段无法并行化,优化器可能不得不把相邻区域也设置为串行,否则数据无法正确传递。尤其是在相关子查询中,外层扫描每返回一行,内层子查询都要执行一次聚合或查找,这种执行方式天然不适合多线程并行。
检查执行计划时,不能只看有没有 Parallelism 运算符,还要看目标扫描节点是否被并行区域覆盖。在 SQL Server Management Studio 的图形执行计划中,并行区域通常用带多个小箭头的黄色区域标识。如果扫描节点远离这个区域,而外层又只有一个线程输入,那这个扫描必然串行。
二、四类嵌套结构最容易破坏并行扫描
第一类是相关子查询,尤其是出现在 SELECT 列表中的标量子查询。这类子查询会对每一行外层结果执行一次内层查询,优化器通常会将其转换为嵌套循环连接,并在上层增加 Compute Scalar。如果内层表较小,优化器会选择串行嵌套循环,因为并行嵌套循环在多线程协调上的开销可能超过收益。但问题在于,外层大表扫描也会因此被限制在串行区域,导致原本可以并行的大表扫描无法分配多个线程。
SELECT o.OrderID,
o.CustomerID,
(SELECT COUNT(*)
FROM dbo.OrderDetail AS od
WHERE od.OrderID = o.OrderID) AS DetailCount
FROM dbo.Orders AS o;
第二类是标量用户定义函数和多语句表值函数。SQL Server 对标量 UDF 的内联处理存在较多限制,很多情况下函数体不会被展开到外层查询中,而是以单独的执行上下文逐行调用。函数内部是串行执行的,外层查询一旦包含这种调用,优化器就很难维持并行区域。即使在支持标量 UDF 内联的版本中,如果函数体内有变量赋值、递归或时间函数等阻碍内联的逻辑,同样会导致并行度下降。
SELECT o.OrderID,
dbo.fn_GetOrderTotal(o.OrderID) AS TotalAmount
FROM dbo.Orders AS o
WHERE o.OrderDate >= '20240101';
第三类是带有 TOP、ROW_NUMBER() 或递归 CTE 的嵌套查询。窗口函数需要先进行排序和分区,递归 CTE 则要维护迭代状态,这些运算符在 SQL Server 中经常被设计为串行执行。如果一个嵌套查询在子查询内部使用了 ROW_NUMBER() OVER (PARTITION BY ...) 再过滤,外层大表扫描也可能被串行化。尤其是带排序列的 TOP,为了保证结果顺序,优化器通常会优先选择串行计划。
第四类是查询提示和资源配置的显式限制。比如 OPTION(MAXDOP 1) 会直接强制整条查询串行,OPTION(FORCESEEK) 可能改变连接顺序和扫描方式,资源池中的 MAX_DOP 设置也会限制工作线程数量。排查时应当首先排除这些人为因素,否则后续看执行计划会得出错误结论。
三、用执行计划和等待统计定位限制点
实际排查时,可以先查看执行计划 XML 中的 NonParallelPlanReason 属性。如果值为 MaxDOPSetToOne,说明查询提示或会话设置强制了串行;如果值为 CouldNotGenerateValidParallelPlan,则通常是优化器认为嵌套结构无法生成合法并行计划。这个属性可以快速区分是人为限制还是优化器限制。对于运行较慢的查询,还可以查看 sys.dm_exec_query_stats 中的 last_worker_time 和 last_elapsed_time,两者比值接近 1 时,基本可以确定实际执行时只有一个线程在工作。
等待统计也能提供参考。如果查询长时间处于 CXPACKET 或 CXCONSUMER 等待,说明并行已经启动,但可能存在线程倾斜或数据分布不均衡;如果完全没有这些等待,并且扫描成本很高,那问题就在执行计划没有为扫描节点生成并行区域。注意,不同 SQL Server 版本中并行等待类型有变化,新版本更常出现 CXCONSUMER,旧版本以 CXPACKET 为主。
查询提示检查不能只看查询文本。查询存储中的固定计划、数据库作用域配置的 LEGACY_CARDINALITY_ESTIMATION、会话中的 SET 选项、以及资源池的 MAX_DOP 都可能改变优化器决策。可以通过以下脚本查看当前服务器与数据库的默认并行配置:
SELECT name,
value_in_use
FROM sys.configurations
WHERE name IN ('max degree of parallelism',
'cost threshold for parallelism');
如果配置没有异常,再看执行计划中扫描节点是否位于串行区域,以及串行区域是否由某个嵌套循环、标量函数或窗口函数导致。这一步通常需要结合图形执行计划的黄色区域边界进行判断。
四、改写嵌套查询恢复并行扫描
大多数情况下,相关子查询可以通过改写为派生表或 CTE 加 JOIN 来解决。下面的写法把逐行执行的标量子查询改成先按 OrderID 聚合,再与外层表连接。聚合操作可以独立在线程间分区,优化器更有可能为聚合和后续连接生成并行计划。
SELECT o.OrderID,
od.DetailCount
FROM dbo.Orders AS o
LEFT JOIN
(
SELECT OrderID,
COUNT(*) AS DetailCount
FROM dbo.OrderDetail
GROUP BY OrderID
) AS od
ON od.OrderID = o.OrderID;
如果标量 UDF 无法内联,可以尝试用内联表值函数替代。内联表值函数会被优化器当作视图展开,更容易保留并行区域。对于逻辑非常复杂的子查询,还可以拆成两步:先把子查询结果物化到临时表或表变量,再与主表连接。临时表可以建立索引,后续扫描和连接都可以并行执行。不过物化本身也有 IO 和写入成本,需要比较总耗时。
调整查询提示是最后的手段。比如优化器因为错误基数估算而选择了串行嵌套循环,可以尝试 OPTION(HASH JOIN) 或 OPTION(MAXDOP 4)。但并行并非一定更快,小表串行扫描可能更高效。并行扫描会增加线程协调、内存分配和调度开销,必须通过实际执行计划和等待统计验证。只有在确认大表扫描是主要瓶颈,并且没有其他串行阻断器时,增加并行度才可能带来稳定收益。