做了多年的数据开发,我越来越觉得 MySQL内置函数 是个"天天用,但未必用得好"的东西。见过不少同事,一条统计 SQL 能绕到应用层写四五十行 Java,无非是拼接字符串、算日期、做排名这些事,其实 SQL 层的内置函数一次就能算完,既省代码,又避免了把全表数据拖回应用层再处理的尴尬。
这篇文章不做官方文档的搬运工,而是从实际项目里整理一些真正有价值的内容:字符串处理、日期时间、条件逻辑、聚合以及 8.0 引入的窗口函数,还有那些函数背后容易踩的暗坑。无论你是刚接触 MySQL 的新手,还是写过几年业务 SQL 的老手,希望这篇能帮你把内置函数用得更顺手,写出更短、更快、更好维护的查询。
1. 字符串类函数:从只会 CONCAT 到能处理真实脏数据
字符串函数是日常 CRUD 里出现频率最高的一类,但大部分人真正用顺手的也就是 CONCAT、SUBSTRING、LENGTH。真实业务里的数据往往远没有文档示例那么干净,字符串函数用得好不好,直接决定了你要不要在后端代码里做一堆文本处理。
1.1 拼接与拆分:CONCAT_WS 和 SUBSTRING_INDEX 的组合玩法
先说拼接。CONCAT 和 CONCAT_WS 的区别,经常被忽略:
SELECT CONCAT('2024', '-', '12', '-', '01'); -- 2024-12-01 SELECT CONCAT('2024', '-', NULL, '-', '01'); -- NULL,整个结果变 NULL SELECT CONCAT_WS('-', '2024', '12', '01', NULL); -- 2024-12-01,自动跳过 NULLCONCAT_WS 里的 WS 是 With Separator 的意思,第一个参数是分隔符。它在遇到 NULL 时不会像 CONCAT 那样把整条结果吞掉,这在拼接多段可选字段时非常实用。比如地址由省、市、区、详细地址四段组成,其中区可能为空,用 CONCAT_WS(' ', province, city, district, detail) 就不会因为某一段为空导致整条地址变 NULL。
再说拆分。SUBSTRING_INDEX 是处理逗号分隔、竖线分隔这类"一列多值"的神器:
SELECT SUBSTRING_INDEX('apple,banana,cherry', ',', 1); -- apple SELECT SUBSTRING_INDEX('apple,banana,cherry', ',', -1); -- cherry,-1 表示从右侧开始取 -- 取中间的 banana,需要嵌套一层 SELECT SUBSTRING_INDEX(SUBSTRING_INDEX('apple,banana,cherry', ',', 2), ',', -1);内层先截到前两个字段得到 'apple,banana',外层再用 -1 取最后一个逗号之后的内容,就拿到了中间段。这个方法在处理 tag、sku 编号这类冗余存储的字符串时很实用,省得在业务代码里 split 来 split 去。
配套的还有 FIND_IN_SET,用来判断某个值是否存在于逗号分隔的字符串里:
SELECT FIND_IN_SET('mysql', 'oracle,mysql,postgresql'); -- 返回 2,表示在第二个位置 SELECT FIND_IN_SET('redis', 'oracle,mysql,postgresql'); -- 返回 0,表示不存在注意 FIND_IN_SET 只支持逗号分隔,如果业务用自定义分隔符,还是得靠 LIKE 或者正则。
1.2 替换、去除与补位:容易被忽略的字符集细节
REPLACE 大家都会用,但多值替换时很多人只知道一层层嵌套:
-- 把文本里的回车和换行统一替换成空格 UPDATE article SET content = REPLACE(REPLACE(content, '\r\n', ' '), '\n', ' ');嵌套逻辑是从内往外执行的,内层先处理 \r\n,外层再处理剩余的 \n。洗数脚本里经常这么干,一次 UPDATE 就完成多步清洗。
TRIM 函数比很多人以为的复杂,它不仅能去掉首尾空格,还能指定去掉哪些字符:
SELECT TRIM(' abc '); -- 'abc' SELECT TRIM(BOTH 'x' FROM 'xabcx'); -- 'abc' SELECT TRIM(LEADING '0' FROM '00700'); -- '700'这里有个很大的误区:TRIM('abc' FROM 'abcxabc') 的结果不是 'x',准确说是 'x'。TRIM 的 FROM 语法是按"字符集合"去掉首尾匹配的字符,而不是按"整个子串"去除。所以 'abcxabc' 去掉首尾的 a、b、c 任意组合后,剩下的就是 'x'。如果你以为它是去掉字符串字面量 'abc' 得到 'x',那只是这个例子的巧合,换成 'abxabc' 就只剩 'x',但换成 'bcxabc' 结果依然是 'x'。理解成"逐字符剥离"就对了。
补位场景用 LPAD 和 RPAD。比如订单号要求统一 5 位,不足补零:
SELECT LPAD(7, 5, '0'); -- 00007 SELECT RPAD('abc', 5, '#'); -- abc##这类需求在做流水号、优惠券编号时很常见。
1.3 正则函数:MySQL 8.0 的文本清洗利器
如果你的 MySQL 还是 5.7,正则这块基本只能靠 REGEXP 运算符做判断,做不了替换和提取。升级到 8.0 后,REGEXP_LIKE、REGEXP_REPLACE、REGEXP_SUBSTR、REGEXP_INSTR 四个函数组合起来,文本清洗能力强了一大截。
-- 提取字符串里的数字 SELECT REGEXP_REPLACE('订单号: SO12345', '[^0-9]', ''); -- 12345 -- 判断是否包含数字 SELECT REGEXP_LIKE('mysql 8.0', '[0-9]'); -- 1 -- 提取第一个数字串 SELECT REGEXP_SUBSTR('序号: A-1024', '[0-9]+'); -- 1024正则函数最大的好处是把原来需要写存储过程或者后端遍历的活,简化成一条 SQL。代价是正则匹配性能不高,如果用在 WHERE 里基本就是全表扫描的命运,更适合在数据导入、清洗的离线场景中使用。
顺带提一句 JSON 函数。如果字段里存的是 JSON 串,8.0 的 JSON_EXTRACT 配合 ->> 运算符能非常方便地取值:
SELECT info->>'$.name' FROM user_profile WHERE id = 1;它等价于 JSON_UNQUOTE(JSON_EXTRACT(info, '$.name')),返回的不是带引号的 JSON 字符串,而是纯文本。日常接口日志、扩展字段存 JSON 的场景非常需要这个。
2. 日期时间函数:业务代码里最容易出暗坑的一块
日期函数的语法不难,难在隐藏的时区、格式和精度问题。我见过太多因为 NOW() 和 SYSDATE() 的差异导致的线上数据对不上,也见过很多人因为 DATE_FORMAT 用错格式符,把小时和分钟搞反。
2.1 当前时间函数:NOW 和 SYSDATE 的区别远比你想象的大
很多开发者默认 NOW() 和 SYSDATE() 是一样的,其实它们在事务行为上有本质区别:
SELECT NOW(), SYSDATE(), CURRENT_TIMESTAMP;NOW() 和 CURRENT_TIMESTAMP 返回的是语句开始执行的时间点,同一语句里多次调用 NOW() 结果一致。而 SYSDATE() 返回的是函数自身被执行的时间点,在不同行、不同子查询里可能不一样。这在一条复杂 SQL 里会造成"时间漂移",比如一条 INSERT ... SELECT 插一万行,用 SYSDATE() 写的每行创建时间可能不同,用 NOW() 则是同一时间。
平时开发建议默认用 NOW() 或 CURRENT_TIMESTAMP。只有在明确需要"当前精确时刻"的审计场景,才考虑 SYSDATE()。另外,SYSDATE() 在复制环境下会导致主从数据不一致,严格模式下的 MySQL 甚至可以直接忽略这个函数。
2.2 日期计算与格式化:DATE_ADD、DATEDIFF、TIMESTAMPDIFF 的正确打开方式
DATE_ADD 和 DATE_SUB 用于日期加减:
SELECT DATE_ADD('2024-01-31', INTERVAL 1 MONTH); -- 2024-02-29,自动处理月末边界 SELECT DATE_SUB('2024-03-01', INTERVAL 1 DAY); -- 2024-02-29 SELECT DATE_ADD(NOW(), INTERVAL 7 DAY); -- 一周后的同一时刻INTERVAL 支持 YEAR、QUARTER、MONTH、WEEK、DAY、HOUR、MINUTE、SECOND,非常灵活。处理"月末+1月"这类需求时,MySQL 会自动做边界处理,不会像某些语言库那样直接报错或者跑到下下月。
DATEDIFF 只按日期部分算天数差,返回的是 expr1 减去 expr2 的结果:
SELECT DATEDIFF('2024-03-01', '2024-02-01'); -- 29如果需要更精确的时间差,用 TIMESTAMPDIFF:
SELECT TIMESTAMPDIFF(HOUR, '2024-01-01 00:00:00', '2024-01-02 12:00:00'); -- 36注意 TIMESTAMPDIFF 的参数顺序是 UNIT, begin, end,也就是"结束时间减开始时间",和 DATEDIFF 的参数顺序正好相反。这个差异经常让人写出负数的差值,排查老半天。
DATE_FORMAT 格式化输出是统计报表的标配:
SELECT DATE_FORMAT('2024-06-18 15:30:45', '%Y-%m-%d %H:%i:%s'); -- 2024-06-18 15:30:45常用格式符可以记这么几个:%Y 四位年份、%y 两位年份、%m 两位月份、%d 两位日、%H 24 小时制、%h 12 小时制、%i 分钟、%s 秒、%W 星期英文名、%M 月份英文名。最容易写错的是把分钟写成 %m,把 24 小时制写成 %h,结果出现 15 点被格式化成 03 点这种诡异数据。
2.3 日期分组的统计暗坑:别把索引让给格式化函数
做日报、月报时,新手最喜欢这么写:
SELECT DATE_FORMAT(created_at, '%Y-%m-%d') AS day, COUNT(*) FROM orders WHERE DATE_FORMAT(created_at, '%Y-%m-%d') >= '2024-06-01' GROUP BY DATE_FORMAT(created_at, '%Y-%m-%d');问题在于 WHERE 里对 created_at 用了函数包裹,即使 created_at 有索引也基本失效,数据量一大就是全表扫描。更合理的做法是先把范围算出来,再对原始列做范围比较:
SELECT DATE_FORMAT(created_at, '%Y-%m-%d') AS day, COUNT(*) FROM orders WHERE created_at >= '2024-06-01 00:00:00' AND created_at < '2024-07-01 00:00:00' GROUP BY DATE_FORMAT(created_at, '%Y-%m-%d');GROUP BY 里保留 DATE_FORMAT 没问题,因为分组操作无论如何都要处理这一列,真正伤索引的是 WHERE 里的函数包裹。这个思路适用于所有日期统计类查询。
时区问题也不能忽视。如果数据库实例、JDBC 连接串、操作系统时区不一致,会出现查出来的时间和实际时间差 8 小时的现象。MySQL 提供了 CONVERT_TZ 做时区转换,但更省心的做法是在连接串里显式指定 serverTimezone,让各个层面时区统一。Docker 部署的 MySQL 尤其容易踩这个坑,容器默认 UTC,记得在启动时挂载 /etc/localtime 或设置 TZ 环境变量。
3. 条件判断与数值函数:把业务逻辑收拢到 SQL 层
很多业务逻辑看着复杂,其实用条件函数和数学函数可以在一层 SELECT 里算完,既减少了代码量,又保证计算逻辑集中在数据库这一侧,方便维护和复用。
3.1 IF、IFNULL、NULLIF:三个看似相似实则各有分工的函数
IF 是最简单的条件函数,等价于三目运算符:
SELECT IF(price > 100, '贵', '便宜') FROM product;IFNULL 专门处理空值:
SELECT IFNULL(remark, '暂无备注') FROM orders;NULLIF 则常常被低估,它的逻辑是:两个参数相等返回 NULL,不相等返回第一个参数。
NULLIF 在防除零场景特别有用:
SELECT total_amount / NULLIF(item_count, 0) FROM orders_detail;如果 item_count 是 0,NULLIF(0, 0) 返回 NULL,整个除法结果是 NULL,而不是 MySQL 默认的 NULL。虽然结果还是 NULL,但至少不会触发除零错误,也不会生成特别离谱的无穷大值。后续再用 IFNULL 包一层就可以显示成 0 或提示文案。
这三个函数的共同点是只能做简单二元判断。逻辑分支一旦超过三种,就应该切到 CASE WHEN,而不是用 IF 和多层 IFNULL 嵌套,否则可读性会迅速恶化。
3.2 数值计算的精度问题:ROUND 的浮点陷阱
数值函数本身不难,难在精度。最经典的例子:
SELECT ROUND(2.675, 2); -- 2.67,而不是期望的 2.68 SELECT CAST(2.675 AS DECIMAL(10,2)); -- 2.68原因是 2.675 在二进制浮点数里存的是一个略小于 2.675 的值,等转成两位小数时被四舍五入成了 2.67。而 DECIMAL 是定点数,按十进制精度存储,不会有这个问题。所以涉及金额、折扣、税率的计算,建议直接用 DECIMAL 类型,或者把计算过程包在 CAST 里。
还有一个容易忽略的细节:ROUND 一个整数时不会自动补小数位。
SELECT ROUND(10, 2); -- 10,不是 10.00 SELECT ROUND(10 / 8, 2); -- 1.25,因为除法本身产生了小数如果你的业务要求统一输出两位小数的字符串,ROUND 解决不了格式问题,得靠 FORMAT 或者 DATE_FORMAT(对,它也能格式数字)配合处理。
其他常用数值函数,顺手列一下:
SELECT CEIL(4.2), FLOOR(4.8); -- 5, 4 SELECT ABS(-10); -- 10 SELECT MOD(17, 5); -- 2 SELECT POWER(2, 10); -- 1024CEIL 向上取整、FLOOR 向下取整,在处理库存、分页、分摊金额时经常用到。MOD 除了取余,还能用来做奇偶判断、分表路由,比如 WHERE MOD(id, 2) = 1 筛选奇数 ID。
3.3 CASE WHEN:别在 WHERE 里为了"灵活"牺牲性能
CASE WHEN 在 SELECT 里做状态翻译、等级判断是最高效的用法:
SELECT username, CASE WHEN score >= 90 THEN 'A' WHEN score >= 60 THEN 'B' ELSE 'C' END AS grade FROM student;很多人习惯用 CASE WHEN 在 WHERE 里写"动态条件",比如根据某个参数决定比较方式:
SELECT * FROM orders WHERE CASE WHEN :filterType = 1 THEN amount > 100 WHEN :filterType = 2 THEN amount < 50 ELSE status = 'NORMAL' END;这种写法看起来支持了"多条件",但实际会让优化器无计可施,索引大概率失效,还得冒着类型不一致的风险。如果条件分支是固定的,不如直接拆成两条 SQL,或者在应用层组装 WHERE 条件。CASE WHEN 最擅长的场景是行内的逻辑变换,而不是改变查询的过滤路径。
4. 聚合与窗口函数:从 GROUP BY 到 8.0 的统计进阶
聚合函数是报表类 SQL 的主角,但很多人用 COUNT、GROUP_CONCAT 只停留在最表面。窗口函数则是在 MySQL 8.0 才得到完整支持,掌握之后很多"分组TopN""累计求和"类需求写起来非常痛快。
4.1 聚合函数里容易被忽略的三个细节
第一个细节是 COUNT 的语义。COUNT() 统计所有行数,包括 NULL;COUNT(col) 只统计该列非 NULL 的行数;COUNT(DISTINCT col) 统计去重后的非 NULL 值个数。很多人以为 COUNT(1) 和 COUNT() 性能有差异,其实 InnoDB 里两者基本没有区别,真正有语义区别的是 COUNT(col)。
第二个细节是 SUM 和 AVG 遇到 NULL 的处理方式。NULL 不参与计算,也不会报错。比如一列数据是 100、NULL、200,SUM 结果是 300,AVG 结果是 150,而不是把 NULL 当 0 处理。如果你希望 NULL 按 0 参与均值,必须先套 COALESCE 或 IFNULL。
第三个细节是 GROUP_CONCAT 的长度限制。默认的 group_concat_max_len 是 1024 字节,超过部分会被静默截断。拼接较长的文本列表时要记得先调大:
SET SESSION group_concat_max_len = 10240; SELECT dept_id, GROUP_CONCAT(DISTINCT employee_name ORDER BY employee_name SEPARATOR ';') FROM employee GROUP BY dept_id;GROUP_CONCAT 内部支持去重、排序、自定义分隔符,一个函数能顶多个步骤。
4.2 ROW_NUMBER、RANK、DENSE_RANK:三种排名的区别要分清
窗口函数第一步就是理解三种排名。看下面这个例子:
SELECT employee_id, salary, ROW_NUMBER() OVER (ORDER BY salary DESC) AS row_num, RANK() OVER (ORDER BY salary DESC) AS rank_val, DENSE_RANK() OVER (ORDER BY salary DESC) AS dense_val FROM employee;三种函数遇到相同工资时的表现完全不同:
| 函数 | 相同工资处理 | 下一名次 | 典型场景 |
|---|---|---|---|
| ROW_NUMBER | 随机指定连续序号 | 顺序递增 | 取 TopN、分页、去重 |
| RANK | 相同工资名次相同 | 跳跃,比如 1,1,3 | 竞赛排名 |
| DENSE_RANK | 相同工资名次相同 | 连续,比如 1,1,2 | 等级划分 |
实际业务里,分组取每组最新一条记录,几乎都是 ROW_NUMBER 的活:
SELECT * FROM ( SELECT *, ROW_NUMBER() OVER (PARTITION BY user_id ORDER BY created_at DESC) AS rn FROM login_log ) t WHERE rn = 1;PARTITION BY 负责分组,ORDER BY 控制组内排序,rn = 1 取每组最新记录。这个写法比自己 JOIN 自己要清晰得多,性能也往往更好。
4.3 窗口聚合与 LAG/LEAD:累计值与环比的实现
窗口函数不仅能排名,还能做聚合,语法是在聚合函数后面加 OVER:
SELECT order_date, amount, SUM(amount) OVER (ORDER BY order_date) AS running_total FROM daily_sales;这条 SQL 算的是按日期排序的累计销售额,一行 SQL 就完成了过去需要用变量或者自连接实现的滚动求和。
同比环比是报表里的高频需求,用 LAG 实现非常直接:
SELECT order_date, amount, LAG(amount, 1) OVER (ORDER BY order_date) AS prev_day_amount, ROUND((amount - LAG(amount, 1) OVER (ORDER BY order_date)) / LAG(amount, 1) OVER (ORDER BY order_date) * 100, 2) AS growth_rate FROM daily_sales;LAG 的第二个参数是向上偏移几个窗口行,默认 1,第三个参数可以指定没有前一行时返回的默认值,避免 NULL 干扰计算。LEAD 则是往下偏移,适合算"距离下一次操作的时间差"这类需求。
窗口函数的功能远不止这些,还有 FIRST_VALUE、LAST_VALUE、NTILE 分桶等。掌握 ROW_NUMBER、SUM OVER、LAG/LEAD 三个,基本能覆盖 80% 的业务场景。
5. 用函数时躲不开的性能与安全红线
函数用得越欢,越要清楚它的副作用。很多线上慢查询,问题不在表结构,而在函数使用方式上。这里把几个高频坑集中列一下。
5.1 隐式类型转换:索引失效的经典元凶
MySQL 在做字符串和数字比较时,会自动把两边转成浮点数。这个转换发生在哪一侧,对索引的影响完全不同:
-- mobile 是 varchar,与数字比较会导致 mobile 列隐式转数字,索引失效 SELECT * FROM user WHERE mobile = 13800138000; -- 参数是字符串,与 varchar 列类型一致,索引正常生效 SELECT * FROM user WHERE mobile = '13800138000';第一条 SQL 里,数据库会对每一行的 mobile 做类型转换,等于用 CAST(mobile AS DOUBLE) 去过滤,索引自然没法用。第二条参数类型与列类型一致,直接匹配,能走正常索引。这个坑特别隐蔽,因为结果往往是对的,直到数据量上去了才发现查询越来越慢。
验证方法很简单,EXPLAIN 看 type 列,如果从 ref 变成了 ALL,基本就是隐式转换在捣乱。
5.2 函数包裹列与函数索引的取舍
WHERE 条件里对索引列包函数,同样会导致索引失效。最典型的就是:
SELECT * FROM orders WHERE DATE(created_at) = CURDATE();这条 SQL 逻辑看着没问题,但 created_at 的索引用不上,只能全表扫。改成范围查询就能保住索引:
SELECT * FROM orders WHERE created_at >= CURDATE() AND created_at < CURDATE() + INTERVAL 1 DAY;如果你确实没法改写法,MySQL 8.0 提供了函数索引,可以对表达式建索引:
CREATE INDEX idx_order_date ON orders ((DATE(created_at)));注意函数索引的语法是函数外多加一层括号。它能解决一部分不得不写函数包裹的场景,但代价是写入时多一层计算,索引维护成本也更高。能改 SQL 的时候,优先改 SQL。
5.3 NULL 参与逻辑运算时的反直觉行为
NULL 与普通值的比较结果永远是 NULL,而 NULL 在 WHERE 里被视为假。这导致一个经典坑:
-- 如果 status 有 NULL 值,这条 SQL 会漏掉这些行 SELECT * FROM orders WHERE status != 'PAID'; -- 必须显式加上 IS NULL 条件 SELECT * FROM orders WHERE status != 'PAID' OR status IS NULL;还有 NOT IN 子查询的坑。如果子查询返回的结果里包含 NULL,那整条 NOT IN 的结果会是空集:
SELECT * FROM child WHERE parent_id NOT IN (SELECT id FROM parent WHERE id IN (...));只要子查询结果中出现 NULL,NOT IN 就匹配不到任何行。稳妥的做法是子查询里显式过滤掉 NULL,或者改用 NOT EXISTS:
SELECT * FROM child c WHERE NOT EXISTS (SELECT 1 FROM parent p WHERE p.id = c.parent_id AND p.id IN (...));处理 NULL 的通用原则是:在计数时用 COUNT(字段) 判断非空数量;在比较时优先用 IS NULL / IS NOT NULL;在需要"兜底值"的展示场景用 COALESCE 或 IFNULL;避免让 NULL 参与到 NOT IN、!=、加法和乘法这类运算中。
最后分享一个我自己的习惯:写完一条带内置函数的 SQL,先不要急着去核对结果,第一件事是 EXPLAIN。函数用得好不好,优化器会直接告诉你答案。很多看起来玄乎的慢查询,本质上不是函数本身慢,而是函数让索引失效、让类型错位、让 NULL 语义跑偏。把这些红线避开,内置函数就是提升开发效率的好帮手。