在工作中接触过不少用 MySQL 的朋友,尤其是刚入门或者写业务 SQL 比较多的同学,最常翻文档的就是这三类函数:日期格式转换、字符串处理、聚合统计。说实话,这三类函数覆盖了日常开发里至少七成以上的 SQL 编写场景。但很多人对它们的理解停留在"单个函数知道大概意思",一旦遇到真实业务需求——比如统计某个月的每日新增、把手机号做脱敏展示、计算每个分类下的订单占比——就不知道怎么组合使用,或者写出来的 SQL 效率很差。
这篇内容我就按实际使用频率和业务场景来梳理 MySQL 常用的日期格式化与转换函数、字符串函数、聚合函数。不只是罗列语法,更重要的是告诉你每个函数在什么场景下用、有什么坑、怎么和别的函数搭配。无论你是刚学 MySQL 的新手,还是写了好几年业务 SQL 的老手,这篇都值得收藏起来当工具手册用。
1. 日期与时间格式化:从时间戳到报表的一站式处理
1.1 当前时间获取:NOW、CURDATE、CURTIME 怎么选
先说最基础的:获取当前时间。MySQL 里常用的有三个函数,分别是 NOW()、CURDATE()、CURTIME()。它们的区别很直白:
NOW()返回完整的日期和时间,格式是YYYY-MM-DD HH:MM:SS,比如2025-01-15 14:23:45。CURDATE()只返回当前日期,格式是YYYY-MM-DD。CURTIME()只返回当前时间,格式是HH:MM:SS。
这三个函数在实际使用中最容易忽略的点是:NOW() 在一条 SQL 语句中多次调用时,返回的是语句开始执行的时间点,而不是每次调用时的实时时间。这一点在写存储过程或者批量插入数据时特别有用。如果你需要的是每条记录插入时的精确时间,可以考虑 SYSDATE(),它能取到函数执行那一刻的时间。但在绝大多数业务场景里,我建议统一用 NOW(),因为它能保证同一事务内取到的时间是一致的,避免出现"同一批数据前后时间差了几秒"这种奇怪的现象。
另外补充一个日常挺实用的小技巧:NOW() 也可以直接参与日期运算,比如 NOW() + INTERVAL 1 DAY 表示明天的这个时候。这种写法比先转成字符串再拼要优雅得多。
1.2 DATE_FORMAT 格式化:报表里最常用的一个函数
如果说日期函数里只能记住一个,那必须是 DATE_FORMAT()。它的作用是把日期时间按照你指定的格式转成字符串,常用于报表展示、分组统计、日志分析等场景。
基本语法:
sql复制DATE_FORMAT(date, format)
其中 format 是格式符,MySQL 官方文档有一套完整的格式符体系,我挑最常用的整理一下:
| 格式符 | 含义 | 示例 |
|---|---|---|
| %Y | 四位数年份 | 2025 |
| %y | 两位数年份 | 25 |
| %m | 两位数月份 | 01 |
| %c | 月份(1-12,不加前导零) | 1 |
| %d | 两位数日期 | 15 |
| %e | 日期(1-31,不加前导零) | 15 |
| %H | 24小时制小时 | 14 |
| %h | 12小时制小时 | 02 |
| %i | 分钟 | 23 |
| %s | 秒 | 45 |
| %W | 星期几英文名 | Wednesday |
| %a | 星期几英文缩写 | Wed |
| %M | 月份英文名 | January |
| %b | 月份英文缩写 | Jan |
| %p | AM或PM | PM |
实际业务里最常见的写法是:
sql复制-- 标准日期时间字符串
SELECT DATE_FORMAT(NOW(), '%Y-%m-%d %H:%i:%s');
-- 结果:2025-01-15 14:23:45
-- 只取到天
SELECT DATE_FORMAT(NOW(), '%Y-%m-%d');
-- 结果:2025-01-15
-- 分组统计时按年月聚合
SELECT DATE_FORMAT(create_time, '%Y-%m') AS month, COUNT(*)
FROM orders
GROUP BY DATE_FORMAT(create_time, '%Y-%m');
我第一次用这个函数的时候踩过一个坑:%i 是分钟,%s 是秒,但很多人会习惯性写成 %M 和 %S。%M 是月份的英文全称,%S 在 MySQL 里其实不算标准格式符(有些版本对大小写不敏感,但官方文档推荐用小写)。如果真的把 %M 用在时间格式化里,返回的会是一串英文月份名,而不是分钟,这在报表里就是明显的数据错误。所以建议直接背下这组常用组合,别每次靠猜。
1.3 STR_TO_DATE:字符串反解析回日期类型
DATE_FORMAT 是把日期转成字符串,反过来 STR_TO_DATE() 则是把字符串解析成日期类型。它特别适合处理外部数据导入、接口入参转换等场景。
语法:
sql复制STR_TO_DATE(str, format)
比如:
sql复制-- 把 '2025/01/15' 转成日期
SELECT STR_TO_DATE('2025/01/15', '%Y/%m/%d');
-- 把带时间的字符串转成 datetime
SELECT STR_TO_DATE('2025-01-15 14:30:00', '%Y-%m-%d %H:%i:%s');
-- 处理只有时分秒的时间字符串
SELECT STR_TO_DATE('14:30:00', '%H:%i:%s');
需要注意的点:STR_TO_DATE() 解析失败时会返回 NULL,而不是报错。这在数据清洗场景里是把双刃剑——一方面它不会让整个 SQL 中断,另一方面如果入参有脏数据,你会默默拿到 NULL 而不知道哪条数据出了问题。我在实际处理导入数据时,习惯先做一轮 SELECT 排查,把 STR_TO_DATE(某个字段, '%Y-%m-%d') IS NULL AND 某个字段 IS NOT NULL 的记录捞出来看看,确认格式异常的数据范围再决定是清洗还是反回给上游。
另一个常见的坑是:%Y 和 %y 的区别。解析带四位年份的字符串必须用 %Y,用 %y 的话 MySQL 会把 2025 解析成 2020 加两位,结果完全不对。反之,如果只有两位年份,用 %y 才合适。
1.4 UNIX_TIMESTAMP 与 FROM_UNIXTIME:时间戳互转
现在的后端接口,尤其是和 Java、Go 服务对接时,时间字段经常用 Unix 时间戳(从 1970-01-01 00:00:00 UTC 到现在的秒数)来传输。MySQL 里对应有两个函数:
sql复制-- 日期时间转时间戳
SELECT UNIX_TIMESTAMP('2025-01-15 14:30:00');
-- 结果类似:1736922600
-- 时间戳转日期时间
SELECT FROM_UNIXTIME(1736922600);
-- 结果:2025-01-15 14:30:00
-- 时间戳转指定格式
SELECT FROM_UNIXTIME(1736922600, '%Y-%m-%d');
-- 结果:2025-01-15
UNIX_TIMESTAMP() 不传参时返回当前时间的时间戳。这个函数有一个在使用上容易被忽略的时区问题:MySQL 的 time_zone 参数会影响转换结果。如果你的服务器和数据库时区设置不一致,同一份时间戳数据可能在不同环境里看到不同的本地时间。所以做时间戳转换前,先确认数据库的时区设置,避免线上和本地结果不一致。
对于国内业务来说,通常把数据库时区设为 +08:00 就能保证 FROM_UNIXTIME 返回北京时间。如果时区设置不对,宁可统一在应用层做转换,也不要一个项目里混着两套逻辑。
1.5 日期加减和差值计算:DATE_ADD、DATE_SUB、DATEDIFF、TIMESTAMPDIFF
日期加减在业务里非常常见,比如算 30 天前、上个月第一天、两个日期之间隔了多少天。MySQL 提供了一组日期运算函数:
sql复制-- 加一天
SELECT DATE_ADD('2025-01-15', INTERVAL 1 DAY);
-- 结果:2025-01-16
-- 减一个月
SELECT DATE_SUB('2025-01-15', INTERVAL 1 MONTH);
-- 结果:2024-12-15
-- 也可以写成加负数
SELECT DATE_ADD('2025-01-15', INTERVAL -1 MONTH);
INTERVAL 后面的单位可以是 DAY、MONTH、YEAR、HOUR、MINUTE、SECOND、QUARTER 等,基本覆盖所有业务周期。
如果只是简单地"加一天",也可以直接写 date + INTERVAL 1 DAY,效果等价。
计算两个日期之间的间隔,有两个函数容易混淆:
DATEDIFF(date1, date2):返回两个日期相差的天数,只比较日期部分,不比较时间。TIMESTAMPDIFF(unit, date1, date2):返回两个日期在指定单位下的差值,单位可以是SECOND、MINUTE、HOUR、DAY、MONTH、YEAR等。
举个例子:
sql复制SELECT DATEDIFF('2025-03-01', '2025-01-15');
-- 结果:45
SELECT TIMESTAMPDIFF(MONTH, '2025-01-15', '2025-03-01');
-- 结果:1(不足完整两个月,取整)
注意 TIMESTAMPDIFF 是拿后面的减去前面的,顺序反了结果就是负数。我写月差统计时经常用它算用户年龄、会员到期剩余月份这些。
1.6 提取日期中的部分:YEAR、MONTH、DAY、DAYOFWEEK
有时候我们不需要整个日期,只需要从日期里抽出年份、月份、季度等信息。这类函数有:
sql复制SELECT YEAR('2025-01-15'); -- 2025
SELECT MONTH('2025-01-15'); -- 1
SELECT DAY('2025-01-15'); -- 15
SELECT QUARTER('2025-01-15'); -- 1
SELECT DAYOFWEEK('2025-01-15'); -- 4(1=周日,4=周三)
SELECT DAYOFYEAR('2025-01-15'); -- 15
DAYOFWEEK 返回的是 1 到 7,对应周日到周六,这和国内习惯"周一为一周开始"不一样,如果不注意很容易写错周报的统计逻辑。想按周一到周日排序或者统计,通常要用 WEEKDAY() 函数,它返回 0 到 6,对应周一到周日。
这类提取函数在实际场景里最常用的是配合 GROUP BY 做按年、按月、按周分组统计。比如统计每个月新增用户数:
sql复制SELECT YEAR(create_time) AS yr, MONTH(create_time) AS mon, COUNT(*)
FROM users
GROUP BY YEAR(create_time), MONTH(create_time);
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 字符串函数:从拼接到清洗的实用函数矩阵
2.1 CONCAT 与 CONCAT_WS:拼接字符串的正确姿势
字符串拼接是日常写 SQL 时最基础也最容易出错的操作。CONCAT() 可以把多个字符串参数拼接成一个字符串:
sql复制SELECT CONCAT('MySQL', '日期函数', '实战');
-- 结果:MySQL日期函数实战
但是 CONCAT 有一个大坑:如果任何一个参数为 NULL,整个结果就是 NULL。这在拼接用户姓名、地址等字段时特别容易出问题。举个例子,你有两张表分别存用户的姓和名,如果中间名是 NULL,那最后拼出来的名字会整体变成 NULL。
解决方案有两个:一是用 IFNULL() 把可能为空的字段兜底,二是直接用 CONCAT_WS()。CONCAT_WS 是 CONCAT With Separator,第一个参数是指定的分隔符,后面各参数中如果有 NULL,它会自动跳过而不是让整个结果变 NULL:
sql复制-- 用逗号分隔拼接
SELECT CONCAT_WS(',', 'a', 'b', 'c');
-- 结果:a,b,c
-- NULL 会被跳过
SELECT CONCAT_WS(' ', '张', NULL, '三');
-- 结果:张 三
这个特性在处理地址拼接、标签拼接、多字段组合显示时非常省心,建议优先使用。
2.2 SUBSTRING、LEFT、RIGHT 与 SUBSTRING_INDEX:灵活的截取方案
截取字符串是数据清洗里频繁用到的操作。MySQL 提供了一组截取函数:
sql复制-- 从第2个字符开始截取3个字符
SELECT SUBSTRING('abcdef', 2, 3);
-- 结果:bcd
-- 从左边截取3个字符
SELECT LEFT('abcdef', 3);
-- 结果:abc
-- 从右边截取2个字符
SELECT RIGHT('abcdef', 2);
-- 结果:ef
SUBSTRING 的起始位置也可以传负数,表示从字符串末尾往前数:
sql复制SELECT SUBSTRING('abcdef', -3, 2);
-- 结果:de
在实际业务里,我更喜欢用 SUBSTRING_INDEX 处理按分隔符拆分的场景,它比 SUBSTRING 更直观。语法是:
sql复制SUBSTRING_INDEX(str, delimiter, count)
count 为正数时从左往右截取到第 count 个分隔符之前;为负数时从右往左截取到倒数第 count 个分隔符之后。
sql复制-- 获取邮箱前缀
SELECT SUBSTRING_INDEX('user@example.com', '@', 1);
-- 结果:user
-- 获取邮箱域名
SELECT SUBSTRING_INDEX('user@example.com', '@', -1);
-- 结果:example.com
-- 截取 IP 的前两段
SELECT SUBSTRING_INDEX('192.168.1.100', '.', 2);
-- 结果:192.168
SUBSTRING_INDEX 在解析日志、拆分 CSV 字段、处理路径等场景里几乎是无敌的存在。不过要注意,MySQL 里没有像 Python 的 split() 那样直接返回数组的函数,需要多次截取时就得嵌套使用,灵活度稍差一些。
2.3 替换与去空格:REPLACE、TRIM、LTRIM、RTRIM
数据清洗中,处理脏数据最常见的就是去掉空格和替换指定字符。
REPLACE(str, from_str, to_str) 的功能是把字符串中的指定子串全部替换成新子串:
sql复制SELECT REPLACE('2025/01/15', '/', '-');
-- 结果:2025-01-15
SELECT REPLACE('MySQL MySQL MySQL', 'MySQL', 'Mysql');
-- 结果:Mysql Mysql Mysql
注意 REPLACE 是全局替换,不是只替换第一次出现的位置。如果你想只替换第一个匹配项,MySQL 没有直接提供类似 REGEXP_REPLACE 的"只替换第一个"参数,需要结合 LOCATE 和 SUBSTRING 手工处理,不过在绝大多数业务场景里全局替换就够了。
去空格方面:
TRIM(str):去除字符串首尾两端的空格。LTRIM(str):去除左侧空格。RTRIM(str):去除右侧空格。
sql复制SELECT TRIM(' MySQL ');
-- 结果:MySQL
MySQL 8.0 以上的 TRIM 还支持指定去除字符:
sql复制-- 去除字符串首尾的逗号
SELECT TRIM(BOTH ',' FROM ',MySQL,');
-- 结果:MySQL
-- 去除左侧的前置零
SELECT TRIM(LEADING '0' FROM '007');
-- 结果:7
这个语法在标准化数据时还挺好用的,尤其是处理编码不规范的产品型号、身份证号之类。
2.4 查找定位:LOCATE、INSTR、FIELD
字符串查找定位主要有三个函数:
LOCATE(substr, str[, pos]):返回子串第一次出现的位置,找不到返回 0。第三个参数可以指定从第几个字符开始找。INSTR(str, substr):等价于LOCATE的简单形式,返回子串第一次出现的位置。FIELD(str, str1, str2, ...):返回str在后面的参数列表中的位置,常用于自定义排序。
sql复制SELECT LOCATE('sql', 'mysql');
-- 结果:3
SELECT INSTR('mysql', 'sql');
-- 结果:3
SELECT LOCATE('a', 'banana', 2);
-- 结果:2(从第2位开始找,第一个就是a)
-- 自定义排序:按指定顺序排列状态
SELECT FIELD(status, 'SUCCESS', 'PENDING', 'FAILED')
FROM orders
ORDER BY FIELD(status, 'SUCCESS', 'PENDING', 'FAILED');
LOCATE 的结果配合 SUBSTRING 可以做到按动态位置截取,比如截取某个标记之后的内容。实际处理日志时,我经常用 SUBSTRING(str, LOCATE('key=', str) + 4) 这种写法来提取某个键对应的值,比正则更轻量。
FIELD 函数在自定义排序里特别实用。比如要按某个业务优先级排序,而不是按字母或数字顺序,ORDER BY FIELD(status, 'SUCCESS', 'PENDING', 'FAILED') 就能优雅实现。
2.5 大小写转换与填充补齐:LOWER、UPPER、LPAD、RPAD
大小写转换很简单:
sql复制SELECT LOWER('MySQL');
-- 结果:mysql
SELECT UPPER('mysql');
-- 结果:MYSQL
LPAD 和 RPAD 则用来填充字符串到指定长度。这在生成订单号、补齐编号、格式化展示时很常用:
sql复制-- 左侧用0补到6位
SELECT LPAD('42', 6, '0');
-- 结果:000042
-- 右侧用*补到5位
SELECT RPAD('abc', 5, '*');
-- 结果:abc**
需要注意,如果原字符串长度已经超过目标长度,LPAD/RPAD 会从右侧截断原字符串,而不是保留完整的原字符串。比如 LPAD('abcdef', 3, '0') 返回 abc,这在生成固定宽度编号时容易让人意外。
2.6 LENGTH 与 CHAR_LENGTH:别再搞混字节数和字符数
LENGTH() 返回字符串的字节数,CHAR_LENGTH() 返回字符串的字符数。这个区别在处理中文、表情符号时非常关键。
sql复制SELECT LENGTH('MySQL');
-- 结果:5
SELECT CHAR_LENGTH('MySQL');
-- 结果:5
-- 中文字符在 utf8mb4 下占3~4个字节,但字符数就是1
SELECT LENGTH('数据库'), CHAR_LENGTH('数据库');
-- 结果:9, 3
如果表的字符集是 utf8mb4,一个汉字占 3 个字节。所以当你想限制用户输入的字符个数时,记得用 CHAR_LENGTH 而不是 LENGTH。写字段校验的 SQL 时,这个差异会直接导致长度判断错误。
手机号脱敏也是字符串函数组合的经典场景,后面单独说。总而言之,字符串函数是数据清洗和展示层处理的基础,掌握好这批函数,很多看起来复杂的 SQL 只需要一两行就能搞定。
3. 聚合函数:从简单计数到复杂统计
3.1 基础聚合:COUNT、SUM、AVG、MAX、MIN
聚合函数大家都不陌生,但深入细节后会发现问题不少。先看基础用法:
sql复制-- 总行数
SELECT COUNT(*) FROM orders;
-- 某字段非空值的数量
SELECT COUNT(pay_time) FROM orders;
-- 求和
SELECT SUM(amount) FROM orders;
-- 平均值
SELECT AVG(amount) FROM orders;
-- 最大值、最小值
SELECT MAX(amount), MIN(amount) FROM orders;
有一个很经典的易错点是:AVG 会自动忽略 NULL 值,但如果所有值都是 NULL,结果也是 NULL。SUM 同样忽略 NULL,但如果全是 NULL,结果为 NULL 而不是 0。很多人在统计报表时直接拿 SUM(amount) 的结果做除法,结果发现是 NULL 导致整个计算链断裂,其实用 IFNULL(SUM(amount), 0) 兜底就行。
3.2 COUNT(*) 与 COUNT(1)、COUNT(字段) 的差异
先给结论:COUNT(*) 和 COUNT(1) 在没有 WHERE 条件下性能基本等价,MySQL 对 COUNT(*) 有专门优化。真正需要注意的是 COUNT(字段):
COUNT(*):统计结果集的总行数,不管有没有 NULL。COUNT(1):统计结果集的总行数,等价于COUNT(*)。COUNT(字段):只统计该字段非 NULL 的行数。
sql复制-- 假设 orders 表有 10 行,其中 3 行 pay_time 为 NULL
SELECT COUNT(*), COUNT(pay_time) FROM orders;
-- 结果:10, 7
这个差异在做"某操作完成率"时特别有用。比如统计订单支付率,直接 COUNT(pay_time) / COUNT(*) 就能得到结果,不用额外写 WHERE pay_time IS NOT NULL 子查询。
3.3 条件聚合:COUNT + CASE WHEN 组合
聚合函数配合 CASE WHEN 可以做条件统计,这是写报表 SQL 必备的技能。比如统计一个订单表里不同状态的订单数:
sql复制SELECT
COUNT(*) AS total_orders,
COUNT(CASE WHEN status = 'PAID' THEN 1 END) AS paid_orders,
COUNT(CASE WHEN status = 'CANCELLED' THEN 1 END) AS cancelled_orders,
SUM(CASE WHEN status = 'PAID' THEN amount ELSE 0 END) AS paid_amount
FROM orders;
这里的关键点:COUNT(CASE WHEN ... THEN 1 END) 利用了聚合函数忽略 NULL 的特性,不满足条件的行返回 NULL 不会被计数。很多人写成 COUNT(CASE WHEN status = 'PAID' THEN 1 ELSE 0 END),结果是统计了全表行数,因为 ELSE 0 不是 NULL,数值型 0 会被 COUNT 纳入统计。这是条件聚合里最容易踩的坑,务必记住:条件计数时 THEN 后面跟一个常量即可,ELSE 分支可以省略,保持结果为 NULL。
这种写法比多次 SELECT ... WHERE 子查询高效得多,一次扫描就能拿到所有分组统计结果。
3.4 GROUP BY 与 HAVING:分组后的过滤条件
GROUP BY 是聚合函数的天然搭档。它的作用是把数据按指定字段分组,然后对每组应用聚合函数。
sql复制-- 按用户统计订单总额
SELECT user_id, SUM(amount) AS total_amount
FROM orders
GROUP BY user_id;
分组之后如果要过滤,不能用 WHERE,而要用 HAVING。WHERE 是在分组前过滤原始行,HAVING 是在分组后过滤聚合结果。比如只保留订单总额超过 1000 的用户:
sql复制SELECT user_id, SUM(amount) AS total_amount
FROM orders
GROUP BY user_id
HAVING SUM(amount) > 1000;
一个常见的性能建议是:能用 WHERE 提前过滤掉的行,尽量用 WHERE 而不是等聚合完再用 HAVING 过滤。因为 WHERE 减少了参与分组的数据量,整体效率会明显提升。比如只统计当月订单,就把时间条件放在 WHERE 里,而不是先按全年分组再 HAVING。
3.5 GROUP_CONCAT:行转列的利器
GROUP_CONCAT 可以把分组内某个字段的值拼成一个字符串,这是"行转列"最简单直接的实现方式。
sql复制SELECT user_id, GROUP_CONCAT(product_name) AS product_list
FROM orders
GROUP BY user_id;
默认拼接结果用逗号分隔,也可以自定义分隔符和排序:
sql复制SELECT user_id,
GROUP_CONCAT(product_name ORDER BY create_time SEPARATOR '、') AS product_list
FROM orders
GROUP BY user_id;
GROUP_CONCAT 默认最大长度是 1024 字节,超过会被截断。如果拼接的字段比较长(比如拼接完整地址、备注),需要提前调大参数:
sql复制SET SESSION group_concat_max_len = 10240;
这个坑我在实际项目中踩过,拼接用户标签列表时,超过 1024 字节后字符串被静默截断,在报表里看起来就是一条不完整的数据,排查了半天才意识到是参数限制。
3.6 聚合函数与 DISTINCT:去重统计
COUNT(DISTINCT 字段) 和 SUM(DISTINCT 字段) 可以实现在聚合时去重:
sql复制-- 统计有订单的用户数
SELECT COUNT(DISTINCT user_id) FROM orders;
-- 统计去重后的商品销售总量(同一个商品重复出现只算一次,这个需求比较少见)
SELECT SUM(DISTINCT product_id) FROM orders;
实际用 COUNT(DISTINCT ...) 的场景很多,比如统计活跃用户数、访问页面的独立 IP 数。需要注意,COUNT(DISTINCT ...) 的性能开销比较大,对大数据量表做精确去重统计时可能很慢,需要结合实际情况评估是否可以用 APPROX_COUNT_DISTINCT(MySQL 8.0 不支持,通常用 Redis HyperLogLog 或 ClickHouse 这类工具处理)。
4. 组合使用与踩坑记录:从函数到真实报表
4.1 实战案例一:按日统计订单金额并格式化输出
需求:统计最近 7 天每天的订单总金额,日期显示格式为 2025-01-15,金额保留两位小数。
sql复制SELECT
DATE_FORMAT(create_time, '%Y-%m-%d') AS order_date,
ROUND(IFNULL(SUM(amount), 0), 2) AS total_amount
FROM orders
WHERE create_time >= DATE_SUB(CURDATE(), INTERVAL 7 DAY)
GROUP BY DATE_FORMAT(create_time, '%Y-%m-%d')
ORDER BY order_date;
这里组合了 DATE_FORMAT、IFNULL、SUM、ROUND、DATE_SUB、CURDATE 多个函数。注意 GROUP BY 后面使用的是 DATE_FORMAT(create_time, '%Y-%m-%d'),SELECT 里给它起了别名 order_date,但分组时不能直接用别名(MySQL 允许部分场景使用别名,但为了兼容性和可读性,建议直接写全表达式)。如果把 create_time 按天分组时直接 GROUP BY DATE(create_time),结果一样但性能会因不同类型索引使用情况有所差异,这个后面展开说。
4.2 实战案例二:用户手机号脱敏
需求:在管理后台列表中显示脱敏手机号,如 138****1234。
sql复制SELECT
CONCAT_WS('****', LEFT(phone, 3), RIGHT(phone, 4)) AS masked_phone
FROM users;
如果手机号字段可能出现 NULL 或空串,用 CONCAT_WS 就自动跳过了 NULL,不会导致整行数据显示为 NULL。这是脱敏场景里最简单稳妥的写法。
4.3 实战案例三:统计每个分类的订单量和占比
需求:统计每个商品分类下的订单数和订单金额占比。
sql复制SELECT
category,
COUNT(*) AS order_cnt,
SUM(amount) AS total_amount,
ROUND(SUM(amount) / (SELECT SUM(amount) FROM orders) * 100, 2) AS amount_percent
FROM orders
GROUP BY category;
子查询里那个 SUM(amount) 在数据量大的时候会被执行多次,但 MySQL 优化器通常会把子查询作为常量缓存,效率尚可。如果想要更高效的写法,可以用窗口函数(MySQL 8.0+):
sql复制SELECT
category,
COUNT(*) AS order_cnt,
SUM(amount) AS total_amount,
ROUND(SUM(amount) / SUM(SUM(amount)) OVER () * 100, 2) AS amount_percent
FROM orders
GROUP BY category;
这里的 SUM(SUM(amount)) OVER () 是先按分类求和,再用窗口函数对全部分类求和,一个 SQL 就能完成占比计算,不用子查询。
4.4 函数使用与索引失效:一个容易被忽视的性能问题
在 WHERE 条件里对索引字段使用函数,通常会导致索引失效。比如 WHERE DATE_FORMAT(create_time, '%Y-%m-%d') = '2025-01-15',MySQL 无法直接利用 create_time 上的索引,因为必须对每一行的 create_time 先做格式化再比较。正确写法是使用范围查询:
sql复制-- 不推荐:索引失效
SELECT * FROM orders
WHERE DATE_FORMAT(create_time, '%Y-%m-%d') = '2025-01-15';
-- 推荐:利用索引的范围查询
SELECT * FROM orders
WHERE create_time >= '2025-01-15 00:00:00'
AND create_time < '2025-01-16 00:00:00';
同理,WHERE YEAR(create_time) = 2025 也不如 WHERE create_time >= '2025-01-01' AND create_time < '2026-01-01'。这是写 SQL 时很容易忽略的性能细节,数据量小的时候没感觉,到了千万级表上差别就是几十毫秒和几秒的距离。
4.5 隐式类型转换:字符串和数字比较的坑
MySQL 在做字符串和数字比较时会发生隐式类型转换,容易产生意料之外的结果。比如 WHERE phone = 13812341234 这种写法,MySQL 会把字符串字段转成数字再比较,一旦字段里有非数字字符,比较结果可能完全不对,而且无法使用索引。
我的建议是:字段是字符串类型,就传字符串;字段是数字类型,就传数字。不要图方便省略引号,也别在应用层把数字格式化成字符串再塞进 SQL。这个习惯能避免绝大多数隐式转换问题。
4.6 日期函数和字符串函数配合时的几个细节
第一,DATE_FORMAT 的结果是字符串,参与排序时按字典序排。日期字符串因为格式固定,字典序和时间序是一致的,所以排序结果没问题。但如果你把日期格式化成 %m/%d/%Y 这种美式风格,排序结果就会乱掉。统一用 %Y-%m-%d 格式输出,是避免排序问题的好习惯。
第二,CURDATE() 返回的类型是日期,和 DATE_FORMAT(CURDATE(), '%Y-%m-%d') 对比时,MySQL 会做隐式转换,结果一般没问题,但为了可读性建议类型保持一致。
第三,拼接字符串时别忘了一句话:"谁也不知道哪个字段会是 NULL"。所有做字符串拼接的地方,要么用 CONCAT_WS,要么用 IFNULL 兜底。我把这个原则当成写 SQL 的默认习惯之后,线上数据接口因为 NULL 拼接出错的情况明显少了。
4.7 聚合函数中的 NULL 陷阱总结
COUNT(字段)不计 NULL,COUNT(*)计所有行。SUM(字段)忽略 NULL,但全 NULL 时返回 NULL,不是 0。AVG(字段)忽略 NULL,等价于SUM(字段)/COUNT(非NULL字段)。MAX、MIN忽略 NULL,全 NULL 时返回 NULL。GROUP_CONCAT默认忽略 NULL 值,不会出现在拼接结果中。
这些特性在某些场景下是优点,比如条件聚合;在另一些场景下是坑,比如金额求和后没做 IFNULL 导致展示成空。关键在于"清楚每一个函数对 NULL 的处理方式"。
5. 日期格式符、字符集与 SQL 模式:容易被忽略的幕后因素
5.1 SQL 模式对日期字符串的影响
MySQL 的 sql_mode 参数会影响日期字符串的解析严格程度。如果 sql_mode 包含 NO_ZERO_DATE,插入 '0000-00-00' 会被拒绝;如果包含 STRICT_TRANS_TABLES,非法的日期格式在严格模式下会直接报错,而非严格模式下则可能变成 '0000-00-00' 并附带警告。
这个差异在数据迁移、导入导出时特别明显。同一段 SQL 在本地库能跑通,在测试库报错,排查到最后往往就是 sql_mode 不同。我的经验是:数据导入前先检查目标库的 sql_mode,必要时在执行会话里临时调整:
sql复制SET SESSION sql_mode = 'ALLOW_INVALID_DATES';
但注意,这只是临时手段,长期来看还是要确保数据本身的合法性。
5.2 字符集对字符串函数的影响
字符串函数的结果和表的字符集直接相关。utf8mb4 是目前最推荐的字符集,能存储完整的 Unicode 字符(包括 emoji),但在做 LENGTH、SUBSTRING 这些操作时,字符集决定了"一个字符占几个字节"。
如果两张表字符集不一致,做关联查询或拼接时可能报 Illegal mix of collations 错误。解决办法是统一库表字符集为 utf8mb4,或者在关联时用 CONVERT(字段 USING utf8mb4) 显式转换。以前接手过一个老项目,表还是 latin1 字符集,中文字段存储乱码,字符串函数的处理结果也千奇百怪,最后全量迁移到 utf8mb4 才彻底解决。
5.3 MySQL 版本差异:8.0 带来的新函数
如果项目用的是 MySQL 5.7,很多函数已经够用;但升到 8.0 后,有两个新语法值得关注:
一是窗口函数(ROW_NUMBER()、RANK()、DENSE_RANK()、SUM() OVER() 等),在做排名、同比环比、移动平均等复杂统计时非常方便,不再需要写一堆子查询和临时变量。
二是公共表表达式(CTE,WITH ... AS),让复杂 SQL 的可读性大幅提升。比如统计每个分类销售额占比,可以先用 CTE 算出总金额,再关联计算:
sql复制WITH total AS (
SELECT SUM(amount) AS total_amount FROM orders
)
SELECT
category,
SUM(amount) AS category_amount,
ROUND(SUM(amount) / total.total_amount * 100, 2) AS percent
FROM orders
CROSS JOIN total
GROUP BY category;
如果还在用 5.7,建议评估升级到 8.0 的收益,尤其是报表统计需求多的场景,窗口函数能省掉大量复杂 SQL。
5.4 日期格式统一:接口层的最后一道防线
数据库层的日期格式化做得再好,如果接口层、前端展示层又自己格式化一遍,一旦格式不一致,最终展示还是会出问题。我的习惯是:数据库层统一输出标准格式字符串或时间戳,展示层的格式化逻辑由前端统一封装,不要各写各的。
比如 Java 后端接 MySQL,直接把 datetime 字段映射成 LocalDateTime 返回 JSON,前端再统一用 dayjs 格式化,这就是一套清晰的链路。反过来,如果有的接口返回字符串、有的返回时间戳,前端就得为每种格式写兼容逻辑,迟早出 bug。
6. 基于实操经验的小工具推荐与速查手册
6.1 常用日期格式速查
| 需求 | 写法 |
|---|---|
| 当前时间 | NOW() |
| 当前日期 | CURDATE() |
| 格式化日期 | DATE_FORMAT(NOW(), '%Y-%m-%d %H:%i:%s') |
| 字符串转日期 | STR_TO_DATE('2025-01-15', '%Y-%m-%d') |
| 日期转时间戳 | UNIX_TIMESTAMP('2025-01-15 14:30:00') |
| 时间戳转日期 | FROM_UNIXTIME(1736922600) |
| 加一个月 | DATE_ADD('2025-01-15', INTERVAL 1 MONTH) |
| 减一天 | DATE_SUB('2025-01-15', INTERVAL 1 DAY) |
| 两个日期差天数 | DATEDIFF('2025-03-01', '2025-01-15') |
| 两个日期差月份 | TIMESTAMPDIFF(MONTH, '2025-01-15', '2025-03-01') |
| 取年份/月份/日 | YEAR(date) / MONTH(date) / DAY(date) |
6.2 常用字符串函数速查
| 需求 | 写法 |
|---|---|
| 拼接字符串(自动跳过NULL) | CONCAT_WS(' ', 'a', NULL, 'b') |
| 截取子串 | SUBSTRING('abcdef', 2, 3) |
| 按分隔符截取 | SUBSTRING_INDEX('a,b,c', ',', 2) |
| 替换 | REPLACE('2025/01/15', '/', '-') |
| 去首尾空格 | TRIM(' MySQL ') |
| 查找位置 | LOCATE('sql', 'mysql') |
| 补位 | LPAD('42', 6, '0') |
| 字符数/字节数 | CHAR_LENGTH(str) / LENGTH(str) |
| 小写/大写 | LOWER(str) / UPPER(str) |
6.3 常用聚合函数速查
| 需求 | 写法 |
|---|---|
| 总行数 | COUNT(*) |
| 非空字段数 | COUNT(字段) |
| 去重统计 | COUNT(DISTINCT 字段) |
| 条件计数 | COUNT(CASE WHEN 条件 THEN 1 END) |
| 求和 | SUM(字段) |
| 平均值 | AVG(字段) |
| 最大/最小 | MAX(字段) / MIN(字段) |
| 分组拼接 | GROUP_CONCAT(字段) |
| 分组后过滤 | GROUP BY 字段 HAVING COUNT(*) > 1 |
6.4 排查 SQL 的推荐工具和习惯
说实话,命令行工具 mysql 客户端足够日常使用,但可视化工具能大幅提高排查效率。我个人的组合是:日常查询用 Navicat 或 DBeaver,看执行计划用 EXPLAIN,排查慢 SQL 用 MySQL 自带的慢查询日志。这些工具本身不复杂,关键是养成习惯——凡是线上 SQL,先 EXPLAIN 看有没有走索引,再考虑跑不跑。
另外,建议在自己常用的工具里收藏一张常用的"日期格式符对照表"和"字符串函数速查表"。遇到不熟悉的函数,先查表再写 SQL,比自己凭记忆拼快得多,也少踩很多坑。
从个人经验来讲,MySQL 函数并不需要背所有,但核心的日期格式转换、字符串处理、聚合统计这三类一定要烂熟于心。因为它们在业务 SQL 里出现的频率实在太高了,而真正让你觉得"难"的并不是某个函数不会用,而是多个函数组合起来解决一个真实业务需求的过程。把上面这些组合场景练熟,日常开发里遇到绝大多数统计、清洗、格式化需求,基本都能手到擒来。最后提醒一句:写 SQL 之前先想清楚数据和索引的关系,别为了函数功能牺牲性能,毕竟线上的表不会永远停留在几千行。
