1. 函数不只是“语法糖”:搞懂 MySQL 函数体系,SQL 水平直接上一个台阶
前两天在群里看人讨论一个需求,说是统计某个时间段内的用户留存,SQL 写到一半卡住了——因为要算日期差、要处理空值、还要对字符串做拆分拼接。评论区给出的方案五花八门,有人建议在应用层用 Java 处理,有人干脆导出到 Excel 里拉透视表。我在底下回了句:“这不就是一个DATEDIFF加一个IFNULL的事吗?”
这其实就是我写这篇 MySQL 函数笔记的初衷。很多人学 SQL 的时候,SELECT、WHERE、JOIN、GROUP BY这些主干语句都没问题,一碰到函数就发怵,总觉得这东西“要用到了再查”。但函数恰恰是 SQL 里性价比最高的一块内容——你不需要写复杂的逻辑,一行函数就能顶十几行应用层代码,还能避免数据在应用和数据库之间来回传输的损耗。
这篇文章我会把 MySQL 里的常用函数按类别拆开讲,每一类给你配实际业务场景下的案例,包含完整的 SQL 写法、执行结果、以及我在真实项目里踩过的坑。内容覆盖字符串处理、数值计算、日期时间、流程控制、聚合统计、窗口函数这几大块,最后专门挑几个高频易错点单独说。无论你是刚学会写SELECT的新手,还是写了两三年 SQL 想系统补一遍函数知识的开发,这篇都能直接用。
MySQL 函数表面上是一个个固定的语法块,但背后其实是三类完全不同的逻辑:一种是对单行数据进行变换(比如字符串拼接、大小写转换),一种是对多行数据进行汇总(比如求和、计数、去重统计),还有一种是基于分组内的相对位置做计算(比如排名、取前一行)。这三类函数的适用场景和底层逻辑差异很大,理解了这个分类,你后面学起来会顺畅很多。
2. 字符串函数:拼接、截取、替换、正则,处理好文本才能做好业务
字符串函数是日常写 SQL 时用得最频繁的一类,几乎每个业务表里都有几个varchar字段,而这些字段的内容基本都需要加工才能直接展示或计算。
2.1 拼接与分隔:CONCAT 和 CONCAT_WS
CONCAT是最基础的字符串拼接函数,用法非常直白:
SELECT CONCAT('Hello', ' ', 'MySQL'); -- 结果:Hello MySQL但这里有个新手最容易踩的坑:只要其中一个参数是NULL,整个结果就是NULL。比如你的用户表里有last_name和first_name两个字段,如果某些用户没填姓氏,CONCAT(last_name, first_name)的结果会直接变成NULL,而不是只返回名字。
我实际项目中一般这样处理:
-- 用户昵称拼接,缺失字段用空串替代 SELECT CONCAT(IFNULL(last_name, ''), IFNULL(first_name, '')) AS full_name FROM users;相比之下,CONCAT_WS要友好得多。它第一个参数是分隔符,遇到NULL值会自动跳过,不会把整个结果搞挂:
SELECT CONCAT_WS('-', '2024', '06', '18'); -- 结果:2024-06-18 -- 如果某个值为 NULL,该值会被跳过,分隔符依然存在 SELECT CONCAT_WS('-', '2024', NULL, '18'); -- 结果:2024-18这里有个重要区别:CONCAT_WS跳过的只是NULL值本身,分隔符还是会保留的。所以如果你想拼地址,省市区有一项缺失,你还得自己再处理一下格式。不过相对于CONCAT的“一空全空”,它已经省了很多事。
2.2 截取与定位:SUBSTRING、SUBSTRING_INDEX、LOCATE
截取字符串有三种常见场景,对应的函数不同,别搞混。
第一种是按位置截取,用SUBSTRING(str, pos, len):
SELECT SUBSTRING('MySQL函数详解', 6, 2); -- 结果:函数注意 MySQL 的下标是从 1 开始的,这点和很多编程语言不一样。pos也可以传负数,表示从末尾往前数位置:
SELECT SUBSTRING('MySQL函数详解', -4, 2); -- 结果:数详第二种是按分隔符截取,用SUBSTRING_INDEX(str, delim, count)。这个函数非常实用,特别是处理路径、IP、编号这类有固定分隔符的字段:
-- 取 IP 的前两段 SELECT SUBSTRING_INDEX('192.168.1.100', '.', 2); -- 结果:192.168 -- count 为负数时从右侧开始取 SELECT SUBSTRING_INDEX('192.168.1.100', '.', -2); -- 结果:1.100我的经验是:在不知道字符串总长度的情况下,SUBSTRING_INDEX往往比SUBSTRING更可靠。比如日志表里存的完整 URL,你想要去掉查询参数,可以直接SUBSTRING_INDEX(url, '?', 1),不用关心 URL 有多长。
第三种是查找子串的位置,用LOCATE(substr, str):
SELECT LOCATE('SQL', 'MySQL函数详解'); -- 结果:4,说明 'SQL' 从第4个字符开始配合SUBSTRING可以做到动态截取。比如某个字段存储格式是“编码-名称”,你想单独把名称部分拿出来,但又不知道编码长度:
SELECT SUBSTRING(product_code, LOCATE('-', product_code) + 1) FROM products;2.3 替换、清理与大小写
REPLACE(str, from_str, to_str)用于替换字符串中的指定子串,这在做数据清洗时几乎天天用:
-- 手机号脱敏 SELECT REPLACE('138****1234', '****', '0000'); -- 结果:13800001234 -- 去掉文本中的换行符 SELECT REPLACE(content, '\n', ' ') FROM articles;TRIM、LTRIM、RTRIM用于去除空格。这里有个细节:TRIM默认只去首尾空格,不会动字符串中间的空格。如果你想清理文本里多余的空格,需要配合REPLACE把两个连续空格替换为单个空格,循环几次,或者干脆在应用层处理。
UPPER和LOWER做大小写转换,一般用于统一比较场景:
-- 不区分大小写地匹配邮箱 SELECT * FROM users WHERE LOWER(email) = LOWER('Test@Example.COM');实际上 MySQL 的默认排序规则(collation)在比较时通常已经忽略大小写了,所以这条 SQL 更多是显式表达意图。
2.4 填充与长度:LPAD/RPAD、LENGTH 与 CHAR_LENGTH
LPAD(str, len, padstr)和RPAD做左填充和右填充,经典场景是订单号补零:
SELECT LPAD('123', 8, '0'); -- 结果:00000123长度函数这里必须重点说,因为LENGTH返回的是字节数,CHAR_LENGTH返回的是字符数。在 UTF-8 编码下,一个中文字符占 3 个字节:
SELECT LENGTH('MySQL数据库'), CHAR_LENGTH('MySQL数据库'); -- 结果:13, 7如果字段长度校验按字符数做,但你又错用了LENGTH,那所有含中文的内容都会被认为“超长”,这是个特别隐蔽的坑。
2.5 正则匹配与替换:REGEXP 与 REGEXP_REPLACE
MySQL 从 8.0 开始内置了比较完整的正则函数,REGEXP用于判断匹配,REGEXP_REPLACE用于按正则替换。比如从一段文本中提取所有数字:
SELECT REGEXP_REPLACE('订单号A123,金额45.6元', '[^0-9.]', ''); -- 结果:12345.6再比如筛选手机号前缀为 138 或 139 的用户:
SELECT * FROM users WHERE phone REGEXP '^13[89][0-9]{8}$';正则能力是字符串函数里上限最高的,建议至少掌握常用的字符类、量词和分组语法。不过要注意,正则操作无法利用索引,数据量大时要谨慎在WHERE里直接使用,能先缩小结果集再正则最好。
3. 数值函数:不只是四舍五入,取整和随机也有学问
数值函数看着简单,但在金额计算、统计报表、折扣分摊这些场景里,选错函数会导致结果直接不对,而且很难发现。
3.1 四舍五入与截断:ROUND、TRUNCATE
ROUND(x, d)四舍五入保留 d 位小数,TRUNCATE(x, d)直接截断不做四舍五入。这俩的区别在实际业务里非常关键,尤其是钱:
SELECT ROUND(123.456, 2), TRUNCATE(123.456, 2); -- 结果:123.46, 123.45如果做对账系统,一单差一分钱,一千单就可能差十块,这种差异在财务口径里是完全不能接受的。所以你对账用哪个函数、舍入规则是什么,必须提前定好,不能随手写。
ROUND还有一个容易忽略的行为:当小数部分正好是 0.5 时,MySQL 的舍入规则是“四舍五入到最近的偶数”(银行家舍入)。比如ROUND(2.5)结果是 2,ROUND(3.5)结果是 4。这一点在做统计汇总时可能跟其他语言的结果不一致,跨系统对数据时要特别小心。
3.2 上下取整与取模:CEIL、FLOOR、MOD
CEIL(x)向上取整,FLOOR(x)向下取整,MOD(a, b)取余数。
分页场景里计算总页数时,向上取整是标准做法:
-- 总共 83 条数据,每页 10 条 SELECT CEIL(83 / 10) AS total_pages; -- 结果:9取模的一个经典用途是做分库分表的路由判断,比如按用户 ID 的尾号决定数据落到哪张子表:
SELECT MOD(10086, 4); -- 结果:2,表示可以路由到编号为 2 的分表另一个场景是抽奖和采样,比如每第 10 个用户抽一个:
SELECT * FROM users WHERE MOD(id, 10) = 0;3.3 随机数与格式化:RAND、FORMAT、POWER
RAND()返回 0 到 1 之间的随机小数,常用在随机排序或者抽样:
-- 随机取 5 条记录 SELECT * FROM products ORDER BY RAND() LIMIT 5;但这个写法有个大坑:数据量一大,ORDER BY RAND()会全表扫描并对每一行计算随机数,性能非常差。几万条数据可能还能扛,上百万条会把数据库直接拖垮。业界常用的替代方案是先取主键范围,再随机选偏移量,或者利用应用层生成随机 ID 再查。
FORMAT(x, d)把数字格式化为带千分位的字符串,常用于报表展示:
SELECT FORMAT(1234567.891, 2); -- 结果:1,234,567.89注意结果类型是字符串,如果还要继续做数值运算,记得再转回来。
POWER(x, y)就是幂运算,在复利计算、增长率的场景里会用到。比如计算某个指标连续增长 12 个月后的倍数:
SELECT POWER(1.02, 12); -- 结果:1.26824179456254554. 日期时间函数:业务统计的基座,时区与格式要格外当心
日期时间函数在所有函数里属于“出错最隐蔽”的一类。写的时候不觉得有问题,一到跨天统计、跨月对比就露馅。
4.1 获取当前时间:NOW、CURDATE、CURTIME、SYSDATE
这几个函数的区别值得背下来:
NOW():返回当前日期时间,执行 SQL 语句的时刻。SYSDATE():返回当前日期时间,函数被调用的时刻。CURDATE():只返回当前日期。CURTIME():只返回当前时间。
NOW()和SYSDATE()的区别很微妙。在一个长 SQL 里,如果多个地方调用NOW(),它们的值都是一样的(语句开始时间);而SYSDATE()是多次调用就多个值。这会导致同一个查询里前后拿到的“当前时间”不一样,在特定的备份、日志对比场景里会造成困惑。我通常统一用NOW(),避免这种不确定性。
4.2 日期格式化与解析:DATE_FORMAT、STR_TO_DATE
DATE_FORMAT(date, format)是日期转字符串,STR_TO_DATE(str, format)是字符串转日期。两者是对称的:
SELECT DATE_FORMAT(NOW(), '%Y-%m-%d %H:%i:%s'); -- 结果:2024-06-18 14:30:00 SELECT STR_TO_DATE('2024/06/18 14:30', '%Y/%m/%d %H:%i'); -- 结果:2024-06-18 14:30:00常用的格式符我给你列全一点,别再每次都去查文档:
| 格式符 | 含义 | 示例输出 |
|---|---|---|
| %Y | 四位年份 | 2024 |
| %y | 两位年份 | 24 |
| %m | 两位月份 | 06 |
| %c | 月份(无前导零) | 6 |
| %d | 两位日 | 18 |
| %e | 日(无前导零) | 18 |
| %H | 24小时制 | 14 |
| %h | 12小时制 | 02 |
| %i | 分钟 | 30 |
| %s | 秒 | 05 |
| %W | 星期名 | Tuesday |
| %w | 星期数字(0-6) | 2 |
这块我最想强调的一点是:能用日期类型直接计算就不要转成字符串再算。比如按月份分组统计,我见过有人这么写:
SELECT DATE_FORMAT(created_at, '%Y-%m') AS month, COUNT(*) FROM orders GROUP BY month;这个写法其实可以用更高效的方式替代:
SELECT DATE_FORMAT(created_at, '%Y-%m') AS month, COUNT(*) FROM orders GROUP BY DATE_FORMAT(created_at, '%Y-%m');或者直接按日期范围分组。虽然 MySQL 在GROUP BY别名的处理上比较宽松,但从可读性和索引利用的角度考虑,直接在表达式上分组更稳妥。
4.3 日期运算:DATE_ADD、DATE_SUB、DATEDIFF、TIMESTAMPDIFF
DATE_ADD(date, INTERVAL expr unit)和DATE_SUB用于在日期上增减一段时间:
-- 30 天前的日期 SELECT DATE_SUB(CURDATE(), INTERVAL 30 DAY); -- 结果:2024-05-19 -- 3 小时后的时间 SELECT DATE_ADD(NOW(), INTERVAL 3 HOUR);这里要留意INTERVAL后面的单位,DAY、HOUR、MINUTE、SECOND、MONTH、YEAR都能用,还可以组合使用:
SELECT DATE_ADD(NOW(), INTERVAL '1:30' HOUR_MINUTE);DATEDIFF(date1, date2)返回两个日期之间的天数差,它只比较日期部分,不比较时间部分:
SELECT DATEDIFF('2024-06-18 23:59:59', '2024-06-17 00:00:01'); -- 结果:1如果业务需要精确到小时、分钟,就得用TIMESTAMPDIFF(unit, datetime2, datetime1)。注意它的参数顺序也有讲究,第一个是单位,后面是结束时间减开始时间:
-- 两笔订单的下单时间差了多少小时 SELECT TIMESTAMPDIFF(HOUR, order_time1, order_time2) FROM orders;4.4 其他实用日期函数:LAST_DAY、DAYOFWEEK、UNIX_TIMESTAMP
LAST_DAY(date)返回所在月份的最后一天,做月报结算非常方便:
-- 上个月的月末日期 SELECT LAST_DAY(DATE_SUB(CURDATE(), INTERVAL 1 MONTH));也可以反过来算一个月的第一天:
SELECT DATE_SUB(CURDATE(), INTERVAL DAYOFMONTH(CURDATE()) - 1 DAY);DAYOFWEEK(date)返回一周中的第几天,注意 MySQL 里1 是周日,2 是周一,跟国内的习惯不太一样。想按周一作为一周起始,要自己调整:
-- 返回数字 1-7,其中 1 对应周一 SELECT (DAYOFWEEK(NOW()) + 5) % 7 + 1;UNIX_TIMESTAMP(date)和FROM_UNIXTIME(ts)负责日期与时间戳之间的互转。对接 Java 后端或者 Redis 缓存时,经常要用时间戳传递时间值:
SELECT UNIX_TIMESTAMP('2024-06-18 14:30:00'); -- 结果:1718692200 SELECT FROM_UNIXTIME(1718692200); -- 结果:2024-06-18 14:30:00时区问题在这里很容易出状况。如果数据库连接参数和服务端时区不一致,FROM_UNIXTIME输出的时间可能就不是你预期的那样。我记得之前排查过一个跨时区部署的项目,开发本地时间正常,到了线上整体偏移 8 小时,最后就是通过统一 MySQL 会话时区解决的。所以看到时间戳转出来的时间不对劲,先别怀疑函数,去检查时区配置。
5. 流程控制函数:让 SQL“长脑子”,写条件逻辑不再全靠应用层
很多开发习惯了把条件判断写在 Java、Python 代码里,其实 MySQL 的流程控制函数完全可以在 SQL 层解决大量逻辑问题。用得好的话,代码简洁、执行高效,一条 SQL 直接把报表需要的各种标记字段算好。
5.1 IF 和 IFNULL:最简单的二分支逻辑
IF(expr, if_true, if_false)就是三元运算符的 SQL 版:
-- 判断库存是否充足 SELECT product_name, IF(stock > 0, '有货', '缺货') AS stock_status FROM products;IFNULL(expr, default_value)专门处理空值,相当于IF(expr IS NULL, default_value, expr)的简写:
-- 没有填写备注时显示“无” SELECT IFNULL(remark, '无') FROM orders;这里要特别注意IFNULL与COALESCE的区别。COALESCE可以接收多个参数,返回第一个非NULL值,而IFNULL只能判断两个参数。多字段取第一个有效值的时候,COALESCE更顺手:
-- 依次取手机号、邮箱、默认值 SELECT COALESCE(phone, email, '未知') FROM users;5.2 CASE WHEN:多分支的逻辑主体
CASE WHEN有两种写法。一种叫简单表达式,基于单个字段的等值判断:
SELECT CASE status WHEN 1 THEN '待支付' WHEN 2 THEN '已支付' WHEN 3 THEN '已发货' ELSE '已完成' END AS status_text FROM orders;另一种叫搜索表达式,可以写多个不同字段的复杂条件:
SELECT CASE WHEN total_amount >= 1000 THEN 'VIP客户' WHEN total_amount >= 500 THEN '优质客户' WHEN total_amount > 0 THEN '普通客户' ELSE '未消费' END AS customer_level FROM orders;把CASE WHEN和聚合函数组合在一起,是 SQL 统计里非常经典的高阶用法。比如同时统计订单表中不同状态的数量:
SELECT COUNT(CASE WHEN status = 1 THEN 1 END) AS pending_cnt, COUNT(CASE WHEN status = 2 THEN 1 END) AS paid_cnt, COUNT(CASE WHEN status = 3 THEN 1 END) AS shipped_cnt FROM orders;这个写法之所以能用,是因为COUNT只会计数非NULL值,而CASE WHEN在不满足条件时返回的是NULL,所以自动被忽略。理解了这个机制,你就能活用SUM、AVG配合CASE WHEN做各种按条件聚合。
5.3 NULLIF:逻辑上“反过来”的守卫
NULLIF(expr1, expr2)的规则是:如果两个表达式相等,返回NULL,否则返回第一个表达式的值。它常用于防止除零错误:
-- 计算转化率,分母为 0 时结果为 NULL SELECT total_click, total_order, total_order / NULLIF(total_click, 0) AS conversion_rate FROM stats;除零在 MySQL 中默认会返回NULL并产生一个警告,但用了NULLIF可以更显式地控制行为,也方便后续用IFNULL给一个默认值:
SELECT IFNULL(total_order / NULLIF(total_click, 0), 0) AS conversion_rate FROM stats;这一套组合拳在指标计算的 SQL 里非常常见,值得作为模板记下来。
6. 聚合函数:COUNT、SUM、AVG 背后的细节,决定报表准不准
聚合函数是GROUP BY的最佳拍档,但它们的行为细节很容易被忽略,导致统计口径出错。
6.1 COUNT 的三种写法和性能差异
COUNT(*)、COUNT(1)、COUNT(column)看起来差不多,实际区别很大:
COUNT(*):统计满足条件的行数,不关心具体列的值。COUNT(1):和COUNT(*)效果一样,统计所有行数,只是写法上用常量 1 代替了星号。COUNT(column):只统计该列非NULL的行数。
关于性能,很多人纠结COUNT(*)和COUNT(1)哪个快。其实在 MySQL 8.0 的 InnoDB 引擎下,两者性能没有实质区别,优化器会把它们转换成类似的执行计划。真正要注意的是COUNT(column),因为它会额外判断NULL,而且在没有索引的情况下无法用覆盖索引优化,性能通常更差。
再强调一遍那个经典陷阱:如果统计口径是“有多少个用户填了手机号”,你用COUNT(*)就会把没填手机号的也算进去,必须用COUNT(phone)。
6.2 SUM、AVG 遇到 NULL 时的行为
SUM(column)会自动忽略NULL值,但如果所有值都是NULL,结果会是NULL而不是 0。处理时最好包一层IFNULL:
SELECT IFNULL(SUM(amount), 0) AS total_amount FROM orders WHERE status = 5;AVG(column)也有同样的行为,它只对非NULL值求平均。举个例子,一个数字序列[10, NULL, 20],AVG的结果是 15,而不是 10。如果业务上希望把NULL当作 0 参与平均,就必须先处理:
SELECT AVG(IFNULL(score, 0)) FROM exam_results;这种细节在绩效统计、评分报表里经常直接影响最终结果,我见过不止一次因为没注意NULL处理导致人均值算错的案例。
6.3 GROUP_CONCAT:一行聚合多行字符串
GROUP_CONCAT可以把分组内的多个值拼成一个字符串,做一对多关联的汇总展示太合适了。比如查出每个用户的全部订单号:
SELECT user_id, GROUP_CONCAT(order_id) AS order_list FROM orders GROUP BY user_id;默认用逗号分隔,可以用SEPARATOR自定义分隔符,还可以按指定顺序拼接:
SELECT user_id, GROUP_CONCAT(order_id ORDER BY created_at DESC SEPARATOR '|') FROM orders GROUP BY user_id;GROUP_CONCAT有一个默认最大长度限制,默认是 1024 字节,超过的部分会被截断。如果遇到拼出来的字符串不完整,大概率就是这个限制在作怪。可以通过设置变量调整:
SET SESSION group_concat_max_len = 1000000;6.4 HAVING 与聚合函数的搭配
WHERE不能直接使用聚合函数作为条件,因为WHERE是在分组前执行的。这个逻辑顺序搞不明白的话,很多人会写出这样的错误 SQL:
-- 错误写法:WHERE 中不能直接用 COUNT(*) SELECT user_id, COUNT(*) FROM orders WHERE COUNT(*) > 5 GROUP BY user_id;正确的做法是用HAVING:
SELECT user_id, COUNT(*) AS order_cnt FROM orders GROUP BY user_id HAVING order_cnt > 5;执行顺序大致是:WHERE过滤原始行 →GROUP BY分组 → 聚合计算 →HAVING过滤分组。理解了这个顺序,你就知道为何WHERE里不能写聚合条件了。
7. 窗口函数:SQL 进阶的分水岭,分组内排名与跨行比较一步到位
窗口函数是 MySQL 8.0 引入的重量级特性,直接让很多原本只能在应用层循环处理的逻辑变成了简单查询。
7.1 窗口函数和普通聚合的区别
普通聚合函数在GROUP BY分组后,每个分组只能返回一行;窗口函数则是在每一行上保留原有的行信息,同时附加计算一组与窗口相关的数值。比如“每个用户最近一笔订单在全平台中的排名”,用普通聚合很难实现,但窗口函数一行就搞定。
窗口函数的通用语法是:
SELECT 列名, 窗口函数() OVER (PARTITION BY 分组列 ORDER BY 排序列 窗口范围) FROM 表名;PARTITION BY是分区,ORDER BY是排序,这两部分可以单独使用,也可以组合。
7.2 排名函数:ROW_NUMBER、RANK、DENSE_RANK
三个函数都是排名的,但结果有区别,而且这个区别非常容易被忽略。直接上一个案例对比:
SELECT student_id, score, ROW_NUMBER() OVER (ORDER BY score DESC) AS row_rank, RANK() OVER (ORDER BY score DESC) AS rank_no, DENSE_RANK() OVER (ORDER BY score DESC) AS dense_rank FROM exam_scores;假设有四条成绩:98、95、95、90,结果分别是:
ROW_NUMBER:1、2、3、4(并列的两个人也被强行分出先后)RANK:1、2、2、4(并列占位,后面跳号)DENSE_RANK:1、2、2、3(并列不占位,后面连续)
业务选哪个,取决于排名规则。如果做比赛排行榜,并列第 2 之后要显示第 4 名,用RANK;如果只是要一个连续的名次,用DENSE_RANK;如果保证每一行都有唯一名次,比如分页分配序号,用ROW_NUMBER。
按分区内排名,配合PARTITION BY使用。比如找出每个部门工资最高的员工:
SELECT * FROM ( SELECT *, ROW_NUMBER() OVER (PARTITION BY dept_id ORDER BY salary DESC) AS rn FROM employees ) t WHERE rn = 1;这个“先开窗再过滤”的写法,是面试和实际业务中出现频率最高的窗口函数应用场景之一。
7.3 移动计算函数:LAG、LEAD、SUM OVER
LAG(column, n)取当前行往前数第 n 行的值,LEAD(column, n)取往后数第 n 行的值。这一对函数在同比环比计算中特别有用。比如对比本月销售额和上月销售额:
SELECT month, sales_amount, LAG(sales_amount, 1) OVER (ORDER BY month) AS prev_month_sales, sales_amount - LAG(sales_amount, 1) OVER (ORDER BY month) AS month_growth FROM monthly_sales;如果没有前一行,LAG返回NULL,正好可以通过IFNULL标记为“首次记录”。
SUM() OVER (ORDER BY ...)可以实现累计求和:
SELECT order_date, amount, SUM(amount) OVER (ORDER BY order_date) AS cumulative_amount FROM orders;这就是统计“截至某天的累计消费额”最简洁的写法,不需要自连接,不需要子查询。窗口函数在跑数脚本里能极大简化逻辑,也更容易维护。
8. 函数使用的高频坑点:这些都是真实项目里倒过霉的地方
函数用多了,踩过的坑也就多了。这一节专门挑几个最容易出问题、又不好排查的点集中说下。
8.1 隐式类型转换:数字和字符串混用
MySQL 在比较不同类型的值时会发生隐式类型转换。比如把字符串字段和数字比较,MySQL 会把字符串转换成数字。转换失败时不报错,而是变成 0,这就可能导致条件匹配到一堆不该匹配的行。
-- 假设 phone 字段为 varchar 类型,这条查询可能匹配到许多异常数据 SELECT * FROM users WHERE phone = 13712345678;建议是:字段是什么类型,条件里就写什么类型。字符换字符,数字换数字,不要依赖 MySQL 帮你转。
8.2 函数作用于索引列时,索引会失效
这是一个非常经典的性能问题。假设created_at列上有索引:
-- 索引失效:对索引列做了函数运算 SELECT * FROM orders WHERE DATE(created_at) = '2024-06-18'; -- 索引可用:改成范围条件 SELECT * FROM orders WHERE created_at >= '2024-06-18 00:00:00' AND created_at < '2024-06-19 00:00:00';原因不复杂:对列做函数运算后,原来的索引顺序不再有效,优化器只能全表扫描。所以能用范围查询表达的条件,尽量不要用函数包裹索引列。这个优化点在线上的慢查询排查中非常常见。
8.3 字符集与排序规则的影响
LENGTH、SUBSTRING等函数的行为受字符集影响。UTF-8 下中文占 3 字节,导致字节长度和字符长度差异大。此外,表字段的排序规则(collation)也可能影响GROUP BY、ORDER BY的结果顺序,比如utf8mb4_general_ci不区分大小写,而utf8mb4_bin区分。凡是对大小写敏感的字符串比较,最好先确认字段的 collation,或者直接用BINARY关键字强制二进制比较。
8.4 时区与会话变量导致的结果漂移
NOW()、FROM_UNIXTIME等都受会话时区影响。部署跨多个地域的应用时,如果数据库连接串没有加serverTimezone参数,或者 MySQL 的time_zone变量不一致,同样一段 SQL 在不同环境下的结果就可能不同。建议在数据库连接参数里固定时区,并且把 MySQL 服务端的默认时区也统一到业务时区。
9. 最后一个建议:函数要当工具库存着,但别在不该用的地方炫技
写了这么长,最后说点实在的。
我个人的使用体会是,函数确实是 SQL 的加速器,但用之前得想清楚“这个计算放在数据库里做,到底合不合适”。像简单的字段拼接、日期格式化、按条件打标,放在 SQL 里效率最高;但过于复杂的正则处理、超大文本的字符串运算、大量数据的逐行计算,放到应用层反而更合理。
另一个建议是:把常用的函数组合整理成自己的 SQL 代码片段库。比如日期按月分组、手机号脱敏、同比增长计算、分组内 Top N 这几类,每个都整理出一个标准 SQL 模板。下次遇到相似需求,直接复制改表名就行,比每次翻文档重新写要快得多。
至于上面提到的那些坑——NULL的处理、隐式转换、索引失效——其实都不是什么高深学问,但随便踩中一个,排查起来都很耗时间。所以我一直觉得,SQL 水平的高低,不取决于你背了多少函数名,而取决于你理解每个函数“在什么情况下表现如何”。希望这篇笔记能帮你把这些细节一次理清,后面写 SQL 的时候能少走点弯路。