1. 内容整体设计与思路拆解
1.1 字符串函数为什么值得单列一章
写 T-SQL 这些年,我越来越觉得字符串处理才是实际项目里最耗精力的部分。表结构设计、索引调优、事务控制都有一套相对固定的方法论,但字符串处理几乎是每天都会碰到新情况的领域:外部接口给过来的字段带一堆不可见字符,客户导入的 Excel 里姓名前后夹着空格,日志表里存了整段 JSON 需要截取某一层信息,报表开发要给手机号做脱敏……每个场景都能用到 SQL Server 的字符串函数。
很多人对字符串函数的理解停留在“会用几个函数名”的程度。但真正系统梳理一遍之后,你会发现函数之间的边界、版本差异、排序规则影响、NULL 传播机制、索引利用情况,每一项单独拿出来都值得深挖。这也是我把第 41 章单独留给字符串函数的原因——它不只是一个语法速查,更是把平时踩过的坑、需要补全的“为什么”一起写清楚。
这一章适合刚起步写存储过程的数据开发,适合在报表里经常对文本字段做加工的 SQL 使用者,也适合做数据迁移和清洗的 ETL 开发者。如果只想知道“用哪个函数能实现什么效果”,直接跳到第 2 节看核心函数拆解;如果想看一套完整的数据处理方案怎么落地,第 3 节的实操场景更有参考价值。
1.2 四大类函数划分与选型思路
我习惯把 SQL Server 的字符串函数分成四类:裁剪类、定位提取类、替换拼接类、转换格式化类。
裁剪类解决的是“字符串两边有杂质”的问题,典型成员是 LTRIM、RTRIM、TRIM,SQL Server 2017 和 2022 又给这组函数加了更强的能力。定位提取类解决的是“我想拿到字符串里的某一段”的问题,代表成员是 CHARINDEX、PATINDEX、LEFT、RIGHT、SUBSTRING。替换拼接类解决的是“多个字符串怎么合并、怎么替换局部内容”的问题,代表成员是 REPLACE、STUFF、CONCAT、CONCAT_WS、STRING_AGG。转换格式化类解决的是“类型转换、大小写转换、格式化输出”的问题,代表成员是 UPPER、LOWER、FORMAT、REPLICATE、REVERSE。
这样划分不是为了分类而分类,而是为了选型的时候更高效。遇到需求先判断它属于哪一类,再从这一类里挑最合适的函数。比如“把名字里的 NULL 变成空字符串”,这看似是转换问题,实际要处理的是 NULL 在拼接时的传播,属于拼接类的边角场景。后面讲 CONCAT 的时候会重点展开,因为这里藏着大量初级开发者最容易犯的错误。
2. 核心细节解析与实操要点
2.1 先搞清楚两个长度:LEN 与 DATALENGTH
LEN 返回的是字符串的字符数,而且会自动忽略末尾的空格。DATALENGTH 返回的是字节数,不会忽略任何空格。这两个函数看似只是口径不同,在实际项目里却经常出问题。
SELECT LEN('SQL Server') AS len_a, -- 10 LEN('SQL Server ') AS len_b, -- 10,尾随空格被忽略 DATALENGTH('SQL Server') AS dlen_varchar, -- 10 DATALENGTH(N'SQL Server') AS dlen_unicode; -- 20关键差异在第一那行和第三行的对比:同一个字符串,LEN 返回 10,DATALENGTH 返回 10,因为在 VARCHAR 类型下每个英文字符只占 1 个字节。如果声明为 NVARCHAR(加 N 前缀),DATALENGTH 就是 20,因为每个字符占 2 个字节。对中文来说更明显:
SELECT LEN(N'数据库') AS len_cn, -- 3 DATALENGTH(N'数据库') AS dlen_cn; -- 6我遇到过的典型场景是:某客户导出的数据文件里,姓名列总是带着几个尾随空格,项目组想按长度判断是否符合长度限制。如果直接用 LEN,尾随空格被忽略,校验结果完全不准;后来改成先 RTRIM 再 LEN,或者用 DATALENGTH 减去空格字节数,才算拿到了真实长度。这里要提醒一句:业务上要判断“实际可见长度”时用 LEN,要判断“存储占用空间”时用 DATALENGTH,两者不能混用。
2.2 定位函数:CHARINDEX 与 PATINDEX 的区别
CHARINDEX 用来找子字符串在目标字符串中的起始位置,PATINDEX 用来按模式找位置。两者返回的都是位置索引,SQL Server 的字符串索引从 1 开始,找不到时返回 0。
SELECT CHARINDEX('@', 'user@example.com') AS at_pos, -- 5 CHARINDEX('@', 'user@example.com', 6) AS at_pos2, -- 0,从第6位开始找不到 PATINDEX('%[0-9]%', 'abc123') AS num_pos; -- 4,第一个数字位置CHARINDEX 的第三个参数是搜索起点,这在循环处理字符串时非常有用。PATINDEX 支持的通配符和 LIKE 一致,% 表示任意长度,_ 表示单个字符,[ ] 表示字符集合。实际使用里,PATINDEX 最常见的需求是“判断文本里有没有数字”“找到第一个非法字符的位置”。
定位函数很少单独使用,多数时候是和 SUBSTRING、LEFT、RIGHT 配合,从一段文本里切出目标片段。比如解析邮箱地址,可以用 CHARINDEX 先找到 @ 的位置,再用 LEFT 和 SUBSTRING 分别取用户名和域名:
DECLARE @email NVARCHAR(100) = N'first.last@company.com'; SELECT LEFT(@email, CHARINDEX('@', @email) - 1) AS user_part, SUBSTRING(@email, CHARINDEX('@', @email) + 1, LEN(@email)) AS domain_part;这里要特别小心:CHARINDEX 返回 0 表示找不到,如果直接用 0 去减 1,会得到 -1,然后传给 LEFT 和 SUBSTRING 就会得到错误结果。我在没做容错处理的旧代码里见过很多次这种问题,轻则返回空值,重则直接抛异常。稳妥写法是先判断 CHARINDEX 大于 0,再执行提取逻辑。
2.3 按位裁剪三兄弟:LEFT、RIGHT、SUBSTRING
LEFT 从左侧取指定字符数,RIGHT 从右侧取,SUBSTRING 从中间任意位置开始取。它们的参数都不复杂,难在实际边界条件的处理。
SELECT LEFT('SQL Server 2022', 3) AS left_3, -- SQL RIGHT('SQL Server 2022', 4) AS right_4, -- 2022 SUBSTRING('SQL Server 2022', 5, 6) AS mid_6; -- ServerSUBSTRING 的参数结构是 SUBSTRING(expression, start, length),start 是 1 基索引,length 是要返回的字符数。几个边界情况我在实际项目中确认过:如果 start 超过字符串长度,返回的是空字符串而不是报错;如果 start 小于 1,会从第一个字符开始计算;如果 length 超过剩余长度,就返回到字符串末尾为止。SQL Server 在这块的处理比较宽松,但宽松不等于安全,你在嵌套使用这些函数时必须自己保证参数有意义。
还有一个实用小技巧:想反转一段文本,可以直接用 REVERSE。虽然 REVERSE 通常用于调试场景,但偶尔也能派上用场。比如要找路径里最后一个反斜杠后面的文件名,可以先 REVERSE,用 CHARINDEX 找第一个反斜杠,再 REVERSE 回来,比循环定位高效得多:
DECLARE @file_path NVARCHAR(255) = N'D:\data\backup\sales_2024.bak'; SELECT REVERSE(LEFT(REVERSE(@file_path), CHARINDEX('\', REVERSE(@file_path)) - 1)) AS file_name;这段代码先反转到“kab_4202_sales...”,找到第一个反斜杠的位置,LEFT 取它左边的部分,再 REVERSE 回来,得到的就是“sales_2024.bak”。这个写法在分区备份文件解析、日志文件路径清洗里非常实用。
2.4 替换与插入:REPLACE 和 STUFF 怎么选
REPLACE 把目标字符串里所有匹配的子串替换成新内容,STUFF 是在指定位置删除指定长度的字符,再插入新字符串。两者功能有交叉,但适用场景完全不同。
SELECT REPLACE('2024-01-15', '-', '/') AS replaced, -- 2024/01/15 STUFF('2024-01-15', 5, 2, '02') AS stuffed; -- 2024-02-15REPLACE 适合全局替换,比如把日期里的横杠换成斜杠,或者把文本里的全角逗号换成半角。STUFF 更适合精确位置的替换,最典型的场景是脱敏。格式化手机号中间四位,一行代码就能搞定:
SELECT STUFF('13812345678', 4, 4, '****') AS masked_phone; -- 138****5678这段逻辑很好理解:从第 4 位开始,删除 4 个字符,再插入 4 个星号。我做身份证号脱敏也常用类似写法,只是删除的长度和插入的内容要按规则调整。STUFF 和 REPLACE 相比,好处是不会误伤其他位置的相同内容,脱敏场景下这一点很关键。
REPLACE 还有一点容易被忽略:匹配是否区分大小写,取决于当前数据库的排序规则。默认的排序规则通常不区分大小写,所以 REPLACE('SQL Server', 'server', 'DB') 也会把 Server 替换掉。如果你需要严格区分大小写,可以给表达式加 COLLATE 指定一个区分大小写的排序规则,比如 Latin1_General_CS_AS:
SELECT REPLACE('SQL Server' COLLATE Latin1_General_CS_AS, 'server', 'DB');这一条在大小写混合的代码类文本处理里尤其重要,不写 COLLATE 的话结果可能完全不是你想要的。
2.5 TRIM 家族:老版本到新版本的进化
LTRIM 和 RTRIM 是几十年的老函数,分别去掉左边和右边的空格。SQL Server 2017 引入了 TRIM,默认同时去掉两边空格,而且可以指定要去掉的字符;SQL Server 2022 进一步允许传入一串字符集合。
SELECT LTRIM(' abc ') AS ltrim_result, -- 'abc ' RTRIM(' abc ') AS rtrim_result, -- ' abc' TRIM(' abc ') AS trim_result, -- 'abc' TRIM(',' FROM ',abc,') AS trim_char; -- 'abc'TRIM(',' FROM ',abc,') 这种写法在清洗接口数据时太有用了。我经常从文件系统或者第三方接口拿到带引号、带逗号、带换行符的字段,以前要先 REPLACE 去掉首尾的特殊字符,现在直接用 TRIM 指定字符集合,一步到位:
SELECT TRIM(',。 ' FROM N',Hello,World。 ') AS cleaned; -- Hello,World2022 版本里,LTRIM 和 RTRIM 也可以指定要裁剪的字符集了。但实际项目里更新的速度没那么快,很多生产环境还停留在 2016 或 2019,所以我写脚本时会优先用兼容性更强的方式,比如 REPLACE + LTRIM/RTRIM 组合,或者用 PATINDEX 判断首尾字符,而不是一上来就依赖新函数。如果你确定生产环境已经是 2022,再用 TRIM 的字符集参数,代码会简洁不少。
2.6 拼接函数:CONCAT 家族与 STRING_AGG
字符串拼接是新手最容易踩 NULL 坑的地方。用 + 号拼接时,只要其中一个是 NULL,整个结果就是 NULL。用 CONCAT 函数拼接时,NULL 会被当成空字符串处理。
SELECT 'a' + NULL + 'b' AS plus_result, -- NULL CONCAT('a', NULL, 'b') AS concat_result; -- 'ab'这在拼接姓名时尤其明显。比如要拼“姓 + 名 + 中间名”,中间名可能为空,用 + 号直接拼接会导致整列变 NULL。我第一次遇到这个问题时排查了很久,后来凡是涉及可空字段的拼接,统一改用 CONCAT,问题彻底消失。CONCAT 还有两个特点:最多接受 254 个参数,参数中的数值、日期类型会被隐式转换成字符串,省去了手动 CAST 的麻烦。
CONCAT_WS 是带分隔符的拼接,SQL Server 2017 引入。用 STRING_AGG 可以把组内多行的字符串聚合成一个字段:
SELECT department_id, STRING_AGG(employee_name, N'、') WITHIN GROUP (ORDER BY employee_id) AS name_list FROM dbo.employee GROUP BY department_id;STRING_AGG 必须在 GROUP BY 下使用,配合 WITHIN GROUP (ORDER BY ...) 可以控制拼接顺序。这个函数在生成汇总标签、做物料清单汇总、报表展示维度名称时非常好用。但它也有两个限制:一个是返回类型是 NVARCHAR(MAX) 或 VARCHAR(MAX),超大聚合时要注意内存压力;另一个是在 SQL Server 2016 及之前版本不可用,老库得用 FOR XML PATH 做替代方案。
3. 实操过程与核心环节实现
3.1 场景一:集成接口数据清洗
某次做外部系统数据迁移,源系统的客户信息表里,地址字段质量非常差:前后有空格、中间有多个连续空格、手机号格式是“138 1234 5678”、部分记录混入全角数字。我把清洗过程拆成三步来做。
第一步,先观察脏数据的长相,用 CHARINDEX 和 SUBSTRING 把疑似有问题的字段抽样输出,确认脏数据的模式,而不是上来就写大段 REPLACE。第二步,逐项清洗:用 TRIM 去掉首尾空格,用 REPLACE 把全角空格替换成普通空格,再多次 REPLACE 把连续多个空格压成一个空格。第三步,统一格式:手机号里的空格和横杠全部去掉,英文姓名做规范化处理。
UPDATE dbo.customer SET address = REPLACE(REPLACE(REPLACE(LTRIM(RTRIM(address)), N' ', N' '), ' ', ' '), ' ', ' '), phone = REPLACE(REPLACE(REPLACE(phone, ' ', ''), '-', ''), ' ', '') WHERE address IS NOT NULL OR phone IS NOT NULL;这里连续做两次把两个空格替换成一个空格,是因为 REPLACE 一次只能处理一种连续情况,两个空格替换完还可能有三个空格压缩后留下的两个空格,所以通常做两到三轮。这个细节不实测很难发现。另外,全角空格在数据库里显示为普通空格,肉眼根本看不出区别,我用 LEN 和 DATALENGTH 对比才定位到问题。
3.2 场景二:按分隔符拆分字符串到多行
系统里有一张标签表,每个产品的标签字段用竖线分隔,比如“热销|新品|包邮”。报表端需要把标签拆成多行做关联统计。SQL Server 2016 起提供了 STRING_SPLIT,一个函数就能搞定:
SELECT p.product_id, s.value AS tag_name FROM dbo.product p CROSS APPLY STRING_SPLIT(p.tags, '|') s;CROSS APPLY 会把每行的分隔字符串拆成多行输出。这个写法简单高效,是我推荐的首选方案。但要注意 STRING_SPLIT 在 2016 和 2017 版本里不保证输出顺序,如果需要按标签在字符串中的出现顺序排列,在 2022 版本之前没有内置方案。我当然还保留着一个 FOR XML PATH 加递归 CTE 的拆分写法,专门给 2014 或更老的环境用,但能用 STRING_SPLIT 就不建议自己写递归了,性能和稳定性差别很明显。
3.3 场景三:按分组生成动态汇总标签
报表里经常需要把每个部门的员工姓名汇总成一行,用顿号或逗号分隔。STRING_AGG 是 2017 以后的答案:
SELECT d.department_name, STRING_AGG(e.employee_name, N'、') WITHIN GROUP (ORDER BY e.employee_id) AS employees FROM dbo.department d LEFT JOIN dbo.employee e ON d.department_id = e.department_id GROUP BY d.department_name;我在 2016 环境下做过 FOR XML PATH 的替代写法,虽然能达到同样效果,但代码可读性差很多。如果生产环境允许,尽量用 STRING_AGG。还有一点容易踩坑:STRING_AGG 输出的最大长度是 NVARCHAR(MAX) 的范围,但如果目标列或变量类型是 NVARCHAR(100),结果会被静默截断。我曾因为目标变量长度不够,导致报表里的汇总名单少了一截,排查了很久才发现是截断问题。
3.4 场景四:报表字段脱敏处理
金融业务里,手机号、身份证、银行卡号在报表展示时都需要脱敏。脱敏规则一般是保留前几位和后几位,中间用星号代替。这种需求用 STUFF 最顺手:
SELECT phone, STUFF(phone, 4, 4, '****') AS phone_masked FROM dbo.customer_card; SELECT id_card, STUFF(id_card, 4, 10, '**********') AS id_masked FROM dbo.customer_card;手机号中间四位隐藏是 STUFF(phone, 4, 4, ''),因为手机号是 11 位,从第 4 位开始替换 4 位,结果类似 1385678。身份证号是 18 位,隐藏第 4 位到 13 位的一个区间,STUFF 同样能做到。要注意的是,STUFF 返回的位置参数如果大于字符串长度,就不会替换任何内容直接返回原串,这在数据不规范时容易导致脱敏不彻底。我一般会先在 where 条件里过滤长度不符合规范的数据,或者在 STUFF 前用 CASE 做一层判断,确保输出一定是脱敏后的格式。
4. 常见问题与排查技巧实录
4.1 NULL 引发的连锁反应
字符串函数和 NULL 打交道的规则很不统一,这是排查字符串异常时必须先检查的问题。+ 号拼接遇 NULL 整体变 NULL,CONCAT 遇 NULL 当空字符串,CHARINDEX 对 NULL 参数直接返回 NULL,REPLACE 对 NULL 参数返回 NULL。这些行为没有一个统一规律可以记,只能靠经验积累。
建议在每个字符串处理开头,先用 ISNULL 或 COALESCE 把可能为 NULL 的字段处理成默认值。比如 ISNULL(phone, ''),或者用 NULLIF 把空字符串还原成 NULL。我在一个清洗脚本里曾经忘记处理 address 字段的 NULL,结果整条记录在拼接省份、城市、区县时变成了 NULL,后续所有判断全部失效,最终查数据时才发现是 NULL 传播导致的。从那以后,我写字符串处理 T-SQL 时会习惯性地扫一眼相关字段是否允许 NULL,再做处理。
4.2 排序规则不一致导致字符串比较出错
联接两个库的表时,经常会遇到两边排序规则不一致的问题,报错信息类似“无法解决 equal to 操作的排序规则冲突”。字符串比较在 SQL Server 里不是简单的字节比较,而是受排序规则控制的。常见的不一致包括大小写敏感与不敏感、中文拼音排序与笔画排序、全角与半角是否等价。
处理方式是在比较或联接时显式指定 COLLATE:
SELECT * FROM dbo.orders o JOIN dbo.customer c ON o.customer_code = c.customer_code COLLATE Chinese_PRC_CI_AS;COLLATE Chinese_PRC_CI_AS 是常见的中文不区分大小写排序规则。实际项目中,我会先查两边库的排序规则是否一致,不一致就在联接条件里补一个 COLLATE 比较,而不是把整个数据库排序规则改掉。改库级排序规则影响面太大,风险很高,不值得为了两个表的联接去动全局配置。
4.3 新函数在低版本不可用
生产环境版本参差不齐,这个问题非常现实。TRIM 只在 2017 及以上版本可用,STRING_AGG 只在 2017 及以上版本可用,STRING_SPLIT 在 2016 开始有,CONCAT_WS 在 2017 开始有,FORMAT 2012 开始有但性能不理想。如果在 2014 环境里直接写 TRIM,整个脚本直接语法报错。
我的习惯是脚本开头附上兼容版本说明,并对关键逻辑准备老版替代方案。比如去掉首尾空格,低版本就用 LTRIM(RTRIM(@value));字符串分割,低版本就用递归 CTE 或 XML 方法;分组拼接,低版本就用 FOR XML PATH。这样迁移脚本到不同版本环境时,不用现场改代码,直接换预写好的替代块就行。
4.4 FORMAT 函数的性能陷阱
FORMAT 函数用起来很直观,比如 FORMAT(GETDATE(), 'yyyy-MM-dd'),但它的性能问题非常严重。FORMAT 内部走了 .NET 运行时,在结果集大的语句里会拖慢整体执行。
我曾经在一个超过五十万行的报表查询里用了 FORMAT 格式化日期和数字,查询从秒级直接变成分钟级,优化时把 FORMAT 替换成 CONVERT + 字符串拼接:
SELECT CONVERT(CHAR(10), order_date, 120) AS order_date_str, CAST(order_amount AS DECIMAL(18,2)) AS amount_str FROM dbo.orders;日期格式 120 表示 yyyy-mm-dd,这是最常用的格式代码。数字转型用 CAST 和 CONVERT 完成,不用 FORMAT。实测替换后查询时间大幅下降。需要保留两位小数的显示效果,可以在应用层处理,也可以在 SQL 里用 STR 或转成字符串后补位。FORMAT 只建议用在小数据量、追求易读性的场景,比如临时查询或报表顶部参数展示。
4.5 字符串处理对索引的影响
WHERE 条件里对字段套了字符串函数,大多数情况下会破坏索引的使用。比如 WHERE UPPER(customer_name) = 'ABC',对 customer_name 列加了函数,SQL Server 没法直接走 customer_name 列的索引,只能做全表扫描或索引扫描后再过滤。
解决思路有两个方向。第一个是改写查询,让字段独立在等号一侧,比如改成 customer_name = 'ABC',如果字段本身不区分大小写,就没必要套 UPPER。第二个是用计算列加索引,比如在表上增加一个始终为 UPPER(customer_name) 的计算列,然后给计算列建索引,这样既能保持查询写法方便,又能利用索引加速。
ALTER TABLE dbo.customer ADD customer_name_upper AS UPPER(customer_name); CREATE INDEX ix_customer_name_upper ON dbo.customer(customer_name_upper);LIKE 模糊查询也有类似规则。LIKE 'abc%' 前缀匹配可以走索引,LIKE '%abc%' 中间匹配在绝大多数情况下走不了索引。开发报表和筛选功能时,我会有意识地设计筛选条件,避免非必要的前导通配符。
4.6 常踩的坑速查表
| 函数 | 常见坑 | 推荐规避方式 |
|---|---|---|
| LEN | 忽略尾随空格 | 需要精确长度时先 RTRIM 再用 LEN |
| DATALENGTH | 返回字节数不是字符数 | 处理 NVARCHAR 时要按 2 字节/字符计算 |
| CHARINDEX | 找不到返回 0 | 用于 SUBSTRING 前必须先判断大于 0 |
| SUBSTRING | start 参数越界 | 结合 LEN 控制范围,避免负值 |
| REPLACE | 大小写匹配取决于排序规则 | 需要区分大小写时加 COLLATE |
| STUFF | 替换长度过长不会报错 | 脱敏前先校验字符串长度 |
| CONCAT 与 + | NULL 处理语义不同 | 可能为 NULL 的字段优先用 CONCAT |
| FORMAT | 执行性能差 | 大数据量用 CONVERT 替代 |
| STRING_AGG | 结果可能被目标字段长度截断 | 目标列类型用 NVARCHAR(MAX) |
| TRIM | 低版本不可用 | 2016 及以下用 LTRIM/RTRIM 组合 |
这张表是我自己在实际项目里积累出来的,写存储过程和报表脚本时对照着排查,能省下不少时间。
最后再说一个我自己的使用习惯:字符串函数写起来简单,但每次改动都可能影响线上数据,尤其是 UPDATE 场景。涉及全表或大批量更新前,我会先把清洗逻辑放在 SELECT 里跑一遍,人工核对结果没问题后再改成 UPDATE。这个习惯帮我挡掉了至少两次线上事故,代价只是多花几分钟,非常值得。后续如果再遇到复杂的字符串清洗需求,可以尝试把多个函数嵌套组合,但注意嵌套层次别太深,超过四五层可读性就会急剧下降,这时应该考虑拆分成多个中间变量逐步处理,既好调试又好注释。