简介:这份PDF资料聚焦SQL Server中行转列的核心技术PIVOT,面向需要处理报表数据转换的数据库开发人员与SQL学习者。内容以WEEK_INCOME收入表为例,从传统CASE加SUM写法切入,逐步讲解PIVOT操作符的语法结构、聚合函数选择、FOR子句的列值转换逻辑,并说明其与UNPIVOT的对应关系,帮助读者理解何时该用PIVOT、何时应改用动态SQL或编程语言处理。资源包共1个PDF文件,大小约66KB,内容紧凑,适合作为查询语法速查与理解参考。目前已有1530人学习下载,读者可从中获得行转列的完整语法示例、聚合值计算思路以及报表查询优化的实用技巧,对日常编写复杂统计查询具有直接参考价值。
1. 行转列为什么总在报表最后一公里翻车
你大概遇到过这种场景:业务方丢来一张订单明细表,每行一条记录,字段是订单号、产品名、数量。对方要的却是一张横向报表——每个产品一列,订单号一行,交叉格子里填数量。用GROUP BY加CASE WHEN硬写,产品从三个变成三十个,SQL 就得改三十遍。这不是 SQL 写得好不好的问题,是行转列这件事本身需要一个专门的语法结构来兜底。
SQL SERVER 给出的答案就是PIVOT。它把「某列的不同取值变成输出结果的列名」这个动作,从手写聚合表达式变成声明式语法。你只需要告诉它三件事:按什么分组、拿哪一列的值当新列名、用什么聚合函数填交叉格。剩下的列展开、空值处理、分组去重,引擎替你完成。
这篇内容面向的是真正要在 SQL SERVER 里出报表、做数据透视的从业者。不管你是刚装完 SQL SERVER 2019 或 2022、正在啃 SQL SERVER 安装教程的新手,还是已经写过几百行CASE WHEN想找更优雅写法的熟手,下面从语法骨架、静态列到动态列、再到性能边界,都会给出可以直接抄进查询窗口的代码和参数说明。PIVOT 不是银弹,但它是行转列这件事在 SQL SERVER 里最该先掌握的那把刀。
2. PIVOT 语法骨架:三要素与最小可跑示例
2.1 先建一张能复现的订单明细表
不搭环境直接讲语法都是空谈。下面这段脚本建一张销售明细表并灌入测试数据,字段刻意保持简单:销售员、季度、金额。这三个字段刚好覆盖 PIVOT 需要的「分组列、透视列、聚合列」三种角色。
-- 建表:销售明细,每行一个销售员一个季度的业绩 IF OBJECT_ID('dbo.SalesDetail', 'U') IS NOT NULL DROP TABLE dbo.SalesDetail; CREATE TABLE dbo.SalesDetail ( SalesPerson NVARCHAR(20), -- 销售员,将来做行 Quarter NVARCHAR(10), -- 季度,将来做列 Amount DECIMAL(10,2) -- 金额,将来做交叉格子的值 ); INSERT INTO dbo.SalesDetail (SalesPerson, Quarter, Amount) VALUES ('张三', 'Q1', 12000.00), ('张三', 'Q2', 15000.00), ('张三', 'Q3', 11000.00), ('李四', 'Q1', 9000.00), ('李四', 'Q2', 18000.00), ('李四', 'Q4', 7000.00), ('王五', 'Q2', 22000.00), ('王五', 'Q3', 16000.00);建表时把Quarter设成NVARCHAR而不是数字,是因为真实业务里透视列往往是「月份名」「产品类别」「渠道」这类文本,提前用文本能暴露后面动态拼接时的引号问题。Amount用DECIMAL而非FLOAT,避免聚合时出现浮点尾差,报表场景这点很关键。
2.2 PIVOT 的三要素:分组、透视、聚合
PIVOT 的完整语法结构可以拆成三块,缺一不可:
SELECT <非透视列>, [列1], [列2], [列3] FROM <源表或子查询> PIVOT ( <聚合函数>(<聚合列>) FOR <透视列> IN ([列1], [列2], [列3]) ) AS <别名>;三要素对应关系是这样的:FOR ... IN里的透视列,它的每个不同取值会变成输出结果的一列;IN列表里写死的值,就是最终列名;聚合函数决定交叉格子里放什么,SUM、COUNT、MAX、AVG都行。分组列则是那些既不在聚合函数里、也不在FOR子句里的列,PIVOT 会自动按它们分组。
拿刚才的表跑一个最小示例:
-- 静态 PIVOT:把季度展开成列,交叉格填金额合计 SELECT SalesPerson, [Q1], [Q2], [Q3], [Q4] FROM dbo.SalesDetail PIVOT ( SUM(Amount) FOR Quarter IN ([Q1], [Q2], [Q3], [Q4]) ) AS PivotTable;执行后张三一行,Q1 到 Q4 四列,没有数据的季度显示NULL。这里SalesPerson是分组列,Quarter是透视列,SUM(Amount)是聚合。注意IN列表里的方括号不能省,因为列名可能含空格或关键字,养成习惯统一加。
2.3 聚合函数的选择直接改变结果语义
同一个 PIVOT 结构,换聚合函数结果完全不同。SUM是求和,COUNT是计数,MAX取最大值。很多人第一次用 PIVOT 会疑惑「为什么我的数据被合并了」,根源就是聚合函数在起作用——PIVOT 本质是「分组聚合 + 列展开」两步合一。
-- 用 COUNT 看每个销售员每季度有多少条记录 SELECT SalesPerson, [Q1], [Q2], [Q3], [Q4] FROM dbo.SalesDetail PIVOT ( COUNT(Amount) FOR Quarter IN ([Q1], [Q2], [Q3], [Q4]) ) AS PivotCount; -- 用 MAX 看每个销售员每季度的最高单笔 SELECT SalesPerson, [Q1], [Q2], [Q3], [Q4] FROM dbo.SalesDetail PIVOT ( MAX(Amount) FOR Quarter IN ([Q1], [Q2], [Q3], [Q4]) ) AS PivotMax;参数说明:COUNT(Amount)统计非空金额条数,如果某行金额为NULL则不计入;MAX在只有一条记录时等于原值,多条时取最大。选哪个取决于业务问题——要总额用SUM,要频次用COUNT,要峰值用MAX。这一步选错,后面所有列名对上了也是错的。
3. 从静态列到动态列:列名不确定时怎么拼 SQL
3.1 静态 PIVOT 的死穴:列名写死
上一章的IN ([Q1], [Q2], [Q3], [Q4])是硬编码。业务方明年加个 Q5,你就得改 SQL。更麻烦的是产品类别、城市、渠道这类维度,取值可能几十上百个,手写列名不现实。这就是静态 PIVOT 的边界:透视列取值固定且少时好用,一旦取值动态增长就撑不住。
判断标准很简单:如果透视列的取值来自另一张配置表,或者会随业务数据增长,就必须上动态 PIVOT。动态 PIVOT 的思路是先用查询把列名拼成一个字符串,再用EXEC或sp_executesql执行拼好的 SQL。
3.2 动态 PIVOT 的拼接模板
动态 PIVOT 分三步:查出所有列名、拼出IN列表、拼出完整 SQL 并执行。下面这段是可直接复用的模板:
DECLARE @cols NVARCHAR(MAX); -- 存放 [Q1],[Q2],... 列名列表 DECLARE @sql NVARCHAR(MAX); -- 存放最终要执行的 SQL -- 第一步:从源表取出所有不重复的季度,拼成 [Q1],[Q2],[Q3],[Q4] SELECT @cols = STRING_AGG(QUOTENAME(Quarter), ',') WITHIN GROUP (ORDER BY Quarter) FROM (SELECT DISTINCT Quarter FROM dbo.SalesDetail) AS t; -- 第二步:拼完整 SQL SET @sql = N' SELECT SalesPerson, ' + @cols + N' FROM dbo.SalesDetail PIVOT ( SUM(Amount) FOR Quarter IN (' + @cols + N') ) AS PivotTable;'; -- 第三步:执行 EXEC sp_executesql @sql;逻辑说明:QUOTENAME给每个季度名加上方括号,防止列名含特殊字符时语法出错;STRING_AGG把多行拼成一个逗号分隔的字符串,WITHIN GROUP (ORDER BY Quarter)保证列顺序稳定;sp_executesql比直接EXEC(@sql)更安全,支持参数化,虽然这里没传参但养成习惯。
参数说明:@cols的类型必须是NVARCHAR(MAX),用VARCHAR在列名含中文时会截断;STRING_AGG在 SQL SERVER 2017 及以上可用,2016 及更早版本要用FOR XML PATH替代。
3.3 老版本兼容:FOR XML PATH 拼列名
如果环境是 SQL SERVER 2016 或 2008 R2,STRING_AGG用不了,得换成STUFF加FOR XML PATH的组合:
DECLARE @cols NVARCHAR(MAX); DECLARE @sql NVARCHAR(MAX); -- 兼容 SQL SERVER 2008+ 的列名拼接 SELECT @cols = STUFF(( SELECT ',' + QUOTENAME(Quarter) FROM (SELECT DISTINCT Quarter FROM dbo.SalesDetail) AS t ORDER BY Quarter FOR XML PATH('') ), 1, 1, ''); SET @sql = N' SELECT SalesPerson, ' + @cols + N' FROM dbo.SalesDetail PIVOT ( SUM(Amount) FOR Quarter IN (' + @cols + N') ) AS PivotTable;'; EXEC sp_executesql @sql;STUFF(..., 1, 1, '')的作用是去掉拼接结果开头的那个逗号。FOR XML PATH('')把每行拼成 XML 片段再合并,这是老版本里最常用的字符串聚合技巧。注意ORDER BY要写在子查询里,否则列顺序不保证。
3.4 动态 PIVOT 的注入风险与参数化
动态 SQL 最大的坑是 SQL 注入。如果列名来自用户输入,直接拼进@sql就是灾难。正确做法是用QUOTENAME包裹所有标识符,并且尽量让列名来自数据库内部查询而非外部输入。
-- 危险写法:直接拼接用户输入 -- SET @sql = '... FOR Quarter IN (' + @userInput + ') ...'; -- 安全写法:列名来自表内查询,且用 QUOTENAME 包裹 SELECT @cols = STRING_AGG(QUOTENAME(Quarter), ',') WITHIN GROUP (ORDER BY Quarter) FROM (SELECT DISTINCT Quarter FROM dbo.SalesDetail) AS t;如果透视列的值确实需要外部传入,用sp_executesql的参数化能力,把值作为参数传而不是拼进字符串。但列名本身无法参数化,这是 PIVOT 动态化的固有约束,只能靠白名单校验。
4. 多列聚合与分组列处理:PIVOT 的进阶用法
4.1 一次 PIVOT 只能聚合一个值列
这是 PIVOT 最容易被误解的地方。PIVOT (SUM(Amount) FOR Quarter IN (...))里,聚合函数只能作用于一个列。如果你想同时看金额合计和订单数量,不能在一个 PIVOT 里写两个聚合。
常见做法是跑两次 PIVOT 再用JOIN合并,或者用CASE WHEN手动构造。下面演示两次 PIVOT 合并:
-- 第一次 PIVOT:金额合计 SELECT SalesPerson, [Q1], [Q2], [Q3], [Q4] INTO #AmountPivot FROM dbo.SalesDetail PIVOT (SUM(Amount) FOR Quarter IN ([Q1],[Q2],[Q3],[Q4])) AS A; -- 第二次 PIVOT:记录条数 SELECT SalesPerson, [Q1] AS Q1_Cnt, [Q2] AS Q2_Cnt, [Q3] AS Q3_Cnt, [Q4] AS Q4_Cnt INTO #CountPivot FROM dbo.SalesDetail PIVOT (COUNT(Amount) FOR Quarter IN ([Q1],[Q2],[Q3],[Q4])) AS C; -- 合并 SELECT a.SalesPerson, a.[Q1], c.Q1_Cnt, a.[Q2], c.Q2_Cnt, a.[Q3], c.Q3_Cnt, a.[Q4], c.Q4_Cnt FROM #AmountPivot a JOIN #CountPivot c ON a.SalesPerson = c.SalesPerson;参数说明:两次 PIVOT 的分组列必须一致,否则JOIN会对不上。临时表用#前缀,会话结束自动清理。如果数据量大,两次扫描源表成本翻倍,可以考虑用CASE WHEN一次扫描出所有指标。
4.2 分组列不止一个时的行为
PIVOT 的分组列是「所有不在聚合和透视里的列」。如果源表有多个非透视列,它们会一起参与分组。看下面这个例子:
-- 源表加一个 Region 列 ALTER TABLE dbo.SalesDetail ADD Region NVARCHAR(20) DEFAULT '华东'; -- 此时 PIVOT 会按 SalesPerson + Region 两个列分组 SELECT SalesPerson, Region, [Q1], [Q2], [Q3], [Q4] FROM dbo.SalesDetail PIVOT (SUM(Amount) FOR Quarter IN ([Q1],[Q2],[Q3],[Q4])) AS P;结果里张三会出现多行,每个 Region 一行。这不是 bug,是 PIVOT 的默认分组逻辑。如果你只想按 SalesPerson 分组,必须在子查询里先把 Region 去掉:
SELECT SalesPerson, [Q1], [Q2], [Q3], [Q4] FROM (SELECT SalesPerson, Quarter, Amount FROM dbo.SalesDetail) AS src PIVOT (SUM(Amount) FOR Quarter IN ([Q1],[Q2],[Q3],[Q4])) AS P;这个「子查询裁剪列」的技巧非常实用。PIVOT 的源不一定非得是基表,任何派生表都行。把不需要参与分组的列提前裁掉,是控制 PIVOT 分组行为最直接的手段。
4.3 用子查询预聚合再 PIVOT
有时候源表粒度太细,直接 PIVOT 会得到错误结果。比如订单明细表里一个订单有多个商品行,你想按订单号透视商品类别,得先按订单号加商品类别聚合,再 PIVOT。
-- 先按订单+类别聚合,再透视 SELECT OrderNo, [电子产品], [服装], [食品] FROM ( SELECT OrderNo, Category, SUM(Qty) AS TotalQty FROM dbo.OrderItems GROUP BY OrderNo, Category ) AS PreAgg PIVOT ( SUM(TotalQty) FOR Category IN ([电子产品], [服装], [食品]) ) AS P;逻辑说明:子查询PreAgg先把每个订单每个类别的数量加总,PIVOT 再把这个预聚合结果展开成列。如果不预聚合,PIVOT 会对原始明细行做SUM,结果虽然可能对,但中间过程多了一层不必要的聚合,数据量大时性能差。
参数说明:预聚合的GROUP BY列必须包含透视列和分组列,否则数据会丢。SUM(Qty)里的Qty是预聚合后的列名,不是原始列名,别搞混。
5. PIVOT 避坑与排查:五个血泪教训
5.1 现象:结果列出现 NULL 一大片,以为数据丢了
原因:PIVOT 对不存在的组合返回NULL,这是正常行为,不是数据丢失。比如李四没有 Q3 记录,Q3 列就是NULL。
解决:用ISNULL或COALESCE把NULL转成 0。注意要包在 PIVOT 外层,不能写在 PIVOT 里面:
SELECT SalesPerson, ISNULL([Q1], 0) AS Q1, ISNULL([Q2], 0) AS Q2, ISNULL([Q3], 0) AS Q3, ISNULL([Q4], 0) AS Q4 FROM dbo.SalesDetail PIVOT (SUM(Amount) FOR Quarter IN ([Q1],[Q2],[Q3],[Q4])) AS P;5.2 现象:动态 PIVOT 报「列名无效」或「语法错误」
原因:拼出来的@sql里列名没加方括号,或者@cols为空导致IN ()语法错误。
解决:先PRINT @sql看拼出来的完整语句,再执行。这是排查动态 SQL 最有效的手段,没有之一。同时确保QUOTENAME包裹了每个列名,并且对空结果做判断:
IF @cols IS NULL BEGIN PRINT '没有可透视的列值'; RETURN; END5.3 现象:PIVOT 后行数变少,怀疑丢数据
原因:PIVOT 隐含GROUP BY,分组列相同的行会被合并。如果源表里分组列有重复,聚合后自然只剩一行。
解决:先确认业务上是否允许合并。如果不允许,说明分组列选少了,把能唯一标识行的列加进子查询。用COUNT(*)对比 PIVOT 前后的行数:
SELECT COUNT(*) AS BeforeRows FROM dbo.SalesDetail; -- PIVOT 后 SELECT COUNT(*) AS AfterRows FROM ( SELECT SalesPerson, [Q1],[Q2],[Q3],[Q4] FROM dbo.SalesDetail PIVOT (SUM(Amount) FOR Quarter IN ([Q1],[Q2],[Q3],[Q4])) AS P ) AS t;5.4 现象:动态 PIVOT 列顺序每次不一样
原因:STRING_AGG或FOR XML PATH没加ORDER BY,SQL SERVER 不保证聚合顺序。
解决:STRING_AGG用WITHIN GROUP (ORDER BY ...),FOR XML PATH把ORDER BY写在子查询里。列顺序不稳定会让下游报表工具解析错位,这个坑很隐蔽。
5.5 现象:PIVOT 查询比手写 CASE WHEN 慢很多
原因:PIVOT 本质是语法糖,执行计划可能不如手写聚合直观。数据量大、透视列多时,PIVOT 的排序和分组开销会放大。
解决:对比执行计划,看是否有额外的 Sort 或 Hash Match 操作。如果透视列超过 50 个,考虑改用CASE WHEN手动聚合,或者把 PIVOT 结果物化到临时表再加索引。没有银弹,只有权衡。
6. 用执行计划验证 PIVOT 开销与一个收尾习惯
PIVOT 写起来简洁,但简洁不等于高效。我一般会在正式用到报表之前,做一次执行计划对比:同一份数据,一份用 PIVOT,一份用CASE WHEN,看两者的逻辑读和 CPU 时间差多少。
SET STATISTICS IO ON; SET STATISTICS TIME ON; -- PIVOT 版本 SELECT SalesPerson, [Q1],[Q2],[Q3],[Q4] FROM dbo.SalesDetail PIVOT (SUM(Amount) FOR Quarter IN ([Q1],[Q2],[Q3],[Q4])) AS P; -- CASE WHEN 版本 SELECT SalesPerson, SUM(CASE WHEN Quarter = 'Q1' THEN Amount ELSE 0 END) AS Q1, SUM(CASE WHEN Quarter = 'Q2' THEN Amount ELSE 0 END) AS Q2, SUM(CASE WHEN Quarter = 'Q3' THEN Amount ELSE 0 END) AS Q3, SUM(CASE WHEN Quarter = 'Q4' THEN Amount ELSE 0 END) AS Q4 FROM dbo.SalesDetail GROUP BY SalesPerson; SET STATISTICS IO OFF; SET STATISTICS TIME OFF;打开STATISTICS IO和STATISTICS TIME后,消息窗口会输出两张表的扫描次数和耗时。多数情况下两者逻辑读接近,但 PIVOT 在透视列多时可能多一次排序。如果发现 PIVOT 版本明显慢,先看源表有没有覆盖索引,再考虑改写。
一个具体技巧:把动态 PIVOT 的列名查询结果缓存到临时表,避免每次执行都扫一遍源表取DISTINCT。对于透视列取值稳定的场景,这一步能省掉一次全表扫描。
-- 缓存列名,适合透视列取值不频繁变化的场景 IF OBJECT_ID('tempdb..#PivotCols') IS NOT NULL DROP TABLE #PivotCols; SELECT DISTINCT Quarter INTO #PivotCols FROM dbo.SalesDetail; DECLARE @cols NVARCHAR(MAX); SELECT @cols = STRING_AGG(QUOTENAME(Quarter), ',') WITHIN GROUP (ORDER BY Quarter) FROM #PivotCols;这个习惯来自一次翻车:报表页面每次刷新都跑动态 PIVOT,源表几百万行,光取DISTINCT就花了三秒。后来把列名缓存成一张配置表,刷新时间降到几百毫秒。PIVOT 本身不慢,慢的是你没控制住它的输入。
我现在写任何动态 PIVOT,第一件事就是PRINT @sql,第二件事就是看执行计划里有没有多余的 Sort。这两步花不了两分钟,但能挡掉后面几小时的排查。希望帮到你。
本文还有配套的精品资源,点击获取