1. 为什么我要自己写一个字符串分割函数
但凡做过几年数据开发的人,大概率都遇到过这种场景:前端传过来一串用逗号拼起来的ID,比如"1001,1002,1003",需要在数据库里把它拆成多行做关联查询;或者从上游系统同步过来一批标签数据,用竖线分隔,要拆开做统计分析。这时候你打开数据库一看,SQL Server 有STRING_SPLIT,但那玩意儿在早期版本里根本不支持输出序号,而且对中文逗号、多字符分隔符的处理也不够灵活;MySQL 倒是有SUBSTRING_INDEX,可它只能取第N个,想拆成结果集还得配合数字辅助表,写起来又臭又长。
我在实际项目里被这个问题反复折磨过好几次。最典型的一次是做一个报表系统,业务方要求支持多选筛选,前端把选中的部门编码用逗号拼成一个字符串丢过来,后端直接把这个字符串塞进IN条件里。结果就是WHERE dept_code IN ('1001,1002,1003')这种写法,数据库把它当成一个完整的字符串去匹配,永远查不出数据。当时年轻,第一反应是在应用层拆好再拼SQL,但这样又带来了SQL注入的风险和性能问题。后来痛定思痛,决定在数据库层面自己实现一套通用的字符串分割函数,SQL Server 和 MySQL 各写一份,彻底解决这类问题。
这篇文章就是把我这些年写过的、改过的、踩过坑的分割函数实现整理出来。不管你是刚入行的数据库新手,还是做了多年CRUD想补补底层功底的老手,都能从里面找到可以直接抄作业的代码。我会把每个实现的设计思路、参数选择、性能考量、以及实际使用中遇到的坑都讲清楚,让你不仅拿到代码,还知道为什么这么写。
2. 字符串分割的核心设计思路拆解
2.1 到底该用函数还是存储过程
这是第一个要做的决策。函数和存储过程在数据库里是两种不同的存在,选错了后面会很难受。
标量函数(Scalar Function)返回单个值,表值函数(Table-Valued Function)返回一张表。字符串分割的本质是"一行变多行",所以表值函数是天然的选择。存储过程虽然也能返回结果集,但它不能用在SELECT ... FROM里面,也没法跟其他表做 JOIN,灵活性差了一大截。我早期图省事写过存储过程版本,结果业务方要求"把分割结果跟订单表关联一下",当场就傻眼了,只能推倒重来。
所以结论很明确:优先用表值函数。SQL Server 里叫Table-Valued Function,MySQL 里没有真正的表值函数,但可以用存储过程配合临时表来模拟,或者干脆用递归CTE(公用表表达式)来实现。下面我会分别讲。
2.2 分隔符的处理策略
分隔符这东西看着简单,实际坑很多。单字符分隔符(比如逗号、竖线)最好处理,多字符分隔符(比如"||"、"::")就麻烦一些。还有一个容易被忽略的点:连续分隔符。比如"1001,,1003",中间有两个逗号,你是当成一个空字符串处理,还是直接跳过?这个取决于业务需求,但函数最好提供一个开关参数来控制。
我的做法是默认跳过空字符串,因为实际业务里连续分隔符往往意味着数据质量问题,返回空行反而会干扰后续查询。但如果业务明确需要保留空位(比如解析CSV时列位置有意义),那就得提供选项。
2.3 序号输出的必要性
很多人写分割函数只返回拆分后的值,不返回序号。这在简单场景下够用,但一旦涉及到"按原始顺序排列"或者"取第N个元素",没有序号就抓瞎了。比如前端传过来"张三,李四,王五",你想知道"李四"是第几个,没有序号列就只能靠字符串查找,效率极低。
所以我的函数设计里,序号列是标配。SQL Server 的STRING_SPLIT在2016版本刚出来时就没有序号,直到2022版本才加了ordinal参数,这就是血的教训。自己写函数,一定要把序号带上。
2.4 性能与内存的权衡
字符串分割本质上是一个循环或者递归的过程。循环次数等于分隔符出现的次数加一。对于短字符串(几百个字符),怎么实现都无所谓;但如果字符串长度上万,分隔符上千个,性能差异就出来了。
SQL Server 里常见的实现方式有三种:WHILE循环配合CHARINDEX、递归CTE、以及利用XML或JSON解析。WHILE循环最直观但性能一般;递归CTE写起来优雅,但默认递归深度限制是100,超过就会报错,需要加OPTION (MAXRECURSION 0);XML 方式性能最好,但代码可读性差,而且对特殊字符需要转义。
MySQL 8.0 之前没有递归CTE,只能用数字辅助表或者存储过程循环。8.0 之后可以用递归CTE,写法跟 SQL Server 类似。我下面会给出两种数据库各自最稳妥的实现。
3. SQL Server 表值函数完整实现
3.1 基础版本:WHILE循环实现
先看最直观的版本,适合理解原理,也适合在低版本 SQL Server 上使用。
CREATE FUNCTION dbo.fn_SplitString ( @InputString NVARCHAR(MAX), @Delimiter NVARCHAR(10) ) RETURNS @Result TABLE ( Id INT IDENTITY(1,1), Value NVARCHAR(MAX) ) AS BEGIN IF @InputString IS NULL OR LEN(@InputString) = 0 RETURN; DECLARE @StartIndex INT = 1; DECLARE @EndIndex INT; DECLARE @Item NVARCHAR(MAX); SET @InputString = @InputString + @Delimiter; WHILE CHARINDEX(@Delimiter, @InputString, @StartIndex) > 0 BEGIN SET @EndIndex = CHARINDEX(@Delimiter, @InputString, @StartIndex); SET @Item = SUBSTRING(@InputString, @StartIndex, @EndIndex - @StartIndex); IF LEN(@Item) > 0 INSERT INTO @Result (Value) VALUES (@Item); SET @StartIndex = @EndIndex + LEN(@Delimiter); END RETURN; END这个版本有几个关键点值得说。第一,我在输入字符串末尾追加了一个分隔符,这样循环条件统一,不用在循环外单独处理最后一段。第二,@Delimiter的长度用LEN而不是固定1,这样支持多字符分隔符。第三,IF LEN(@Item) > 0这行就是控制是否跳过空字符串的开关,如果你需要保留空位,把这行去掉即可。
调用方式:
SELECT * FROM dbo.fn_SplitString('1001,1002,1003', ',');结果会返回三行,Id 分别是1、2、3,Value 分别是1001、1002、1003。
3.2 进阶版本:支持序号和空值保留
基础版本够用,但不够灵活。实际项目里我更需要一个带选项参数的版本。
CREATE FUNCTION dbo.fn_SplitStringEx ( @InputString NVARCHAR(MAX), @Delimiter NVARCHAR(10), @KeepEmpty BIT = 0 ) RETURNS @Result TABLE ( Ordinal INT, Value NVARCHAR(MAX) ) AS BEGIN IF @InputString IS NULL OR LEN(@InputString) = 0 RETURN; DECLARE @StartIndex INT = 1; DECLARE @EndIndex INT; DECLARE @Item NVARCHAR(MAX); DECLARE @Ordinal INT = 0; SET @InputString = @InputString + @Delimiter; WHILE CHARINDEX(@Delimiter, @InputString, @StartIndex) > 0 BEGIN SET @EndIndex = CHARINDEX(@Delimiter, @InputString, @StartIndex); SET @Item = SUBSTRING(@InputString, @StartIndex, @EndIndex - @StartIndex); SET @Ordinal = @Ordinal + 1; IF @KeepEmpty = 1 OR LEN(@Item) > 0 INSERT INTO @Result (Ordinal, Value) VALUES (@Ordinal, @Item); SET @StartIndex = @EndIndex + LEN(@Delimiter); END RETURN; END这个版本把序号显式地作为一列返回,而且序号是在插入前就递增的,所以即使跳过了空字符串,序号也不会断。这一点很重要,因为有时候你需要知道某个值在原始字符串里的位置。
3.3 高性能版本:XML解析法
当字符串很长、分割次数很多时,WHILE循环的性能会成为瓶颈。这时候可以用 XML 方式,把字符串转成 XML 节点列表,一次性解析。
CREATE FUNCTION dbo.fn_SplitStringXml ( @InputString NVARCHAR(MAX), @Delimiter NVARCHAR(10) ) RETURNS TABLE AS RETURN ( SELECT ROW_NUMBER() OVER (ORDER BY (SELECT NULL)) AS Ordinal, LTRIM(RTRIM(T.c.value('.', 'NVARCHAR(MAX)'))) AS Value FROM ( SELECT CAST('<x>' + REPLACE( REPLACE( REPLACE(@InputString, '&', '&'), '<', '<'), @Delimiter, '</x><x>') + '</x>' AS XML) AS XmlData ) AS T CROSS APPLY XmlData.nodes('/x') AS T2(c) WHERE LTRIM(RTRIM(T.c.value('.', 'NVARCHAR(MAX)'))) <> '' );这个版本是内联表值函数(Inline TVF),性能比多语句表值函数好很多,因为查询优化器可以把它展开优化。注意里面的REPLACE嵌套,是为了处理 XML 特殊字符。&必须最先替换,否则会把后面替换产生的<里的&又转义一遍,导致结果错误。这个顺序坑了我半个小时。
注意:XML 方式对分隔符里包含 XML 特殊字符的情况支持不好,如果你的分隔符是
"<"或者"&",建议还是用 WHILE 版本。
3.4 三种实现方式的对比
| 实现方式 | 适用场景 | 优点 | 缺点 |
|---|---|---|---|
| WHILE循环 | 通用场景、低版本 | 逻辑直观、易调试 | 长字符串性能差 |
| 带选项WHILE | 需要控制空值 | 灵活、序号可控 | 同上 |
| XML解析 | 高性能要求 | 速度快、内联可优化 | 特殊字符需转义 |
我个人的选择是:日常用带选项的 WHILE 版本,数据量大的ETL场景用 XML 版本。两者可以共存,按需调用。
4. MySQL 存储过程与递归CTE实现
4.1 MySQL 8.0 递归CTE方案
MySQL 8.0 引入了递归CTE,这让字符串分割变得优雅很多。核心思路是:每次递归找到第一个分隔符,切出前面的部分,然后把剩下的部分传给下一轮递归。
WITH RECURSIVE split_cte AS ( SELECT 1 AS ordinal, SUBSTRING_INDEX('1001,1002,1003', ',', 1) AS value, SUBSTRING('1001,1002,1003', LENGTH(SUBSTRING_INDEX('1001,1002,1003', ',', 1)) + 2) AS remainder UNION ALL SELECT ordinal + 1, SUBSTRING_INDEX(remainder, ',', 1), IF(LOCATE(',', remainder) > 0, SUBSTRING(remainder, LENGTH(SUBSTRING_INDEX(remainder, ',', 1)) + 2), '') FROM split_cte WHERE remainder <> '' ) SELECT ordinal, value FROM split_cte;这里有几个细节。SUBSTRING_INDEX(str, ',', 1)取第一个分隔符之前的部分。LENGTH(...) + 2里的+2是因为分隔符本身占一个字符,再加1跳过它。如果分隔符是多字符,这个偏移量要相应调整。remainder <> ''是递归终止条件。
这个写法可以直接用在查询里,不需要创建存储过程。但如果你要在多个地方复用,还是封装成存储过程更好。
4.2 MySQL 存储过程版本
MySQL 的存储过程可以返回结果集,虽然不能像表值函数那样用在 FROM 子句里,但配合临时表也能达到类似效果。
DELIMITER $$ CREATE PROCEDURE sp_SplitString( IN p_input VARCHAR(65535), IN p_delimiter VARCHAR(10) ) BEGIN DECLARE v_start INT DEFAULT 1; DECLARE v_end INT; DECLARE v_item VARCHAR(1000); DECLARE v_ordinal INT DEFAULT 0; DROP TEMPORARY TABLE IF EXISTS tmp_split_result; CREATE TEMPORARY TABLE tmp_split_result ( ordinal INT, value VARCHAR(1000) ); IF p_input IS NULL OR LENGTH(p_input) = 0 THEN SELECT * FROM tmp_split_result; ELSE SET p_input = CONCAT(p_input, p_delimiter); WHILE LOCATE(p_delimiter, p_input, v_start) > 0 DO SET v_end = LOCATE(p_delimiter, p_input, v_start); SET v_item = SUBSTRING(p_input, v_start, v_end - v_start); SET v_ordinal = v_ordinal + 1; IF LENGTH(v_item) > 0 THEN INSERT INTO tmp_split_result VALUES (v_ordinal, v_item); END IF; SET v_start = v_end + LENGTH(p_delimiter); END WHILE; SELECT * FROM tmp_split_result; END IF; END$$ DELIMITER ;调用方式:
CALL sp_SplitString('1001,1002,1003', ',');这个存储过程每次调用都会重建临时表,所以不用担心数据残留。但要注意,临时表是会话级别的,同一个会话里多次调用会互相覆盖,如果嵌套调用会有问题。实际使用中我建议把结果先落到普通表或者用递归CTE替代。
4.3 MySQL 数字辅助表方案
在 MySQL 8.0 之前,没有递归CTE,最常用的方案是数字辅助表。思路是预先准备一张包含连续整数的表,然后用SUBSTRING_INDEX配合序号来切分。
-- 创建数字辅助表 CREATE TABLE numbers (n INT PRIMARY KEY); INSERT INTO numbers (n) SELECT a.N + b.N * 10 + c.N * 100 + 1 FROM (SELECT 0 AS N UNION SELECT 1 UNION SELECT 2 UNION SELECT 3 UNION SELECT 4 UNION SELECT 5 UNION SELECT 6 UNION SELECT 7 UNION SELECT 8 UNION SELECT 9) a CROSS JOIN (SELECT 0 AS N UNION SELECT 1 UNION SELECT 2 UNION SELECT 3 UNION SELECT 4 UNION SELECT 5 UNION SELECT 6 UNION SELECT 7 UNION SELECT 8 UNION SELECT 9) b CROSS JOIN (SELECT 0 AS N UNION SELECT 1 UNION SELECT 2 UNION SELECT 3 UNION SELECT 4 UNION SELECT 5 UNION SELECT 6 UNION SELECT 7 UNION SELECT 8 UNION SELECT 9) c ORDER BY 1;这张表包含1到1000的连续整数,足够大多数场景使用。然后分割查询这样写:
SELECT n AS ordinal, SUBSTRING_INDEX(SUBSTRING_INDEX('1001,1002,1003', ',', n), ',', -1) AS value FROM numbers WHERE n <= LENGTH('1001,1002,1003') - LENGTH(REPLACE('1001,1002,1003', ',', '')) + 1;这个写法的精髓在于SUBSTRING_INDEX嵌套。内层取前N段,外层取最后一段,就得到了第N个元素。WHERE条件计算分隔符数量加一,就是总段数。这个方案性能很好,因为不需要循环,纯集合操作。
提示:数字辅助表的行数要大于最大可能的分割段数。如果字符串可能包含上千个分隔符,辅助表就要建到上千行。我一般建到10000行,用交叉连接很容易生成。
4.4 两种数据库实现的差异对照
| 对比项 | SQL Server | MySQL |
|---|---|---|
| 函数类型 | 表值函数,可用于FROM | 存储过程或CTE |
| 递归支持 | 递归CTE,默认深度100 | 8.0+支持递归CTE |
| 分隔符处理 | CHARINDEX定位 | LOCATE定位 |
| 序号输出 | IDENTITY或ROW_NUMBER | 变量递增或ROW_NUMBER |
| 性能优化 | XML解析、内联TVF | 数字辅助表 |
5. 实操过程中的关键细节与避坑经验
5.1 分隔符长度导致的偏移量错误
这是最容易踩的坑。很多人在计算下一个起始位置时,习惯性地写@EndIndex + 1,默认分隔符只有一个字符。但如果分隔符是"||",加1就错了,会漏掉一个字符。正确的做法是@EndIndex + LEN(@Delimiter)。我在一个项目里用"::"做分隔符,就是因为这个加1的问题,导致所有偶数位置的数据都少了一个字符,排查了半天才发现。
5.2 空字符串与NULL的区别
LEN('')返回0,LEN(NULL)返回NULL。在IF判断里,NULL会导致条件为假,所以如果输入是NULL,函数会直接返回空结果集,这是符合预期的。但如果你在函数内部对NULL做了拼接操作,比如SET @InputString = @InputString + @Delimiter,结果还是NULL,循环条件CHARINDEX返回NULL,循环体不会执行,最终返回空结果集。这个行为是安全的,但最好在函数开头显式判断NULL并返回。
5.3 递归CTE的深度限制
SQL Server 的递归CTE默认最大递归深度是100。如果你的字符串有超过100个分隔符,就会报错The statement terminated. The maximum recursion 100 has been exhausted。解决办法是在查询末尾加OPTION (MAXRECURSION 0),0表示不限制。但要注意,不限制递归深度有栈溢出的风险,对于超长字符串,还是用WHILE循环或者数字辅助表更稳妥。
5.4 临时表在存储过程中的并发问题
MySQL 存储过程里用临时表存分割结果,在同一个会话里多次调用会互相干扰。比如你在一个存储过程里调用了分割过程,然后又调用另一个也用了同名临时表的过程,数据就乱了。我的做法是临时表名加上随机后缀,或者干脆用递归CTE替代存储过程。如果非要用临时表,记得在过程开头DROP TEMPORARY TABLE IF EXISTS,过程结束时不删,让会话结束时自动清理。
5.5 性能实测数据参考
我在一台测试机上做过对比,输入字符串包含1000个逗号分隔的数字,SQL Server 三种实现方式的耗时大致如下:
| 实现方式 | 耗时(毫秒) | 备注 |
|---|---|---|
| WHILE循环 | 约120 | 稳定但偏慢 |
| XML解析 | 约15 | 最快 |
| 递归CTE | 约80 | 受递归深度影响 |
MySQL 方面,数字辅助表方案约20毫秒,存储过程循环约150毫秒,递归CTE约60毫秒。数据仅供参考,实际性能跟硬件和字符串特征有关,但趋势是一致的:集合操作优于循环,内联优于多语句。
6. 常见问题排查与速查表
6.1 分割结果少了最后一段
这是最经典的问题。原因通常是循环终止条件写成了CHARINDEX(@Delimiter, @InputString, @StartIndex) > 0,但没有在输入字符串末尾补分隔符。最后一段后面没有分隔符,CHARINDEX返回0,循环退出,最后一段就丢了。解决办法就是在循环前执行SET @InputString = @InputString + @Delimiter。
6.2 中文分隔符乱码
如果分隔符是中文逗号",",在NVARCHAR类型下没问题,但如果输入字符串是VARCHAR类型,而数据库排序规则不是中文,就可能出现乱码。建议函数参数统一用NVARCHAR,调用时字符串前加N前缀,比如N'1001,1002'。
6.3 分割后需要去重
有时候业务要求分割后去重。这个不要在函数里做,因为函数应该保持单一职责。在调用函数后加DISTINCT或者GROUP BY即可。比如:
SELECT DISTINCT Value FROM dbo.fn_SplitString('a,b,a,c', ',');6.4 常见问题速查表
| 问题现象 | 可能原因 | 解决方法 |
|---|---|---|
| 结果少最后一段 | 未补末尾分隔符 | 循环前拼接分隔符 |
| 多字符分隔符切错 | 偏移量加1而非加长度 | 用LEN(@Delimiter) |
| 递归报错深度超限 | 默认100层限制 | 加OPTION (MAXRECURSION 0) |
| 中文乱码 | 字符集不匹配 | 统一用NVARCHAR/N前缀 |
| 空字符串干扰 | 未过滤空值 | 加LEN判断或KeepEmpty参数 |
| 临时表数据串 | 会话内多次调用 | 加随机后缀或改用CTE |
6.5 一个容易被忽略的边界情况
如果输入字符串本身就是分隔符,比如",",按照我的实现,会返回一个空结果集(因为跳过了空字符串)。但有些业务期望返回两个空字符串。这个没有对错,取决于约定。我的建议是在函数文档里写清楚行为,让调用方知道。如果确实需要保留,把KeepEmpty参数设为1即可。
7. 实际项目中的应用场景扩展
7.1 多选筛选条件的动态SQL
回到开头提到的报表筛选场景。有了分割函数,可以这样写:
SELECT o.* FROM orders o INNER JOIN dbo.fn_SplitString(@DeptCodes, ',') d ON o.dept_code = d.Value;这样就把一个字符串参数变成了一个可以JOIN的结果集,既避免了SQL注入,又利用了索引。比在应用层拼SQL优雅得多。
7.2 标签数据的行转列与列转行
用户标签通常存成"tag1|tag2|tag3"的形式。用分割函数可以轻松拆成多行,然后跟标签维度表关联,做标签统计。反过来,用STRING_AGG(SQL Server)或GROUP_CONCAT(MySQL)可以把多行合并回字符串。这两个函数配合使用,行转列和列转行就都齐了。
7.3 批量导入时的数据清洗
从外部系统导入数据时,经常遇到一个字段里塞了多个值的情况。比如地址字段"省,市,区"。用分割函数拆开后,可以分别校验省市区是否合法,把不合法的数据挑出来。这比在应用层用代码拆效率高得多,尤其是数据量大的时候。
7.4 与JSON函数的配合
现在很多数据库都支持JSON类型。如果上游传过来的是JSON数组,比如["1001","1002"],其实不需要分割函数,直接用JSON解析函数就行。但如果传过来的是"1001,1002"这种字符串,还是得用分割函数。我一般会在ETL层做个判断,能JSON就JSON,不能就分割。
8. 我个人的一些使用体会
写了这么多年分割函数,最大的体会是:不要过度设计。我见过有人写了一个支持正则表达式分隔符、支持转义字符、支持嵌套引号的分割函数,代码上千行,结果项目里从来没用过那些高级功能,反而因为逻辑复杂出了好几个bug。对于90%的场景,一个支持多字符分隔符、能跳过空值、带序号的简单函数就足够了。
另一个体会是,函数命名要规范。我习惯用fn_前缀表示函数,sp_前缀表示存储过程。虽然 SQL Server 里sp_前缀是系统存储过程保留的,自定义的用sp_会有轻微性能损失,但团队里统一了命名规范,维护起来方便很多。如果在意性能,可以用usp_或者proc_前缀。
最后说一个实际踩过的坑。有一次我在一个高并发的系统里用了多语句表值函数做分割,结果发现CPU占用很高。排查后发现是表值函数里的INSERT INTO @Result操作在每次调用时都会分配表变量,数据量大时内存压力很大。后来改成内联表值函数(XML版本),问题就解决了。所以如果你的分割函数会被频繁调用,优先考虑内联表值函数,而不是多语句的。
对于MySQL,我现在的首选是递归CTE,代码短、可读性好、性能也不错。只有在MySQL 5.7及以下版本才用数字辅助表。存储过程版本我基本已经弃用了,因为临时表的并发问题太烦人,而且调用方式不如CTE灵活。
如果你正在做数据仓库或者ETL开发,建议把分割函数作为基础工具库的一部分,提前建好。等到用的时候直接调用,比临时写要省事得多。而且这种函数一旦写好,基本不用改,属于一次投入长期受益的典型。