上个月排查一个活动报表的慢查询,表只有三十来万行,索引建得也齐全,可一条统计SQL跑了快六秒。拉开执行计划一看,问题不在表结构,而在一句WHERE DATE_FORMAT(create_time, '%Y-%m-%d') = '2024-12-01'。索引列被函数包了一圈,优化器直接放弃走索引,老老实实全表扫描。这种MySQL内置函数用得不当引发的性能问题,我见过太多次。日期函数、字符串函数、数学函数这些看起来不起眼的小工具,平时写CRUD用不上几个,可真遇到统计报表、数据清洗、排序分页的场景,用得好和用不好,差别往往是数量级的。
这篇把我在实际项目里常用的MySQL内置函数系统过一遍,不按官方手册罗列,而是按"日常到底怎么用、有哪些坑"来讲。每个函数都会给到典型用法和踩过的坑。适合刚入门想系统补基础的人,也适合写了两三年SQL、但只会用几个常用函数的老手对照查漏。
1. 先从一个慢查询说起:函数用不好的代价
1.1 一个DATE_FORMAT引发的全表扫描
先说开头那个案例。原始需求很简单:统计2024年12月1日这一天的订单量和销售额。第一版SQL是这么写的:
SELECT COUNT(*), SUM(amount) FROM orders WHERE DATE_FORMAT(create_time, '%Y-%m-%d') = '2024-12-01';逻辑上完全正确,create_time 上也有普通索引,但执行计划显示type: ALL,扫了整张表。原因是MySQL对索引列做了函数运算之后,索引失效了。优化器无法用B+树直接按日期定位,只能先把每行的 create_time 都格式化一遍再比对。
同样的需求,改成范围查询就能稳稳走索引:
SELECT COUNT(*), SUM(amount) FROM orders WHERE create_time >= '2024-12-01 00:00:00' AND create_time < '2024-12-02 00:00:00';这个改写不是MySQL独有的技巧,而是所有数据库通用的原则:别让函数站在索引列那一边。后面讲字符串函数时,还有类似的坑。
1.2 内置函数家族全景
MySQL内置函数按用途大概分这几类:
| 类别 | 代表函数 | 典型用途 |
|---|---|---|
| 日期时间 | NOW、DATE_FORMAT、DATE_ADD、DATEDIFF | 时间格式化、日期运算、报表分组 |
| 字符串 | CONCAT、SUBSTRING、REPLACE、TRIM | 拼接、截取、清洗、脱敏 |
| 数学 | ROUND、CEIL、FLOOR、RAND | 取整、随机抽样、数值精度控制 |
| 流程控制 | IF、IFNULL、NULLIF、CASE WHEN | 条件分支、空值兜底 |
| 聚合 | COUNT、SUM、AVG、GROUP_CONCAT | 统计汇总、行转列 |
| 加密散列 | MD5、SHA2、AES_ENCRYPT | 密码散列、数据签名 |
| 信息类 | VERSION、DATABASE、LAST_INSERT_ID | 环境检查、自增主键回取 |
| 类型转换 | CAST、CONVERT | 显式类型转换 |
实际项目里80%的SQL也就用到前四类,但聚合函数和数据清洗场景对字符串、日期函数的要求很高。接下来按类别拆开讲,重点放在"为什么这么用"和"边界情况返回什么"。
2. 日期时间函数:格式转换、日期运算与时区细节
日期函数是报表需求里躲不开的一类。按月分组、计算库存账龄、统计活跃天数,全都依赖它。
2.1 获取当前时间的四兄弟:NOW、CURDATE、CURTIME、SYSDATE
SELECT NOW(), CURDATE(), CURTIME(), SYSDATE();返回结果大概这样:
NOW():2024-12-18 14:30:25,日期时间都有CURDATE():2024-12-18,只有日期CURTIME():14:30:25,只有时间SYSDATE():看起来和 NOW() 一样,实际有微妙区别
NOW()取的是当前语句开始执行那一刻的时间,不管这条SQL跑多久,在同一批数据里 NOW() 都保持不变;SYSDATE()是函数实际执行到那一刻的时间,如果一条SQL里多次调用,或者SQL执行时间很长,SYSDATE()可能返回不同的值。在普通查询里这个差异可以忽略,但在长事务、存储过程或者批量更新里,这个差异会导致判断基准不一致。
我写过一版库存脚本,用了 SYSDATE() 判断超时时间,结果批处理跑了两分钟后,同一批货的"最后操作时间"居然不一样。排查半天才定位到是这个函数的问题。批量处理的场景统一用 NOW(),别用 SYSDATE()。
CURRENT_TIMESTAMP是NOW()的同义词,CURRENT_DATE、CURRENT_TIME分别对应 CURDATE 和 CURTIME,习惯写哪个都行。
2.2 格式化与反向解析:DATE_FORMAT、STR_TO_DATE
这是报表分组最常用的组合。
DATE_FORMAT(date, format)把日期时间按指定格式转成字符串:
SELECT DATE_FORMAT(NOW(), '%Y-%m-%d %H:%i:%s'); -- 2024-12-18 14:30:25 SELECT DATE_FORMAT(NOW(), '%Y-%m'); -- 2024-12 SELECT DATE_FORMAT(NOW(), '%W'); -- Wednesday常用格式符先列出来,避免每次都翻手册:
| 格式符 | 含义 | 示例 |
|---|---|---|
| %Y | 四位年份 | 2024 |
| %y | 两位年份 | 24 |
| %m | 月份,两位数 | 12 |
| %c | 月份,一位或两位 | 12 |
| %d | 日,两位数 | 18 |
| %e | 日,一位或两位 | 18 |
| %H | 小时,24小时制 | 14 |
| %h | 小时,12小时制 | 02 |
| %i | 分钟 | 30 |
| %s / %S | 秒 | 25 |
| %W | 星期全名 | Wednesday |
| %a | 星期缩写 | Wed |
| %M | 月份全名 | December |
| %b | 月份缩写 | Dec |
| %j | 一年中的第几天 | 353 |
| %p | AM 或 PM | PM |
按月份分组报表,惯用写法是GROUP BY DATE_FORMAT(create_time, '%Y-%m')。注意这里的 GROUP BY 直接用别名或者重复表达式都行。后面给完整例子。
STR_TO_DATE(str, format)是反向操作,把字符串解析成日期。ETL导入外部数据时特别有用,比如收到的源文件里写的是"2024/12/18 14:30",标准格式存不进去:
SELECT STR_TO_DATE('2024/12/18 14:30', '%Y/%m/%d %H:%i'); -- 2024-12-18 14:30:00解析失败返回 NULL,不会抛异常。所以大批量导入时,先用一个 SELECT 验证格式是否匹配,再执行 INSERT,否则容易导入一批 NULL 进去还查不出来。
2.3 日期运算:DATE_ADD、DATEDIFF、TIMESTAMPDIFF
日期加减用DATE_ADD和DATE_SUB:
SELECT DATE_ADD('2024-12-18', INTERVAL 1 MONTH); -- 2025-01-18 SELECT DATE_SUB('2024-12-18', INTERVAL 7 DAY); -- 2024-12-11INTERVAL后面可以跟 YEAR、QUARTER、MONTH、WEEK、DAY、HOUR、MINUTE、SECOND,也能拼写组合形式,比如INTERVAL '1:30' HOUR_MINUTE,但实际项目里用到复合单位的场景很少,不用强行记。
日期差计算有两个容易混淆的函数:DATEDIFF和TIMESTAMPDIFF。
SELECT DATEDIFF('2024-12-18', '2024-12-01'); -- 17,单位固定为天 SELECT TIMESTAMPDIFF(MONTH, '2024-12-01', '2025-02-01'); -- 2,单位可以指定DATEDIFF(expr1, expr2)返回 expr1 减 expr2 的天数,参数只要日期部分,时间部分忽略。TIMESTAMPDIFF(unit, datetime_expr1, datetime_expr2)返回的是expr2 减 expr1,方向跟 DATEDIFF 相反,这个太容易搞反了。我习惯记法:TIMESTAMPDIFF 是"后面的减前面的"。
TIMESTAMPDIFF单位支持 MICROSECOND、SECOND、MINUTE、HOUR、DAY、WEEK、MONTH、QUARTER、YEAR,算账龄、算时长都靠它。比如统计用户注册到首单的天数:
SELECT user_id, TIMESTAMPDIFF(DAY, register_time, first_order_time) AS days_to_first_order FROM user_stat;还有一个PERIOD_DIFF(p1, p2),专门算两个年月字符串之间差几个月,参数格式是 YYYYMM 或 YYMM 的数字,注意不是日期:
SELECT PERIOD_DIFF(202502, 202412); -- 22.4 提取年月日周:YEAR、MONTH、DAY、WEEK、QUARTER、LAST_DAY
对于已经存在的日期列,需要单独取年、月、日做统计时,这些函数最直接:
SELECT YEAR('2024-12-18'), MONTH('2024-12-18'), DAY('2024-12-18'); -- 2024, 12, 18 SELECT QUARTER('2024-12-18'), WEEK('2024-12-18'), DAYOFWEEK('2024-12-18'); -- 4, 51, 4DAYOFWEEK返回的索引是 1=Sunday 到 7=Saturday,跟国内习惯不一样。要按周一作为一周第一天统计,用WEEKDAY,它返回 0=Monday 到 6=Sunday。
LAST_DAY(date)返回当月最后一天,算月末截止时点很常用:
SELECT LAST_DAY('2024-02-15'); -- 2024-02-29拿它配合日期加减,可以快速构造上月末、本月初、下月初这些报表边界日期。
2.5 Unix时间戳与不可忽略的时区问题
UNIX_TIMESTAMP()把日期转成秒级时间戳,FROM_UNIXTIME()反向转换:
SELECT UNIX_TIMESTAMP('2024-12-18 14:30:00'); -- 具体的秒数 SELECT FROM_UNIXTIME(1734500000); -- 2024-12-18 14:13:20这里有个藏得很深的坑:这两个函数的转换结果取决于当前会话的 time_zone。如果你在配置文件里把数据库连接time_zone设置成了+00:00,而 Java 应用跑在Asia/Shanghai,那么存进去的时间戳在 MySQL 里给 FROM_UNIXTIME 一转换,就会差8个小时。
我处理过一个线上告警时间错乱的问题,最后排查到是连接串上少配了serverTimezone=Asia/Shanghai,应用层和数据库层各按各的时区理解时间戳。涉及时间戳转换,先确认三层时区一致:JVM时区、JDBC连接时区、MySQL会话时区。
MySQL 8.0 下查看当前时区和会话时区:
SELECT @@global.time_zone, @@session.time_zone;2.6 日期函数使用时的性能红线
再强调一遍开头那个教训,用日期函数时,优先保证索引列保持原样。下面这几种写法都会让索引失效:
WHERE YEAR(create_time) = 2024 WHERE MONTH(create_time) = 12 WHERE DATE(create_time) = '2024-12-01'改写方向是把条件变成范围:
WHERE create_time >= '2024-12-01' AND create_time < '2024-12-02' WHERE create_time >= '2024-12-01 00:00:00' AND create_time < '2025-01-01 00:00:00'年份统计就拼一个年初到明年初的范围。别嫌啰嗦,对千万级表来说,这决定了查询是毫秒级还是秒级。
3. 字符串函数:从拼接拆分到清洗脱敏的实用技巧
字符串函数在日常需求里出现频率最高。写接口、做报表、清理脏数据,到处都能碰上。
3.1 拼接与分隔:CONCAT、CONCAT_WS
CONCAT(str1, str2, ...)是最基础的拼接函数:
SELECT CONCAT('订单', '编号', 1001); -- 订单编号1001注意一个小陷阱:CONCAT里任何一个参数为 NULL,整个结果就是 NULL。数据清洗时经常会遇到这个情况——某个字段为空,拼接结果整个消失。
SELECT CONCAT('用户ID:', NULL, '结束'); -- NULL所以常用CONCAT_WS(With Separator)来处理。它在参数之间加分隔符,同时会自动跳过 NULL:
SELECT CONCAT_WS('-', '2024', '12', '18'); -- 2024-12-18 SELECT CONCAT_WS('-', '2024', NULL, '18'); -- 2024-18注意 CONCAT_WS 跳过的是 NULL,不是空字符串。空字符串仍然会拼进去。
3.2 GROUP_CONCAT:分组内字符串聚合
严格来说 GROUP_CONCAT 是聚合函数,但它的返回值是字符串,归到字符串这一节更好理解。它把同一分组内的多行内容拼成一行:
SELECT category, GROUP_CONCAT(product_name SEPARATOR '、') FROM products GROUP BY category;执行结果类似这样:
category | GROUP_CONCAT(product_name) ------------+--------------------------- 电子产品 | 手机、电脑、耳机 图书 | 小说、历史、科普有几个实用细节:
SEPARATOR不指定时默认用逗号。- 可以在拼接时排序:
GROUP_CONCAT(product_name ORDER BY price DESC SEPARATOR '、')。 - 可以去重:
GROUP_CONCAT(DISTINCT tag ORDER BY tag SEPARATOR '|')。 - 最大长度默认 1024 字节,拼接内容多时会被静默截断。需要加大时:
SET SESSION group_concat_max_len = 1048576;遇到过导出商品标签功能的同事,拼出来总少一截,怎么查都查不到原因,最后就是栽在这个默认长度上。动态 SQL 里如果拼的内容可能很长,每条连接会话都要设置这个变量,可以在连接池初始化时统一执行。
3.3 截取函数:SUBSTRING、LEFT、RIGHT、SUBSTRING_INDEX
SUBSTRING(str, pos, len)从指定位置截取:
SELECT SUBSTRING('hello world', 3, 5); -- llo wMySQL 的字符串位置从 1 开始。pos可以传负数,表示从尾部倒数第几个位置开始截取:
SELECT SUBSTRING('hello world', -5, 3); -- worLEFT(str, n)和RIGHT(str, n)分别从左侧和右侧截取 n 个字符:
SELECT LEFT('订单号20241218', 3); -- 订单号 SELECT RIGHT('订单号20241218', 4); -- 1218SUBSTRING_INDEX(str, delim, count)是处理分隔字符串的王牌函数。它在字符串中查找分隔符,返回第 count 次出现分隔符之前(或之后)的子串:
SELECT SUBSTRING_INDEX('a,b,c,d', ',', 2); -- a,b SELECT SUBSTRING_INDEX('a,b,c,d', ',', -2); -- c,dcount 为正数,返回从左往右数到第 count 个分隔符的左侧内容;count 为负数,返回从右往左数到第 |count| 个分隔符的右侧内容。
用它可以实现简单的拆分。比如有一个包含多个标签的字段,存的是"教育,科技,生活",要取第一个标签:
SELECT SUBSTRING_INDEX(tags, ',', 1) FROM article;还能配合嵌套取中间段。比如要取第二个标签:
SELECT SUBSTRING_INDEX(SUBSTRING_INDEX(tags, ',', 2), ',', -1) FROM article;这个嵌套写法在数据清洗里常见,值得记下来。
3.4 查找定位:LOCATE、INSTR、POSITION、FIELD
判断一个字符串里是否包含某段内容,用LOCATE或者INSTR:
SELECT LOCATE('world', 'hello world'); -- 7,找不到返回0 SELECT INSTR('hello world', 'world'); -- 7 SELECT POSITION('world' IN 'hello world'); -- 7LOCATE(substr, str, pos)还可以指定从第几位开始找。三个函数语义基本一致,写哪个都行,习惯用 LOCATE 的多一些。
用法上最常见的场景是"包含"条件。要查所有包含"旗舰店"的店铺名:
SELECT * FROM shop WHERE LOCATE('旗舰店', shop_name) > 0;注意这个写法同样会放弃索引,和LIKE '%旗舰店%'一样。需要加速时考虑全文本索引或者其他方案。
FIELD(value, val1, val2, ...)返回 value 在参数列表里的位置,从 1 开始,不在列表里返回 0。它最实用的场景是自定义排序优先级:
SELECT order_status, COUNT(*) FROM orders GROUP BY order_status ORDER BY FIELD(order_status, '已完成', '处理中', '待支付');这样可以把"已完成"排到最前面,而不是默认的字母序。
3.5 长度、大小写与空白:CHAR_LENGTH 和 LENGTH 的字节陷阱
CHAR_LENGTH(str)返回字符数,LENGTH(str)返回字节数。在 utf8mb4 字符集下,一个中文占3个字节,一个 emoji 占4个字节:
SELECT CHAR_LENGTH('你好'), LENGTH('你好'); -- 2, 6 SELECT CHAR_LENGTH('hello'), LENGTH('hello'); -- 5, 5之前有个同事做字段长度校验,页面提示"最多10个字符",他写了个WHERE LENGTH(name) > 10来判断超长,结果用户输入了4个汉字就报错了,因为 LENGTH 算出来是 12。校验用户输入的字符个数,统一用CHAR_LENGTH。
大小写转换函数是UPPER(str)/LOWER(str),别名UCASE/LCASE:
SELECT UPPER('mysql'), LOWER('MySQL'); -- MYSQL, mysql注意它在非英文字符上有边界行为,比如德语的 ß 转大写可能是 SS。不过国内项目基本碰不到这类问题。
去空格有三件套:TRIM(str)去掉首尾空格,LTRIM(str)去左侧,RTRIM(str)去右侧。TRIM还能指定去除字符:
SELECT TRIM(' abc '); -- abc SELECT TRIM(LEADING '0' FROM '007123'); -- 7123实际项目里RTRIM很常用,因为 CHAR 类型补全空格、人工录入多打空格这类脏数据,都要靠它洗一遍。清洗时建议用TRIM后再更新回字段,否则排序和去重都会出问题。
3.6 替换与脱敏:REPLACE、LPAD、RPAD
REPLACE(str, from, to)做全量替换:
SELECT REPLACE('13812345678', '138', '139'); -- 13912345678注意 REPLACE 会把所有匹配都替换掉,不像 Java 里的 replaceFirst。需求要"只换第一个出现"时,可以配合 SUBSTRING_INDEX 自己组装。
LPAD(str, len, padstr)和RPAD(str, len, padstr)按指定长度填充:
SELECT LPAD('7', 4, '0'); -- 0007 SELECT RPAD(LEFT('13812345678', 3), 11, '*'); -- 138********第二个例子是手机号中间脱敏的常见写法:取前3位,再右边补8个星号,效果就是138********。更标准的脱敏是保留前3后4:
SELECT CONCAT(LEFT(phone, 3), '****', RIGHT(phone, 4)) FROM user;这类脱敏 SQL 在测试环境刷数据时特别实用,后面实战部分再给完整例子。
3.7 字符串函数导致的索引失效
和日期函数一样,在索引列上套字符串函数同样会废掉索引。最典型的两个:
WHERE LEFT(phone, 3) = '138' WHERE SUBSTRING(name, 1, 2) = '张'能改写的话尽量改写成范围或前缀匹配。LIKE '138%'在 phone 列有索引时是可以走索引的,只有通配符在中间或末尾的情况才会失效:
WHERE phone LIKE '138%' -- 可以走索引 WHERE phone LIKE '%138' -- 无法走索引 WHERE phone LIKE '%138%' -- 无法走索引4. 数学函数:取整、随机数与计算结果精度控制
数学函数在业务SQL里相对配角,但用的地方都很关键,尤其是取整和随机抽样,踩坑概率非常高。
4.1 取整四兄弟:ROUND、CEIL、FLOOR、TRUNCATE
四个函数的差异:
SELECT ROUND(2.5), CEIL(2.1), FLOOR(2.9), TRUNCATE(2.999, 2); -- 3, 3, 2, 2.99| 函数 | 行为 | 示例 |
|---|---|---|
| ROUND(x, d) | 四舍五入,d 为小数位数 | ROUND(2.5) = 3 |
| CEIL(x) | 向上取整 | CEIL(2.1) = 3 |
| FLOOR(x) | 向下取整 | FLOOR(2.9) = 2 |
| TRUNCATE(x, d) | 直接截断,不做四舍五入 | TRUNCATE(2.999, 2) = 2.99 |
ROUND(2.45, 1)返回 2.5,这是常规理解。但要注意浮点数的经典坑:由于 IEEE 754 表示误差,ROUND(1.005, 2)的结果不是 1.01,而是 1.00。这不是MySQL的bug,是几乎所有编程语言和数据库用二进制浮点数算小数都会遇到的事。涉及金额、税率、百分比这类对精度有要求的数据,不要在SQL里用浮点数做四舍五入,用 DECIMAL 类型字段运算,或者干脆在应用层计算。
TRUNCATE(x, d)的小数位数 d 还可以传负数,表示在小数点左侧截断:
SELECT TRUNCATE(1234.567, -2); -- 12004.2 随机数 RAND 与抽样的正确姿势
RAND()返回 [0, 1) 范围内的随机浮点数。RAND(N)接收一个种子,同一种子生成的序列完全一致,这个特性可以用来复现问题:
SELECT RAND(), RAND(10), RAND(10); -- 首次执行 RAND() 随机,RAND(10) 固定返回相同结果常见用法是随机抽样:
SELECT * FROM user ORDER BY RAND() LIMIT 5;这个写法在数据量小的时候没问题,数据量一大就是灾难。ORDER BY RAND()会对每一行生成随机值再排序,百万级表上这个排序开销不容小觑。
数据量大时,更高效的做法是随机出主键范围再取数。比如 id 大致连续:
SELECT * FROM user WHERE id >= (SELECT FLOOR(RAND() * (SELECT MAX(id) FROM user))) ORDER BY id LIMIT 5;遇到 id 有空洞时可能取不到足够条数,需要加上补查逻辑。实际抽奖、随机推荐场景,建议把随机范围算好在应用层生成几个候选 id,再用 IN 查询,压力小很多。
4.3 取余、整除、幂与开方:MOD、DIV、POWER、SQRT
取余MOD(x, y)和取模运算符%等价:
SELECT MOD(10, 3), 10 % 3; -- 1, 1DIV是整除,返回结果的整数部分:
SELECT 10 DIV 3; -- 3POWER(x, y)做幂运算,SQRT(x)开平方:
SELECT POWER(2, 10), SQRT(16); -- 1024, 4实际业务里 MOD 最常见的场景是分表路由、按余数分组采样。比如按用户ID分10张表,路由规则基本都会用到user_id % 10或者MOD(user_id, 10)。
4.4 千分位格式化:FORMAT
FORMAT(x, d)把数字格式化为带千分位分隔符的字符串,保留 d 位小数:
SELECT FORMAT(1234567.891, 2); -- 1,234,567.89注意返回值是字符串,不是数字。前端展示金额时可以直接用它拼出友好的展示文案,但后续要做数值运算时,得先转回数字,否则拿一个带逗号的字符串去加减乘除,结果会有问题。
另外有个容易忽略的细节:FORMAT 的第二个参数 d 代表小数位数,它同时会做四舍五入。FORMAT(2.345, 2)返回2.35,但同样受浮点数精度影响,对精度敏感的仍然建议先转 DECIMAL。
4.5 三角函数与常数:用的少但不该没听说过
日常业务SQL里极少直接用 SIN、COS、TAN。但PI()和角度弧度转换RADIANS()/DEGREES()在地理坐标计算、可视化开发里会碰到。
SELECT PI(); -- 3.141593 SELECT DEGREES(PI() / 2); -- 90在涉及地图围栏、坐标距离估算时,偶尔会用到球面距离公式,其中就会涉及弧度转换。这种场景建议把计算放到应用层,SQL里只负责查数据,不然查询语句又长又难测试。
5. 流程控制与杂项函数:查询里的"语法糖"
这部分函数让SQL从"取数工具"变成"带逻辑的处理工具",做好分支判断、空值兜底和类型转换。
5.1 IF、IFNULL、NULLIF 与 CASE WHEN
IF(expr, true_value, false_value)是简化的三元表达式:
SELECT user_name, IF(status = 1, '启用', '禁用') AS status_name FROM user;IFNULL(expr1, expr2)是空值兜底函数,expr1 为 NULL 时返回 expr2,否则返回 expr1:
SELECT user_name, IFNULL(nickname, user_name) AS display_name FROM user;NULLIF(expr1, expr2)反过来:两个参数相等时返回 NULL,不相等时返回 expr1。它最经典的用法是防除零:
SELECT amount / NULLIF(quantity, 0) AS avg_price FROM order_detail;当 quantity 为 0 时,NULLIF(quantity, 0)返回 NULL,整个除法结果为 NULL,不会报错。外面再套一层 IFNULL,就能把结果变成 0 或其他兜底值:
SELECT IFNULL(amount / NULLIF(quantity, 0), 0) AS avg_price FROM order_detail;多条件分支用CASE WHEN:
SELECT product_name, CASE WHEN stock = 0 THEN '无货' WHEN stock < 10 THEN '库存紧张' ELSE '库存充足' END AS stock_status FROM product;CASE WHEN 还能在聚合里做条件统计。统计订单里支付成功和取消的数量:
SELECT SUM(CASE WHEN status = 1 THEN 1 ELSE 0 END) AS paid_count, SUM(CASE WHEN status = 3 THEN 1 ELSE 0 END) AS cancelled_count FROM orders;这种写法比多次查同一张表高效得多。
5.2 聚合函数的细节:COUNT、SUM、AVG、MAX、MIN
聚合函数是报表的基石,但每个都有容易被忽略的细节。
COUNT(*)统计行数,包含 NULL 的行;COUNT(column)统计该列非 NULL 的行数。以前流传"COUNT(1) 比 COUNT() 快"的说法,在 MySQL 8.0 的 InnoDB 引擎下两者没有性能差异,放心用 COUNT()。
COUNT(DISTINCT column)做精确去重统计,数据量大时很慢,可以借助COUNT(DISTINCT column)配合IF实现按条件去重:
SELECT COUNT(DISTINCT user_id) AS all_users, COUNT(DISTINCT IF(order_cnt > 0, user_id, NULL)) AS active_users FROM user_stat;SUM(column)忽略 NULL,不会因为某行是 NULL 就把整个结果变 NULL。AVG(column)也是忽略 NULL,这里有一个反直觉的点:AVG 不是"每个分组的总值除以全组记录数",而是除以非 NULL 值的个数。需要按全组行数平均时,要手动算SUM(column) / COUNT(*)。
MAX和MIN对字符串也能用,按字符序比较。实际使用中更要注意的是:MySQL 8.0 的优化器支持MAX(column)利用索引跳过大量数据,但前提是条件里不要把索引列包上函数。
5.3 加密与散列:MD5、SHA2、AES_ENCRYPT
MySQL 内置了常用散列函数:
SELECT MD5('abc'); -- 900150983cd24fb0d6963f7d28e17f72 SELECT SHA2('abc', 256); -- 64位十六进制字符串 SELECT SHA1('abc'); -- 40位十六进制字符串MD5 早已不适合存高安全级别的密码,但它在业务里仍然很常用,比如生成接口签名、对文本做指纹去重。前两年我处理内容去重需求,就是先对正文做 MD5,再对指纹建唯一索引,几十万条数据秒级去重。
SHA2 第二个参数支持 224、256、384、512,其中 256 最常用。线上系统如果老接口用的是 MD5,新老系统对接时需要明确是统一用哪种散列,不然后端对不上签名就抓瞎。
AES_ENCRYPT/AES_DECRYPT是对称加密,和散列不同,可以解密回来。它的用法:
-- 加密 SELECT AES_ENCRYPT('敏感内容', 'encrypt_key'); -- 解密 SELECT CAST(AES_DECRYPT(encrypted_col, 'encrypt_key') AS CHAR) FROM secret_table;AES_ENCRYPT 返回二进制串,直接存字段会造成乱码,一般先 HEX() 再存。密钥管理是另一个大话题,这里只提醒一句:密钥不要硬编码进SQL脚本,更不要出现在日志里。生产环境密钥一般从配置中心拉取到应用层,由应用层做加密后入库,避免数据库里明文密钥。
5.4 信息函数:VERSION、DATABASE、USER、LAST_INSERT_ID
排查环境问题时常用的:
SELECT VERSION(); -- 8.0.36 SELECT DATABASE(); -- 当前库名 SELECT USER(), CURRENT_USER(); -- 当前连接用户 SELECT CONNECTION_ID(); -- 当前连接IDLAST_INSERT_ID()返回当前连接上一条 INSERT 产生的自增ID,注意三个要点:
- 它绑定的是当前会话连接,不是全局。别的连接插入的数据它感知不到。
- 没有新插入时,再次调用仍然返回最近一次插入的ID,可能造成误判。
- 一次插入多行时,只返回第一行的自增ID。
组合插入主从表数据时会用到。插入订单主表拿到 order_id,再插入明细表:
INSERT INTO orders(user_id, amount) VALUES (1001, 99.90); -- 假设这是第一条新插入,得到 order_id 10086 INSERT INTO order_detail(order_id, product_name, price) VALUES (LAST_INSERT_ID(), '商品A', 99.90);注意多行插入时,LAST_INSERT_ID()返回的是第一条数据的ID,如果需要每一行的ID,得在应用层解析或者改写循环插入。
5.5 类型转换:CAST、CONVERT
显式类型转换用CAST(expr AS type):
SELECT CAST('123' AS SIGNED); -- 123 SELECT CAST('2024-12-18' AS DATE); -- 2024-12-18 SELECT CAST(123 AS CHAR); -- '123'CONVERT(expr, type)作用相同,CONVERT还可以转字符集:
SELECT CONVERT(name USING utf8mb4) FROM old_table;转换时的失败行为要看情况:字符串转数字时遇到非数字字符,只取前缀可识别部分。CAST('123abc' AS SIGNED)返回 123,CAST('abc123' AS SIGNED)返回 0,不会报错。这正是数据清洗时需要警惕的:静默转换不会告诉你数据有问题,导入前最好先用 REGEXP 校验格式。
5.6 其他实用函数:COALESCE、GREATEST、LEAST、UUID
COALESCE(expr1, expr2, ...)返回参数列表中第一个非 NULL 值,相当于多个字段间取"首个有值":
SELECT COALESCE(mobile, phone, '无联系方式') FROM contact;GREATEST(a, b, c)返回最大值,LEAST(a, b, c)返回最小值,多个列横向比较时好用:
SELECT GREATEST(price1, price2, price3) AS max_price FROM product;UUID()生成一个标准 UUID 字符串:
SELECT UUID(); -- 0a3dd514-1a34-11ef-9ed2-0242ac110002可以作为不依赖自增ID的业务主键。但 UUID 作为主键在 InnoDB 里会引发随机插入,造成页分裂和碎片,性能敏感的大表不建议直接用字符串 UUID 做主键,可以考虑 UUID 转成二进制或者改用雪花ID。
6. 三个业务场景的函数组合实战
单独记函数不如看组合。最后用三个实际需求把常用函数串一遍。
6.1 月度订单报表:日期函数加聚合函数
需求:统计最近6个月每个月的订单量、销售额,并计算环比增长率。
WITH monthly_stats AS ( SELECT DATE_FORMAT(create_time, '%Y-%m') AS month, COUNT(*) AS order_cnt, SUM(amount) AS total_amount FROM orders WHERE create_time >= DATE_SUB(DATE_FORMAT(CURDATE(), '%Y-%m-01'), INTERVAL 5 MONTH) GROUP BY DATE_FORMAT(create_time, '%Y-%m') ) SELECT month, order_cnt, total_amount, LAG(order_cnt, 1) OVER (ORDER BY month) AS prev_cnt, ROUND( (order_cnt - LAG(order_cnt, 1) OVER (ORDER BY month)) / NULLIF(LAG(order_cnt, 1) OVER (ORDER BY month), 0) * 100, 2 ) AS growth_rate FROM monthly_stats ORDER BY month;这里用到了多个前面讲过的点:
DATE_FORMAT做月份分组DATE_SUB和DATE_FORMAT(CURDATE(), '%Y-%m-01')构造6个月前的月初日期LAG窗口函数取上一行数据NULLIF防止上个月订单量为0时除零报错ROUND控制百分比小数位
如果数据库是 MySQL 5.7,没有窗口函数,LAG这段可以改成本月数据 LEFT JOIN 上月数据,或者用标量子查询实现。
6.2 用户手机号脱敏:字符串函数组合
需求:把测试环境里所有 user 表的手机号处理成脱敏形式,保留前3后4。
UPDATE user SET phone = CONCAT(LEFT(phone, 3), '****', RIGHT(phone, 4)) WHERE phone REGEXP '^1[3-9][0-9]{9}$';先判断手机号格式是否合法,再脱敏,避免把脏数据也原样处理进去。执行前先 SELECT 预览:
SELECT phone AS original_phone, CONCAT(LEFT(phone, 3), '****', RIGHT(phone, 4)) AS masked_phone FROM user LIMIT 10;这种做法在刷测试库、脱敏导出场景里很常见。如果还需要生成不重复的手机号,可以在脱敏后再用CONCAT拼上随机后缀,但要注意保证不违反手机号格式校验逻辑。
6.3 分类商品聚合行转列:GROUP_CONCAT
需求:把每个分类下的商品名拼成一行,按价格升序排列。
SELECT category, COUNT(*) AS product_count, GROUP_CONCAT(product_name ORDER BY price ASC SEPARATOR '、') AS product_list FROM products GROUP BY category;再配合 SUBSTRING_INDEX 取列表里的第一个商品:
SELECT category, SUBSTRING_INDEX(GROUP_CONCAT(product_name ORDER BY price ASC SEPARATOR '、'), '、', 1) AS cheapest_product FROM products GROUP BY category;在这个需求里,GROUP_CONCAT 的排序、去重、分隔符设置、长度限制就全部用上了。如果拼接结果超过 1024 字节,记得先加大 group_concat_max_len。
6.4 字符串数字混排问题:ORDER BY 的小坑
需求:对编号字段排序,但编号是P-1、P-2、P-10这种带前缀的字符串。
直接ORDER BY code会得到字典序:
P-1 P-10 P-2因为'10'和'1'比较时,先比第一位,结果'10' < '2'。要按数字部分排,可以:
SELECT * FROM product ORDER BY CAST(SUBSTRING(code, 3) AS SIGNED);更粗暴但常见的写法是加0隐式转数字:
SELECT * FROM product ORDER BY SUBSTRING(code, 3) + 0;这类写法能应付大多数简单场景,但前提是截出来的部分确实都是数字,否则转换结果会是0,排序就不准了。
整理完这一圈函数,我最大的体会是:函数本身不难记,难的是知道每个函数在真实数据下的行为差异。建议你把常用函数写进一个测试SQL文件,建一张临时表,把 NULL、空字符串、特别大的整数、浮点数精度这些边界情况都跑一遍,亲眼看看返回什么。我自己就是这么积累的,比翻文档印象深得多。真到排查线上慢查询或者数据错乱的时候,这些"看起来基础"的东西,往往是破局的钥匙。