跳到主要内容

行列转换

问题

SQL 中如何实现行转列(PIVOT)和列转行(UNPIVOT)?

答案

行列转换是数据分析和报表中的常见需求。比如把"每行一条销售记录"转换为"每列一个月份"的汇总表。这在做交叉报表、数据透视时经常遇到。


行转列(PIVOT)

纵向排列的数据转换为横向展示

原始数据

namequarterrevenue
张三Q110000
张三Q212000
张三Q311000
张三Q415000
李四Q18000
李四Q29000
李四Q39500
李四Q411000

期望结果

nameQ1Q2Q3Q4
张三10000120001100015000
李四80009000950011000

方法 1:CASE WHEN(通用,所有数据库都支持)

SELECT 
name,
SUM(CASE WHEN quarter = 'Q1' THEN revenue ELSE 0 END) AS Q1,
SUM(CASE WHEN quarter = 'Q2' THEN revenue ELSE 0 END) AS Q2,
SUM(CASE WHEN quarter = 'Q3' THEN revenue ELSE 0 END) AS Q3,
SUM(CASE WHEN quarter = 'Q4' THEN revenue ELSE 0 END) AS Q4
FROM sales
GROUP BY name;
理解 CASE WHEN 行转列的原理

对于张三的 4 行数据,CASE WHEN quarter = 'Q1' THEN revenue ELSE 0 END 只在 Q1 那行返回 10000,其他行返回 0。SUM 后就得到 10000。每个 CASE WHEN 都"挑选"出对应季度的值。

方法 2:IF 函数(MySQL 简写)

SELECT 
name,
SUM(IF(quarter = 'Q1', revenue, 0)) AS Q1,
SUM(IF(quarter = 'Q2', revenue, 0)) AS Q2,
SUM(IF(quarter = 'Q3', revenue, 0)) AS Q3,
SUM(IF(quarter = 'Q4', revenue, 0)) AS Q4
FROM sales
GROUP BY name;

方法 3:crosstab(PostgreSQL)

-- PostgreSQL 使用 tablefunc 扩展
CREATE EXTENSION IF NOT EXISTS tablefunc;

SELECT * FROM crosstab(
'SELECT name, quarter, revenue FROM sales ORDER BY 1, 2',
'SELECT DISTINCT quarter FROM sales ORDER BY 1'
) AS ct(name TEXT, Q1 NUMERIC, Q2 NUMERIC, Q3 NUMERIC, Q4 NUMERIC);

列转行(UNPIVOT)

横向排列的数据转换为纵向展示——行转列的逆操作。

原始数据

namemathenglishscience
张三908592
李四789085

期望结果

namesubjectscore
张三math90
张三english85
张三science92
李四math78
李四english90
李四science85

方法 1:UNION ALL(通用)

SELECT name, 'math' AS subject, math AS score FROM scores
UNION ALL
SELECT name, 'english', english FROM scores
UNION ALL
SELECT name, 'science', science FROM scores
ORDER BY name, subject;

方法 2:CROSS JOIN + CASE(更灵活)

-- 构造科目列表,然后交叉连接
SELECT
s.name,
t.subject,
CASE t.subject
WHEN 'math' THEN s.math
WHEN 'english' THEN s.english
WHEN 'science' THEN s.science
END AS score
FROM scores s
CROSS JOIN (
SELECT 'math' AS subject
UNION ALL SELECT 'english'
UNION ALL SELECT 'science'
) t;

方法 3:LATERAL(PostgreSQL)

-- PostgreSQL 使用 LATERAL + VALUES
SELECT s.name, t.subject, t.score
FROM scores s,
LATERAL (VALUES
('math', s.math),
('english', s.english),
('science', s.science)
) AS t(subject, score);

实战场景

场景:月度销售报表

将每日销售数据转换为月度列展示:

-- 原始数据:日期级别的销售记录
-- 目标:每个商品一行,每个月一列
SELECT
product_name,
SUM(CASE WHEN MONTH(sale_date) = 1 THEN amount ELSE 0 END) AS "1月",
SUM(CASE WHEN MONTH(sale_date) = 2 THEN amount ELSE 0 END) AS "2月",
SUM(CASE WHEN MONTH(sale_date) = 3 THEN amount ELSE 0 END) AS "3月",
-- ...
SUM(CASE WHEN MONTH(sale_date) = 12 THEN amount ELSE 0 END) AS "12月",
SUM(amount) AS "全年合计"
FROM sales
WHERE YEAR(sale_date) = 2024
GROUP BY product_name;

场景:学生成绩透视表

-- 每个学生各科成绩横向展示
SELECT
s.name,
MAX(CASE WHEN c.subject = '数学' THEN sc.score END) AS 数学,
MAX(CASE WHEN c.subject = '英语' THEN sc.score END) AS 英语,
MAX(CASE WHEN c.subject = '物理' THEN sc.score END) AS 物理
FROM students s
JOIN scores sc ON s.id = sc.student_id
JOIN courses c ON sc.course_id = c.id
GROUP BY s.name;
为什么用 MAX 而不是 SUM?

因为每个学生每门课只有一个成绩,MAXSUM 效果一样。但如果用 SUM,当某门课有多次考试时可能会加错。语义上 MAX 更准确——"取这门课的成绩"。

场景:多维数据透视

-- 按渠道 × 月份的订单数透视表
SELECT
channel,
SUM(CASE WHEN month = '2024-01' THEN orders ELSE 0 END) AS "2024-01",
SUM(CASE WHEN month = '2024-02' THEN orders ELSE 0 END) AS "2024-02",
SUM(CASE WHEN month = '2024-03' THEN orders ELSE 0 END) AS "2024-03",
SUM(orders) AS total
FROM (
SELECT
channel,
DATE_FORMAT(order_date, '%Y-%m') AS month,
COUNT(*) AS orders
FROM orders
WHERE order_date >= '2024-01-01' AND order_date < '2024-04-01'
GROUP BY channel, DATE_FORMAT(order_date, '%Y-%m')
) monthly
GROUP BY channel
-- 添加合计行
UNION ALL
SELECT
'合计' AS channel,
SUM(CASE WHEN month = '2024-01' THEN orders ELSE 0 END),
SUM(CASE WHEN month = '2024-02' THEN orders ELSE 0 END),
SUM(CASE WHEN month = '2024-03' THEN orders ELSE 0 END),
SUM(orders)
FROM (
SELECT DATE_FORMAT(order_date, '%Y-%m') AS month, COUNT(*) AS orders
FROM orders
WHERE order_date >= '2024-01-01' AND order_date < '2024-04-01'
GROUP BY DATE_FORMAT(order_date, '%Y-%m')
) monthly;

场景:JSON 格式的动态列转行

-- MySQL 5.7+:将 JSON 数组展开为多行
SELECT
id,
JSON_UNQUOTE(JSON_EXTRACT(tags, CONCAT('$[', n.n, ']'))) AS tag
FROM articles
CROSS JOIN (
SELECT 0 AS n UNION ALL SELECT 1 UNION ALL SELECT 2
UNION ALL SELECT 3 UNION ALL SELECT 4
) n
WHERE JSON_EXTRACT(tags, CONCAT('$[', n.n, ']')) IS NOT NULL;

-- MySQL 8.0+:用 JSON_TABLE 更简洁
SELECT a.id, t.tag
FROM articles a,
JSON_TABLE(a.tags, '$[*]' COLUMNS (tag VARCHAR(50) PATH '$')) t;

行列转换的陷阱

陷阱 1:NULL 值处理

-- 如果某个季度没有数据,CASE WHEN 返回的是 NULL 而不是 0
-- ❌ 结果中会有 NULL
SELECT name,
SUM(CASE WHEN quarter = 'Q1' THEN revenue END) AS Q1 -- 没有 ELSE,默认 NULL
FROM sales GROUP BY name;

-- ✅ 使用 ELSE 0 或 COALESCE
SELECT name,
COALESCE(SUM(CASE WHEN quarter = 'Q1' THEN revenue END), 0) AS Q1
FROM sales GROUP BY name;

陷阱 2:数据类型不一致

-- UNION ALL 列转行时,所有列的数据类型必须一致
-- ❌ 可能报错:math 是 INT,但 'math' 是 VARCHAR
SELECT name, CAST('math' AS CHAR) AS subject, CAST(math AS DECIMAL) AS score FROM scores
UNION ALL
SELECT name, 'english', english FROM scores;

陷阱 3:列名包含特殊字符

-- 月份、日期等作为列名时,需要用反引号(MySQL)或双引号(PostgreSQL)包裹
SELECT
product,
SUM(CASE WHEN month = '2024-01' THEN amount END) AS `2024-01`, -- MySQL
SUM(CASE WHEN month = '2024-02' THEN amount END) AS "2024-02" -- PostgreSQL
FROM sales GROUP BY product;

常见面试问题

Q1: 行转列有几种方法?分别适用什么场景?

答案

方法数据库支持适用场景
CASE WHEN + 聚合所有数据库 ✅列名已知且固定
IF 函数MySQL简写版 CASE WHEN
PIVOTSQL Server、Oracle原生支持
crosstabPostgreSQL需要 tablefunc 扩展
动态 SQL所有列名不确定时(运行时生成列名)

最通用的方案是 CASE WHEN + 聚合函数,面试中一定要会写。

Q2: 如果行转列的列名不确定怎么办?

答案

需要使用动态 SQL,在运行时拼接出列名:

-- MySQL 动态行转列(存储过程)
SET @sql = NULL;
SELECT
GROUP_CONCAT(DISTINCT
CONCAT('SUM(CASE WHEN quarter = ''', quarter, ''' THEN revenue ELSE 0 END) AS `', quarter, '`')
) INTO @sql
FROM sales;

SET @sql = CONCAT('SELECT name, ', @sql, ' FROM sales GROUP BY name');
PREPARE stmt FROM @sql;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;

在实际工作中,动态列转换通常在应用层BI 工具中完成,而不是纯 SQL。

Q3: UNION 和 UNION ALL 有什么区别?

答案

对比UNIONUNION ALL
去重✅ 自动去重❌ 保留所有行
性能慢(需要排序去重)
使用建议确实需要去重时列转行、明确无重复时

在做列转行时,通常用 UNION ALL 而不是 UNION,因为数据本身不会重复,而且 UNION ALL 性能更好。

Q4: 面试题——将学生成绩从列展示转为行展示

原始数据

namemathenglishscience
张三908592

答案

SELECT name, 'math' AS subject, math AS score FROM scores WHERE math IS NOT NULL
UNION ALL
SELECT name, 'english', english FROM scores WHERE english IS NOT NULL
UNION ALL
SELECT name, 'science', science FROM scores WHERE science IS NOT NULL;

Q5: 面试题——给定订单数据,统计每个用户每个月的消费金额(行转列)

原始数据orders 表):

user_idorder_dateamount
10012024-01-05200
10012024-02-12350
10012024-03-08180
10022024-01-20500
10022024-03-15120

期望结果(每个用户一行,每个月一列):

user_id1月2月3月
1001200350180
10025000120

答案

-- 用 CASE WHEN + SUM 实现行转列
-- SUM 可以汇总同一用户同一月份的多笔订单
SELECT
user_id,
-- ELSE 0 保证没有消费的月份显示 0 而不是 NULL
SUM(CASE WHEN MONTH(order_date) = 1 THEN amount ELSE 0 END) AS `1月`,
SUM(CASE WHEN MONTH(order_date) = 2 THEN amount ELSE 0 END) AS `2月`,
SUM(CASE WHEN MONTH(order_date) = 3 THEN amount ELSE 0 END) AS `3月`,
SUM(amount) AS 合计
FROM orders
WHERE YEAR(order_date) = 2024
GROUP BY user_id
ORDER BY user_id;
扩展:如果需要展示全年 12 个月

按同样的模式把 4 月到 12 月的 CASE WHEN 补全即可。实际工作中,如果月份不固定,可以用存储过程动态拼接 SQL(参考 Q2 的动态 SQL 方案)。

Q6: 面试题——某门课有多次考试成绩,行转列时应该用 SUM 还是 MAX?

场景exam_scores 表记录了每次考试的成绩,同一个学生同一门课可能有多次考试。

student_idsubjectscore
1001数学75
1001数学88
1001英语90
1002数学82

答案

选择哪个聚合函数取决于业务语义

-- ✅ 用 MAX:取该科目的最高分("最好成绩"报表)
SELECT
student_id,
MAX(CASE WHEN subject = '数学' THEN score END) AS 数学最高分,
MAX(CASE WHEN subject = '英语' THEN score END) AS 英语最高分
FROM exam_scores
GROUP BY student_id;
-- 结果:1001 的数学 = 88(两次中取最高)

-- ✅ 用 SUM:累计该科目的总分("总分"报表)
SELECT
student_id,
SUM(CASE WHEN subject = '数学' THEN score ELSE 0 END) AS 数学总分,
SUM(CASE WHEN subject = '英语' THEN score ELSE 0 END) AS 英语总分
FROM exam_scores
GROUP BY student_id;
-- 结果:1001 的数学 = 163(75 + 88)

-- ✅ 用 AVG:取该科目的平均分("平均成绩"报表)
SELECT
student_id,
ROUND(AVG(CASE WHEN subject = '数学' THEN score END), 1) AS 数学均分,
ROUND(AVG(CASE WHEN subject = '英语' THEN score END), 1) AS 英语均分
FROM exam_scores
GROUP BY student_id;
聚合函数语义适用场景
MAX取最高分每科只有一个成绩时也适用(结果与 SUM 等同)
SUM累加所有成绩计算总分、销售额累加
AVG取平均分多次考试取平均成绩
COUNT统计次数统计参加了几次考试
常见错误

如果每个学生每门课只有一个成绩,MAXSUM 结果相同。但一旦有重复行SUM 会累加出错误结果。因此在不确定数据是否唯一时,用 MAX 语义上更安全。

Q7: 列转行时,如何处理 NULL 值?

场景student_scores 宽表中,有些学生没有参加某门课的考试,对应列为 NULL

namemathenglishscience
张三9085NULL
李四NULL7888

如果直接用 UNION ALL 展开,结果中会包含 score IS NULL 的行,通常不希望显示"没参加考试"的记录。

答案

-- ❌ 不过滤 NULL:结果中包含"张三-science-NULL"和"李四-math-NULL"
SELECT name, 'math' AS subject, math AS score FROM student_scores
UNION ALL
SELECT name, 'english' AS subject, english AS score FROM student_scores
UNION ALL
SELECT name, 'science' AS subject, science AS score FROM student_scores
ORDER BY name, subject;

-- ✅ 用 WHERE IS NOT NULL 过滤:只保留有成绩的行
SELECT name, 'math' AS subject, math AS score FROM student_scores WHERE math IS NOT NULL
UNION ALL
SELECT name, 'english' AS subject, english AS score FROM student_scores WHERE english IS NOT NULL
UNION ALL
SELECT name, 'science' AS subject, science AS score FROM student_scores WHERE science IS NOT NULL
ORDER BY name, subject;

-- 结果(干净无 NULL):
-- 张三 | english | 85
-- 张三 | math | 90
-- 李四 | english | 78
-- 李四 | science | 88
另一种写法:在外层过滤

如果已经写好了 UNION ALL,也可以用子查询在外层统一过滤:

SELECT * FROM (
SELECT name, 'math' AS subject, math AS score FROM student_scores
UNION ALL
SELECT name, 'english' AS subject, english AS score FROM student_scores
UNION ALL
SELECT name, 'science' AS subject, science AS score FROM student_scores
) t
WHERE score IS NOT NULL
ORDER BY name, subject;

两种方式效果相同,但在每个 SELECT 中过滤(第一种)性能更好,因为数据库可以在读取时就跳过 NULL 行,减少 UNION ALL 合并的数据量。

Q8: 面试题——将宽表的多个指标列转换为指标名 + 指标值两列(UNPIVOT)

场景user_metrics 表记录了每个用户多个行为指标,需要转为指标名 + 指标值的格式,方便统一处理。

原始数据

user_idpage_viewsclickspurchases
1001120355
100280202

期望结果

user_idmetric_namemetric_value
1001page_views120
1001clicks35
1001purchases5
1002page_views80
1002clicks20
1002purchases2

答案

-- 通用方案:UNION ALL(所有数据库都支持)
-- 每个指标列对应一个 SELECT,用字符串字面量作为指标名
SELECT user_id, 'page_views' AS metric_name, page_views AS metric_value FROM user_metrics
UNION ALL
SELECT user_id, 'clicks' AS metric_name, clicks AS metric_value FROM user_metrics
UNION ALL
SELECT user_id, 'purchases' AS metric_name, purchases AS metric_value FROM user_metrics
ORDER BY user_id, metric_name;
-- PostgreSQL 方案:LATERAL + VALUES(更简洁,只扫描一次表)
SELECT
u.user_id,
m.metric_name,
m.metric_value
FROM user_metrics u,
LATERAL (VALUES
('page_views', u.page_views),
('clicks', u.clicks),
('purchases', u.purchases)
) AS m(metric_name, metric_value)
ORDER BY u.user_id, m.metric_name;
-- SQL Server / Oracle 原生 UNPIVOT 语法
SELECT user_id, metric_name, metric_value
FROM user_metrics
UNPIVOT (
metric_value FOR metric_name IN (page_views, clicks, purchases)
) AS unpvt
ORDER BY user_id, metric_name;
方案数据库支持优点
UNION ALL所有数据库 ✅最通用,易理解
LATERAL + VALUESPostgreSQL ✅只扫描一次表,性能更好
UNPIVOT 语法SQL Server、Oracle ✅语法简洁,原生支持
CROSS JOIN + CASE所有数据库 ✅灵活,适合指标列数多时
注意数据类型一致性

UNION ALL 要求所有 SELECT 的对应列类型兼容。如果 page_viewsINTconversion_rateFLOAT,合并为 metric_value 时需要用 CAST 统一类型,否则会报错或精度丢失。


相关链接