我不是来背函数手册的。MySQL 里那些常见函数,真正决定你是“会用”还是“用得好”的,往往不是语法本身,而是你知不知道它什么时候会坑你、什么时候能救命。这些年排查慢 SQL、做数据清洗、写统计报表,我发现自己真正高频用到、也最值得花心思去琢磨的,其实就是那么几类函数。这篇不打算按官方文档给你罗列一遍,而是挑出实战中最常踩坑、也最能提升效率的场景,配合执行计划、索引利用和真实的报错案例,一起拆开讲。
1. 函数用得不对,索引就废了:从执行计划说起
很多人在简历上写“熟悉 MySQL 常见函数”,结果第一道面试题就翻车——WHERE DATE(create_time) = '2024-01-01' 和 WHERE create_time >= '2024-01-01' AND create_time < '2024-01-02',哪个更快?答案是后者,而且可能快出几个数量级。
原因在于函数作用于索引列之后,索引的有序性对优化器来说就失效了。DATE(create_time) 相当于对每一行都先做一次运算,再把结果拿去比较,B+Tree 的二分查找完全派不上用场,只能老老实实全表扫描。你可以用 EXPLAIN 看一眼,type 那栏从 ref 或 range 变成 ALL,rows 的估算值一下子飙上去,这就是函数拖垮索引的铁证。
再说一个更隐蔽的坑:隐式类型转换。MySQL 里如果比较的两边类型不一致,它会自动把字符转成数字,或者反过来。问题是,一旦索引列被隐式转换,和显式调用函数一样会导致索引失效。比如 phone 字段是 VARCHAR,你写 WHERE phone = 13800138000,MySQL 会把字符串列全部转成数字再比,索引就废了。类似的情况还有 status 是 INT,你写 WHERE status = '1',虽然结果可能没错,但某些版本下优化器也会做转换,扫描行数悄悄变多。
所以在写条件时记住一条原则:让索引列独立出现在比较符的一侧,别让它被函数或类型转换包裹。如果确实需要对时间字段做日期过滤,用范围条件替代函数;如果非要用函数,那就要接受全表扫描的现实,或者考虑改成生成列加索引的方案。
拿一个真实案例来说。之前有个订单表,数据量一千多万,运营每天要查当天的付款订单,SQL 写的是:
sql复制SELECT * FROM orders WHERE DATE(pay_time) = CURDATE();
这条 SQL 每次跑十几秒,把凌晨的定时任务都拖垮了。改成范围查询之后:
sql复制SELECT * FROM orders
WHERE pay_time >= CURDATE() AND pay_time < CURDATE() + INTERVAL 1 DAY;
执行时间直接掉到几十毫秒。为什么?因为 pay_time 上建的索引终于能用上了。这就是常见函数在 WHERE 里的第一个大坑——用不好,索引白建。
另外,LIKE 操作符配合前置通配符也一样,WHERE name LIKE '%张' 会让索引失效,因为字符串的匹配必须从开头才能走前缀索引。这个虽然不是函数,但背后的原理是相通的:你要让查询条件的形式能匹配上索引的排列顺序。
顺带提一下,有些函数是“无辜”的,比如在常量上调用 CURDATE()、NOW(),它们不影响索引列本身,可以放心用。真正要注意的,永远是函数包在列名外面的情况。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 聚合函数用不好,统计报表全是坑:GROUP BY 和 HAVING 的实战细节
聚合函数是统计报表的根基,但也是隐性 Bug 的重灾区。COUNT(*)、SUM()、AVG()、MAX()、MIN() 算是最常用的五个,看上去没什么技术含量,实际上细节多得很。
先说说 COUNT(*) 和 COUNT(1)、COUNT(字段) 的区别。很多老 DBA 会告诉你,COUNT(*) 和 COUNT(1) 没区别,InnoDB 引擎下 optimizer 都会优化成一样的执行计划。但 COUNT(字段) 就不同了,它只统计该字段非 NULL 的行数。如果你统计用户表中“填写了手机号的用户数”,用 COUNT(phone) 是合理的;但如果你以为 COUNT(phone) 等于总用户数,而 phone 字段允许 NULL,那结果就会少。所以计数之前,先想清楚你统计的语义是“行数”还是“非空值个数”。
另一个经典问题是 SUM() 遇到 NULL 的情况。MySQL 的聚合函数会自动忽略 NULL,但如果这一组数据全是 NULL,SUM() 返回的是 NULL 而不是 0。这个坑在出报表时尤其致命——JAVA 端用 BigDecimal 接收还好,如果用基本类型接收,直接抛空指针。我的习惯是外层套一个 IFNULL(SUM(amount), 0),既保证语义清晰,也防止程序端炸掉。
AVG() 也有类似问题,它本质上是 SUM() / COUNT(非空),NULL 不参与分母计算。如果你期望 NULL 按 0 参与平均,那要先 IFNULL 再 AVG,或者用 SUM/COUNT(*) 自己算。
GROUP BY 后面能不能跟别名?MySQL 的扩展语法允许,但 SQL 标准不允许,而且一旦用到函数别名(比如 GROUP BY 年份)在部分模式下会报错。为了兼容性和可读性,建议在 GROUP BY 里写原始表达式或列名,不要写别名。
HAVING 和 WHERE 的执行顺序也是高频考点。WHERE 在分组之前过滤行,HAVING 在分组之后过滤组。很多人用 WHERE SUM(amount) > 100 直接报错,就是因为聚合的结果在 WHERE 阶段还不存在。反过来,能用 WHERE 过滤掉的数据,尽量不要留到 HAVING 里,因为先缩小数据集再做聚合,性能会好很多。
举个例子,统计每个品类的销售额,只看金额超过一万的品类:
sql复制SELECT
category_id,
SUM(amount) AS total_amount
FROM orders
WHERE order_status = 'paid' -- 先用 WHERE 缩小范围
GROUP BY category_id
HAVING SUM(amount) > 10000; -- 再对聚合结果过滤
这个 SQL 的顺序是:先取已支付订单,再按品类分组求和,最后筛掉不达标的组。如果你把 order_status = 'paid' 写进 HAVING,虽然也能跑出正确结果,但 MySQL 需要把更多行带入分组阶段,白白浪费资源。
再说一个 GROUP BY 的隐藏问题:ONLY_FULL_GROUP_BY 模式。MySQL 5.7 之后默认开启这个模式,意思是你 SELECT 出来的非聚合列,必须出现在 GROUP BY 里。很多人从 5.6 升级到 5.7,以前跑得好好的 SQL 突然报错,就是因为这个。比如:
sql复制SELECT user_id, order_date, SUM(amount)
FROM orders
GROUP BY user_id;
在 5.6 下能跑,但取出的 order_date 是这一组里的任意一条,具有不确定性。5.7 之后直接报错,逼着你把 SQL 写严谨。我看到不少人在生产环境遇到这个问题,第一反应是想改 sql_mode,我劝你别这么干——默认严格模式是 MySQL 官方深思熟虑的,它保护的正是数据的确定性。真的要取“每个用户最近一次下单日期和金额”,老老实实写子查询或窗口函数。
聚合函数里还有一个容易被忽视的性能点:GROUP BY 的列如果能走索引,MySQL 不需要临时表和文件排序,执行计划的 Extra 字段里就不会出现 Using temporary 和 Using filesort。如果你的分组查询经常出现这两个关键字,就要检查一下索引设计是否匹配分组字段。
3. 字符串函数花样多,但业务场景才是试金石
字符串函数是日常开发里使用频率最高的类别,但也是最容易写出“看似正确实则慢得要命”的 SQL。CONCAT、SUBSTRING、REPLACE、LENGTH、CHAR_LENGTH、LEFT、RIGHT、TRIM、LOCATE、SUBSTRING_INDEX 这些,几乎每个项目都会用到。
先说一个字符长度的问题。LENGTH() 返回的是字节数,CHAR_LENGTH() 返回的是字符数。在 UTF-8 编码下,一个中文汉字占 3 个字节,但只算 1 个字符。如果你要校验用户输入的昵称是不是超过 20 个“字”,用 CHAR_LENGTH(nickname) 而不是 LENGTH(nickname)。之前遇到过有人用 LENGTH 限制评论内容长度,结果英文能输 200 个,中文不到 70 个就被截断,用户疯狂反馈,查了半天才发现是这个原因。
字符串拼接也是经典场景。早期版本里 CONCAT 如果遇到任何一个参数为 NULL,整个结果就变 NULL。比如:
sql复制SELECT CONCAT(first_name, ' ', last_name) FROM users;
只要 last_name 是 NULL,结果就是 NULL,前端拿到的就是“null”字符串或者空,反正不是你想要的。应对方案是用 CONCAT_WS(带分隔符的拼接),它会自动跳过 NULL 值:
sql复制SELECT CONCAT_WS(' ', first_name, last_name) FROM users;
如果分隔符也需要处理,那得自己套 IFNULL。这里有一个取舍:到底是让分隔符保留从而显示“张 ”,还是让拼接结果干脆跳过空值显示“张”,完全取决于业务上你想怎么呈现。我见过一个地址拼接的需求,用户没填门牌号,拼接结果最后多了一个“-”,逼着开发在 SQL 里写一堆嵌套的 CASE WHEN 去判断,其实用 CONCAT_WS 一行就能解决。
SUBSTRING_INDEX 这个函数在做标签拆分、路径解析的时候特别好用。假设 tags 字段存的是逗号分隔的标签(虽然我强烈不建议这种设计,但旧系统总会有),你要取第一个标签:
sql复制SELECT SUBSTRING_INDEX(tags, ',', 1) FROM articles;
取最后一个标签,把计数改成 -1 就行。它也可以用来取中间某一段,比如 SUBSTRING_INDEX(SUBSTRING_INDEX(tags, ',', 2), ',', -1) 取第二个标签。这个套娃写法初看不直观,但实际处理 CSV 字段时非常顺手。
LOCATE 和 INSTR 都是找子串位置的。LOCATE('abc', content) 返回第一次出现的位置,从 1 开始计数,找不到返回 0。INSTR 参数顺序和 LOCATE 反着来,INSTR(content, 'abc')。这个位置信息配合 SUBSTRING 可以模拟“截取某个标记前后内容”的效果,用来替代一些简单的字符串解析。
还有一个很多人忽略的细节:字段比较时的排序规则。MySQL 的字符串比较默认跟 collation 有关,utf8mb4_general_ci 和 utf8mb4_unicode_ci 下,'a' = 'A' 的判定是相等的,因为 _ci 结尾的排序规则不区分大小写。如果你要在用户登录时区分用户名大小写,就要用 BINARY 关键字或选择 _bin 排序规则:
sql复制SELECT * FROM users WHERE BINARY username = 'Admin';
这个坑在系统迁移或者从 Oracle、SQL Server 迁移到 MySQL 时特别常见——源库区分大小写,目标库不区分,线上瞬间出现重复账号。
字符串函数还有一个容易被忽视的性能隐患:在 WHERE 里对文本列做 LOWER(name) = 'admin' 或者 REPLACE(phone, '-', ''),索引必然失效。这类清洗操作最好的归宿是在写入时完成——程序端统一格式后再入库,或者用生成列。MySQL 5.7 之后支持在生成列上建索引,可以把这类需求从查询时转移到写入时,查询直接命中索引。这是解决“不得不做清洗匹配”的正路,而不是在 SQL 里死磕函数。
4. 日期时间函数:时区、存储类型和边界条件的三重考验
日期时间函数的坑,大多数不是函数本身复杂,而是对“时间到底是什么”理解不够。MySQL 里日期时间相关的类型主要有 DATE、DATETIME、TIMESTAMP,函数则有 NOW()、CURDATE()、DATE_FORMAT()、DATEDIFF()、TIMESTAMPDIFF()、DATE_ADD()、DATE_SUB()、LAST_DAY()、YEAR()、MONTH() 等等。
先解决最基础的问题:NOW() 和 CURDATE() 的区别。NOW() 返回日期加时间,CURDATE() 只返回日期。CURRENT_TIMESTAMP 是 NOW() 的同义词。真正容易出错的是在不同会话时区下,这些函数返回的结果可能不一样。MySQL 的 time_zone 参数可以设置全局和会话级别。如果你的应用服务器和数据库服务器不在同一个时区,或者你改了数据库时区但没有同步改连接参数,NOW() 返回的时间就可能和预期差好几个小时。
TIMESTAMP 类型内部存储的是 UTC 时间,显示时会根据会话时区转换;DATETIME 存储的是字面值,不做时区转换。这个差异在跨时区业务里非常关键。如果你的用户分布在不同时区,建议统一用 TIMESTAMP 存储时间点,前端展示时再按用户所在时区转换。如果你存的是 DATETIME,那每次迁移服务器或者改时区配置,历史数据看起来就全乱了。
时间计算函数里,DATEDIFF() 计算两个日期相差的天数,适合算年龄、算会员剩余天数:
sql复制SELECT DATEDIFF('2024-03-01', '2024-02-01'); -- 结果是 29
它只比较日期部分,即使传进来的是带时间的值,也会先截断时间再比较。如果你需要精确的小时差或者秒差,用 TIMESTAMPDIFF(),指定单位即可:
sql复制SELECT TIMESTAMPDIFF(HOUR, start_time, end_time);
这个函数比 DATEDIFF 灵活得多,支持 SECOND、MINUTE、HOUR、DAY、WEEK、MONTH、QUARTER、YEAR。我统计订单从下单到支付的耗时,就是用 TIMESTAMPDIFF(MINUTE, created_at, paid_at) 算出来的,配合 GROUP BY 按分钟区间切片,可以快速定位支付环节的瓶颈。
日期加减操作,我建议统一用 DATE_ADD() 和 DATE_SUB(),而不是直接 date + INTERVAL 1 DAY 这种写法。虽然两者等价,但 DATE_ADD 的语义更清晰,也更容易维护。下面几个例子涵盖了常见的报表周期计算:
sql复制-- 近 7 天(含今天)
WHERE create_time >= DATE_SUB(CURDATE(), INTERVAL 6 DAY)
-- 本月第一天
WHERE create_time >= DATE_FORMAT(CURDATE(), '%Y-%m-01')
-- 上个月第一天和最后一天
SELECT
DATE_FORMAT(DATE_SUB(CURDATE(), INTERVAL 1 MONTH), '%Y-%m-01') AS first_day,
LAST_DAY(DATE_SUB(CURDATE(), INTERVAL 1 MONTH)) AS last_day;
DATE_FORMAT() 是格式化函数,但它也是性能杀手。前面说过,在 WHERE 条件里对日期字段使用 DATE_FORMAT(create_time, '%Y-%m-%d') = '2024-01-01' 会导致索引失效。这属于“能用范围查询就绝不用函数包裹”的典型案例。但 DATE_FORMAT 在 SELECT 输出层面用来做展示格式化,是完全没有问题的。
还有一个容易忽略的点:DATE_FORMAT 的格式化参数大小写敏感。%Y 是四位年份,%y 是两位年份;%m 是带前导零的月份,%c 是不带前导零的月份;%H 是 24 小时制,%h 是 12 小时制。写错大小写不会报错,但结果完全不对,这种 Bug 特别难查,因为它不报错,只是数据看起来怪怪的。
边界条件也值得提。比如算“上个月”的订单,很多人写:
sql复制WHERE MONTH(create_time) = MONTH(DATE_SUB(CURDATE(), INTERVAL 1 MONTH))
这个写法有两个问题:第一,MONTH(create_time) 导致索引失效;第二,如果现在是 3 月,你统计的是 2 月的数据,但跨年的时候,比如 1 月统计上个月,这个条件会变成 MONTH(create_time) = 12,而 12 月可能是上一年的,也可能是今年的,逻辑直接出错。正确写法是用范围:
sql复制WHERE create_time >= DATE_FORMAT(DATE_SUB(CURDATE(), INTERVAL 1 MONTH), '%Y-%m-01')
AND create_time < DATE_FORMAT(CURDATE(), '%Y-%m-01')
半开区间 [start, end) 是处理时间范围最稳的策略,统一用 >= 和 <,天然避开 23:59:59 或者 59.999 秒带来的精度问题。
5. 条件判断与逻辑重构:IF、IFNULL、NULLIF 和 CASE WHEN 的取舍
条件判断函数在 SQL 里的作用,是让你在数据层面直接完成逻辑分支,减少应用层的二次处理。MySQL 里主要就是 IF()、IFNULL()、NULLIF() 和 CASE WHEN。很多人习惯全部用 IF 嵌套,代码可读性差,执行效率也不一定好。这里聊聊不同场景下的选型逻辑。
IFNULL(expr1, expr2) 是最简单的:expr1 不为 NULL 就返回 expr1,否则返回 expr2。它只处理 NULL,不处理空字符串和 0。如果字段默认值是空字符串,IFNULL 不会生效,还得用 NULLIF 先转换,或者用 CASE。
NULLIF(expr1, expr2) 的作用正好反过来:如果两个表达式相等,返回 NULL,否则返回 expr1。一个常见用途是防止除零错误:
sql复制SELECT
total_amount / NULLIF(order_count, 0) AS avg_amount
FROM stats;
当 order_count 为 0 时,NULLIF 把它变成 NULL,除法结果变成 NULL,而不是报“Division by 0”错误。外层再套一个 IFNULL 就能给默认值:
sql复制SELECT
IFNULL(total_amount / NULLIF(order_count, 0), 0) AS avg_amount
FROM stats;
这套组合拳在统计场景里几乎是标配。
IF(expr1, expr2, expr3) 是三目运算,适合简单的二分支逻辑。比如统计订单时给支付渠道打标:
sql复制SELECT
IF(pay_type = 1, '微信', '非微信') AS pay_channel_label
FROM orders;
但一旦分支超过两个,或者条件比较复杂,IF 嵌套会让 SQL 变成一坨难以维护的东西。这时候用 CASE WHEN 更合适:
sql复制SELECT
CASE
WHEN amount >= 10000 THEN '大单'
WHEN amount >= 1000 THEN '中单'
ELSE '小单'
END AS order_level
FROM orders;
CASE WHEN 是标准 SQL 语法,可读性强,也方便加新的分支,在报表 SQL 里是首选。一个容易忽略的细节是:CASE WHEN 的条件是从上往下匹配的,匹配到第一个为真的条件就返回,后续不再判断。所以分支的顺序很重要——你把“amount >= 1000”写在“amount >= 10000”前面,大单就永远匹配不到。这个逻辑错误不会报错,只会让数据悄悄出错。
条件函数在聚合统计里的妙用也很多。典型的需求是“统计不同渠道的订单数”,可以用 SUM(IF(...)) 实现条件聚合,一次扫描出多列结果:
sql复制SELECT
DATE(create_time) AS day,
SUM(IF(pay_type = 1, 1, 0)) AS wechat_orders,
SUM(IF(pay_type = 2, 1, 0)) AS alipay_orders,
SUM(IF(pay_type = 3, 1, 0)) AS card_orders
FROM orders
WHERE create_time >= DATE_SUB(CURDATE(), INTERVAL 7 DAY)
GROUP BY DATE(create_time);
这样一条 SQL 就把一周内每天的渠道订单数全部拉出来,比写三条 SQL 然后应用层合并高效得多,也避免多次全表扫描。
在 WHERE 条件里,IF 函数的使用要格外谨慎。WHERE IF(status = 1, price > 100, price > 200) 这种写法虽然能跑,但它让优化器无法有效利用索引,等同于在每个索引列上盖了一层不确定性。我的建议是:能用逻辑运算表达的,就别用 IF。上面的条件改成:
sql复制WHERE (status = 1 AND price > 100)
OR (status <> 1 AND price > 200)
虽然看起来长了一些,但每一列都独立裸奔,索引利用的可能性大增。在 SQL 优化这件事上,牺牲一点写代码的优雅来换取执行效率,永远值得。
6. 加密哈希与安全函数:不只是 MD5 那么简单
MD5、SHA、AES 加密、RAND 随机数,这些函数在安全相关的场景里承担着关键职责,但也是被误用最多的。很多年前的项目里,用户密码直接 MD5(password) 存储,现在回头看简直是灾难级操作。MD5 算法本身已经能被 GPU 以每秒数百亿次的速度碰撞,再加上现在有彩虹表,弱口令几乎等于明文。
MySQL 里做密码哈希,至少要用 SHA2() 算法并加盐:
sql复制INSERT INTO users (username, password_hash)
VALUES ('zhangsan', SHA2(CONCAT(password_salt, '原始密码'), 256));
SHA2 函数第二个参数可以是 224、256、384、512,对应 SHA-2 系列的不同位数。加盐是为了防止两个相同密码的用户得到相同哈希,也能抵御彩虹表。每个用户的盐应该是随机且唯一的,推荐用 UUID() 或者 RANDOM_BYTES() 生成。
RANDOM_BYTES(len) 是一个容易被忽视的强随机数生成器,它利用操作系统的随机源生成指定长度的随机字节,可以用 HEX() 包一层得到十六进制字符串。比传统的 RAND() 更适合用于生成盐值、令牌:
sql复制SELECT HEX(RANDOM_BYTES(16));
RAND() 是伪随机数,通常用于“随机抽取 N 条记录”之类的场景:
sql复制SELECT * FROM questions ORDER BY RAND() LIMIT 5;
这个 SQL 看起来很美好,实际上数据量一大就惨不忍睹——ORDER BY RAND() 需要生成每一行的随机值再排序,走不了索引,全表扫描加文件排序,百万行数据直接跑出好几秒。生产环境下更稳妥的做法是应用层随机出 5 个 ID 范围再查询,或者用 WHERE id >= FLOOR(RAND() * (SELECT MAX(id) FROM questions)) 这种近似方案。但要注意后一种方案如果行之间存在空洞,取不到 5 条就得补一次查询,也不完美。总之,用 RAND 做抽样时,心里要有数:它背后意味着全表扫描加排序,不是随便用用的。
MD5 和 SHA 系列虽然常用于生成数据指纹、做唯一性校验,但要注意两点:第一,MD5 不是加密算法,是摘要算法,它不可逆;第二,不同平台对中文字符的编码处理可能不同,同样的字符串在 UTF-8 和 GBK 下算出的 MD5 值可能不一样。如果你在 MySQL 端算 MD5,在 Java 端也算 MD5,要对齐编码,否则两边结果不一致,排查起来非常头疼。一般约定:在程序端统一用 UTF-8 编码计算摘要,数据库端只做存储和等值查询。
AES 加解密在 MySQL 里也有内置函数,AES_ENCRYPT() 和 AES_DECRYPT(),适合对敏感字段做列级加密。AES 是对称加密,加解密用同一个密钥。使用方法:
sql复制-- 加密存储
INSERT INTO member (id_card, id_card_encrypted)
VALUES (1, AES_ENCRYPT('110101199001011234', 'your-secret-key'));
-- 解密读取
SELECT CAST(AES_DECRYPT(id_card_encrypted, 'your-secret-key') AS CHAR) AS id_card
FROM member;
这里有几个容易踩的坑。第一,AES_ENCRYPT 返回的是二进制数据,存储时字段类型必须是 VARBINARY 或 BLOB,用 VARCHAR 存会导致数据损坏。第二,密钥不要硬编码在 SQL 里,更不要提交到代码仓库,最好从配置中心或密钥管理系统获取。第三,默认的加密模式在不同版本 MySQL 中可能不同,如果程序端用 OpenSSL 加密、数据库端用 AES_DECRYPT 解密,或者反过来,两边要约定好算法模式、填充方式和初始向量,否则解密出来是乱码或直接报错。
哈希和加密函数还有一个容易被忽略的场景:数据脱敏和匿名化。比如日志表里的手机号、身份证号,开发环境要给外包团队使用,但真实数据不能暴露,可以用 SHA2 生成不可逆的匿名 ID 替换敏感字段:
sql复制UPDATE users
SET phone_anon = SHA2(phone, 256)
WHERE id > 0;
这样既保留了同一用户的数据关联性(同一个手机号生成同一个哈希),又没法反推出真实手机号,比随机替换更能保持数据的分析价值。
7. 窗口函数和开窗统计:报表 SQL 的新时代写法
MySQL 8.0 引入了窗口函数(Window Function),这算是近十年 MySQL 在分析能力上最大的一次升级。以前用“分组取最新一条”这类需求,得靠子查询、JOIN 自己,写出来的 SQL 又长又绕;有了窗口函数,一段代码直接搞定,而且逻辑清晰,性能也不差。
窗口函数的核心思想是:在结果集的每一行上,基于一个“窗口”(由 PARTITION BY 划分、ORDER BY 排序)进行计算,但不像 GROUP BY 那样把多行合并成一行。常见的窗口函数有 ROW_NUMBER()、RANK()、DENSE_RANK()、LAG()、LEAD()、SUM() OVER() 等等。
举一个最常见的实战场景:“取每个用户最近的一笔订单”。老写法用子查询:
sql复制SELECT o.*
FROM orders o
JOIN (
SELECT user_id, MAX(created_at) AS max_time
FROM orders
GROUP BY user_id
) t ON o.user_id = t.user_id AND o.created_at = t.max_time;
这个写法有两个问题:如果同一个用户在同一秒下了两笔订单,会查出两行;而且子查询产生的临时表在大数据量下可能撑爆内存。
窗口函数写法:
sql复制SELECT *
FROM (
SELECT
o.*,
ROW_NUMBER() OVER(PARTITION BY user_id ORDER BY created_at DESC) AS rn
FROM orders o
) t
WHERE rn = 1;
这个方法背后的逻辑是:先给每个用户的订单按时间倒序编号,然后只取编号为 1 的那一行。如果只想保证时间最新但允许同一用户多行,可以把 ROW_NUMBER 换成 RANK,这样并列第一的都会保留。
处理累计值和移动平均也是窗口函数的强项。比如统计每个用户逐日累计消费金额:
sql复制SELECT
user_id,
order_date,
SUM(amount) OVER(PARTITION BY user_id ORDER BY order_date) AS cumulative_amount
FROM user_daily_orders;
这个 SUM OVER 的语义是:按用户分组,按日期排序,计算从最早日期到当前行的累加和。这就是“运行总计”,在财务报表里很常用。如果要看最近 3 天的滑动平均值:
sql复制SELECT
user_id,
order_date,
AVG(amount) OVER(PARTITION BY user_id ORDER BY order_date ROWS BETWEEN 2 PRECEDING AND CURRENT ROW) AS moving_avg_3d
FROM user_daily_orders;
ROWS BETWEEN 定义了窗口的边界,这是窗口函数最灵活也最需要理解的部分。写错了窗口范围,统计结果就完全不对。日常用得最多的是这几档:
ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW:从分组第一行到当前行(默认累计)ROWS BETWEEN 2 PRECEDING AND CURRENT ROW:当前行和前两行ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING:当前行到分组最后一行
LAG() 和 LEAD() 用来访问同一分组内前一行或后一行的数据,适合算环比、同比。比如算每个用户相邻两笔订单的时间间隔:
sql复制SELECT
user_id,
created_at,
LAG(created_at) OVER(PARTITION BY user_id ORDER BY created_at) AS prev_time,
TIMESTAMPDIFF(MINUTE,
LAG(created_at) OVER(PARTITION BY user_id ORDER BY created_at),
created_at
) AS gap_minutes
FROM orders;
LAG 在窗口函数里没有前一行时返回 NULL,所以第一行订单的 gap_minutes 是 NULL,计算时需要处理掉。TIMESTAMPDIFF 在这里和 LAG 嵌套用,恰好实现了“相邻两笔订单的间隔”这个统计口径。
窗口函数在 GROUP BY 聚合结果上再做排名也常用。比如要算“每个品类销售额排名前 3 的品牌”,可以先聚合再开窗:
sql复制SELECT *
FROM (
SELECT
category_id,
brand_id,
SUM(amount) AS sales,
ROW_NUMBER() OVER(PARTITION BY category_id ORDER BY SUM(amount) DESC) AS rank_no
FROM orders
GROUP BY category_id, brand_id
) t
WHERE rank_no <= 3;
注意这里 SUM(amount) 同时出现在聚合和窗口函数里,MySQL 8.0 是支持这种写法的,但逻辑上要分清楚:GROUP BY 先按品类和品牌汇总,窗口函数再对汇总结果排名。
我用窗口函数替换掉之前很多子查询加 JOIN 的写法之后,SQL 的可读性和执行效率都有明显提升。这里要提醒一句,8.0 之前的版本不能用窗口函数——很多老系统还跑在 5.7 上,写代码前先确认版本,别到时候部署到生产环境才报语法错误。
8. 一条慢 SQL 的体检报告:用 EXPLAIN 复盘函数使用是否合理
最后聊一个实操性很强的复盘方法。每次写完带函数的 SQL,别急着上线,先花十秒钟跑一下 EXPLAIN,看看执行计划长什么样。EXPLAIN 的输出里,重点看几个字段:type、key、rows、Extra。
type 从好到差排列大概是:system > const > eq_ref > ref > range > index > ALL。如果看到 ALL,说明全表扫描;看到 index,说明虽然扫了索引,但可能扫了整棵索引树;range 是范围扫描,通常可以接受;ref 和 eq_ref 代表精准匹配,已经不错。key 字段显示实际用到的索引名,如果为 NULL,说明没走索引。rows 是预估扫描行数,这个数字越大,SQL 越有优化空间。
Extra 字段里如果出现 Using filesort,说明排序没有走索引,数据量一大就会出现文件排序;出现 Using temporary,说明用了临时表,常见于 GROUP BY 和 DISTINCT 操作。出现 Using index 是好事,说明覆盖索引生效,查询只需要读索引,不需要回表。
拿一个实际例子复盘。假设我想统计 2024 年每个月的订单量和销售额,写法一:
sql复制SELECT
DATE_FORMAT(created_at, '%Y-%m') AS month,
COUNT(*) AS order_cnt,
SUM(amount) AS sales
FROM orders
WHERE created_at >= '2024-01-01' AND created_at < '2025-01-01'
GROUP BY DATE_FORMAT(created_at, '%Y-%m');
这个 SQL 在 WHERE 条件上用了范围查询,索引用得上;但 GROUP BY 后面跟的是 DATE_FORMAT(created_at, '%Y-%m'),这个表达式不是索引列本身,所以 MySQL 需要先按 created_at 索引取出数据,再对每行计算格式化表达式,然后做分组,结果 Extra 里大概率出现 Using temporary 和 Using filesort。
如果数据量只有几万行,问题不大;但到了千万级,这个临时表和文件排序的成本就很可观。MySQL 8.0 里可以改用函数索引,先建一个生成列,再对这个列建索引,让 GROUP BY 直接走索引分组。另一种思路是改写 SQL,对原始时间列分组,输出后再格式化:
sql复制SELECT
DATE_FORMAT(month_start, '%Y-%m') AS month,
order_cnt,
sales
FROM (
SELECT
DATE_FORMAT(created_at, '%Y-%m-01') AS month_start,
COUNT(*) AS order_cnt,
SUM(amount) AS sales
FROM orders
WHERE created_at >= '2024-01-01' AND created_at < '2025-01-01'
GROUP BY DATE_FORMAT(created_at, '%Y-%m-01')
) t;
但你发现没有,子查询里 GROUP BY 仍然跟的是表达式。只要分组键不是裸列,索引分组就无从谈起。要真正优化,要么建生成列加索引,要么接受在数据量可控范围内的全表分组。这就是函数使用中“鱼和熊掌”的典型取舍,你必须清楚自己写在 SQL 里的每个函数,到底是在影响索引利用,还是在影响分组排序。EXPLAIN 就是替你做体检的工具,它会直接告诉你答案。
第二个常见的复盘场景是字符串模糊匹配。比如用户搜索商品名包含“手机”的记录:
sql复制SELECT * FROM products
WHERE name LIKE '%手机%';
EXPLAIN 的结果大概率是 type=ALL、rows 等于全表行数,因为前置通配符导致索引失效。这类 SQL 在数据量小的时候没感觉,但只要表过百万,响应时间就会明显变长。MySQL 5.7 之后,可以考虑用全文索引来覆盖这类需求:
sql复制ALTER TABLE products ADD FULLTEXT INDEX ft_name (name);
SELECT * FROM products
WHERE MATCH(name) AGAINST('手机' IN NATURAL LANGUAGE MODE);
全文索引和 LIKE 的语义不完全一样,它基于分词和相关性,适合真正的全文检索场景。对中文分词来说,MySQL 默认全文索引对中文支持并不理想,因为中文没有天然空格分隔。这通常是一个需要引入搜索引擎(比如 Elasticsearch)的信号。如果只是偶发的管理端查询,数据量也不大,那老老实实全表扫也问题不大,但要心里有数。
还有一种情况:函数用在了 JOIN 的关联条件上。比如表 A 的 user_id 是整数,表 B 的 user_code 是字符串而且前缀带字母,你 JOIN 的时候可能想写 A.user_id = CAST(B.user_code AS UNSIGNED),这一下就导致 B 表关联字段的索引失效,JOIN 过程变成逐行全表扫描。正确的做法是,从设计上统一关联字段的数据类型,或者在 B 表新增一个生成列存储纯数字部分并加索引。总之,函数出现在 JOIN ON 里,通常是数据库设计出了问题——类型不一致被强行拉着表关联,这种场合同样在说数据模型需要修正。
最后总结一下复盘逻辑:写完 SQL 后,先看 WHERE 条件里有没有对索引列施加函数或隐式转换;再看 GROUP BY 和 ORDER BY 里的表达式是否可能匹配索引;再看 JOIN ON 里有没有跨类型的强制转换;最后确认一下使用的函数是否与 MySQL 版本兼容。每一步检查完,再配合 EXPLAIN 验证,绝大多数函数相关的性能问题都可以在自测阶段暴露出来,而不是上线后被监控告警揪出来。
在我个人的实践中,真正让 SQL 从“能跑”变成“跑得漂亮”的,除了对常见函数的语义了如指掌,更重要的是对函数“副作用”的敬畏——它可能让索引失效、可能产生隐式转换、可能在不同版本或排序规则下表现出不一样的行为。每写一个函数,都值得问一句:这一列后面有没有索引?这个表达式可不可以等值改写?这个函数真的有必要出现在这个位置吗?把这三个问题养成条件反射,MySQL 常见函数才真正成了你的工具,而不是定时炸弹。
