前几天在帮运营导一份“用户最近90天活跃且累计消费超过500元”的名单,本来以为就是个简单的多表关联加过滤,结果发现真正麻烦的不是关联,而是同一张明细表里要同时判断用户等级、订单状态、消费区间好几个维度。如果每个条件都单独写一段WHERE,SQL会膨胀得没法看,而且跑起来也要反复扫表。当时换了一批内置函数重写,整个查询干净了一大截。这篇文章是上一篇“常用的内置函数”的下篇,上篇聊了字符串、日期、数学这些基础款,这篇我打算把更偏向场景化的四类函数一次性讲透:条件判断、正则文本处理、按组拼接、窗口计算。它们不像SUBSTRING_INDEX那样随手就能用上,但一旦遇到适合的业务场景,效果几乎是降维打击。
1. 条件判断函数:让查询在扫描时顺便完成分类
1.1 CASE WHEN比你想的更强大
先聊最常用的CASE WHEN。很多初学者把它当成SELECT里的“翻译工具”,比如把状态码1翻译成“已支付”、2翻译成“已发货”,这当然没错,但CASE WHEN真正的威力在于它可以嵌进任何表达式里,尤其是聚合函数的参数。
举个实际例子。订单表order表里有city、amount、pay_status三个字段,现在要看每个城市的高额订单占比。很多人第一反应是分两次查:
sql复制SELECT city, COUNT(*) FROM orders WHERE amount >= 500 GROUP BY city;
SELECT city, COUNT(*) FROM orders WHERE amount < 500 GROUP BY city;
然后再到Excel里手工拼。其实用CASE WHEN配合SUM,一次扫描就能出结果:
sql复制SELECT
city,
SUM(CASE WHEN amount >= 500 THEN 1 ELSE 0 END) AS high_cnt,
SUM(CASE WHEN amount < 500 THEN 1 ELSE 0 END) AS low_cnt,
COUNT(*) AS total_cnt
FROM orders
GROUP BY city;
这里的核心逻辑是:CASE WHEN在每一行数据被扫描时先做判断,判断结果变成1或0,SUM再把这些1和0累加起来。也就是说,COUNT做的事情被SUM代替了,但条件判断本身被“塞”进了聚合过程里。这样做的好处很明显——表只需要被读一遍,不需要为了不同条件反复扫同一张表。
CASE WHEN还有另一种写法,叫“简单CASE表达式”,适合字段值精确匹配的场景:
sql复制SELECT
CASE pay_status
WHEN 1 THEN '已支付'
WHEN 2 THEN '已发货'
WHEN 3 THEN '已签收'
ELSE '未知'
END AS status_name
FROM orders;
这种写法看着简洁,但只支持等值比较。一旦判断条件涉及范围、模糊匹配、多字段组合,就必须用“搜索CASE表达式”:
sql复制CASE
WHEN amount >= 1000 THEN '大额'
WHEN amount >= 500 THEN '中额'
ELSE '小额'
END
搜索CASE才是实际开发中的主力。它从上到下逐一判断WHEN条件,一旦命中就立刻返回,后面的WHEN不会再执行。这个特性平时没问题,但如果你写的条件有包含关系,顺序就非常重要。比如上面的例子,如果把amount >= 500写在amount >= 1000前面,那么所有满1000的订单都会先命中“中额”,大额永远统计不出来。这种bug在逻辑上不会报错,但结果错得悄无声息,排查起来往往要花很长时间。
1.2 NULL判断的三个经典坑
很多SQL初学者都会在NULL上栽跟头。NULL不是0,也不是空字符串,它表示“不知道”或“不存在”。用等号判断NULL永远返回NULL,这个布尔结果既不是TRUE也不是FALSE,在WHERE里会被当作不成立处理,于是WHERE field = NULL查出来的结果永远是空。
正确的判断方式有两种:IS NULL和IS NOT NULL。如果你的查询需求是要拿一个字段和另一个“可能为NULL的字段”做比较,那更稳妥的是用<=>安全等于运算符:
sql复制SELECT * FROM orders WHERE amount <=> NULL;
这个写法在实际工作中不算高频,但它是MySQL8.0之前处理NULL等值比较的少数干净方案之一。
再说IFNULL函数。它的作用是:如果第一个参数是NULL,就返回第二个参数。这个函数很直观,但它只能处理两个参数,无法处理“多个字段逐层取第一个非NULL值”的场景。比如订单表里有收货地址字段address、备用地址backup_address,现在要导出一个总能拿到地址的列表,如果address为NULL就看backup_address:
sql复制SELECT
order_id,
IFNULL(address, backup_address) AS final_address
FROM orders;
如果加一层更复杂的兜底逻辑,比如address为空看backup,backup为空看默认地址,IFNULL嵌套就很丑:
sql复制SELECT
order_id,
IFNULL(IFNULL(address, backup_address), '默认地址') AS final_address
FROM orders;
这时换成COALESCE会更清爽。COALESCE接收多个参数,返回第一个非NULL值:
sql复制SELECT
order_id,
COALESCE(address, backup_address, '默认地址') AS final_address
FROM orders;
IFNULL和COALESCE的区别还体现在类型转换上。IFNULL会对结果做隐式类型转换,如果你传的第一个参数是字符串、第二个是数字,返回结果可能会被转成数值类型,导致拼接时出现意外的类型错误。COALESCE的类型转换规则更复杂,但在多数场景下它更接近你直觉上想要的类型。我的建议是:双字段判空用IFNULL没问题,多字段或者跨类型判空一律用COALESCE,少踩很多坑。
1.3 用条件函数做多口径统计
回到开头那张订单表。运营想要的数据不是简单加个WHERE,而是要看到每个用户在各种条件下的交叉对比关系。这种场景最适合把CASE WHEN写到聚合函数里,一次性构建多个统计列:
sql复制SELECT
user_id,
COUNT(*) AS total_orders,
SUM(CASE WHEN amount >= 500 THEN 1 ELSE 0 END) AS high_amount_orders,
SUM(CASE WHEN pay_status = 1 THEN amount ELSE 0 END) AS paid_amount,
MAX(CASE WHEN product_type = '电子券' THEN order_time END) AS last_coupon_order_time
FROM orders
WHERE order_time >= DATE_SUB(NOW(), INTERVAL 90 DAY)
GROUP BY user_id;
这里的MAX(CASE WHEN ... END)是一种很巧妙的小技巧:当某个条件不满足时CASE返回NULL,MAX会自动忽略NULL,所以就能取到“满足某条件那一行”的某个字段值。这相当于在不做自关联的情况下,完成了一次“对组内满足条件的行取值”的操作。类似的思路可以用MIN、AVG、GROUP_CONCAT等任何聚合函数,条件函数和聚合函数一组合,统计口径的灵活度直接上升一个数量级。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 正则函数:文本清洗里最锋利的刀刃
2.1 MySQL的正则表达式家族
MySQL 8.0开始提供了四个完整的正则函数,分别是REGEXP_LIKE、REGEXP_INSTR、REGEXP_REPLACE、REGEXP_SUBSTR。它们对应了文本处理的四个基本动作:判断是否匹配、返回匹配位置、替换匹配内容、提取匹配子串。
很多人早期接触MySQL正则时只知道WHERE field REGEXP 'pattern'这种用法,它实际上等同于REGEXP_LIKE的简写形式。如果你只需要过滤,那继续用REGEXP没问题。但如果要做提取、替换,就必须用后面三个函数了。
几个函数的核心区别我用一个表整理出来:
| 函数 | 作用 | 返回值 |
|---|---|---|
| REGEXP_LIKE(expr, pat) | 判断expr中是否存在匹配pat的子串 | 1或0 |
| REGEXP_INSTR(expr, pat) | 返回第一个匹配子串的起始位置 | 整数 |
| REGEXP_REPLACE(expr, pat, repl) | 将所有匹配pat的子串替换为repl | 字符串 |
| REGEXP_SUBSTR(expr, pat) | 提取第一个匹配pat的子串 | 字符串 |
值得注意的是,MySQL的正则默认是不区分大小写的,如果想区分大小写,要在模式串里加(?-i)前缀,或者用BINARY关键字强制二进制比较。这个细节很容易被忽略,特别是在做日志文本匹配的时候。
2.2 三个可直接套用的清洗场景
场景一:从订单备注里提取手机号。
运营部门经常在备注里留联系方式,格式五花八门,有的是“手机:13812345678”,有的是“请联系13812345678王经理”。统一提取的写法:
sql复制SELECT
order_id,
remark,
REGEXP_SUBSTR(remark, '1[3-9][0-9]{9}') AS extracted_phone
FROM orders
WHERE REGEXP_LIKE(remark, '1[3-9][0-9]{9}');
用REGEXP_LIKE放到WHERE里做前置过滤,是考虑到有些备注完全没有手机号,REGEXP_SUBSTR会返回NULL,数据量一大,导出的表里会有一堆空行,加了WHERE以后结果集干净很多。
场景二:手机号脱敏。
这应该是所有做数据分析的人都会遇到的需求。直接把中间四位替换成星号:
sql复制SELECT
phone,
REGEXP_REPLACE(phone, '([0-9]{3})[0-9]{4}([0-9]{4})', '\\1****\\2') AS masked_phone
FROM users;
注意这里用了反向引用\\1和\\2,分别代表第一个和第二个括号里捕获的内容。MySQL的字符串字面量里反斜杠本身是转义字符,所以写反向引用时要写两个反斜杠,这是初学者最容易卡住的地方。如果你觉得反斜杠的转义太绕,可以把[0-9]换成\\d对比一下,后者在MySQL里要写\\\\d,反而更痛苦。所以我个人建议在MySQL正则里尽量用[0-9]这种显式字符类,少用\\d这类简写,阅读和排错都更方便。
场景三:从URL里提取域名。
网站行为日志里常有一整条带参数的URL,想只要域名部分,可以用REGEXP_REPLACE把协议、路径、参数都剥掉:
sql复制SELECT
url,
REGEXP_REPLACE(url, '^(https?://)?([^/]+).*$', '\\2') AS domain
FROM access_log;
这个正则会先把开头的协议部分和路径部分匹配掉,只保留第二组捕获的域名内容。如果URL里没有协议头,第一个括号捕获组匹配不到任何字符也没关系,第二个捕获组依然能命中主机名。这种提取方式比用SUBSTRING_INDEX一层层截取要稳得多,因为URL的结构变化太多,字符串截取很难覆盖所有格式。
2.3 正则效率的硬伤要提前知道
正则函数很方便,但它们在MySQL里基本都有同一个毛病:无法走索引。REGEXP_LIKE在WHERE里过滤时,即使字段上建了普通索引,MySQL也只能全表扫描逐行匹配。原因很简单——索引要求前缀匹配,正则是全串任意位置的模式匹配,B+树索引帮不上忙。
所以使用正则前一定要先评估数据量。如果一张表几百万行,用正则做全表过滤是灾难级的慢。我一般会退而求其次,先用能被索引利用的普通条件缩小结果集,再在剩余数据里跑正则:
sql复制SELECT
order_id,
remark
FROM orders
WHERE created_at >= '2024-01-01'
AND remark REGEXP '1[3-9][0-9]{9}';
先把时间字段的索引优势用足,让MySQL扫尽可能少的行,再对扫描结果做正则匹配,线上性能会好看很多。这个思路同样适用于下一节要讲的GROUP_CONCAT和各类聚合函数。
3. GROUP_CONCAT与字符串聚合:把多行压成一行的魔法
3.1 什么场景需要行转“串”
有一类需求在报表统计中非常常见:一张订单明细表里同一个订单ID对应多件商品,每个商品是一行,运营要导出到Excel时却希望一个订单只占一行,商品名称全部拼在一个单元格里。
直接GROUP BY订单ID再SELECT商品名,MySQL会报错,因为商品名不在聚合函数里。用MIN或MAX只能取到一个,丢失大量信息。GROUP_CONCAT就是为这个场景而生的聚合函数,它能把组内多行的某个字段拼接成一个字符串:
sql复制SELECT
order_id,
GROUP_CONCAT(product_name SEPARATOR ';') AS product_list
FROM order_items
GROUP BY order_id;
默认情况下GROUP_CONCAT用逗号作为分隔符,可以用SEPARATOR指定其他分隔符。如果商品很多,拼出来的字符串可能很长,所以要了解它的长度限制,这一点我在后文专门讲。
3.2 GROUP_CONCAT的完整语法与常用变体
GROUP_CONCAT的完整语法比大多数人想象的更丰富:
sql复制GROUP_CONCAT(
DISTINCT expr
ORDER BY expr ASC/DESC
SEPARATOR 'separator'
)
三个可选部分分别解决三个问题:
DISTINCT去掉组内的重复值。比如用户的商品标签可能有重复收藏,拼接时不想看到重复标签:
sql复制SELECT
user_id,
GROUP_CONCAT(DISTINCT tag ORDER BY tag SEPARATOR '、') AS tag_list
FROM user_tags
GROUP BY user_id;
ORDER BY控制拼接顺序。如果拼一组时间序列,比如用户的登录记录,顺序就很重要:
sql复制SELECT
user_id,
GROUP_CONCAT(login_date ORDER BY login_date ASC SEPARATOR ' -> ') AS login_sequence
FROM user_login_log
GROUP BY user_id;
这个语法比先拼完再排序要靠谱得多,因为它是在分组聚合内部完成的排序,不需要借助其他函数。
还有一个经常被忽略的特性:GROUP_CONCAT会忽略NULL值。如果组内某些行的字段值是NULL,它们不会出现在拼接结果里。如果你需要用占位符标出“这一行本来是空的”,要先用COALESCE把NULL转换成占位字符串再做拼接:
sql复制GROUP_CONCAT(COALESCE(product_name, '未知商品') SEPARATOR ';')
否则你会看到某订单有3条明细但拼出来只有2个商品名——这种隐性丢数据的情况很容易被忽略。
3.3 长度上限:一个要提前防范的坑
GROUP_CONCAT的结果有最大长度限制,由group_concat_max_len参数控制,默认值1024字节。这个上限不是字符数,是字节数。在utf8mb4字符集下,一个汉字占3到4字节,也就是说默认限制下只能拼大概两三百个汉字。一旦拼接内容超过这个长度,MySQL不会报错,而是直接把结果截断到上限,而且没有任何提示。最坑的是,这种截断往往要到数据导出Excel后,运营发现某个单元格内容不完整,才会回头找到你。
线上遇到需要拼接较长的文本时,我一般会在执行前动态调大会话参数:
sql复制SET SESSION group_concat_max_len = 102400;
一个要注意的细节:SET SESSION只对当前连接生效,不影响其他会话。如果是在存储过程或定时任务里用,建议在任务开始时先执行这句;如果是在一次性的临时查询里用,直接在同一个连接里执行即可。
如果拼接内容经常超过几万字节,就要考虑是不是业务设计的问题了——也许不该用单字段装这么多信息,改成导出JSON或分sheet会更合适。
3.4 顺带一提的JSON聚合函数
MySQL 8.0新增了JSON_ARRAYAGG和JSON_OBJECTAGG,作用和GROUP_CONCAT类似,但结果不是普通字符串,而是JSON数组或JSON对象。比如要给前端返回某个用户的所有地址列表,用JSON_ARRAYAGG能让应用层少做一次字符串解析:
sql复制SELECT
user_id,
JSON_ARRAYAGG(address) AS address_json
FROM user_addresses
GROUP BY user_id;
JSON_ARRAYAGG面对NULL值的处理和GROUP_CONCAT一样——NULL会被跳过。但JSON格式本身适合表达列表结构,后续在前端直接JSON.parse就能用。如果你有API接口要返回这类嵌套结构,用JSON_ARRAYAGG比GROUP_CONCAT拼字符串再解析更合理,因为它不必关心分隔符冲突和转义问题。
4. 窗口函数:让每条记录带上前后的对比趋势
4.1 GROUP BY丢掉了什么
GROUP BY聚合的副作用是——结果集的行数被压缩了。一个用户组内原本有50条订单,GROUP BY之后只能剩1行。有些分析场景恰恰需要保留每条订单明细,同时让每条明细带上用户级别的汇总数据,比如每个订单金额占用户总付费金额的比例。
窗口函数就是这种场景的标准解。它的核心语法是OVER()子句,表示在某一个“窗口”内做计算,窗口可以由PARTITION BY划分,相当于分组但不合并行:
sql复制SELECT
order_id,
user_id,
amount,
SUM(amount) OVER (PARTITION BY user_id) AS user_total_amount,
ROUND(amount / SUM(amount) OVER (PARTITION BY user_id), 4) AS amount_ratio
FROM orders;
这个查询返回的行数和orders表完全一致,每一行旁边多了一列user_total_amount,是该用户所有订单的金额汇总。因为窗口计算不会折叠行,所以你可以继续引用order_id等非聚合字段,不会报错。
4.2 ROW_NUMBER、RANK、DENSE_RANK怎么选
窗口函数里的排序三兄弟经常让人混淆,它们的区别其实就在并列名次的处理上:
| 函数 | 行为 | 示例结果 |
|---|---|---|
| ROW_NUMBER() | 无论是否并列,每行一个连续编号,不重复 | 1,2,3,4 |
| RANK() | 相同值同排名,后续排名跳号 | 1,1,3,4 |
| DENSE_RANK() | 相同值同排名,后续排名连续 | 1,1,2,3 |
经典的业务场景是“查每个分类下销量前三的商品”。用MySQL 5.7的写法需要自关联加变量,比较痛苦;5.8之后用ROW_NUMBER一步到位:
sql复制SELECT
category_id,
product_id,
sales_amount
FROM (
SELECT
category_id,
product_id,
sales_amount,
ROW_NUMBER() OVER (PARTITION BY category_id ORDER BY sales_amount DESC) AS rn
FROM product_sales
) t
WHERE rn <= 3;
内层子查询先按品类分组并给每个商品按销量降序编号,外层再过滤rn小于等于3的行。所有商品信息都能正常保留,不用GROUP BY,也不会因为取MAX丢其他字段。
遇到“排行榜并列名次”的需求时——比如活动排名有并列前三名,奖品只有一份,但又希望名单里不出现跳号——就改用RANK或DENSE_RANK。按照MySQL官方文档的定义,RANK和DENSE_RANK都不保证生成的排名数量与行数一致,使用时应根据业务想要的奖励规则来决定。
4.3 LAG与LEAD:让当前行跟上一次对比
窗口函数里还有一对非常实用的函数:LAG取当前行之前的某一行,LEAD取当前行之后的某一行。这个能力在做环比分析时极其有用。比如要看用户每个月消费的环比增长,传统做法是让本月数据LEFT JOIN上月数据,用窗口函数可以让当前行直接带上一次消费金额:
sql复制SELECT
user_id,
DATE_FORMAT(order_time, '%Y-%m') AS month,
SUM(amount) AS monthly_amount,
LAG(SUM(amount), 1) OVER (PARTITION BY user_id ORDER BY DATE_FORMAT(order_time, '%Y-%m')) AS prev_month_amount
FROM orders
GROUP BY user_id, DATE_FORMAT(order_time, '%Y-%m');
GROUP BY先把同一个用户同一个月份的订单汇总成一行,LAG再在这个结果基础上往前取一个月。注意LAG函数里也可以写聚合结果,因为窗口计算发生在GROUP BY之后。这样就能在当前行看到“上个月我消费了多少”。
4.4 滚动窗口与移动平均
窗口函数里还可以用ROWS BETWEEN指定窗口范围,做出移动平均这类效果。比如统计每个用户最近3笔订单的平均金额,用于观察消费趋势的变化:
sql复制SELECT
user_id,
order_time,
amount,
AVG(amount) OVER (
PARTITION BY user_id
ORDER BY order_time
ROWS BETWEEN 2 PRECEDING AND CURRENT ROW
) AS moving_avg_3
FROM orders;
ORDER BY order_time和ROWS BETWEEN 2 PRECEDING AND CURRENT ROW合并起来的意思就是:“把当前行和它前面的两行放在一起求平均”。这样每条订单记录旁边多了一列移动平均值,等于给离散的订单数据做了一次平滑处理,后续画趋势图或者判断用户消费是否有上升势头都很方便。
这里有一个容易踩的坑:窗口函数中的ORDER BY如果不写,窗口默认覆盖整个分区;如果写ORDER BY但没有ROWS BETWEEN,窗口默认是从分区第一行到当前行,而不是“当前行以及前后几行”这种传统的滑动窗口。理解这个默认行为,能解释很多窗口函数结果“为什么比预期大”的疑问。比如下面的写法:
sql复制SUM(amount) OVER (PARTITION BY user_id ORDER BY order_time)
它的意思是累计求和,从该用户的第一笔订单加到当前这笔,而不是只算3条。想限制在最近3笔一定要写明ROWS BETWEEN。
4.5 MySQL 8.0以下版本没有窗口函数怎么办
如果线上还是MySQL 5.7,窗口函数用不了,只能用用户变量模拟。比如实现“每个分类下按销量排序的编号”可以用这种写法:
sql复制SET @prev_category := NULL;
SET @rn := 0;
SELECT
category_id,
product_id,
sales_amount,
@rn := IF(@prev_category = category_id, @rn + 1, 1) AS rn,
@prev_category := category_id
FROM product_sales
ORDER BY category_id, sales_amount DESC;
核心思路是用一个临时变量记录上一个分类ID,当当前行的分类和上一个分类不同时重新从1开始编号,否则继续累加。这种方式能跑,但有一个致命问题:结果严重依赖SELECT输出顺序和变量的赋值顺序。MySQL优化器不保证输出顺序和赋值顺序一致,尤其SQL复杂以后极容易出现排序不对导致编号错乱。因此生产环境建议优先考虑升级到MySQL 8.0,如果实在无法升级,慎重使用变量方案,并做好充分的测试。
5. 这些函数容易踩的五个隐藏坑
5.1 WHERE里不能直接使用SELECT别名
很多人在写完带函数的长SELECT之后,顺手就在WHERE里用别名继续过滤:
sql复制SELECT
user_id,
SUM(amount) AS total_amount
FROM orders
WHERE total_amount > 500
GROUP BY user_id;
这段SQL放到MySQL里会直接报错。SQL的逻辑执行顺序里,WHERE发生在SELECT之前。也就是说,SELECT后面定义的别名total_amount,在WHERE执行的那个阶段还不存在。想过滤聚合结果,应该用HAVING,或者把整个SELECT作为子查询再包一层WHERE:
sql复制SELECT
user_id,
total_amount
FROM (
SELECT
user_id,
SUM(amount) AS total_amount
FROM orders
GROUP BY user_id
) t
WHERE total_amount > 500;
理解这个执行顺序,对于排查“函数结合别名报错”的问题至关重要。SQL语句的执行顺序大致为:FROM -> WHERE -> GROUP BY -> HAVING -> SELECT -> ORDER BY -> LIMIT。你在哪个阶段想引用哪个阶段的产物,直接决定句子能不能跑通。
5.2 函数包裹字段会让索引失效
在WHERE里直接对字段套函数,是导致慢查询的常见原因。比如:
sql复制WHERE DATE(create_time) = '2025-01-01'
这样写语法没问题,但create_time字段如果建了索引,MySQL无法用它,因为索引里存的是完整时间戳,不是DATE()函数处理后的值。等于放弃索引走全表扫描。正确的写法是直接用范围查询:
sql复制WHERE create_time >= '2025-01-01 00:00:00'
AND create_time < '2025-01-02 00:00:00'
同理,上文中提到的所有正则函数在WHERE里过滤都无法走索引,字符串函数如LEFT、SUBSTRING也是一样。所以使用内置函数时,要清楚地意识到:“函数处理列”几乎都是索引杀手”。如果查询性能是关键指标,优先把函数用在SELECT输出阶段,而不是WHERE过滤阶段;如果必须过滤,就用“函数处理常量”而不是“函数处理字段”。
举一个反直觉但正确的例子:
sql复制WHERE phone = 13800138000
很多人觉得没问题,但如果phone字段在表里是VARCHAR类型,这个等号左边是字符串字段、右边是数值常量,MySQL会隐式地把字段转换成数值再比较,一样让索引失效。应改成:
sql复制WHERE phone = '13800138000'
匹配字段存储类型来写常量,是避免隐式转换最简单的方法。
5.3 IFNULL和空字符串是两码事
我见过不少线上bug出在IFNULL只处理NULL、不处理空字符串''上。业务表里很多可空字段既有NULL,也有写入落库时的空字符串。IFNULL(address, '默认值')只能把真正的NULL替换成默认值,但如果address是空字符串,IFNULL原样返回空字符串。
对应策略是写一个更完整的判断:
sql复制CASE
WHEN address IS NULL OR address = '' THEN '默认值'
ELSE address
END
如果你不喜欢长CASE,也可以用NULLIF把空字符串先转成NULL,再交给IFNULL处理:
sql复制IFNULL(NULLIF(address, ''), '默认值')
NULLIF的作用是:当两个参数相等时返回NULL,不相等时返回第一个参数。NULLIF(address, '')就是把空字符串转换为NULL,随后IFNULL把它继续转成默认值。这个组合在数据清洗领域很常用,值得记下来。
5.4 GROUP_CONCAT结果被截断的隐藏因素
前面提到group_concat_max_len默认1024字节。还有一个很容易被忽略的地方:GROUP_CONCAT的DISTINCT去重发生在拼接之前,如果列里本身有大量重复值,DISTINCT能让结果短不少,但如果想去重后又希望保留所有行的原始顺序,代码会变得复杂。其实GROUP_CONCAT里的ORDER BY排序发生在GROUP BY分组之后,它只影响拼接顺序,不会改变WHERE和GROUP BY的过滤逻辑,这一点要和普通ORDER BY区分开。
万一拼接结果里出现分隔符字符,比如商品名里本身包含逗号,导出CSV后就会被拆列。解决思路是SEPARATOR换成一个不太可能出现的人工分隔符,比如|||,并在后续处理中统一替换。
5.5 正则函数的多字节和转义问题
最后再补一个中文环境下的实际问题。MySQL默认正则引擎对多字节字符的支持并不完美。REGEXP_LIKE判断一个字符串“是否包含中文”时,如果写成[一-龥]这种区间,不同字符集下表现可能不一致。更稳的方案是用HEX或者LENGTH配合字符集判断,或者直接交给程序应用层处理。
正则替换涉及反斜杠时,MySQL的字符串转义会先吃掉一层反斜杠,所以\\1在传给正则引擎时已经变成\1,这是反向引用的正确写法。如果你写\1,MySQL在字符串解析阶段就会报错或者当普通字符匹配。多写一层反斜杠是这类问题的通用解。
最后的建议
这一套函数组合覆盖了日常大部分数据处理场景,但真正工作里遇到问题时,我建议你先停下来想清楚:“哪个环节在消耗最多的查询时间?”如果是要做分组汇总,优先GROUP_CONCAT和窗口函数;如果清洗脏数据文本被搞了很久,优先正则三件套;如果发现一堆OR条件拼SQL拼到怀疑人生,试试CASE WHEN放进聚合函数里重构一遍。
另外提醒一句,函数能解决方便性问题,却不能解决数据质量本身的问题。NULL和空字符串都来自上游数据写入,能统一规范的地方最好在写入前解决,别把所有脏数据都交给SQL硬扛。我个人踩过几次坑后形成的习惯是:写任何带函数判断的SQL之前,先看一眼EXPLAIN执行计划,确认没有触发全表扫描。线上环境根本不在乎你的SQL用了多巧妙的函数,它只在乎哪些查询会拖垮CPU和IO。先吃饱这顿饭,再研究菜刀上雕花的事。
