
简介这份PDF资料聚焦SQL Server中字符串聚合的实用技巧面向数据库开发与运维人员尤其是仍在使用SQL Server 2017之前版本、无法直接调用STRING_AGG函数的场景。资源以AggregationTable测试表为例演示如何通过自定义T-SQL函数AggregateString将同一Id下的多个Name字段值拼接为“赵孙李”“钱周”这类聚合结果弥补SUM、AVG、COUNT、MAX、MIN等数值聚合函数在字符串处理上的不足。压缩包内仅含1个PDF文件约35KB篇幅精炼便于快速查阅与收藏。目前已有3509人学习下载说明该问题在实际开发中具有普遍性。读者可从中掌握自定义字符串聚合函数的完整定义思路、调用方式与分组查询写法并了解新旧版本SQL Server在字符串聚合方案上的差异适合作为日常开发中的速查参考。1. 字符串聚合这件事为什么值得单独拎出来讲如果你写过报表大概率遇到过这种需求一个客户对应多个订单号一条 SQL 查出来要显示成“订单A,订单B,订单C”挤在一格里。用GROUP BY分组之后其他列都能聚合唯独字符串没法直接SUM。这时候就需要字符串聚合函数出场了。SQL Server 在这方面经历过一段“没有官方函数”的尴尬期。早年大家靠FOR XML PATH拼字符串写法绕、转义坑多还得处理、、这些特殊字符。直到 SQL Server 2017 才正式引入STRING_AGG才算有了一个像样的原生方案。所以这个标题背后其实横跨三代写法老项目的FOR XML PATH、过渡期的STUFF FOR XML、以及新版本的STRING_AGG。你手上是哪个版本决定了你能用哪套方案。这篇文章面向的是需要做报表拼接、日志归并、标签聚合的开发和 DBA。不管你是刚装完 SQL Server 2019 想跑通第一个聚合查询还是在维护一个 SQL Server 2008 R2 的老系统没法升级下面都会给出能直接抄的写法和参数说明。2. 三种字符串聚合写法从 FOR XML PATH 到 STRING_AGG2.1 先搞清楚你的版本能用什么选型第一步不是看语法好不好看而是看数据库版本。STRING_AGG是 SQL Server 2017 (兼容级别 140) 才有的2016 及以前只能用FOR XML PATH。你可以用下面这条语句确认版本和兼容级别SELECT VERSION AS 版本信息, SERVERPROPERTY(ProductMajorVersion) AS 主版本号, compatibility_level AS 兼容级别 FROM sys.databases WHERE name DB_NAME();ProductMajorVersion返回 11 是 201212 是 201413 是 201614 是 201715 是 201916 是 2022。兼容级别低于 140 时即使装在 2019 上STRING_AGG也可能报错需要ALTER DATABASE ... SET COMPATIBILITY_LEVEL 140。这一点在从 2008 还原备份到新实例的场景里特别容易翻车——库还原上来了函数却用不了。2.2 FOR XML PATH 写法老版本唯一可靠的路在没有STRING_AGG的年代标准套路是用FOR XML PATH()把每行拼成 XML 片段再用STUFF去掉开头的分隔符。假设有一张订单明细表要按客户把订单号拼起来-- 建测试数据 CREATE TABLE #OrderDetail ( CustomerId INT, OrderNo VARCHAR(20) ); INSERT INTO #OrderDetail VALUES (1, A001), (1, A002), (1, A003), (2, B100), (2, B101); -- FOR XML PATH 聚合写法 SELECT CustomerId, STUFF(( SELECT , OrderNo FROM #OrderDetail AS inner_t WHERE inner_t.CustomerId outer_t.CustomerId FOR XML PATH(), TYPE ).value(., NVARCHAR(MAX)), 1, 1, ) AS OrderList FROM #OrderDetail AS outer_t GROUP BY CustomerId;逻辑说明子查询里, OrderNo让每一行输出成,A001这样的片段FOR XML PATH()把多行结果拼成一个 XML 字符串。STUFF(..., 1, 1, )的作用是删掉第一个字符也就是开头多出来的那个逗号。.value(., NVARCHAR(MAX))是把 XML 类型转回普通字符串同时做实体解码。参数说明PATH()里的空字符串表示不要行标签这是关键写成PATH(row)会多出row标签。TYPE关键字让子查询返回 XML 类型而不是字符串这样才能调用.value()方法。分隔符想换成别的改, OrderNo里的逗号即可。这里有个血泪经验如果OrderNo里含有、、不加TYPE和.value()的写法会把这些字符转义成amp;、lt;输出结果直接错乱。加上TYPE再.value()才能正确还原。2.3 STRING_AGG 写法新版本的清爽方案SQL Server 2017 之后同样的需求一行就能搞定SELECT CustomerId, STRING_AGG(OrderNo, ,) AS OrderList FROM #OrderDetail GROUP BY CustomerId;STRING_AGG(expression, separator)第一个参数是要拼接的列或表达式第二个参数是分隔符。它自动处理了分隔符位置不会像FOR XML PATH那样在开头多一个字符所以不需要STUFF。排序控制是STRING_AGG的一个重点。默认拼接顺序不保证想要按订单号排序得用WITHIN GROUPSELECT CustomerId, STRING_AGG(OrderNo, ,) WITHIN GROUP (ORDER BY OrderNo DESC) AS OrderList FROM #OrderDetail GROUP BY CustomerId;WITHIN GROUP (ORDER BY ...)决定了拼接顺序ASC或DESC都支持。注意这个子句只能出现在STRING_AGG后面不能单独用。还有一个容易忽略的点STRING_AGG的输入表达式如果是非字符串类型SQL Server 会隐式转换但转换规则可能不符合预期。比如INT列拼接时会按数字转字符串但如果混了NULLNULL会被直接跳过不会变成空字符串占位。这一点和FOR XML PATH的行为一致但和某些人直觉不同。2.4 两种写法的性能对比与选择建议小数据量下两种写法差异不明显但数据量上去之后区别就出来了。FOR XML PATH本质是构造 XML 再解析中间会有额外的类型转换开销STRING_AGG是原生聚合执行计划更干净。对比项FOR XML PATHSTRING_AGG最低版本SQL Server 2005SQL Server 2017特殊字符处理需 TYPE value()自动处理排序控制子查询内 ORDER BYWITHIN GROUP分隔符位置需 STUFF 去头自动大数据量性能较差较好返回类型NVARCHAR(MAX)取决于输入类型选择建议很直接能用STRING_AGG就用它除非你被版本锁死。如果必须用FOR XML PATH记得始终带上TYPE和.value()这是避免转义问题的后悔药。3. 分组内排序、去重与超长截断STRING_AGG 的进阶参数3.1 分组内排序的三种实现路径STRING_AGG的WITHIN GROUP只能按一个方向排序但实际需求经常是“先按状态排再按时间排”。这时候有两种做法一是把排序键拼成一个表达式二是用子查询先排好再聚合。先看拼排序键的做法SELECT CustomerId, STRING_AGG(OrderNo, ,) WITHIN GROUP ( ORDER BY StatusPriority ASC, CreateTime DESC ) AS OrderList FROM ( SELECT CustomerId, OrderNo, CASE Status WHEN 紧急 THEN 1 WHEN 正常 THEN 2 ELSE 3 END AS StatusPriority, CreateTime FROM #OrderDetail ) AS t GROUP BY CustomerId;这里把状态映射成数字优先级再和创建时间一起放进ORDER BY。WITHIN GROUP支持多列排序写法和普通ORDER BY一样。另一种是子查询预排序SELECT CustomerId, STRING_AGG(OrderNo, ,) AS OrderList FROM ( SELECT CustomerId, OrderNo FROM #OrderDetail ORDER BY CustomerId, OrderNo ) AS sorted GROUP BY CustomerId;但要注意这种写法在 SQL Server 里并不保证外层聚合时保持子查询的顺序。执行计划可能会重排数据所以更可靠的做法还是用WITHIN GROUP。子查询预排序只在某些特定执行计划下有效不能当作通用方案。3.2 去重聚合为什么 DISTINCT 不能直接塞进 STRING_AGG很多人第一反应是写STRING_AGG(DISTINCT OrderNo, ,)但在 SQL Server 2017 到 2019 的早期版本里STRING_AGG不支持DISTINCT关键字。直接写会报语法错误。SQL Server 2022 开始才支持STRING_AGG(DISTINCT ...)。在 2017 和 2019 上去重得绕一下SELECT CustomerId, STRING_AGG(OrderNo, ,) AS OrderList FROM ( SELECT DISTINCT CustomerId, OrderNo FROM #OrderDetail ) AS distinct_rows GROUP BY CustomerId;先用DISTINCT在子查询里去掉重复行再聚合。这个方案在数据量大时会有额外的排序开销但逻辑正确。如果去重键和聚合键不同比如按客户聚合但要按订单号去重子查询里SELECT DISTINCT CustomerId, OrderNo就够了。注意SQL Server 2022 的STRING_AGG(DISTINCT ...)仍然不支持WITHIN GROUP和DISTINCT同时使用两者只能选一个。需要同时去重和排序时还是得走子查询方案。3.3 超长截断与 NVARCHAR(MAX) 的边界STRING_AGG的返回类型取决于输入表达式。如果输入是VARCHAR(20)返回类型是VARCHAR(MAX)如果输入是NVARCHAR(20)返回NVARCHAR(MAX)。但有一个限制STRING_AGG的结果最大是 8000 字节VARCHAR或 4000 字符NVARCHAR超过会报错“结果长度超过限制”。这个限制在拼接大量长字符串时很容易触发。解决办法是先把输入转成MAX类型SELECT CustomerId, STRING_AGG(CAST(OrderNo AS NVARCHAR(MAX)), ,) AS OrderList FROM #OrderDetail GROUP BY CustomerId;把输入转成NVARCHAR(MAX)后返回类型也是NVARCHAR(MAX)上限变成 2GB基本够用。但要注意转成MAX类型后性能会下降因为MAX类型的数据不在行内存储会有额外的 LOB 读取开销。所以只在确实可能超长时才转不要无脑全转。FOR XML PATH方案没有这个 8000 字节的限制因为它返回的就是NVARCHAR(MAX)。所以在需要拼接超长文本且版本较老时FOR XML PATH反而有优势。4. 避坑与排查字符串聚合最常见的五个翻车现场4.1 现象结果里出现amp;lt;而不是原始字符原因用FOR XML PATH时没有加TYPE和.value()SQL Server 把 XML 实体转义直接输出了。解决子查询改成FOR XML PATH(), TYPE).value(., NVARCHAR(MAX))。如果已经加了TYPE但没调.value()结果会是 XML 类型在 SSMS 里显示正常但程序读取时可能报类型错误所以.value()不能省。4.2 现象STRING_AGG 报“参数数据类型 nvarchar 对于 string_agg 函数的参数 1 无效”原因输入表达式是NVARCHAR(MAX)或某些不支持的类型的组合。STRING_AGG对输入类型有要求不能直接接受MAX类型作为输入虽然可以接受MAX作为转换目标。解决把输入转成非MAX的字符串类型比如CAST(OrderNo AS NVARCHAR(4000))或者检查是否混用了TEXT、NTEXT这些已废弃类型。TEXT类型必须先转成VARCHAR(MAX)再转成VARCHAR(8000)才能用。4.3 现象分组内排序不生效结果顺序随机原因用了子查询预排序但外层没有WITHIN GROUP或者WITHIN GROUP的ORDER BY列在子查询里被去掉了。解决确保WITHIN GROUP (ORDER BY ...)里的列在SELECT列表或子查询输出中存在。如果排序键是计算列要在子查询里先算好并命名外层直接引用别名。4.4 现象STRING_AGG 结果被截断末尾字符丢失原因输入列定义太短或者中间结果超过了 8000 字节限制但没报错而是静默截断。静默截断通常发生在隐式转换时比如VARCHAR(8000)转VARCHAR(MAX)的过程中。解决显式CAST成NVARCHAR(MAX)并检查所有中间表达式的长度定义。用DATALENGTH()函数验证实际字节数SELECT CustomerId, DATALENGTH(STRING_AGG(CAST(OrderNo AS NVARCHAR(MAX)), ,)) AS 字节数 FROM #OrderDetail GROUP BY CustomerId;4.5 现象FOR XML PATH 在包含子查询时性能急剧下降原因FOR XML PATH的子查询对外层每一行都要执行一次如果外层分组多、子查询又没走索引就是典型的 N1 问题。解决在子查询的关联列上建索引比如CustomerId。如果数据量特别大考虑先用临时表把分组结果算好再对临时表做FOR XML PATH。另一种思路是改用STRING_AGG它的执行计划通常是流聚合不需要逐行子查询。5. 用窗口函数做分组内编号再配合聚合输出5.1 分组内排序编号的通用写法有时候需求不只是拼接还要在拼接结果里带上序号比如“1:A001, 2:A002, 3:A003”。这时候需要先用窗口函数在分组内编号再聚合。SELECT CustomerId, STRING_AGG( CAST(SeqNo AS VARCHAR(10)) : OrderNo, , ) WITHIN GROUP (ORDER BY SeqNo) AS NumberedList FROM ( SELECT CustomerId, OrderNo, ROW_NUMBER() OVER ( PARTITION BY CustomerId ORDER BY OrderNo ) AS SeqNo FROM #OrderDetail ) AS numbered GROUP BY CustomerId;ROW_NUMBER() OVER (PARTITION BY CustomerId ORDER BY OrderNo)在每个客户分组内按订单号生成 1、2、3 的序号。外层STRING_AGG把序号和订单号拼成1:A001的格式再用WITHIN GROUP (ORDER BY SeqNo)保证拼接顺序和编号一致。这个模式在生成“组内排名 明细”类报表时特别有用。比如电商场景里按用户列出最近浏览的商品带浏览顺序或者工单系统里按工单列出处理步骤带步骤序号。5.2 验证聚合结果是否正确的三个检查点写完聚合查询不要直接交付至少做三个验证。第一检查分组数是否和预期一致SELECT COUNT(DISTINCT CustomerId) AS 预期分组数 FROM #OrderDetail;第二检查拼接后的元素个数是否等于组内行数SELECT CustomerId, LEN(STRING_AGG(OrderNo, ,)) - LEN(REPLACE(STRING_AGG(OrderNo, ,), ,, )) 1 AS 元素个数, COUNT(*) AS 实际行数 FROM #OrderDetail GROUP BY CustomerId;LEN(聚合结果) - LEN(替换掉逗号后的结果) 1就是分隔符数量加一也就是元素个数。这个值和COUNT(*)必须相等不等就说明有NULL被跳过了或者有重复。第三检查特殊字符是否被正确处理。往测试数据里插一条带和的记录看输出是否原样保留。这一步在FOR XML PATH方案里尤其重要。5.3 我自己的习惯先写测试数据再写聚合这些年做报表开发我养成了一个习惯不管多简单的聚合先在临时表里造三五条边界数据——包含NULL、包含特殊字符、包含重复值、包含超长字符串——然后再写聚合语句。这样能在开发阶段就把转义、去重、截断这些问题暴露出来而不是等上线后用户反馈“导出的 Excel 里怎么有乱码”。STRING_AGG和FOR XML PATH都不是什么复杂技术但细节多版本差异大。把版本确认、转义处理、排序控制、长度检查这四步做成固定流程基本就不会翻车了。希望帮到你。本文还有配套的精品资源点击获取