
1. 字段拼接到底解决什么问题先搞清楚场景再动手做数据这行的几乎没有人能绕开把几个字段拼成一个这件事。你可能正在调一张报表甲方要求联系人一列必须显示成张三-华东区-13800000000这种格式也可能在做数据清洗要把省市县三级地址拼成一整条完整地址还有可能你要给订单生成一个人工可读的编号把日期和自增序号拼起来。这些需求的共同点就是原始表里字段是拆开的需要一个表达式把它们揉成一个新字段。拼接看起来简单一个函数就能搞定但真正坑人的地方在于不同数据库的语法不一样遇到 NULL 值的行为不一样拼接后的字段能不能被索引、会不会撑爆字段长度、聚合拼接时结果被静默截断这些才是让一线上班族在半夜爬起来改 SQL 的原因。我见过太多人写了一段在自己环境跑得好好的 SQL换到另一个数据库直接报错或者悄悄返回错误结果最后一排查全是拼接的函数语义差异导致的。这篇内容我打算把字段拼接这件事从能用讲到用得稳。不管你是刚学 SQL 语句的新手还是已经天天写原生 SQL 的老手都能从中找到点东西新手可以拿到一份可以直接抄的语法对照表老手可以关注一下 NULL 传染、聚合截断、索引失效这几个容易翻车的点。全文我会以 MySQL 为主线同时把 SQL Server、Oracle、PostgreSQL、Hive 的写法都摆出来对照因为现实项目里跨库迁移太常见了只懂一种写法迟早要吃亏。2. 主流数据库拼接语法全对照一个需求五种写法2.1 先看一张能直接抄的对照表在动手写之前把各家的语法差异先摆清楚比写完再查错要高效得多。下面这张表是我自己整理并反复验证过的覆盖了拼接运算符、拼接函数以及最关键的 NULL 处理行为。数据库拼接方式拼接函数遇到 NULL 的行为MySQL无原生运算符默认CONCAT、CONCAT_WSCONCAT任一参数为 NULL 则整体返回 NULLCONCAT_WS跳过 NULLSQL ServerCONCAT、CONCAT_WS2017遇 NULL 返回 NULLCONCAT把 NULL 当空串Oracle||CONCAT仅接受两个参数||与CONCAT都把 NULL 当空串PostgreSQL||CONCAT、CONCAT_WS||遇 NULL 返回 NULLCONCAT把 NULL 当空串Hive无CONCAT、CONCAT_WSCONCAT遇 NULL 返回 NULLCONCAT_WS跳过 NULL这张表里最值得记的就是最后一列。语法记错了报错还能立刻发现NULL 处理错了是不报错的它只会给你一个错误的空结果然后在业务侧引起一串莫名其妙的投诉。所以我在任何项目里做拼接第一反应都是问自己这几个字段里有没有允许为空的注意Oracle 的CONCAT只接受两个参数想拼三段以上就得嵌套所以 Oracle 里优先用||可读性和性能都更省心。2.2 MySQLCONCAT 与 CONCAT_WS 的分工MySQL 里最常用的是CONCAT(str1, str2, ...)它接受任意多个参数把它们首尾相接返回。看一个最基本的例子SELECT CONCAT(last_name, first_name) AS full_name FROM employees;这段代码有个前提last_name和first_name都不为 NULL。只要其中一个是 NULL整个结果就是 NULL。很多新人第一次遇到这个问题会觉得莫名其妙明明另一个字段有值为什么整列都空了。这就是所谓的 NULL 传染性它是 SQL 三值逻辑的一部分不是 bug是设计。CONCAT_WS的第一个参数是分隔符后面的参数才是要拼接的内容W 是 with separator 的意思SELECT CONCAT_WS(-, province, city, district) AS full_address FROM user_address;它的两个优势非常实在一是分隔符统一写在开头不用在每两个字段之间重复写二是它会自动跳过 NULL 值并且在跳过的时候连分隔符一起跳过不会出现北京--朝阳区这种中间空一截的结果。做地址、路径这类拼接我基本都会优先选CONCAT_WS。2.3 SQL Server加号与 CONCAT 的取舍SQL Server 里最传统的写法是用加号SELECT last_name first_name AS full_name FROM employees;加号的问题和 MySQL 的CONCAT一样遇到 NULL 就整体变 NULL。而且加号还有个额外的坑如果参与拼接的是数字类型会被解释成算术加法而不是字符串拼接。比如SELECT 1 2得到的是 3不是 12。所以数值字段拼接前一定要先转换类型SELECT CAST(order_date AS VARCHAR(10)) - CAST(order_id AS VARCHAR(20)) AS order_no FROM orders;从 SQL Server 2012 开始有了CONCAT函数它的行为更接近大家对拼接的直觉NULL 会被当作空字符串处理SELECT CONCAT(order_date, -, order_id) AS order_no FROM orders;2017 版本之后又加入了CONCAT_WS和 MySQL 的用法一致。实际选型上我的建议是如果字段允许为空或者存在数值字段优先用CONCAT系列能少写一堆ISNULL包裹如果确定字段都是非空字符串用加号反而更简洁执行计划也更简单。2.4 Oracle双竖线与 CONCAT 的区别Oracle 里最顺手的是双竖线运算符SELECT last_name || first_name AS full_name FROM employees;它对 NULL 是宽容的A || NULL结果是 A不会整体变 NULL。这一点和 MySQL 完全相反也是跨库迁移时最容易出问题的地方——同样的业务逻辑从 Oracle 迁到 MySQL原来能正常显示的字段突然全空了八成就是这里没处理。CONCAT(a, b)在 Oracle 里只能传两个参数三段拼接要写成CONCAT(CONCAT(a, b), c)可读性差很多所以基本只在需要兼容某些框架时才用。2.5 PostgreSQL 与 Hive 的写法差异PostgreSQL 支持||运算符行为是遇 NULL 返回 NULL但它同时提供了CONCAT行为是跳过 NULL。同一个库里两套语义这点要特别注意团队里最好统一约定用哪一个不然代码 Review 的时候很容易看漏。Hive 面向大数据场景CONCAT遇 NULL 返回 NULLCONCAT_WS跳过 NULL。Hive 有个额外区别它的CONCAT参数数量是有限制的早期版本对超多参数的拼接支持并不好所以字段特别多的时候用CONCAT_WS或者嵌套写法更稳。而且 Hive 的CONCAT_WS要求第一个参数必须是字符串常量不能是列名这点和 MySQL 不同写之前最好先跑一条小样例验证。3. NULL 值是拼接地雷处理策略与参数推演3.1 NULL 传染到底怎么回事很多人把 NULL 理解成空字符串这是所有问题的根源。在 SQL 里NULL 的含义是未知,而不是空的。张三 拼上一个未知结果当然是未知这就是 NULL 传染的直觉解释。用具体数据感受一下会更清楚。假设有一张表name是 张三phone是 NULLSELECT CONCAT(name, -, phone) FROM t; -- 结果 NULL SELECT CONCAT_WS(-, name, phone) FROM t; -- 结果 张三同一个需求两个函数给出完全不同的结果。如果这条 SQL 是用来做导出文件的第一种写法会让这一整行都变成空用户会以为数据丢了其实是 SQL 表达式把未知值放大成了整列未知。注意判断聚合结果有没有异常不能只看总行数要单独统计拼接结果中为 NULL 的行数SELECT COUNT(*) FROM t WHERE CONCAT(...) IS NULL这一步能帮你提前发现大量隐形丢数据。3.2 四种空值兜底方案的分场景选择既然 NULL 会传染那就要在拼接前把它变成可控的值。常用的有三种函数加上一种结构改写一共四种思路COALESCE(expr, )标准 SQL 函数接受任意多个参数返回第一个非 NULL 的值几乎所有数据库都支持是跨库迁移时的首选。IFNULL(expr, )MySQL 专用只接两个参数写法短逻辑清楚。ISNULL(expr, )SQL Server 专用同样两个参数。NVL(expr, )Oracle 专用两个参数。场景推荐方案理由跨库迁移、多数据库兼容COALESCE标准函数各库行为一致MySQL 单库、追求简洁IFNULL参数少一目了然SQL Server 单库ISNULL与库内其他代码风格统一Oracle 单库NVL老代码习惯短小直接只在某一段需要兜底CONCAT_WS不用包裹每个字段最省事参数选择上还有一个细节兜底值到底该填空串还是别的占位符做展示类需求时我倾向于填空串让结果干净做拼装编号、拼装主键这类要求唯一性的场景我倾向于填一个有意义的占位比如 UNKNOWN这样后续排查能一眼看出哪些记录缺字段而不是生成一堆看上去正常、其实信息缺失的编号。3.3 空字符串和 NULL 不是一回事还有一个特别容易被忽略的点空字符串和 NULL 是两回事。CONCAT(张三, -, )得到的是张三-末尾会多一个分隔符而 NULL 走CONCAT_WS时连分隔符都跳过。这意味着如果数据源不统一一部分是 NULL、一部分是空串最后拼出来的格式会参差不齐有的末尾带横线有的不带。处理办法是在拼接前把两种空值归一化SELECT CONCAT_WS(-, NULLIF(TRIM(name), ), NULLIF(TRIM(phone), )) AS contact FROM t;TRIM去掉首尾空白NULLIF(x, )把空串转成 NULL最后交给CONCAT_WS统一跳过。这三步看起来啰嗦但实测下来它能消掉绝大多数格式不统一的数据质量问题。我自己在做用户数据清洗时基本都会把这套组合做成一个固定的表达式模板哪里要拼接就往里套。4. 从简单拼接到复杂重组四个实战案例逐步拆解4.1 案例一姓名与地址的规范展示需求很朴素把last_name和first_name拼成全名把省市区拼成完整地址导出给业务方看。SELECT CONCAT_WS( , NULLIF(TRIM(last_name), ), NULLIF(TRIM(first_name), )) AS full_name, CONCAT_WS(, NULLIF(TRIM(province), ), NULLIF(TRIM(city), ), NULLIF(TRIM(district), )) AS full_address FROM user_info;这里我做了一个取舍全名用空格分隔地址用空串分隔。原因是中文姓名张 三加空格在有些场景会被当成两个词但在导出到 Excel 做姓名匹配时带空格反而更容易被识别成完整字段。地址则相反省市区中文之间不需要加任何符号直接连起来读起来最自然。实操心得CONCAT_WS的第一个分隔符参数是字符串常量如果业务方希望改成分隔符可配置就得改用嵌套的CASE WHEN判断成本会上升不少。所以拿到需求时先跟对方确认分隔符是固定的还是可变的这决定了你的写法。4.2 案例二日期与序号补零拼装业务编号这个场景太常见了订单号要长成ORD20240615-0007这种样子日期部分加四位补零序号。拼接本身不难难的是补零和类型转换。-- MySQL SELECT CONCAT(ORD, DATE_FORMAT(create_time, %Y%m%d), -, LPAD(seq, 4, 0)) AS order_no FROM orders; -- SQL Server SELECT CONCAT(ORD, CONVERT(VARCHAR(8), create_time, 112), -, RIGHT(0000 CAST(seq AS VARCHAR(10)), 4)) AS order_no FROM orders; -- Oracle SELECT ORD || TO_CHAR(create_time, YYYYMMDD) || - || LPAD(seq, 4, 0) AS order_no FROM orders;补零这段值得展开说一下。MySQL 和 Oracle 有现成的LPAD(str, len, pad)第三个参数是填充字符写起来最省心。SQL Server 没有LPAD传统做法是RIGHT(0000 数值, 4)先用零拼在前面再从右边截取四位思路是反转的但效果等价。这里有个隐藏条件seq不能超过四位否则RIGHT截取会把高位数字砍掉生成重复编号。所以拼装编号一定要在业务层或数据库层加一个唯一性约束别指望表达式本身能保证。日期格式化部分MySQL 的DATE_FORMAT、SQL Server 的CONVERT样式码 112 对应 yyyymmdd、Oracle 的TO_CHAR各有一套迁移时这三行基本都得重写这也是为什么我建议把这类格式化逻辑尽量收敛到应用层数据库只管存取少一点方言依赖。4.3 案例三多行字段合并成一列这是拼接里难度最高的一类不是横向拼字段而是纵向把多行合并成一个值。典型需求是一个订单下所有商品名拼成一个字符串。-- MySQL SELECT order_id, GROUP_CONCAT(product_name SEPARATOR ,) AS products FROM order_items GROUP BY order_id; -- SQL Server 2017 SELECT order_id, STRING_AGG(product_name, ,) AS products FROM order_items GROUP BY order_id; -- SQL Server 2017 之前 SELECT order_id, STUFF((SELECT , product_name FROM order_items i2 WHERE i2.order_id i1.order_id FOR XML PATH()), 1, 1, ) AS products FROM order_items i1 GROUP BY order_id; -- Oracle 11gR2 SELECT order_id, LISTAGG(product_name, ,) WITHIN GROUP (ORDER BY product_name) AS products FROM order_items GROUP BY order_id; -- PostgreSQL SELECT order_id, STRING_AGG(product_name, ,) AS products FROM order_items GROUP BY order_id;GROUP_CONCAT有一个必须知道的默认行为结果长度受group_concat_max_len限制默认是 1024 字节超出的部分会被静默截断不报错。这个坑我踩过一次一个订单商品特别多导出的字符串莫名其妙少了一截查了半天才发现是这个参数。稳妥的做法是执行前先设置SET SESSION group_concat_max_len 102400;或者在会话连接初始化时就配好。LISTAGG在 Oracle 里也有类似问题早期版本超过 4000 字节会直接报错ORA-01489新版可以用ON OVERFLOW TRUNCATE显式指定截断行为比静默丢数据友好得多。SQL Server 的STRING_AGG在结果超长时则是直接报错不截断。三种行为三种处理方式迁移前一定要实测。另外注意GROUP_CONCAT的结果顺序默认是不确定的如果要保证顺序得写成GROUP_CONCAT(product_name ORDER BY product_name SEPARATOR ,)。做对账或比对类需求时不指定顺序会导致两次查询结果顺序不同误判成数据变更。4.4 案例四把拼接结果写回新字段有时候不只是查询时拼而是要新增一个物理字段把拼接结果落库比如加一个full_name列-- 第一步加字段 ALTER TABLE employees ADD COLUMN full_name VARCHAR(100); -- 第二步写回拼接结果 UPDATE employees SET full_name CONCAT_WS( , NULLIF(TRIM(last_name), ), NULLIF(TRIM(first_name), ));新字段的长度怎么定这里有个可以推演的方法先算出源字段的最大长度之和再留 30% 余量。假设last_name是VARCHAR(50)、first_name是VARCHAR(50)中文在 utf8mb4 下按字符算最多就是 50 字符每人加一个空格理论上限 101取整设成VARCHAR(120)比较稳妥。如果直接用VARCHAR(100)且数据超长在 MySQL 严格模式下会报 1406 错误并中断整个 UPDATE非严格模式下则静默截断两种情况都挺难受。注意写回物理字段后源字段一更新新字段就会过期。要么在应用层同步更新要么建触发器维护。触发器维护的好处是自动坏处是排查问题时多了一层隐蔽逻辑团队里如果没有约定很容易出现改了源字段但报表没变的诡异现象。我自己更倾向于在应用层显式更新逻辑可见性更高。5. 性能与索引拼接字段慢查询的排查思路5.1 拼接表达式为什么用不上索引这是拼接最容易被忽视的代价。假设employees表在last_name上有索引下面这条查询是用不上的SELECT * FROM employees WHERE CONCAT(last_name, first_name) 张三丰;原因很直白索引里存的是last_name的原始值数据库没法提前知道CONCAT之后的结果是什么只能把每一行的两个字段取出来算一遍再比较等价于全表扫描加逐行计算。数据量一大这条 SQL 就会出现在慢查询日志里。同理WHERE UPPER(name) ABC、WHERE DATE(create_time) 2024-06-15这类对字段做函数包装的写法都会让索引失效。这是同一类问题的不同表现排查时看到函数出现在等号左边就要警觉。5.2 三条能落地的优化路径第一条路是把查询条件改成不包装字段的形式让条件落在原始字段上WHERE last_name 张 AND first_name 三丰代价是业务逻辑要拆开如果拼接的分隔符不确定拆解起来会麻烦但性能收益最大。第二条路是用函数索引MySQL 8.0 和 PostgreSQL 都支持-- MySQL 8.0 ALTER TABLE employees ADD INDEX idx_full_name ((CONCAT(last_name, first_name))); -- PostgreSQL CREATE INDEX idx_full_name ON employees ((last_name || first_name));建好之后等号左边的表达式必须和索引定义的表达式完全一致包括函数名、参数顺序、空格差一个字符就用不上这点非常严格。第三条路就是把拼接结果物理落库并建普通索引也就是 4.4 节讲的做法。这条路的性能最好代价是维护成本需要根据写入频率和查询频率权衡。写入很少、查询很多的报表类表我一般会选这条路写入频繁的订单主表我会优先选函数索引避免维护负担。5.3 慢 SQL 定位的实操步骤拼接相关的慢查询排查我一般按这个顺序走打开慢查询日志或直接在监控里找出耗时 Top 的 SQL确认是不是拼接表达式导致的。用EXPLAIN看执行计划重点看type是不是ALL全表扫描rows扫描行数是不是接近表总行数。把WHERE条件里的函数去掉改成等价的条件再看执行计划是否走了索引。如果条件确实没法改评估数据量决定是加函数索引还是物理落库。用真实数据量的测试环境回归别在开发库那几百行数据上验证成果。第 5 步特别重要。开发库数据少走不走索引都很快看不出差别等上线到了千万级才暴露问题那时候改起来成本高多了。我在实际项目里的做法是任何涉及拼接的查询上线前都要在生产数据的脱敏子集上跑一遍看执行计划而不是看耗时因为执行计划比耗时更能反映真实情况。6. 常见问题速查与避坑清单6.1 问题速查表现象可能原因处理办法拼接结果整列变 NULL使用了会传染 NULL 的函数或运算符改用CONCAT_WS或在字段外包COALESCE结果末尾多出分隔符源数据里存在空字符串用NULLIF(TRIM(x), )转成 NULL数字拼接变成了加法SQL Server 的被识别为算术运算用CAST转字符串或改用CONCAT聚合结果莫名其妙少了内容group_concat_max_len默认 1024 被截断会话内设置更大的值或改用报错型写法拼接结果写回时报 1406目标字段长度不够按源字段长度之和加余量重新定义列宽拼接条件查询很慢表达式导致索引失效改为原始字段条件加函数索引或物理落库Oracle 迁 MySQL 后字段全空6.2 我自己踩过的几个坑第一个坑是分隔符位置不统一。有次做地址拼接一部分数据用空格、一部分用横线因为两个开发各写各的代码 Review 时没人注意到。结果导出的地址文件里格式五花八门业务方打电话来问是不是数据出错了。后来我们的约定是所有对外输出的拼接表达式必须集中写在一个视图里不允许散落在各个查询中谁改都改那一处。第二个坑是聚合顺序。GROUP_CONCAT不指定ORDER BY时结果顺序不确定我们做两个环境的数据比对时同样的数据比对出了差异查了两天。教训是只要拼接结果会参与比对或作为缓存键就必须显式指定顺序。第三个坑是字符串长度。做用户标签拼接时一个人可能有几十个标签用GROUP_CONCAT拼出来长度轻松超过 1024一开始没注意上线后一部分用户的标签显示不全但没有任何报错提示属于最难查的那类问题。现在我的习惯是只要用聚合拼接先跑一条查询统计最大长度SELECT MAX(LENGTH(GROUP_CONCAT(tag_name SEPARATOR ,))) FROM user_tags GROUP BY user_id;先看上限再决定要不要调参数比出事以后再查要省事得多。6.3 拼接结果拿去执行动态 SQL 时的提醒有些场景是把拼接出来的字符串当成 SQL 语句去执行比如拼装表名、拼装查询条件。这里一定要走参数化传值不要直接把用户输入拼进语句里。拼接和参数化是两码事拼接处理的是你自己可控的字段和格式参数化处理的是外部传入的值。把这两件事混在一起轻则 SQL 语法出错重则带来安全风险。我的做法是凡是涉及外部输入的一律用参数占位符凡是拼接的都限定在字段名、格式模板、固定常量这类可控范围内。最后分享一个我一直在用的小技巧。写复杂拼接前先单独把每个参与拼接的字段跑出来看一眼实际值特别是看有没有 NULL 和空串还有没有首尾空格。这一步花不了一分钟但能省掉后面大量的调试时间。我见过太多人一上来就写完整的拼接表达式结果结果不对再来回猜是哪个字段的问题来回试五六次比一开始花一分钟看数据慢得多。数据这东西你越早看清它的真实样子后面就越省心。