GROUP BY 与 OFFSET/FETCH 同用会导致性能雪崩,因 SQL Server 先全量扫描、分组、排序,再分页;需建覆盖索引实现流式聚合,或拆分分页与聚合逻辑,大偏移量必须改用游标分页。

GROUP BY 和分页(OFFSET/FETCH)一起用,基本等于主动触发性能雪崩——除非你提前建对索引、拆开逻辑,或彻底换掉分页方式。
为什么 GROUP BY + OFFSET/FETCH 会慢到查不出结果
SQL Server 不是先分页再分组,而是先全量扫描 WHERE 匹配的所有行 → 全量哈希分组或排序分组 → 再对分组结果排序 → 最后才跳过 OFFSET 行。哪怕你只要第 100 页的 10 条分组,它也可能扫千万行、做千万行的哈希聚合。
典型信号:
- 执行计划里
Hash Match (Aggregate)节点的Actual Number of Rows是几百万,而最终输出只有几十行 - 该节点上游是
Clustered Index Scan或大范围Index Scan,不是Index Seek -
logical reads高达数十万甚至上百万(用SET STATISTICS IO ON看)
覆盖索引让 GROUP BY 变成流式聚合(Stream Aggregate)
目标是绕过哈希/排序,让 SQL Server 按索引物理顺序“边读边分组”。这要求索引字段顺序和查询结构严丝合缝:
-
WHERE条件字段必须是索引最左列(如status = 'shipped'→ 索引首列为status) -
GROUP BY字段紧随其后,且顺序完全一致(如GROUP BY region, category→ 索引为(status, region, category)) -
SELECT中的聚合字段(如SUM(price))必须INCLUDE进索引,否则回表会打断流式路径 - 避免在
WHERE或GROUP BY中用函数:YEAR(create_time)、UPPER(name)会让索引失效
示例索引与查询:
CREATE NONCLUSTERED INDEX IX_orders_filter_group ON orders (status, region, category) INCLUDE (price);
SELECT region, category, SUM(price) FROM orders WHERE status = 'shipped' GROUP BY region, category ORDER BY region OFFSET 200 ROWS FETCH NEXT 10 ROWS ONLY;
分页基数低时:先分页分组键,再 JOIN 补聚合
当 GROUP BY 字段值很少(比如 region 只有 5 个),但每个分组要算多个重聚合(SUM、AVG、COUNT DISTINCT…),直接分页聚合代价太高。可拆成两步:
- 第一步:只查分组键 + 轻量指标(如
COUNT(*)),用OFFSET/FETCH分页 - 第二步:把这 10 个
(region, category)键批量JOIN原表或预聚合表,补全其他字段
这样每次翻页只跑一次轻量分页,聚合计算只针对当前页的键执行,避免重复全量扫描。
大偏移量分页必须放弃 OFFSET,改用游标式分页
哪怕没 GROUP BY,OFFSET 100000 ROWS 也极慢;加上分组后,基本不可用。真实业务中应替换为基于上一页末尾值的条件分页:
- 如果分页依据是时间,用
WHERE created_at < @last_created_at+TOP 50 - 如果依据是分组键组合,可用
WHERE (region, category) < (@last_region, @last_category)(需 SQL Server 2022+ 支持行构造器比较) - 注意:游标分页无法跳转任意页,但滚动加载、消息流等场景更稳定、可预测
真正难的不是写对语法,而是判断什么时候该停用 OFFSET、什么时候该把聚合从查询里拎出来做预计算——这些决策点,往往在业务需求评审阶段就该介入,而不是等报表接口超时了再翻执行计划。

















