news 2026/10/18 4:39:36

SQL多行数据合并到一行:聚合函数详解与避坑指南

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
SQL多行数据合并到一行:聚合函数详解与避坑指南

上周做培训报名统计的时候,同事提了一个特别实在的需求:报名表里一个学员对应多门课程,一行一条记录,但最终导出的 Excel 里,领导希望每个学员只占一行,所报课程按顺序合并到同一个单元格里,中间用顿号隔开。这就是典型的“SQL 多行数据合并到一行中的一个字段”场景。

这类需求在数据开发、报表导出、运营取数里太常见了——一个用户多个标签、一个订单多个商品、一个客户多个联系方式,最后都要压缩成一行一个字段。核心实现方式也不复杂,就是分组聚合类函数的应用。但实际写起来,排序、去重、关联表的重复行、分隔符边界、长度限制这些坑一个比一个隐蔽,纯靠临时拼 SQL 非常容易翻车。这篇文章我打算把整个知识点拆透,从不同数据库的语法差异,到真正干活时容易被坑的细节,再到性能优化思路,一次性讲清楚,适合经常写 SQL 的取数同学、数据开发,以及报表需求的一线实操者阅读。

1. 多行合并的核心需求与主流数据库的聚合语法

1.1 一个真实的报表场景:报名记录与课程清单

先看一个具体例子。有一张报名表叫 signup,记录每个学员报的课程,结构大致是这样的:

user_iduser_namecourse_nameregister_time
1001张三SQL基础2024-01-05
1001张三Python入门2024-01-06
1001张三数据分析实战2024-01-07
1002李四Excel函数2024-01-05

报表需要输出的格式是:

user_iduser_namecourse_list
1001张三SQL基础、Python入门、数据分析实战
1002李四Excel函数

这就是最典型的多行转一行、多值合到一个字段。SQL 层面的做法,就是按 user_id 分组,在分组内把 course_name 拼成一个带分隔符的字符串。听起来简单,但不同数据库提供的函数和写法并不完全一样,而且反过来还引出一个问题:为什么不用程序拼接,非要 SQL 来做?因为数据量大的时候,把几万行明细拉到程序里循环拼接,网络传输和内存开销都非常可观,直接在数据库完成聚合能省掉大量无谓的数据搬运。

1.2 一张表看懂五种数据库的聚合函数

主流数据库对这个需求都有现成的聚合函数,我整理了一张对照表,工作中按需取用:

数据库聚合函数核心语法模板版本备注
MySQL / MariaDBGROUP_CONCATGROUP_CONCAT([DISTINCT] column [ORDER BY ...] [SEPARATOR ','])所有常用版本均支持
PostgreSQLSTRING_AGGSTRING_AGG(column, '分隔符' [ORDER BY ...])9.0 及以上
SQL ServerSTRING_AGGSTRING_AGG(column, '分隔符') WITHIN GROUP (ORDER BY ...)2017 及以上;老版本用 FOR XML PATH
OracleLISTAGGLISTAGG(column, '分隔符') WITHIN GROUP (ORDER BY ...)11gR2 及以上;旧库可临时用 WM_CONCAT
SQLiteGROUP_CONCATGROUP_CONCAT(column, '分隔符')全版本支持

以 MySQL 为例,前面那个需求一句话就能出来:

SELECT user_id, user_name, GROUP_CONCAT(course_name SEPARATOR '、') AS course_list FROM signup GROUP BY user_id, user_name;

默认情况下,MySQL 的 GROUP_CONCAT 使用逗号作为分隔符,所以当需求是中文顿号时,必须显式使用 SEPARATOR '、'。PostgreSQL 和 SQLite 则更直接,把分隔符作为第二个参数传进去就行。

1.3 为什么首选聚合函数,而不是游标或自连接

我见过不少同事第一次遇到这种需求时,第一反应是写存储过程,开个游标,逐行循环把值拼到变量里。这种方式功能上能实现,但往往代码量大、运行速度慢,而且游标在并发高的环境里还可能造成锁竞争,维护起来也头疼。

还有人用自连接加分组去拼,思路大概是先查出所有记录,然后想办法错位拼接。这种写法在数据量小的时候勉强能看,一旦同一个分组下的行数多了,自连接会先产生笛卡尔积,中间结果集呈指数膨胀,再去重反而更慢。我见过一个分组的成员数量不到 100 个,自连接拼出来的中间表却有上万行,跑一次全表扫描要好几秒,完全没有必要。

聚合函数是 SQL 原生针对分组聚合设计的机制,内部已经做了优化,语义清晰、代码简短、性能也可控。这就像你要数班级人数,直接用 COUNT 就行,没必要把所有学生拉出来一个个数。能用原生聚合解决的问题,尽量不要手写循环。

2. 排序与去重:合并结果可控的两个关键操作

2.1 函数内部的 ORDER BY:排序不再依赖子查询

很多人在第一次写 GROUP_CONCAT 的时候,会先写一个带 ORDER BY 的子查询,或者用 GROUP BY 时的排序去“暗示”聚合顺序,结果发现拼出来的顺序乱掉。

-- 这种写法,合并结果的顺序可能不对 SELECT user_id, GROUP_CONCAT(course_name SEPARATOR '、') AS course_list FROM ( SELECT user_id, course_name FROM signup ORDER BY register_time ) t GROUP BY user_id;

原因在于 SQL 优化器并不保证子查询的排序结果在进入分组聚合时还保留,它可能重新规划执行路径。正确的做法,是把排序意图直接写进聚合函数内部:

SELECT user_id, GROUP_CONCAT(course_name ORDER BY register_time SEPARATOR '、') AS course_list FROM signup GROUP BY user_id;

PostgreSQL 和 Oracle 是类似的:

-- PostgreSQL SELECT user_id, STRING_AGG(course_name, '、' ORDER BY register_time) AS course_list FROM signup GROUP BY user_id;

SQL Server 则把排序放在 WITHIN GROUP 子句里:

SELECT user_id, STRING_AGG(course_name, '、') WITHIN GROUP (ORDER BY register_time) AS course_list FROM signup GROUP BY user_id;

排序字段不一定要是合并字段本身。比如按报名时间排序比按课程名字排序更符合业务直觉,这里 register_time 和 course_name 可以不在 SELECT 的普通列中,只要和当前分组有关即可。

2.2 DISTINCT 去重:消除关联产生的重复值

另一个高频问题是重复值。还是报名场景,如果同一个学员同一门课程报了多次,或者因为业务逻辑问题在明细表里产生了重复记录,GROUP_CONCAT 会把重复课程名原样拼出来:

SELECT user_id, GROUP_CONCAT(course_name SEPARATOR '、') AS course_list FROM signup GROUP BY user_id;

结果可能会出现“SQL基础、SQL基础、Python入门”这种尴尬的清单。解决办法很简单,DISTINCT 直接放在聚合函数内部:

SELECT user_id, GROUP_CONCAT(DISTINCT course_name SEPARATOR '、') AS course_list FROM signup GROUP BY user_id;

注意 DISTINCT 的位置是在函数名和字段名之间,不是加在 GROUP BY 上。这点和 SUM(DISTINCT col) 的语法逻辑一致。数据库会先对分组内的 course_name 去重,再执行拼接。

2.3 既去重又排序的规范写法

实际需求往往同时要求去重和排序。比如课程清单不能重复,同时希望按课程名称的拼音顺序展示。这时可以把 DISTINCT 和 ORDER BY 组合使用:

-- MySQL SELECT user_id, GROUP_CONCAT(DISTINCT course_name ORDER BY course_name SEPARATOR '、') AS course_list FROM signup GROUP BY user_id;

PostgreSQL 因为 STRING_AGG 在 9.0 之后并不直接支持 DISTINCT,很多人会先对明细做子查询去重,再在外面聚合:

SELECT user_id, STRING_AGG(course_name, '、' ORDER BY course_name) AS course_list FROM ( SELECT DISTINCT user_id, course_name FROM signup ) t GROUP BY user_id;

这里有个经验之谈:当数据量在百万级上下,与其让数据库在聚合前做 DISTINCT 去重,不如先通过子查询把重复数据过滤掉,外面只做纯粹的拼接。这样执行计划的中间结果更小,整体耗时往往更低。我实际测过,同样 200 万行的明细表,子查询先去重再聚合比直接 DISTINCT 聚合要快 20% 左右,差异在 Shell 端也能明显感觉到。

3. 关联查询下的重复行陷阱:先合并还是先关联

3.1 一个让合并结果无辜翻倍的错误案例

前面讲的都是单表分组,真正容易翻车的是多表关联下的合并。因为有 JOIN 的存在,分组内同一个值可能被关联出多行,聚合时就会出现莫名其妙的重复。

举个具体例子:一个学员可能既报名了课程,又报名了线下活动。现在要每个学员的课程清单和活动清单都输出到一个查询结果里。如果图省事,把课程表和活动表都 JOIN 到用户表上,再一次性分组聚合:

SELECT u.user_id, GROUP_CONCAT(uc.course_name SEPARATOR '、') AS course_list, GROUP_CONCAT(ue.event_name SEPARATOR '、') AS event_list FROM users u LEFT JOIN user_course uc ON u.user_id = uc.user_id LEFT JOIN user_event ue ON u.user_id = ue.user_id GROUP BY u.user_id;

小明报了 3 门课、参加了 2 场活动,那么 JOIN 之后,3 门课会各自和 2 场活动组合,产生 6 行中间结果。在这个中间结果上做 GROUP_CONCAT,课程清单里每门课至少会重复 2 次,活动清单里每场活动至少重复 3 次。哪怕你在聚合函数里加了 DISTINCT,也只是掩盖了 JOIN 导致的笛卡尔积问题,数据量一大,磁盘临时表和排序开销一样会爆炸。

更隐蔽的是数值字段的重复问题,比如同时关联订单表和退款表去 SUM 金额,金额翻倍你可能过几天才发现报表对不上。字符串合并只是让它更直观地暴露出来。

3.2 先聚合再关联:把粒度控制在下游

规避这个问题的核心原则只有一个:在“一对多”的关联场景里,先把最细粒度的表各自聚合到唯一的键上,再彼此关联。回到刚才的例子,正确写法是先把课程和活动各自按 user_id 聚合成一条,再去关联用户主表:

WITH course_agg AS ( SELECT user_id, GROUP_CONCAT(course_name SEPARATOR '、') AS course_list FROM user_course GROUP BY user_id ), event_agg AS ( SELECT user_id, GROUP_CONCAT(event_name SEPARATOR '、') AS event_list FROM user_event GROUP BY user_id ) SELECT u.user_id, c.course_list, e.event_list FROM users u LEFT JOIN course_agg c ON u.user_id = c.user_id LEFT JOIN event_agg e ON u.user_id = e.user_id;

这样,每个聚合结果天然以 user_id 作为唯一粒度,两个聚合结果 JOIN 时不会产生放大。我个人的习惯是,只要查询里出现了两个以上的一对多关系,第一步永远是写各自的聚合子查询或 CTE,再考虑关联。这个习惯救过我很多次。

3.3 JOIN 效率更高的进阶写法与适用边界

除了先聚合再关联,还有一种进阶写法值得掌握:lateral join(PostgreSQL 和 MySQL 8.0.14+ 支持)或 SQL Server 的 APPLY。它可以在主表每一行的上下文中执行一个聚合子查询,适合“主表先被过滤成很少几行”的场景:

-- PostgreSQL 示例 SELECT u.user_id, uc.course_list, ue.event_list FROM users u LEFT JOIN LATERAL ( SELECT STRING_AGG(course_name, '、' ORDER BY register_time) AS course_list FROM user_course uc WHERE uc.user_id = u.user_id ) uc ON true LEFT JOIN LATERAL ( SELECT STRING_AGG(event_name, '、') AS event_list FROM user_event ue WHERE ue.user_id = u.user_id ) ue ON true;

当 users 表本身有索引主键且过滤条件能快速定位到少量用户时,LATERAL 的方式非常高效,因为它不会像先聚合再关联那样把整张大表的所有分组都算出来,而是按需计算。反之,如果 users 表要过滤出 10 万行,LATERAL 会执行 10 万次子查询,每次扫一遍 user_course,性能反而严重恶化。这种情况下,宁可一次性把整个 user_course 按 user_id 聚合好再 JOIN。

4. 容易被忽略的边界条件:分隔符、NULL 与长度限制

4.1 分隔符冲突:当字段值本身就包含逗号

多数人第一次写这个需求时,都会下意识地选择逗号做分隔符,图省事。但导出 CSV 时,如果字段值里本身就含有逗号,整列就会错位,Excel 打开后数据突然变成多列,排查半天才发现是分隔符冲突。

举个例子,如果课程名称是“数据分析,从入门到实战”,用逗号合并后,CSV 解析器会认为 “数据分析” 是一列、“从入门到实战” 是另一列。解决方案有几个:

  • 换成一个业务字段里几乎不会出现的分隔符,比如竖线 |、波浪号 ~,代码层面替换成本极低。
  • MySQL 里用 SEPARATOR 显式指定,比如 SEPARATOR '|'。
  • 如果下游必须用逗号,那就要在导出环节做转义,用双引号包裹整个字段,这属于工具层处理,SQL 里别硬扛。

我的建议是,合并字段时统一使用竖线或换行符,减少 90% 的转义需求。尤其在做报表自动导出、然后交给非技术人员用 Excel 打开的场景,多花一秒钟选分隔符,能省掉后面无数张“数据乱了”的工单。

4.2 NULL 值参与聚合的奇怪表现

聚合函数对 NULL 的处理方式和直觉有很大差异。GROUP_CONCAT 和 STRING_AGG 默认都会忽略 NULL 值,也就是说如果某个分组里 5 行记录,其中 3 行课程名是 NULL,最后合并出的字符串只有 2 个值。这本身是好事。

容易误判的其实是这两个点:一是空字符串和 NULL 不一样,空字符串会被拼进去,于是结果里可能出现连续两个分隔符,比如“SQL基础、、Python入门”;二是当某个分组下所有值都为 NULL 时,不同数据库的返回结果并不统一,有的返回 NULL,有的返回空字符串,还有的返回空数组。

稳妥的做法是,在聚合前统一清洗:

-- MySQL SELECT user_id, GROUP_CONCAT( CASE WHEN course_name IS NULL OR course_name = '' THEN NULL ELSE course_name END SEPARATOR '、' ) AS course_list FROM signup GROUP BY user_id;

或者用 CONCAT_WS 再包一层,把 NULL 和空串都清理干净。曾经因为忽略了这个细节,我导出的报表里用户课程清单出现连续顿号,业务方反馈“看起来像数据有洞”,排查了两天才定位到是底层明细表里存在空字符串的脏数据,从那以后我写合并 SQL 时都会习惯性加一个清洗条件。

4.3 聚合结果被截断的隐性问题

这是最致命也最隐蔽的一个坑。MySQL 的 GROUP_CONCAT 默认最大长度为 1024 字节,注意是字节不是字符。在 UTF-8 编码下,一个中文字符占 3 字节,也就是说默认情况下,合并结果超过 340 个左右的中文字符,后面的内容会被静默丢弃,不报错、不警告。

我曾有一次导出学员选课清单时,一个学员报了 50 多门课,课程名又长,结果导出到 Excel 后课程列表少了一截。我当时第一反应是查询条件写错了,反复检查 SQL,后来才发现是默认长度限制把字符串截断了。

解决办法是执行前调整会话变量:

SET SESSION group_concat_max_len = 10240000; -- 然后再执行查询 SELECT user_id, GROUP_CONCAT(course_name SEPARATOR '、') AS course_list FROM signup GROUP BY user_id;

这里有个连接池的细节需要注意:SESSION 变量只对当前连接生效。如果程序通过连接池访问数据库,下一次复用同一连接时,之前的 SET 可能已经不在执行上下文中了,所以最好在专用取数连接里设置,或者直接改到应用的初始化 SQL 中。Oracle 的 LISTAGG 也有类似的 4000 字节限制,超出会直接报 ORA-01489 错误,需要转成 CLOB 或其他方案处理。SQL Server 的 STRING_AGG 用 VARCHAR(MAX) 类型,通常不会被截断,但如果隐式转换到非 MAX 类型,照样会踩坑。

如果实在担心分隔符和转义问题,还有一个更现代的思路:用 JSON 数组聚合。MySQL 8.0 支持 JSON_ARRAYAGG,PostgreSQL 有 json_agg,SQL Server 可以用 FOR JSON PATH。这样每个值作为 JSON 数组元素保存,天然规避了分隔符冲突和字符串截断,下游解析也更可靠。代价是结果可读性变差,适合程序消费而不是人眼阅读的场景。

5. 数据量大时的性能优化思路

5.1 聚合慢的根源与索引策略

多行合并本质上是在分组内做字符串拼接,和 SUM、COUNT 这类简单数值聚合不同,字符串拼接涉及反复的内存分配和字符数组拷贝。当中间结果达到 MB 级别时,聚合耗时往往不是线性增长,而是接近指数级上扬。

性能问题最常见的根源有三个:

  • GROUP BY 的字段没有索引,数据库无法利用索引有序性直接分组,只能建临时表加排序。
  • 聚合前关联了过多明细表,中间结果膨胀后,每个分组的拼接量成倍增加。
  • 聚合函数内使用了复杂的表达式,比如对字段做正则替换、大小写转换,导致每行都要额外计算。

针对这三类问题,实操优先做以下调整:

  1. 在 GROUP BY 字段上建立合适的索引,让分组尽可能走索引有序扫描;如果多个字段分组,考虑联合索引顺序。
  2. 在聚合子查询的最内层就把无关数据过滤掉,缩小进入聚合的数据集。
  3. 复杂表达式先通过派生表或 CASE WHEN 清洗成固定值,再进聚合函数。
  4. 避免聚合整表,能按时间范围、业务状态先裁剪的就先裁剪。

比如要汇总本月报名课程的用户清单,用 date 条件过滤后再聚合,比在 JOIN 之后再 WHERE 要快得多,尤其是明细表有百万级记录时。

5.2 从“查询时聚合”到“写入时聚合”的取舍

再往上走一步,如果同样一个“用户课程清单”要反复被报表、接口、看板使用,每次都实时跑聚合计算,数据库压力会非常大。这种情况下,可以考虑从“查询时聚合”改为“写入时聚合”。

思路很简单:在数据落库或定时任务里,把合并后的结果预先算好,存到一张汇总表里。比如每天凌晨跑一次任务,把当天新增的 user_course 增量合并到汇总表的 course_list 字段中,查询时直接 SELECT 汇总表即可。这样查询性能最高,但代价是数据会有一天的延迟,且需要在业务侧维护汇总表的更新逻辑。

如果必须实时又要减少数据库压力,可以考虑在应用层做最后一次拼接:数据库只查明细或查半聚合结果,然后由应用代码把列表转成字符串。我实测过,当查询结果集中每个用户只有几十个标签、总行数几万行时,应用层拼接比数据库 GROUP_CONCAT 反而更快,因为它把高 CPU 的字符串操作从数据库转移到了更易扩展的应用服务器上,数据库只需要专注扫描和传输。

不过任何一次具体选型,都得结合自己的数据规模、查询频率、可接受的延迟和运维成本来权衡,不要迷信某一种方案就是银弹。我个人的经验是:低频的临时报表用数据库聚合最省事;高频且对延迟敏感的接口查询,优先考虑汇总表;数据量极大且维度特别多的场景,再考虑应用层中间结果缓存。

多行合并这个知识点,刚上手时觉得只是一个函数的事,真正在生产环境里跑上几轮,才会遇到排序失效、关联重复、分隔符截断这些连环坑。我自己的体会是,有一种非常值得养成的习惯:每次写完这种聚合 SQL,先手工构造几行数据把预期结果算出来,再去实际跑,对不上就复盘原因。这比直接在生产库上反复试错要安全和快得多。希望这篇分享能帮你少走一些弯路,也欢迎在实际使用中总结出自己的那套“组合拳”。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/18 4:37:53

Unity VR棒球交互开发:物理代理与击球检测实战

1. 从"能跑起来"到"打得出去":VR棒球交互的核心难点在哪上一篇文章里我们把开发环境搭好了,APK也顺利装进了头显,手柄能追踪、场景能渲染,看起来一切就绪。但真正动手做棒球玩法的时候,你会发现一…

作者头像 李华
网站建设 2026/10/18 4:33:42

Ubuntu定时开机全解析:RTC唤醒、方案选择与踩坑指南

你可能和我一样,一开始在Ubuntu里搜“定时开机”,以为装个软件就能让电脑自己醒过来。我手头有一台装了Ubuntu的主机,需要每天早晨8点自动开机,把夜间收到的数据先做一轮备份,再进入待命状态。试了一圈才发现&#xff…

作者头像 李华
网站建设 2026/10/18 4:33:22

Windows Server 2019主域控制器搭建实战:从规划到排错

1. 动手前的核心思路:主域控制器到底在解决什么问题1.1 域控的本质:给整个网络定规矩先不急着进服务器管理器点按钮,把域控的原理闹明白,后面操作会顺手很多。所谓主域控制器,简单说就是一台承担“身份认证中枢”角色的…

作者头像 李华
网站建设 2026/10/18 4:31:33

SpringBoot家庭理财系统实战:需求、数据库设计与核心代码拆解

1. 先从需求说起:为什么家庭也需要一套理财系统很多人一听“家庭理财系统”就下意识觉得是个记账本,但其实家庭理财和普通记账之间差着好几层。日常记账只管“花了多少”,而家庭理财系统要解决的是三个更深层的问题:钱花在哪里、该…

作者头像 李华
网站建设 2026/10/18 4:31:19

中老年驼奶粉年销过亿操盘复盘:不靠低价,靠信任与渠道

去年年初,我接手了一个中老年驼奶粉品牌的年度操盘。12个月后,全渠道回款过了1个亿。团队复盘时,很多人的第一反应是:你们一定在拼价格。说实话,我们一场价格战都没打过。这个品类不是靠低价能打出来的,恰恰…

作者头像 李华