☰
MySQL内置函数避坑指南:字符串、日期、窗口函数与性能优化
2026/10/2 14:33:10 网站建设 项目流程

做了多年的数据开发,我越来越觉得 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,自动跳过 NULL

CONCAT_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); -- 1024

CEIL 向上取整、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 语义跑偏。把这些红线避开,内置函数就是提升开发效率的好帮手。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询