写SQL的时候,你们有没有过这样的经历:想在查询里拼接一段字符串,想给订单日期换一个展示格式,想对某个字段做条件判断,第一反应不是写SQL,而是先打开搜索引擎敲“MySQL 字符串函数”?我以前就是这样,直到有一次项目里的定时统计报表突然变慢,排查下来才发现,写SQL的人在WHERE条件里用了DATE_FORMAT(create_time, '%Y-%m-%d') = '2025-01-05'。这一写,索引直接失效,千万级的订单表只能全表扫描,每次跑二十多秒,数据库CPU经常被打满。
问题的根源并不是函数语法记不牢,而是对内置函数的行为边界和性能代价缺少整体认知。MySQL内置函数看着又多又杂,真正常用的其实集中在几大类里。这篇文章想用一个做过多年数据相关开发的视角,把字符串、日期时间、数值与流程控制、聚合函数这些使用经验和踩过的坑一起讲清楚。不管你是刚开始学SQL,还是已经写了好几年业务查询,应该都能找到一点能直接用的东西。
1. 内置函数的分工地图:先建立整体认知,再去记函数用法
我先坦白一件事:我自己刚开始写SQL的时候,根本不看官方文档,都是用到哪个临时搜哪个。搜索本身没问题,问题在于脑子里没有“地图”的时候,搜到的函数只能解决眼前这一句话,过两天换个场景照样抓瞎。后来我慢慢意识到,MySQL内置函数哪怕有成百上千个,真正的高频工具其实就集中在五个大类里。搞清楚这几个大类各自解决什么问题,比死记几百个函数名重要得多。
1.1 为什么数据库要内置这么多函数
SQL本质上是描述性语言。你给数据库的是“我要什么”,至于怎么算,由数据库引擎自己去处理。可是实际业务里,有大量重复出现的通用计算:把两个字段拼起来、把时间格式化成报表要的样子、把金额四舍五入到两位、把一个分组下的多行记录合并成一行。这些操作如果都取回应用层再处理,每条记录都要经过一次网络传输,代码还要重复写,既慢又容易不一致。
内置函数的意义,就是把这批高频率的计算下沉到数据库内部,一条SQL直接出结果。理解这件事之后,面对一个新函数时你的第一反应就不该是“记语法”,而该是“它处理什么类型的数据、输入什么、输出什么”。函数是工具,工具是配合数据形态用的,脱离数据谈函数没有意义。
1.2 五类高频函数的选择逻辑
在工作里反复用的函数,基本可以划分成五类,我是按实际使用频率和出问题频率来排的:字符串、日期时间、数值、流程控制、聚合。日常的增删改查、报表统计基本绕不开这五类。
| 函数类别 | 处理对象 | 典型输出 | 高频场景 |
|---|---|---|---|
| 字符串函数 | 字符、文本 | 拼接、截取、替换后的文本 | 脱敏、地址拼接、文案清洗、编码处理 |
| 日期时间函数 | 时间、日期、时间戳 | 格式化文本或时间值 | 报表统计、时区换算、跨天统计 |
| 数值函数 | 数字 | 计算后的数字 | 金额精度、比率计算、随机抽样 |
| 流程控制函数 | 任意表达式 | 按条件选择后的值 | 逻辑分支、NULL兜底、行列转换 |
| 聚合函数 | 多行数据 | 单行汇总结果 | 统计报表、拼接标签、嵌套输出 |
配合一个简单的决策顺序:
- 看一眼手上数据是什么形态——是文本、日期还是数字?这决定去哪个函数族里找工具。
- 再看这个处理是“一行变一行”还是“多行变一行”——前者大概率用普通函数,后者用聚合函数。
- 最后想边界情况:NULL会出现吗?字符集是中文还是英文?长度会不会超过限制?
这三个问题想完,函数选型基本不会跑偏。
2. 字符串函数实战:拼接、截取、替换里的那些边界问题
字符串函数是我写过最多、也踩坑最多的一类。它看着简单,但坑往往藏在两个地方:空值和字符长度。下面按场景拆开讲。
2.1 CONCAT与CONCAT_WS:一个空值就能让整个输出变NULL
先看一行代码:
SELECT CONCAT('张', NULL, '三');直觉上你可能觉得会返回“张三”,实际上MySQL返回的是NULL。CONCAT的逻辑是:只要任何一个参数是NULL,整个结果就是NULL。这个特性非常容易在拼接地址、拼接姓名的时候爆炸。之前我遇到过一张用户地址表,省、市、区三个字段里“区”允许为空,结果用CONCAT拼出来的地址整条变空,用户下单时地址在后台显示不完整,客服排查了好久才发现是空值的问题。
这个问题最直接的解法是用CONCAT_WS:
SELECT CONCAT_WS('-', '广东省', NULL, '南山区'); -- 返回 广东省-南山区CONCAT_WS的第一个参数是分隔符,它会自动跳过NULL值。所以做多字段拼接时的推荐习惯是:能上CONCAT_WS就上CONCAT_WS,只要分隔符一致,就省掉了一堆IFNULL,代码会干净很多。
不过反过来想,如果业务要求把NULL当成空字符串拼进结果,那就得用IFNULL显式处理,比如CONCAT(IFNULL(city, ''), IFNULL(area, ''))。关键是你要清楚自己到底想要哪种语义:NULL是“跳过”,还是“显示为空”,两种场景用的函数不一样。
2.2 SUBSTRING起始位置从1开始:一个坐标习惯引发的失误
SUBSTRING(str, pos, len)是我们截取文本时最常用的函数,但有一个细节很多人第一次写就会搞错:下标从1开始,不是从0开始。写惯了Java、Python的人容易直接写SUBSTRING(name, 0, 2),结果发现取出来是空的,或者少了一位。
SELECT SUBSTRING('hello world', 1, 5); -- hello SELECT SUBSTRING('hello world', 7); -- world SELECT SUBSTRING('hello world', -5); -- world负数的意思是“从末尾往前数”,这在取文件扩展名、取银行卡号后四位时非常方便。SUBSTRING还有一个冷门但好用的变体SUBSTRING_INDEX,它按分隔符取某一侧的子串:
SELECT SUBSTRING_INDEX('2025-01-05 10:30', '-', 1); -- 2025 SELECT SUBSTRING_INDEX('2025-01-05 10:30', ' ', 1); -- 2025-01-05如果你需要按分隔符拆第一个、最后一个字段,这个函数比SUBSTRING配合LOCATE计算位置方便得多。LOCATE和INSTR用来找子串位置,配合SUBSTRING做动态截取也很常见。但有个容易忽略的点:LOCATE是否区分大小写跟collation绑定。如果列是utf8mb4_bin,它就是大小写敏感的,测试时一定要拿真实数据的排序规则验证一遍。
2.3 LENGTH算的是字节,不是字符
这个问题我必须单独拿出来讲。LENGTH('数据库')返回9,CHAR_LENGTH('数据库')返回3。原因很简单:LENGTH按字节数算,在utf8mb4字符集下一个中文汉字占3个字节;CHAR_LENGTH按字符数算。对英文和数字来说两者一样,对中文、emoji、全角符号来说差异很大。
实际项目里这个坑长这样:业务要求对留言内容限制最多50个字符,代码里校验通过了,结果SQL里用LENGTH(message) > 150在数据库层面做二次拦截,把不少中文留言误伤了。再比如短信签名截断,凡是需要“按用户感知的字符个数”处理的场景,就一定要用CHAR_LENGTH,否则会截出半个汉字,存储上没问题,显示出来是一堆乱码。如果消息推送服务要按字节数计费,那就用LENGTH配合CHAR_LENGTH算差值,基本能反推出字符串里到底有多少多字节字符。
2.4 用一段SQL完成昵称脱敏的完整例子
字符串函数组合在一起能干什么?脱敏是个特别典型的例子。假设用户表里昵称叫nickname,展示时想把中间字符打码,只保留首位和末位:
SELECT CASE WHEN CHAR_LENGTH(nickname) <= 1 THEN CONCAT(nickname, '*') WHEN CHAR_LENGTH(nickname) = 2 THEN CONCAT(LEFT(nickname, 1), '*') ELSE CONCAT(LEFT(nickname, 1), REPEAT('*', CHAR_LENGTH(nickname) - 2), RIGHT(nickname, 1)) END AS masked_nickname FROM user;思路是先用CHAR_LENGTH判断长度边界,再结合LEFT、RIGHT、REPEAT三个函数。中间用REPEAT补足星号,保证脱敏后的字符数和原昵称一致。跑出来的效果大致是:
- “张” → “张*”
- “张三” → “张*”
- “张三丰” → “张*丰”
- “我爱吃大米” → “我***米”
CASE分支里我把两个字的昵称单独拎出来很重要:如果直接走到第三个分支,LEFT取首字、RIGHT取末字,中间有0个字符,REPEAT会输出空字符串,结果“张三”变“张三”,等于没脱敏。边界分支不处理,这类问题极难在测试阶段发现。
另外,REPLACE也值得提一句:它做的是普通字符串替换,不支持正则。真要按正则替换,得用REGEXP_REPLACE(MySQL 8.0+)。如果你还在用5.7,那就只能先查出来在应用层处理,或者用嵌套REPLACE硬写。别指望REPLACE能理解类似[0-9]这样的表达式,它只会当字面量去匹配。
3. 日期时间函数避坑:格式化、时区与范围查询
日期时间函数的坑比字符串更隐蔽,因为它涉及时区、格式、类型三层问题。而且日期字段一旦在WHERE条件里用错,往往就是慢查询的起点。
3.1 NOW与SYSDATE:看起来一样,执行语义不一样
很多人以为NOW()和SYSDATE()是同一个函数,其实不是。NOW()返回当前语句开始执行的时间,SYSDATE()返回函数真正被执行那一刻的时间。对于一条普通的短查询,两者几乎没区别;但对于一条执行时间很长的批量SQL,SYSDATE()在每一行上可能拿到不一样的时间,NOW()则全程一致。
这会带来一个非常隐蔽的问题:如果你在WHERE里用SYSDATE()判断“创建时间在最近5分钟之内”,而这条SQL因为大量数据扫描跑了10分钟,那不同批次拿到的“当前时间”都在变化,统计口径就漂移了。所以常规业务里我推荐统一用NOW(),它能保证一条语句内的所有行共享同一个时间基准。除非你真的需要“函数执行瞬间”的真实时间,才考虑SYSDATE。
3.2 DATE_FORMAT与STR_TO_DATE:一对互逆的转换器
DATE_FORMAT把日期时间转成指定格式的字符串,STR_TO_DATE完成反向操作,把字符串解析成日期时间:
SELECT DATE_FORMAT(NOW(), '%Y-%m-%d %H:%i:%s'); -- 如 2025-01-05 14:30:00 SELECT STR_TO_DATE('2025-01-05 14:30:00', '%Y-%m-%d %H:%i:%s'); -- 2025-01-05 14:30:00需要注意两点。第一,STR_TO_DATE解析失败时不抛异常,而是返回NULL并产生一个警告。如果程序没检查NULL,拿这个日期去做比较或者插入,就会出现数据问题。第二,格式串里的%Y是四位年份,%y是两位年份;%m是数字月份,%M是英文月份名。搞混了会解析出离谱的值。
一个比较实用的组合是:外部接口返回的时间字符串往往带毫秒或者时区偏移,比如2025-01-05T14:30:00.000+08:00。这种格式不能直接STR_TO_DATE,需要先用SUBSTRING拆掉T和毫秒部分再转换。字符串清洗和日期转换经常要配合使用,这也是前面花那么多篇幅讲字符串函数的原因。
3.3 UNIX_TIMESTAMP与FROM_UNIXTIME:时间戳换算要关注会话时区
很多项目把时间存成bigint类型的秒级时间戳。展示时用FROM_UNIXTIME转成datetime,反过来,如果表里存的是时间戳,而你手里只有datetime,就用UNIX_TIMESTAMP先换算再比较。
SELECT FROM_UNIXTIME(1736054400); -- 时间戳转datetime SELECT UNIX_TIMESTAMP('2025-01-05 16:00:00'); -- datetime转时间戳这两个函数的行为跟会话时区强相关。同一个时间戳,在UTC会话里转出来和在北京时区会话里转出来会差8个小时。数据库连接池如果配置的时区不统一,不同服务看到的同一字段显示结果就不一样。这类问题的排查方向永远是“先统一连接时区,再谈函数转换”,顺序反了会越查越晕。
3.4 日期比较的正确姿势:一个慢查询的完整复盘
这是本篇最想放大的案例。某项目里有一个订单流水表,量级在千万以上。某天发现一条统计昨天订单量的查询跑得非常慢,一看SQL是这样:
SELECT COUNT(*) FROM orders WHERE DATE_FORMAT(create_time, '%Y-%m-%d') = '2025-01-05';问题一目了然:DATE_FORMAT把create_time这个索引列包裹住了。MySQL对create_time的索引按原始datetime值排序,但WHERE条件要求的是“格式化后的日期等于某天”。优化器没法直接使用索引,只能把整表拉出来,逐行做格式化再跟字符串比较。这个状态下EXPLAIN的type几乎必然是ALL,rows会到千万级别。
我当时把它改成范围查询:
SELECT COUNT(*) FROM orders WHERE create_time >= '2025-01-05 00:00:00' AND create_time < '2025-01-06 00:00:00';改写后,优化器能拿着索引去查“从这天零点开始,到第二天零点之前”的所有记录,扫描的行数从千万级降到几万级。慢查询从20多秒直接变成几十毫秒。如果嫌两个时间边界写得啰嗦,也可以写成:
WHERE create_time >= '2025-01-05' AND create_time < '2025-01-06';MySQL对日期字符串和datetime列比较时会自动把'2025-01-05'转成'2025-01-05 00:00:00',效果一样。再进一步,如果日期是外部传参,还可以用DATE_ADD生成下一天边界:
WHERE create_time >= ? AND create_time < DATE_ADD(?, INTERVAL 1 DAY);这套逻辑的本质是:让索引列保持原样,把所有函数运算都移到等号右侧的字面量一侧。函数去加工边界值,而不是加工索引列本身。这个习惯后面会单独展开。
4. 数值与流程控制函数:让SQL具备判断和转换能力
字符串和日期处理完,剩下的是数值计算和逻辑分支。这类函数在报表里出现频率极高,出错方式也比较统一:精度差异、NULL传播、类型转换。
4.1 ROUND、TRUNCATE、FLOOR:三种“取整”不是一回事
| 函数 | 行为 | 例子 | 适用场景 |
|---|---|---|---|
| ROUND(2.567, 2) | 四舍五入到指定小数位 | 2.57 | 金额展示、比率保留位数 |
| TRUNCATE(2.567, 2) | 直接截断,不做四舍五入 | 2.56 | 不想要进位、只需要截位 |
| FLOOR(2.567) | 向下取整 | 2 | 分页、件数、不超过某个整数 |
| CEIL(2.1) | 向上取整 | 3 | 页数、需要进一位的场景 |
这里最容易被忽略的是TRUNCATE和ROUND的差异。比如计算优惠分摊金额,你希望每一笔都截断,避免进位导致总额跟订单对不上,那TRUNCATE就比ROUND靠谱。反过来,如果业务明确要求四舍五入,你用了TRUNCATE,报表最后一位就会总是差一分钱。
再说ROUND的负数行为。ROUND(-2.5)在MySQL里返回-3,它是远离零方向的四舍五入。如果你在应用层用其他语言算过一个数,再拿到MySQL里用ROUND验证,会在负值边界上对不上。遇到这种严格要求的场景,建议先跟业务确认采用哪种舍入规则。
MOD、POWER、SQRT、ABS这些数学函数虽然不常出现在报表标题里,但做分表逻辑、随机抽样时很有用。比如按用户ID的哈希值模上分表数量:MOD(CRC32(user_id), 8),能快速定位用户落在哪张表,这也是数值函数一个容易被忽视的应用场景。
4.2 用IFNULL和NULLIF组一个安全除法
报表统计分析里有一个高频需求:算比率。比如动销率=有销量商品数/库存商品总数,或者退款率=退款单数/总单数。分母可能为零,直接写除法,MySQL会返回NULL并告警:
SELECT SUM(sales) / SUM(stock) FROM daily_stats;如果SUM(stock)是0,整条结果就是NULL,更严重的是某个分组的库存为0时,那个分组的比率会直接消失。常规做法是配合NULLIF和IFNULL:
SELECT store_id, IFNULL(SUM(sales) / NULLIF(SUM(stock), 0), 0) AS sales_rate FROM daily_stats GROUP BY store_id;这里NULLIF(SUM(stock), 0)的作用是:当分母为0时,把分母变成NULL;MySQL的除法碰到NULL分母,结果就是NULL;最后IFNULL把NULL兜底成0。这样写既不会让SQL报错,又能让比率为0的分组正常出现在报表里。
IFNULL和COALESCE看起来差不多,都用于取第一个非NULL值,区别是COALESCE可以接收多个参数,返回第一个非NULL值,IFNULL只接收两个参数。习惯上,单一字段兜底用IFNULL,多账户余额、多来源字段按优先级取第一个非空值时用COALESCE。
4.3 IF和CASE WHEN:分支函数怎么选
IF(expr, v1, v2)适合简单的二分支,比如“性别=1显示男性,否则显示女性”。但一旦分支超过两个,IF就开始嵌套,可读性断崖式下降。这时应该用CASE WHEN:
SELECT username, CASE WHEN score >= 90 THEN '优秀' WHEN score >= 60 THEN '及格' ELSE '不及格' END AS score_level FROM exam;CASE表达式能放在SELECT、WHERE、ORDER BY、GROUP BY里,还能配合聚合做行转列。举个实用例子:把每天订单量按渠道转成一列一列的统计:
SELECT DATE(create_time) AS day, SUM(CASE WHEN channel = 'app' THEN amount ELSE 0 END) AS app_amount, SUM(CASE WHEN channel = 'web' THEN amount ELSE 0 END) AS web_amount FROM orders GROUP BY DATE(create_time);这就是用CASE WHEN配合SUM做横向透视,报表系统经常这么干。顺便说一句,这里对create_time用了DATE(),如果orders表很大,建议先考虑把数据按日期分区,或者在应用层先传入日期范围,否则同样有不小的扫描代价。
4.4 CAST与CONVERT:显式转换比隐式转换可靠
CAST('123' AS UNSIGNED)能显式把字符串转成整数,CAST('2025-01-05' AS DATE)能转成日期。但隐式转换则是另一回事。当字符串和数字比较时,MySQL会把字符串转成数字再比较,这经常让索引失去作用。典型场景是用户编号列存的是带前导零的字符串,比如'000123',你用WHERE user_no = 123去查,MySQL会把列里的字符串逐行转成数字再匹配,索引自然失效。这种情况下应该让参数也保持字符串形态:WHERE user_no = '123'。
类型转换的第二个风险是非严格模式下的静默行为。CAST('abc' AS UNSIGNED)在非严格模式下返回0并给警告,在严格模式下才会报错。如果业务对数据质量要求高,建议开启严格模式,让这类问题在开发阶段就暴露出来,而不是到生产环境才变成一个看似神秘的错误。
5. 聚合函数的高阶玩法:从基础统计到报表拼接
聚合函数是报表SQL的骨架,这一节把最容易出问题的地方讲透:NULL处理、GROUP_CONCAT的边界、以及JSON_ARRAYAGG怎么帮你省代码。
5.1 COUNT和SUM遇见的NULL陷阱
COUNT()和COUNT(column)有本质区别。COUNT()数的是所有行,COUNT(column)只数column不为NULL的行。之前有个同事想统计“已支付订单量”,用COUNT(pay_time)来做,结果有些订单元数据里pay_time被置空,导致报表少统计了好几万行。事后复盘,核心问题就是COUNT(column)把NULL自动过滤了。
SUM(column)也有类似问题:它对NULL行不参与计算,但如果整组都是NULL,SUM返回NULL而不是0。前端拿到NULL再渲染,常常显示成空白。所以报表输出SUM时习惯性包一层IFNULL:
SELECT IFNULL(SUM(amount), 0) AS total_amount FROM orders;AVG面对NULL时同样跳过。如果需要把NULL当0参与平均,就得自己写:SUM(IFNULL(amount, 0)) / COUNT(*)。这个写法在处理用户评价分数、商家服务分这类指标时很常用。
5.2 GROUP_CONCAT:把多行标签拼成一行
很多时候你会遇到“一个用户有多个标签,要在一行里展示”的需求,GROUP_CONCAT就是为此设计的:
SELECT user_id, GROUP_CONCAT(DISTINCT tag ORDER BY tag SEPARATOR '、') AS tag_list FROM user_tag GROUP BY user_id;DISTINCT用来去重,ORDER BY可以让标签顺序稳定,SEPARATOR指定分隔符。三个参数能组合使用,这是GROUP_CONCAT最容易被低估的地方。
但GROUP_CONCAT有一个非常坑的参数:group_concat_max_len,默认只有1024字节。一旦拼接结果超过这个长度,MySQL会静默截断,不报错。报表里某个用户标签一多,标签列表被截成半截,肉眼不容易发现。解决方式有两种:一是执行SET SESSION group_concat_max_len = 10240;临时调大,二是连接初始化时统一设置。我建议在项目的数据库初始化脚本里就把这个值改到一个业务可接受的上限,而不是等有人半夜被生产问题叫起来处理。
另一个容易翻车的是标签本身包含分隔符。比如标签名就是“苹果、香蕉”,分隔符也用顿号,拼出来之后会分不清是一个标签还是两个。如果业务标签确实可能包含逗号、顿号这类字符,我会直接用JSON_ARRAYAGG替代纯文本拼接,至少前端解析不会出错。
5.3 JSON_ARRAYAGG:让嵌套结果一步到位
MySQL 5.7开始支持JSON_ARRAYAGG和JSON_OBJECTAGG。这个函数改变了我处理“一对多嵌套”的方式。以前要查“每个用户最近买了哪些商品”,一般要先查用户再逐个查商品列表,或者用一个大的JOIN拿回应用层再分组。现在可以直接在SQL里组装:
SELECT user_id, JSON_ARRAYAGG( JSON_OBJECT('goods_name', goods_name, 'price', price, 'num', num) ) AS goods_list FROM order_detail GROUP BY user_id;查询结果里goods_list直接就是JSON数组,应用层拿到后解析即可,省了一次二级查询和一堆拼接代码。报表接口尤其适合这种写法,能明显减少数据库往返次数,在连接池紧张时收益很大。
JSON_OBJECTAGG也很有意思,它可以把“属性名-属性值”的键值对聚合成一个JSON对象。像用户自定义字段、商品扩展属性这种EAV结构,用一行SQL就能还原成对象,比在应用里循环拼Map省心得多。
不过JSON函数也有边界:输入字段如果是非法JSON文本,会被转义或产生解析问题。使用时确保原始字段类型是字符串或数字,避免混入二进制或过长的文本。绝大多数报表场景没问题,但在大数据量下,JSON类聚合的计算成本比纯文本要高,这个心里要有数。
6. 内置函数的性能代价:索引、EXPLAIN和写法习惯
写完前面五类函数,最后一个必须谈的部分是性能。很多实战问题不是函数用法错了,而是函数用错了位置,导致索引失效、扫描行数暴涨、整个数据库CPU被拖垮。这一节把函数和索引的关系讲清楚。
6.1 函数包裹索引列之后,索引为什么失效
MySQL的B+树索引按列的真实值有序存储。索引就像一本按姓氏拼音排好的电话本。你在WHERE里写DATE_FORMAT(create_time, '%Y-%m-%d') = '2025-01-05',相当于问“电话本里所有姓名以某个字结尾的人”,电话本的排序帮不了你,只能从头翻一遍。优化器也是一样,它没法利用create_time索引的排序信息,只能全表扫描。
类似会让索引失效的典型写法有一大串:
DATE_FORMAT(column, ...) = 字符串YEAR(column) = 2025LEFT(column, 3) = 'abc'SUBSTRING(column, 1, 3) = 'abc'column + 1 = 100(把列放进表达式运算)
这些写法的共同问题就是:索引列没有保持原始形态。
6.2 把函数赶到等号另一边去
既然索引列要保持原样,那函数就要尽量作用在字面量上。同样一个需求,至少有几组等价写法:
-- 不推荐 WHERE DATE_FORMAT(create_time, '%Y-%m-%d') = '2025-01-05'; -- 推荐 WHERE create_time >= '2025-01-05' AND create_time < '2025-01-06';-- 不推荐 WHERE YEAR(create_time) = 2025; -- 推荐 WHERE create_time >= '2025-01-01' AND create_time < '2026-01-01';-- 不推荐 WHERE UNIX_TIMESTAMP(create_time) > 1736054400; -- 推荐 WHERE create_time > FROM_UNIXTIME(1736054400);第三组尤其经典。UNIX_TIMESTAMP包裹索引列,索引失效;FROM_UNIXTIME作用在字面量上,create_time保持原样,索引就可以正常走。这三组改写的本质是同一个:把函数放在等号右侧,让索引列的形态不被破坏。
MySQL 8.0引入表达式索引后,有些场景确实可以把DATE_FORMAT(create_time, ...)建上索引,但表达式索引需要额外的存储空间,写入时也有维护成本。我的个人建议是,能通过范围查询等价改写就先改写,表达式索引留给那些确实改写不了的高频查询。
6.3 用EXPLAIN把慢SQL打回原形
EXPLAIN是验证SQL执行计划最直接的工具。只要在SQL前面加上EXPLAIN,就能看到优化器准备怎么跑这条语句。我重点看四列:
| 字段 | 看到什么说明有问题 |
|---|---|
| type | 出现ALL,就是全表扫描 |
| key | 是NULL,说明没用到任何索引 |
| rows | 远大于实际返回行数,说明扫描量异常 |
| Extra | 出现Using filesort或Using temporary,要警惕排序和临时表 |
拿前面那个慢查询例子来说,改写前跑EXPLAIN,type是ALL、rows好几千万、key为NULL;改写后type变成range、key显示create_time索引、rows降到一千以内。这一眼就能看出改写是否生效。
我自己的习惯是:凡是带函数、带聚合、带大表JOIN的SQL,写完先EXPLAIN一遍,再进测试环境拿真实分布的数据跑一次。这个习惯看着多花一分钟,实际能省下无数个因为慢SQL被叫起来处理事故的深夜。
6.4 几条长期有效的自查清单
最后整理一份我写SQL时会反复对照的清单,算是这两年踩坑得出的经验。
- 处理前先问数据形态:文本、日期还是数字?对应函数族别搞混。
- 用到NULL相关函数,心里先过一遍:这里NULL会出现吗?出现后结果是什么?
- 字符串长度是按字符还是字节?涉及中文、emoji,按字符思路一律用CHAR_LENGTH。
- WHERE或ON条件里,索引列是否保持原样?被函数包裹就赶紧改写。
- 聚合字段输出时,SUM、AVG等是否包了IFNULL兜底?
- 新建报表SQL,发布前EXPLAIN跑一遍,看type、key、rows是否正常。
- 用GROUP_CONCAT时,确认group_concat_max_len没有隐患。
这七条不用全背,核心就一句话:内置函数是拿来加工数据的,不是拿来折腾索引的。能用函数加工参数,就让参数去承担转换;索引列保持它本来的样子,数据库才能把速度发挥出来。
写到这里,顺便聊聊我自己的体会。刚开始写SQL那会儿,遇到新需求的第一反应总是“查一下函数怎么用”,后来发现,查用法其实是最简单的一步。真正有价值的是在写完后反问自己:这个函数会不会因为NULL改变了结果?会不会因为字符集算错长度?会不会让索引失效?这三个问题问下来,过稿率和线上稳定性会明显提升。尤其是最后一个问题,配合EXPLAIN一遍遍实践,时间长了会变成肌肉记忆。
最后分享一个我现在还在坚持的小习惯:在代码仓库里维护一份“SQL自查笔记”,每次踩到新坑就补一条。别小看这个笔记,很多函数边界问题,比如DATE_FORMAT的索引失效、GROUP_CONCAT的静默截断、COUNT空值的少统计,都是这样一条条沉淀下来的。下次再遇到凌晨报警的一条慢SQL,翻翻笔记往往比翻搜索引擎更快。