做 MySQL 这行时间久了会发现,最容易被忽略却又天天躲不开的,就是“内置函数”这四个字。新同事问我问题,十次里有五次不是索引方案出了问题,而是某个字符串拼接、日期转换或者条件判断的函数没用好,结果要么结果不对,要么一条 SQL 跑了十几秒。我早些年也觉得函数无非是翻文档,后来线上排障多了,才明白函数掌握得扎不扎实,直接决定了你写出来的 SQL 是“能跑”还是“能高效跑”。
这篇文章不打算把 MySQL 几百个内置函数像说明书一样列一遍,那没有意义。我按实际业务中最常用的几类——字符串、数值、日期时间、流程控制——逐一拆开讲,每一类都结合项目里真实遇到的场景,最后再专门聊一个容易被忽略的维度:函数使用和索引性能之间的红线。适合刚把 SELECT 和 JOIN 搞清楚的新手建立完整认知,也适合写业务 SQL 多年的开发者对照自检。
1. 字符串函数:业务SQL里最常用的一组
字符串处理在业务 SQL 里的出场率,可能比你预想的还要高。订单号聚合展示、日志字段拆分、昵称脱敏、从设备编码里抽地区信息,各类杂活都落在字符串函数身上。这类函数看似简单,实际最容易在细节上翻车。
1.1 拼接与截取的基本功,别让 NULL 背锅
先说说 CONCAT 和 CONCAT_WS。很多人想拼一个“订单号-用户名”这样的展示字段,顺手就写CONCAT(order_no, '-', user_name)。表面看起来没问题,可一旦 user_name 为 NULL,整个结果会直接变成 NULL,而不是你期望的“订单号-”。这是 NULL 参与字符串拼接时的默认行为,也是业务数据不干净时最容易踩的坑。
我现在的习惯是优先用CONCAT_WS,它的第一个参数是分隔符,后面的参数里只要遇到 NULL,会自动跳过而不是让整个结果作废。同样拿“订单号-用户名”举例:
SELECT CONCAT_WS('-', order_no, user_name) AS display_label FROM order_info;注意 CONCAT_WS 的参数顺序是“分隔符在前,拼接内容在后”,很多新同事第一次用会写反,结果第一个字段成了分隔符。另外还需要留意,CONCAT_WS 并不会自动把数字转成字符串类型,如果你参与拼接的是 DECIMAL 金额字段,建议先显式转一下,避免后续程序端拿到一个带着诡异精度的字符串。
截取函数里,SUBSTRING 是最常用的,但参数起点要从 1 开始,不是从 0 开始。SUBSTRING('abcdef', 2, 3)的结果是bcd,这一点和很多编程语言不同。还有一个好用的函数是SUBSTRING_INDEX,它按分隔符截取前面 N 段或后面 N 段,特别适合解析固定格式编码。比如设备编码是“华北-北京-朝阳区-001”,想取省市区三段:
SELECT SUBSTRING_INDEX('华北-北京-朝阳区-001', '-', 3); -- 结果:华北-北京-朝阳区取最后一段则传负数:
SELECT SUBSTRING_INDEX('华北-北京-朝阳区-001', '-', -1); -- 结果:001这里有个隐藏细节:SUBSTRING_INDEX 的负数是“从右往左数第几个分隔符之后”的意思,和你直觉里的“倒数第几位”不完全一样,最好在本地跑一次确认结果再上线。
1.2 查找与替换:参数顺序是事故高发区
字符串查找函数很容易混。LOCATE(substr, str)是子串在前、源串在后;INSTR(str, substr)恰好反过来,源串在前、子串在后;POSITION(substr IN str)也是子串在前。这三个函数返回值都是子串第一次出现的位置,没找到返回 0。
| 函数 | 参数顺序 | 返回值 | | --- | --- | --- | | LOCATE(substr, str) | 子串在前,源串在后 | 首次出现位置 | | INSTR(str, substr) | 源串在前,子串在后 | 首次出现位置 | | POSITION(substr IN str) | 子串在前,源串在后 | 首次出现位置 |
我见过某次批量更新脚本,同事把 INSTR 的参数写反了,导致一批本该命中的记录全没匹配上,数据修复时对账才发现。这类函数没有编译期报错,写错了只是返回 0,危害比想象中更大。
REPLACE 用来做简单替换很顺手,语法是REPLACE(str, from_str, to_str),从字符串里把指定子串全部替换。它的一个常见误解是“REPLACE 会直接改表里的数据”,其实不会,它只返回替换后的结果,要真正落库得配合 UPDATE。举一个实际场景:业务方要求把所有分类编码里的旧前缀 “CAT_” 统一改成 “PD_”,可以这样写:
UPDATE product_info SET category_code = REPLACE(category_code, 'CAT_', 'PD_') WHERE category_code LIKE 'CAT_%';注意 LIKE 条件不能省,否则会全表扫描所有行,哪怕字段本身就含 CAT_ 的才需要更新,这属于 SQL 性能意识问题,后面性能部分还会展开。
1.3 空格与补位:线上数据有时候比你想的脏
TRIM、LTRIM、RTRIM 分别是去除两侧、左侧、右侧空格。麻烦的是业务系统录入数据时经常混入全角空格,或是字符串中间夹着不可见字符,TRIM 去不掉。遇到过用户昵称前后有空格导致登录校验失败,应用层判断不出来,最后定位到是 RTRIM 没用的场景。
补位函数 LPAD 和 RPAD 在生成流水号、编号时非常有用。比如订单序号每天从 1 自增,要补齐 8 位:
SELECT LPAD(seq, 8, '0') FROM order_sequence; -- seq = 12 时返回 00000012补位函数同样要注意“字符”和“字节”的区别。LENGTH()返回字节长度,CHAR_LENGTH()返回字符长度。在 utf8mb4 字符集下,一个中文字符占 3 个字节,很多人在敏感数据脱敏时用 LENGTH 判断手机号位数,结果遇到中文备注字段直接失真。建议所有涉及中文的业务字段判断长度,一律用 CHAR_LENGTH。
2. 数值函数:别让精度问题坑到业务
数值函数在报表统计、对账、分片任务里频繁出现。很多人觉得数值函数是最不用动脑的一类,恰恰相反,精度问题和隐式转换造成的线上事故,我见过不止一次。
2.1 ROUND、TRUNCATE、CEIL、FLOOR 怎么选
ROUND 是四舍五入,TRUNCATE 是直接截断,CEIL 是向上取整,FLOOR 是向下取整。看起来非常简单,具体业务里差别就很要命了。比如统计用户平均消费金额,财务要求保留两位小数,ROUND 和 TRUNCATE 的结果可能就差一分钱,这分钱在明细对不上时就是排查半天的根源。
SELECT ROUND(125.567, 1); -- 125.6 SELECT TRUNCATE(125.567, 1); -- 125.5 SELECT CEIL(12.3); -- 13 SELECT FLOOR(12.7); -- 12ROUND 的第二个参数可以是负数,表示保留到小数点左边。比如ROUND(125.567, -1)返回 130,这在做金额分段统计时挺有用。但有一点要特别强调:金额相关的字段在表结构设计时就应该用 DECIMAL,而不是 FLOAT 或 DOUBLE。浮点数在计算机里是近似存储,用 ROUND 处理浮点金额,可能出现 9.99 变 10.00 这种对不上的情况,本质不是函数的问题,是数据类型选错了。
TRUNCATE 的应用场景通常是“不想要四舍五入的进位”。比如统计每秒并发均值,精度要求不高,直接截断到整数反而更符合预期。
2.2 类型转换:CAST 与 CONVERT 别用反
CAST 的语法是CAST(expr AS type),CONVERT 在 MySQL 里有两种写法:一种是CONVERT(expr, type),另一种是CONVERT(expr USING charset)。日常转换字符串到数值、日期,两种都可以:
SELECT CAST('123.45' AS DECIMAL(10,2)); -- 结果 123.45 SELECT CONVERT('2024-03-15', DATE); -- 结果 2024-03-15类型转换的坑主要集中在失败时。字符串转数值时如果字符串不是合法数字,MySQL 通常不会报错,而是把前面的数字部分转出来,后面非数字内容丢弃,比如CAST('12abc' AS SIGNED)返回 12。这种“半成功”的结果在数据清洗时最坑人,你以为排掉了脏数据,其实脏数据已经悄悄混进统计结果里了。
还有一点容易被忽略:隐式转换。列是 VARCHAR 类型,查询条件传的是数值,MySQL 会把列里的字符串转成数字再去比较。这种转换看着没毛病,但会导致索引失效,后面性能部分我会单独讲一个活生生的例子。
2.3 MOD、DIV 和除法:看起来像,用法完全不同
MOD 是取余,和百分号%等价;DIV 是整数除法,只保留商的整数部分;普通的/则返回浮点数。三者最大的区别在结果类型和场景取舍。
SELECT MOD(17, 5); -- 2 SELECT 17 DIV 5; -- 3 SELECT 17 / 5; -- 3.4000MOD 在分片任务里用得很多。比如要把一万条数据分给四个消费者实例处理,每个实例只处理自己分片,直接在 SQL 里用MOD(id, 4) = 0作为过滤条件就能实现,避免应用层做复杂的路由逻辑。DIV 则常用于把秒级数据折算成分钟级、小时级,比如统计接口调用次数时,把时间戳除以 60 再取整归组,比用日期函数在性能上更轻量。
3. 日期时间函数:排障时会为你省下不少时间
日期时间函数是我认为最值得花时间吃透的一类。不是因为语法难,而是因为日期时间的存储、格式化、时区、索引失效,每个环节都可能成为线上事故的诱因。
3.1 获取当前时间:NOW 和 SYSDATE 不是一回事
MySQL 里获取当前时间有好几个函数,最常用的是 NOW()、CURRENT_TIMESTAMP、CURDATE()、CURTIME()、SYSDATE()。很多人以为它们是等价的,实际并非如此。NOW() 和 CURRENT_TIMESTAMP 取的是语句开始执行的时间,一条 SQL 不管跑多久,NOW() 在整个执行过程里保持不变;SYSDATE() 则是实时取当前时间,等同一条慢 SQL 里不同行的 SYSDATE() 可能不一样。
这个差异在 binlog 复制场景里尤其敏感。主库上一条语句执行时间较长,NDB 引擎或基于 STATEMENT 格式的复制,SYSDATE() 在主从库分别取值可能产生不一致,甚至引发数据同步错乱。所以我在生产环境里的习惯是明确用 NOW(),而不是 SYSDATE(),除非业务真的需要“实时时间”这个语义。
CURDATE() 只返回日期部分,CURTIME() 只返回时间部分,日常业务里按“今天”统计订单时,用 CURDATE() 做条件很自然:
SELECT COUNT(*) FROM order_info WHERE create_date = CURDATE();注意这里 create_date 必须是 DATE 类型,如果是 DATETIME 类型,应该用范围查询,否则会吞掉当天的索引。
3.2 DATE_FORMAT 和 STR_TO_DATE 的格式对照
DATE_FORMAT 用于把日期类型格式化成指定字符串,STR_TO_DATE 用于把字符串解析成日期。业务系统对接接口、导出报表时,这两个函数的出场率极高。
| 格式符 | 含义 | 示例 |
|---|---|---|
| %Y | 四位年份 | 2024 |
| %y | 两位年份 | 24 |
| %m | 两位月份 | 03 |
| %c | 一位或两位月份,无前导零 | 3 |
| %d | 两位日期 | 15 |
| %H | 24小时制 | 08 |
| %i | 分钟 | 30 |
| %s | 秒 | 45 |
SELECT DATE_FORMAT(CURDATE(), '%Y-%m-%d'); SELECT STR_TO_DATE('2024-03-15 10:30:00', '%Y-%m-%d %H:%i:%s');这里要提醒一个边界:DATE_FORMAT 返回的是字符串,不是日期。字符串和日期类型在部分 MySQL 版本中做比较时能自动转换,但转换规则在不同字符集和 SQL MODE 下可能不同。更稳妥的做法是:需要日期过滤就用日期类型比较,需要展示格式再放到 SELECT 列表里格式化,而不是反过来。
另外一个高频故障点是 STR_TO_DATE 解析失败。比如%Y-%m-%d和%y-%m-%d写反,输入 ‘2024-03-15’ 时用%y解析会出现年份错乱;输入格式与字符串不匹配时,STR_TO_DATE 不会抛异常,而是返回 NULL,这同样会让 WHERE 条件无意中过滤掉一批数据。
3.3 日期加减、跨月与区间判断
DATE_ADD 和 DATE_SUB 负责日期加减,必须配合 INTERVAL 关键字使用,比如加 3 天、减 1 个月:
SELECT DATE_ADD('2024-01-31', INTERVAL 1 MONTH); -- 结果 2024-02-29,自动按跨月处理这个例子很有代表性。1 月 31 日加一个月,如果简单按“每月 30 天”算就会错,MySQL 会自动调整到 2 月的最后一天。跨年和闰年也一样,函数内部都已经处理好了,比在应用层手工换算可靠得多。
DATEDIFF 返回两个日期之间的天数差,计算逻辑是第一个参数减第二个参数。TIMESTAMPDIFF 更灵活,可以按 YEAR、MONTH、DAY、HOUR、MINUTE、SECOND 计算差值,参数顺序是TIMESTAMPDIFF(unit, datetime_expr1, datetime_expr2),返回 expr2 减 expr1 的结果。经常有人把两个时间参数写反,结果得到负数,排查半天才发现是顺序反了。
计算用户年龄就是个典型场景:
SELECT TIMESTAMPDIFF(YEAR, birth_date, CURDATE()) AS age FROM user_profile;最后一个实用函数是 LAST_DAY,返回某个月的最后一天,在财务结账、统计月度账单场景很常用:
SELECT LAST_DAY('2024-02-01'); -- 结果 2024-02-293.4 时间戳与时区:线上告警背后的八小时
UNIX_TIMESTAMP 和 FROM_UNIXTIME 是用来在时间戳和日期时间之间转换的。UNIX_TIMESTAMP('2024-01-01 00:00:00')返回秒级时间戳,FROM_UNIXTIME(1704067200)把时间戳转回日期时间。
这个环节最经典的坑是时区。MySQL 服务器的时区参数time_zone可以设置为 SYSTEM、UTC 或某个偏移量。如果数据库时区是 UTC,而业务方在东八区,直接 FROM_UNIXTIME 出来的时间会比本地时间少 8 小时。我在某次系统联调时就遇到过这种情况:上游传来的时间戳在 MySQL 里转成日期时间后,所有时间点都提前 8 小时,最后对账比对不上,查了一圈才发现是测试环境 time_zone 被改成了 UTC,而生产环境是东八区。
我的建议是:生产环境所有 MySQL 实例统一设置time_zone = '+08:00'或统一保持一致,同时业务字段尽量用 DATETIME 而不是 INTEGER 存时间戳,这样可读性好,排障时一眼能看出问题。真的需要用时间戳做计算时,再用 UNIX_TIMESTAMP 转,而不是在表结构里埋雷。
4. 流程控制函数:把判断逻辑压缩进一条SQL
流程控制函数让 SQL 从“取数工具”变成“能算逻辑的脚本”。用得好,原来需要应用层循环或者多条 SQL 才能完成的事情,一条 SQL 就能出结果。
4.1 IF、IFNULL、NULLIF:三个容易混淆的兄弟
IF(expr, true_value, false_value) 是最直白的条件判断,类似编程语言里的三元表达式。IFNULL 则专门处理 NULL 值,IFNULL(expr, replace_value)在 expr 为 NULL 时返回 replace_value。NULLIF 的作用相反,NULLIF(expr1, expr2)当两个值相等时返回 NULL,不相等时返回 expr1。
SELECT IF(1 > 2, 'yes', 'no'); -- no SELECT IFNULL(NULL, '默认值'); -- 默认值 SELECT NULLIF('a', 'a'); -- NULL SELECT NULLIF('a', 'b'); -- a它们之间的区别很容易让人混淆,但业务场景是很清晰的。IFNULL 用来处理字段为 NULL 时的默认展示;NULLIF 常用于避免除零错误,比如统计订单金额除以订单数,当订单数为 0 时直接除会报错,用total_amount / NULLIF(order_count, 0)可以优雅地让结果为 NULL,再由外部程序决定展示逻辑。
还有个细节:IFNULL 只处理 NULL,不处理空字符串。空字符串在 MySQL 里不是 NULL,所以IFNULL(user_name, '匿名')面对空字符串时返回的还是空字符串,而不是“匿名”。如果需要同时处理空字符串和 NULL,要配合 NULLIF 或 TRIM 一起用:IFNULL(NULLIF(TRIM(user_name), ''), '匿名')。
4.2 CASE WHEN 做分组统计是真正高效的写法
CASE WHEN 是 SQL 里最灵活的条件表达式,有两种写法。简单写法是CASE 列 WHEN 值 THEN 结果 END,适合等值判断;搜索写法是CASE WHEN 条件 THEN 结果 END,适合范围判断。实际业务里,我几乎都写搜索写法,可读性更好,也更容易扩展。
最经典的应用是把行转列:统计某段时间每天的成功订单数和取消订单数,不需要对订单表跑两遍,一次扫描就能完成:
SELECT DATE(create_time) AS stat_date, SUM(CASE WHEN status = 'paid' THEN 1 ELSE 0 END) AS paid_cnt, SUM(CASE WHEN status = 'cancelled' THEN 1 ELSE 0 END) AS cancel_cnt FROM order_info WHERE create_time >= '2024-03-01' AND create_time < '2024-04-01' GROUP BY DATE(create_time);这段 SQL 的核心思路是“按条件打标记再聚合”,比COUNT(DISTINCT CASE WHEN ...)或多次扫描子查询的方式更简洁。有两个要点:一是 SUM 里的 ELSE 0 不能省,否则不满足条件的行会产生 NULL,虽然 SUM 会忽略 NULL,但阅读代码的人容易误解;二是 WHERE 条件里不要写成DATE(create_time)等于某天,这会绕过索引,正确写法是用范围查询,把条件卡在 create_time 的原始列上。
4.3 COALESCE 与空值汇总
COALESCE 函数接收多个参数,按从左到右的顺序返回第一个不是 NULL 的值。它的意义在于可以做多层回退,比如:
SELECT COALESCE(real_name, nick_name, '匿名用户') AS display_name FROM user_info;这个函数在输出层面的价值很大,能把数据质量问题的负面影响控制住。同样要强调,COALESCE 只跳过 NULL,不会跳过空字符串。数据入库时如果程序习惯性把没有值的字段写成空字符串而不是 NULL,COALESCE 就发挥不了预期作用。这里我建议在表结构设计阶段就约定:可选字段要么设 DEFAULT NULL,应用层不传值就存 NULL,这样查询时的空值逻辑才能统一。
5. 性能红线:函数虽好用,别让它拖垮SQL
前面讲的都是函数本身的用法。实际开发中,函数写对了不代表 SQL 就健康,真正让一条 SQL 从毫秒级退化到秒级的,往往是函数被用在了不该用的位置。
5.1 WHERE 条件里使用列函数导致索引失效
最典型的反例是WHERE DATE(create_time) = '2024-03-15'。create_time 列上明明建了索引,但 MySQL 必须先把每一行的 create_time 都通过 DATE() 转换一次,再和常量比较,索引在这个场景里失效了,底层走的是全表扫描。
我在某次报表优化中亲眼见过这类 SQL:数据量 200 万行,用了 DATE 函数后查询耗时从 0.1 秒涨到 3.4 秒。改成范围查询后回到了几十毫秒。
-- 常见但较慢的写法 SELECT * FROM order_info WHERE DATE(create_time) = '2024-03-15'; -- 建议的写法 SELECT * FROM order_info WHERE create_time >= '2024-03-15 00:00:00' AND create_time < '2024-03-16 00:00:00';反过来,需要把字符串转日期时,直接在字段上加 CAST 也有同样问题。判断一条 SQL 是否存在索引失效,最简单的方式是看 EXPLAIN 输出里 type 列,如果从 ref 或 range 变成 ALL,基本可以判定是没走索引,此时优先检查 WHERE 条件里有没有对索引列做包装计算。
5.2 隐式类型转换也是隐蔽的性能杀手
隐式类型转换比显式函数更难揪出来,因为它不写在 SQL 里,是 MySQL 在比较时自动做的。典型场景是索引列是 VARCHAR,查询条件却传了数值:
-- user_id 列类型是 VARCHAR,且建了索引 SELECT * FROM user_info WHERE user_id = 123456;表面看 user_id = 123456 没有任何函数,但 MySQL 需要把 user_id 从字符串转为数字,再与 123456 比较。转换后的结果无法走索引,比如 200 万行的表,可能直接全表扫描。
正确做法是让查询条件与列类型一致,写成字符串:
SELECT * FROM user_info WHERE user_id = '123456';这个坑在很多库存编码、订单号字段上特别常见,因为这类字段业务上像数字,但设计表时为了前缀扩展用了 VARCHAR。排查时通过 EXPLAIN 看到 type 是 ALL,而条件匹配规则又是等值查询,第一时间就应该怀疑隐式转换。
5.3 报表场景:先缩小范围,再格式化数据
基于函数做报表统计时,最理想的执行顺序是“先让引擎用索引把数据范围缩小,再在结果集上做格式化”。实践上就是把过滤条件尽量落在原始列的范围比较上,把 DATE_FORMAT、SUBSTRING 这类处理尽量放在 SELECT 列表或 GROUP BY 中。
有一次同事优化月度销售统计,原来 SQL 里写:
SELECT DATE_FORMAT(create_time, '%Y-%m') AS month, SUM(amount) FROM order_info WHERE DATE_FORMAT(create_time, '%Y-%m') = '2024-01' GROUP BY month;这个写法先对全表每一行做格式化,再做等值判断,索引基本废了。改成范围过滤后再聚合,查询时间从 2.8 秒降到 80 毫秒:
SELECT DATE_FORMAT(create_time, '%Y-%m') AS month, SUM(amount) FROM order_info WHERE create_time >= '2024-01-01' AND create_time < '2024-02-01' GROUP BY DATE_FORMAT(create_time, '%Y-%m');这里要注意 GROUP BY 里已经不可避免地要对 create_time 做格式化,但此时参与分组的数据量已经被范围条件限制得很小,性能损耗几乎可以忽略。
5.4 一段给所有业务开发的经验话
函数本身不会拖垮数据库,拖垮数据库的是对函数使用场景的不敏感。我见过有人在一个 SELECT 里嵌套五六层函数,看起来炫技,实际上可读性和维护性都很差,后续接手的人根本不敢改。
我的经验是:函数使用有三个原则。第一,WHERE 条件的左值尽量避免任何函数和转换;第二,和索引列比较时,保持两侧类型一致;第三,能在应用层算好的值,就别让数据库每次查询重复计算。比如“近 30 天”这个时间窗,与其在 SQL 里写DATE_SUB(CURDATE(), INTERVAL 30 DAY),不如由程序算好后作为参数传进来,既清楚又稳定。
另外,写完一条带函数的 SQL,花十秒钟跑一下 EXPLAIN,比什么都管用。很多线上慢查询,在开发环境数据量小的时候根本看不出来,到了生产环境一放大就原形毕露。把“EXPLAIN 看一眼”变成肌肉记忆,是函数学习之外更重要的事。
我在实际工作中还有一个体会:MySQL 内置函数的知识点很零散,靠背是背不全的,最好的方式是在真实查询里反复试错,然后把自己的踩坑经历沉淀成一张速查表。比如字符集是 utf8mb4 时 LENGTH 和 CHAR_LENGTH 的区别,金额字段为什么必须配 DECIMAL,时间戳和时区如何统一,这些都是文档里不会主动提醒你的经验。希望这篇梳理,能让你在写 SQL 时少走几段弯路。