做后端开发的这些年,我的一个强烈感受是:MySQL内置函数用得好不好,直接决定你写SQL的效率和优雅程度。很多人遇到字符串截取、日期换算、分组拼接、条件判断这类需求,第一反应是拉数据到应用层用代码处理,结果网络开销白白增加,代码还变得又臭又长。其实MySQL官方提供的那几十个内置函数,覆盖了绝大多数日常开发场景,关键在于你能不能成体系地掌握,并且知道每个函数背后的边界条件和隐藏坑。下面把我多年项目里高频使用、反复踩坑、最终沉淀下来的MySQL内置函数使用心得,完整梳理出来,适合所有写MySQL的开发者和数据分析同学参考。
1. 函数体系全景认知与选型思路
1.1 为什么你值得花时间系统梳理内置函数
我先说一个特别常见的现象:不少同事写SQL的时候,能用函数的地方尽量不用,宁可把数据查出来之后在Java、Python、Go代码里做循环处理。比如要把用户姓名和手机号拼在一起,就在应用层一个个对象取字段再拼接;要判断订单是否超时,就在代码里先取当前时间,再跟数据库返回的时间做比较。这么做不是不行,但带来的问题是实实在在的:应用层代码膨胀、数据库查询返回大量明细数据占用带宽、CPU和内存都在应用服务器上白白烧掉,而数据库本身的算力反而闲置着。
把数据处理逻辑下沉到SQL里,用内置函数直接在查询阶段完成计算和转换,在很多场景下是更合理的选择。数据库服务器的计算资源往往比应用服务器更充足,而且数据在引擎内部流转,省掉了网络传输的成本。我实际做过一次对比,一个每天跑几十万行的报表任务,把日期格式化、求和、空值兜底全部用内置函数重写之后,整个任务从耗时8分钟降到了不到2分钟,应用代码还少了上百行。这种收益是立竿见影的。
对于刚开始接触MySQL的同学,我的建议是先建立一个完整的地图:知道有哪些类别的函数、每个类别有哪些常用成员、这些函数分别解决什么问题。不需要一开始就背下所有函数签名,而是先建立体感,遇到具体需求的时候能想起来"这里应该有一个函数可以处理",再回头查文档。这比死记硬背有效得多。
1.2 一个能记住全部常用函数的分类框架
MySQL的内置函数数量很多,文档页面翻起来让人头大,但真正高频使用的其实就六个大类。我整理了一个分类框架,按数据处理流程来记忆,比按字母序硬背要好用得多:
| 函数类别 | 核心解决的问题 | 高频成员举例 |
|---|---|---|
| 字符串函数 | 文本清洗、拼接、抽取、格式化 | CONCAT、SUBSTRING、REPLACE、TRIM、UPPER、LOWER |
| 数值函数 | 计算、取整、精度控制 | ROUND、CEIL、FLOOR、TRUNCATE、ABS、MOD |
| 日期时间函数 | 日期格式化、差值计算、时间截取 | NOW、DATE_FORMAT、DATEDIFF、TIMESTAMPDIFF、DATE_ADD |
| 流程控制函数 | 条件判断、空值兜底、逻辑分支 | IF、CASE WHEN、IFNULL、COALESCE、NULLIF |
| 聚合函数 | 分组统计、汇总计算 | COUNT、SUM、AVG、MAX、MIN、GROUP_CONCAT |
| 类型转换与杂项 | 类型转换、唯一标识、系统信息 | CAST、CONVERT、UUID、DATABASE、VERSION |
我自己在记忆上的一个窍门是:把函数想象成"数据流水线上的一台台加工设备"。原始数据进来,先是字符串设备做清洗,数值设备做计算,日期设备处理时间,流程控制设备做分流,最后聚合设备汇总打包。这样在写SQL的时候,顺着数据的流向想,自然而然地能回忆起对应类别的函数,不需要刻意背。
1.3 版本差异,决定你的一部分函数能不能用
这里必须提醒一个容易被忽略的问题:MySQL的版本差异会影响函数的可用性和行为。最典型的就是窗口函数,MySQL 8.0才开始支持,5.7及更早的版本里你写ROW_NUMBER() OVER()直接报语法错误。我接手过一个老项目,生产环境还是5.7,同事在开发环境用8.0写了窗口函数,部署到生产直接全线报错,最后只能改写成相关子查询,绕了一圈才解决问题。
另一个版本差异的典型是REGEXP_LIKE函数,8.0版本开始提供,早期版本只有REGEXP操作符。如果你做正则匹配,需要先确认目标环境的版本。还有GROUP BY的排序行为也有变化:5.7里GROUP BY默认会按分组字段做隐式排序,8.0里去掉了这个隐式排序,如果业务依赖ORDER BY的隐含效果,升级之后会发现结果顺序变了。
我的建议是:开发前先确认数据库版本,并且尽量让开发环境和生产环境保持一致。写函数的时候,能用通用函数的就不要依赖太新的特性;如果确实需要窗口函数等新特性,那就在项目文档里明确标注"依赖MySQL 8.0+",省得后面踩版本坑。
2. 字符串函数:日常开发最高频的一组
2.1 拼接与截取:别再在应用层拼字符串了
字符串函数里,我和我的团队用得最多的就是拼接和截取这两类。先看拼接,最基础的是CONCAT和CONCAT_WS两个函数。很多新手只知道CONCAT,但它的一个毛病是:只要有一个参数是NULL,整个结果就是NULL。比如你要给用户拼接VIP等级和备注信息,某个字段是空的,结果整条拼接串都变成了NULL,在页面上就是一个大窟窿。
CONCAT_WS是"WITH SEPARATOR"的意思,第一个参数是分隔符,后面的参数是要拼接的内容。它的优势在于:分隔符只写一次,而且它遇到NULL会跳过,不会把整个结果搞成NULL,只是在结果里缺一个片段而已。比如:
SELECT CONCAT_WS('-', 'VIP', NULL, '2024') AS info; -- 结果: VIP-2024这个特性在做报表列名拼接、地址拼接、多字段展示的时候非常实用。我经常建议团队:凡是字段可能为空的拼接场景,一律用CONCAT_WS,别用CONCAT。
截取也是高频操作。SUBSTRING函数有两种常用姿势:SUBSTRING(str, pos)是从pos位置截到末尾,SUBSTRING(str, pos, len)是从pos开始取len个字符。记住这里的pos是从1开始的,不是从0开始,跟很多编程语言习惯不一样,新手经常在这里翻车。比如SUBSTRING('abcdef', 2, 3)结果是'bcd',不是'abc'。
一个非常典型的业务场景是手机号脱敏:
SELECT CONCAT(SUBSTRING(phone, 1, 3), '****', SUBSTRING(phone, 8)) AS masked_phone FROM user_info;这段SQL把11位手机号的前3位和后4位保住,中间4位用星号代替。注意SUBSTRING(phone, 8)没写第三个参数,表示从第8位一直截到末尾。这种脱敏逻辑用SQL直接做,既快又不容易出错,应用层连数据都不用看了。
2.2 清洗与替换:数据质量问题的第一道防线
数据仓库里最容易出问题的就是脏数据:字段前后带空格、中间混入多余字符、格式不统一。我在做数据清洗任务时,REPLACE和TRIM这两组函数几乎是必用的。
REPLACE的语法是REPLACE(str, from_str, to_str),把目标字符串里的所有from_str替换成to_str。它有几个隐藏细节值得注意:第一,它是全量替换,所有匹配到的子串都会被替换,不会只换第一个;第二,它区分大小写,这在某些场景下会是麻烦,需要配合LOWER或UPPER使用。
我踩过一个很典型的坑:清洗用户输入的城市名时,原始数据里有"北京市"和"北京"两种写法,想着统一成"北京",一个REPLACE搞定。但跑完发现"北京市海淀区"变成了"北京海淀区",虽然这个结果在部分场景下是可接受的,但如果不小心把"北京市"替换成"北京市区"这种写法,数据就乱了。所以用REPLACE做数据规范化之前,最好先跑一遍SELECT看清楚一共有多少种变体,再决定替换规则。
TRIM家族是处理空格的神器。TRIM(str)默认去掉字符串两端的空格,LTRIM只去左边,RTRIM只去右边。还有一个稍微冷门但特别好用的写法:TRIM(BOTH 'x' FROM str),可以去掉两端指定的字符,比如去掉字段里的引号、去掉换行符都可以用它。MySQL里CHAR(10)是换行符,清洗文本字段时经常需要跟TRIM组合使用:
UPDATE user_remark SET remark = TRIM(BOTH CHAR(10) FROM remark) WHERE remark LIKE CONCAT('%', CHAR(10), '%');2.3 定位、大小写与正则:文本处理的高级玩法
定位函数我常用的是LOCATE和INSTR,两者功能类似,都是返回子串在字符串中第一次出现的位置。差别在于参数顺序:LOCATE(substr, str)是先写子串再写源串,INSTR(str, substr)是先写源串再写子串。这个顺序非常容易记混,我自己就吃过亏,后来干脆统一用LOCATE,因为它的语义更接近英文"locate something in somewhere"。
定位函数最常见的用途是判断某个字段里是否包含特定文本,配合WHERE条件使用。不过要注意,如果子串不存在,LOCATE返回0,在WHERE里要用LOCATE('xx', col) > 0或者直接用INSTR(col, 'xx') > 0。很多人写WHERE LOCATE('xx', col)以为能过滤,结果把0当成真值处理了,查出来的数据完全不对。
大小写转换函数UPPER和LOWER没什么难度,但有个问题值得关注:在utf8mb4字符集下,UPPER对英文字母有效,对中文没有影响(中文没有大小写概念),而对某些特殊语系字母的处理可能有差异。做数据归并时,我通常会把英文代码统一成大写再比较,避免因为大小写不一致导致两条相同的数据被当成不同的值。
正则表达式在较新版本的MySQL里变得更顺手了。8.0+可以直接用REGEXP_LIKE(str, pattern)做匹配判断,配合REGEXP_REPLACE和REGEXP_SUBSTR可以完成很复杂的文本抽取。比如要从一段地址里提取手机号:
SELECT REGEXP_SUBSTR('收货地址: 北京市朝阳区 13800138000 张三', '[0-9]{11}') AS phone;这个查询直接能拿出11位数字,比在应用层写正则再调库函数要简洁得多。不过正则表达式在SQL里调试起来比较费劲,我的习惯是先单独跑一条SELECT确认匹配效果,再嵌到正式查询里。
3. 数值函数与日期时间函数:统计报表的地基
3.1 数值处理:取整、四舍五入与溢出边界
数值函数看似简单,但边界情况特别多。先说取整。CEIL向上取整,FLOOR向下取整,这两个行为很直观。业务里最常见的是订单金额向上取整到元、分页计算总页数时向上取整。比如一个列表每页显示10条,总共33条,总页数要用CEIL(33/10)算出4页,你要是写成整数除法得到3,页面就少了一页,这个bug很隐蔽。
ROUND和TRUNCATE是另一个高频混淆点。ROUND是四舍五入,TRUNCATE是直接截断。比如ROUND(3.14159, 2)结果是3.14,TRUNCATE(3.14159, 2)结果也是3.14,看起来一样;但ROUND(3.145, 2)结果是3.15(严格说是四舍五入到3.15,浮点精度下可能是3.14或3.15,这正是浮点数的另一个坑),而TRUNCATE(3.145, 2)结果是3.14,不会进位。统计金额这类场景,财务上到底该进位还是该截断,一定要先跟业务方确认清楚,不要想当然。
我遇到过最隐蔽的数值坑是ROUND返回类型的问题。如果传入的参数是DECIMAL类型,ROUND返回的也是DECIMAL;如果传入的是浮点类型,返回的可能就是浮点类型,存在精度表示不一致的问题。做金额汇总时尽量用DECIMAL列,少用FLOAT和DOUBLE,这是我在生产库上吃过亏之后得到的教训。
MOD取模函数在分表分库场景里特别常用,比如根据用户ID的哈希值决定数据落到哪张分表。MySQL的MOD对负数有自己的一套规则:MOD(-7, 3)的结果是2,因为结果跟除数的符号一致。这个跟别的语言里取模规则可能不同,写跨语言逻辑的时候要格外小心,最好统一约定计算方式。
3.2 日期格式化与时区:不能不知道的细节
日期时间函数是整个内置函数体系里最容易出莫名问题的一大类,第一个大坑就是时区。
MySQL的NOW()、CURDATE()、CURTIME()返回的是当前会话时区的时间。如果应用服务器和数据库服务器不在同一时区,或者数据库的time_zone参数没有配好,你查出来的时间就会跟业务期望差几个小时。我遇到过的情况是:数据库服务器用的是UTC时区,应用服务器在东八区,报表统计"今天的订单量"从凌晨开始就少算了一部分,排查了整整一个下午才发现是时区问题。
DATE_FORMAT是最常用的日期格式化函数,它的格式符非常丰富,但也很容易记错。我记住的核心几个:%Y是四位年份,%y是两位年份,%m是两位月份,%d是两位日,%H是24小时制小时,%i是分钟,%s是秒。注意%i代表分钟,不是%m,这个特别容易混淆。常见的需求是把时间字段按天分组统计:
SELECT DATE_FORMAT(create_time, '%Y-%m-%d') AS day, COUNT(*) FROM order_info GROUP BY DATE_FORMAT(create_time, '%Y-%m-%d') ORDER BY day;这里还有一个性能相关的问题:用DATE_FORMAT包住字段后,索引会失效。对于大表这种写法会走全表扫描,我一般在第6章详细讲这个问题。这里先记住结论:能用范围查询的就别用函数包字段。
UNIX_TIMESTAMP和FROM_UNIXTIME是一对转换函数。UNIX_TIMESTAMP把日期时间转成整数时间戳,FROM_UNIXTIME反过来。做跨系统数据对接时经常需要这两兄弟来转换格式,比如从接口拿到的数据是时间戳,存库前要转成DATETIME。这里也有个时区坑:FROM_UNIXTIME默认按会话时区转换,如果会话时区是UTC,转换出来的时间就跟北京时间差8小时。我在做对接脚本时,会先把会话时区固定下来:
SET time_zone = '+08:00';3.3 日期加减与差值计算:业务周期的核心工具
DATE_ADD和DATE_SUB是做日期加减的标准函数,配合INTERVAL关键字使用,语义非常清晰。比如统计最近30天的订单量,可以这么写:
SELECT COUNT(*) FROM order_info WHERE create_time >= DATE_SUB(NOW(), INTERVAL 30 DAY);这种写法还有个附带的好处:它没有把函数套在create_time列上,所以优化器可以正常使用create_time上的索引。如果反过来说WHERE create_time >= NOW() - 30,虽然看起来差不多,INTERVAL表达式的清晰度和可读性都会差一些,还容易引发类型转换问题。所以我一直推荐团队统一用DATE_ADD和DATE_SUB来表达时间窗口。
计算两个日期相差多少天,最直接的是DATEDIFF,用法是DATEDIFF(expr1, expr2),结果是expr1减去expr2的天数。这里有个限定条件:它只比较日期部分,时间部分(时分秒)完全忽略。如果你要算"两个时间点之间精确到秒的差值",DATEDIFF就不行了,得用TIMESTAMPDIFF。TIMESTAMPDIFF可以指定单位,支持SECOND、MINUTE、HOUR、DAY、MONTH、YEAR等。
我做过一个会员年龄自动计算的需求,就是通过TIMESTAMPDIFF的YEAR单位实现的:
SELECT user_id, TIMESTAMPDIFF(YEAR, birth_date, CURDATE()) AS age FROM user_info WHERE birth_date IS NOT NULL;需要说明的是,TIMESTAMPDIFF计算年龄用的是跨过多少个整年,不是简单除以365,所以它能避开闰年和生日边界的问题,结果更符合业务直觉。
4. 条件判断与流程控制:让SQL具备表达能力
4.1 IF与CASE WHEN:从二元到多分支
条件判断函数是让SQL从"单纯的查询"变成"带逻辑的查询"的关键。最基础的是IF函数,它接受三个参数:IF(cond, true_value, false_value)。适合做简单的二元判断,比如把状态码翻译成中文:
SELECT order_no, IF(status = 1, '已支付', '未支付') AS status_text FROM order_info;但IF函数的可扩展性很差,一旦判断条件多起来,写成嵌套IF就会变成一团浆糊。这种场景必须用CASE WHEN。CASE WHEN有两种写法:一种是CASE后面跟列名,做等值匹配;另一种是CASE后面不跟列名,每个WHEN子句写完整的布尔表达式。后者的表达能力更强,几乎可以应对任意条件组合。
我在实际业务里经常用CASE WHEN做区间分档,比如给订单按金额分档统计:
SELECT CASE WHEN amount < 100 THEN '低金额' WHEN amount < 1000 THEN '中金额' ELSE '高金额' END AS amount_level, COUNT(*) AS cnt FROM order_info GROUP BY amount_level;注意GROUP BY后面可以直接用SELECT里的别名,这在MySQL里是允许的。另外一个容易被忽略的点是CASE WHEN的求值顺序:它是从上到下依次判断,一旦某个WHEN条件满足就立即返回,后续的WHEN不会再计算。所以写条件时要注意顺序,把最严格、最精确的条件放在前面,否则结果可能不符合预期。比如上面的例子先判断amount < 100,再判断amount < 1000,如果反过来写,小于100的订单会全部落入中金额分档。
4.2 空值处理三兄弟:IFNULL、COALESCE与NULLIF
数据库里NULL值是一个幽灵一样的存在,很多查询结果莫名其妙地不对,排查到最后都是NULL在捣乱,所以空值处理函数要格外重视。
IFNULL(expr1, expr2)是最简单的:如果expr1是NULL,返回expr2;否则返回expr1。比如用户昵称为空时显示"匿名用户":
SELECT IFNULL(nickname, '匿名用户') AS display_name FROM user_info;COALESCE是IFNULL的加强版,可以接多个参数,返回第一个非NULL的值。这个函数在做多字段备选时特别好用,比如三个联系电话,优先展示第一个非空的:
SELECT COALESCE(phone1, phone2, phone3, '无联系方式') AS contact FROM user_info;NULLIF有两个参数,当两个参数相等时返回NULL,否则返回第一个参数。它最经典的应用是防止除零错误。如果有一个计算是金额除以单量,但某些订单的单量是0,直接除会报错,用NULLIF把0转成NULL再除,结果变成NULL,后续再用IFNULL兜底:
SELECT IFNULL(SUM(amount) / NULLIF(SUM(quantity), 0), 0) AS avg_price FROM order_info;这里还有一个必须强调的细节:NULL和空字符串''完全是两回事。IFNULL('', 'default')返回的是''不是'default',因为空字符串不是NULL。很多业务系统里字段存了空字符串,导致IFNULL兜底不生效,排查的时候一定要先搞清楚NULL和空串到底哪种情况在数据里真实存在。
4.3 窗口函数:分组TopN与排名的现代化方案
窗口函数是MySQL 8.0引入的杀手级能力,它最大的价值是在不改变行数的情况下,基于分组做计算。在它出现之前,你要做"每个分类下金额最高的前三名"这种分组TopN查询,得写复杂相关子查询或者应用层二次分组,代码晦涩且性能堪忧。有了窗口函数,这类需求一下子变得清晰。
三个排名函数是重点:ROW_NUMBER、RANK、DENSE_RANK。它们都用于排序,但并列名次的处理不同。ROW_NUMBER是连续编号,不管有没有并列,就是1、2、3、4这样排;RANK遇到并列会跳过后续名次,比如两个第一名并列,下一个直接是第3名;DENSE_RANK遇到并列不跳号,两个第一名并列,下一个是第2名。
分组取TopN的标准写法是这样的:
SELECT * FROM ( SELECT order_no, category, amount, ROW_NUMBER() OVER(PARTITION BY category ORDER BY amount DESC) AS rn FROM order_info ) t WHERE t.rn <= 3;这段SQL先用窗口函数按category分组,组内按金额降序生成编号,外层再过滤出编号小于等于3的记录,就是每个分类下金额最高的三单。这个写法在报表系统里几乎是标配。窗口函数相对于普通GROUP BY的核心区别在于:GROUP BY会合并多行成一行,窗口函数保留所有行,只是在额外列上做计算,所以它特别适合"分组后还要展示明细字段"的场景,比如分页列表里展示每行的分组排名。
5. 聚合函数与分组统计:从明细数据到汇总结果
5.1 COUNT家族:三种写法,性能与语义全解析
COUNT大概是所有聚合函数里最被人低估的一个。它的三种写法COUNT(*)、COUNT(1)、COUNT(列)的行为差异,很多写了两年SQL的人也说不清楚。
先说语义上的差异。COUNT()统计的是结果集的行数,无论这行的某些列是不是NULL,它都会计入。COUNT(1)在语义上和COUNT()完全等价,也是统计行数,1只是占位符。真正有差别的是COUNT(列):它只统计该列非NULL的行数。如果某一列很多行的值是NULL,COUNT(该列)的结果会比COUNT()小。这个差异在统计业务数据时要特别小心。比如统计订单表里填写了备注的订单数,应该用COUNT(remark),如果你写COUNT(),就把所有没填备注的订单也算进去了。
性能上,在过去MySQL 5.x版本里,COUNT()的优化做得比COUNT(列)更到位,因为引擎知道COUNT()不需要判断NULL,可以直接扫描索引计数。而在COUNT(列)时,如果列允许NULL,引擎需要逐行判断是否NULL,代价更高。到了MySQL 8.0,优化器对COUNT()和COUNT(1)的处理已经没有实际差异,但COUNT(列)依然受制于NULL判断。所以我的建议是:默认写COUNT(),需要统计非NULL数量时才用COUNT(列)。
还有一个千万要记住的点:COUNT函数的结果不会返回NULL,如果没有匹配行,它返回0。这一点跟SUM不同,后面会讲到。所以在报表里直接取COUNT的值拼接展示字符串,是比较安全的。
5.2 SUM、AVG、MAX、MIN的边界行为
SUM求和时遇到NULL是按0处理的,但如果结果集完全没有行,SUM返回的是NULL而不是0。这个"空集返回NULL"的行为,是报表里最经典的隐性bug之一。我经常看到同事的统计接口在某天没有任何订单数据时返回null,前端直接展示空白,而不是0,排查半天才发现是SUM在搞鬼。
正确的做法是给SUM套上IFNULL:
SELECT IFNULL(SUM(amount), 0) AS total_amount FROM order_info WHERE create_time >= DATE_SUB(CURDATE(), INTERVAL 1 DAY);AVG的行为和SUM类似,空集返回NULL,而且AVG会忽略NULL值。这里有个业务陷阱:AVG只对非NULL值求平均,如果你的数据里有大量NULL(比如金额字段很多行是NULL),AVG的结果会被"抬高",因为NULL值相当于没参与计算。如果你希望把NULL当成0来算平均,需要先自行处理:
SELECT AVG(IFNULL(amount, 0)) FROM order_info;MAX和MIN在数值上的行为很直观,但在字符串列上的比较是按字符集排序规则来的。同样的字段,在不同的排序规则下,MAX可能返回完全不同的记录。比如在utf8mb4_general_ci下比较大小写不敏感,在utf8mb4_bin下大小写敏感,结果就可能不同。如果你用MAX取某个文本列的"最大版本号",一定要注意排序规则带来的影响。版本号这种场景如果需要按数值比较,建议先把版本号转成数字结构,比如拆成三段数字分别比较,或者用专门的版本号存储格式。
5.3 GROUP_CONCAT:分组字符串拼接的利器
GROUP_CONCAT是我个人非常喜欢的一个函数,它能在一个分组内把多个行的字段值拼成一个字符串。比如要展示"每个分类下有哪几个商品",以前得在应用层循环拼接,现在一条SQL就搞定:
SELECT category, GROUP_CONCAT(product_name ORDER BY product_name SEPARATOR '、') AS product_list FROM product_info GROUP BY category;这个函数支持在参数里直接ORDER BY,也支持SEPARATOR自定义分隔符,非常灵活。但这里有个非常隐蔽的坑:GROUP_CONCAT默认的拼接长度上限是1024字节。一旦拼接的字符串总和超过这个长度,结果会被截断,而且MySQL不会报错,只是静默地给你一个被截断的字段。我在一个商品标签拼接场景里就吃过这个亏,标签多一点的商品,展示出来的标签列表突然少了最后几个,查了半天数据都对,最后才发现是截断问题。
解决方案是把会话变量group_concat_max_len调大:
SET SESSION group_concat_max_len = 65535;如果是在报表任务或者持久化SQL里用到GROUP_CONCAT,建议在建连的时候统一设置这个参数,或者在SQL里先SET再查询。另外提醒一点:GROUP_CONCAT里如果要拼接多个字段,字段里的分隔符要先做替换,否则结果会混淆。比如商品名称里本来就带逗号,分隔符再用逗号,整个字符串就没法拆分了。
6. 常见问题与排查技巧实录
6.1 隐式类型转换,让函数“失效”的头号元凶
MySQL在比较不同类型的数据时,会自动做隐式类型转换,这个特性方便的同时也埋了很多雷。最典型的是字符串列和数字比较。表里的user_id字段如果是VARCHAR类型,查询条件写成WHERE user_id = 123,MySQL会把整列字符串转成数字再比较。一旦列里有非数字字符,转换规则就会变得很奇怪,比较结果可能完全背离预期。
更麻烦的是,隐式类型转换经常会让本可以走索引的查询变成全表扫描。我排查过一条线上慢查询,SQL很简单,就是用户ID去查订单,但每次执行都要扫几百万行。原因就是用户ID在订单表里是字符串类型,查询条件里传的是数字,MySQL对该列做了类型转换,索引就失效了。修复方式很简单,把查询条件也改成字符串:
WHERE user_id = '123'这个问题的排查技巧是:用EXPLAIN看执行计划,发现type列是ALL或者rows特别大,而SQL本身逻辑没毛病,就重点检查字段类型和条件的类型是否一致。我的习惯是写SQL前先看表的字段定义,VARCHAR对VARCHAR、数字对数字,避免依赖隐式转换。
6.2 字符集与排序规则,文本比较异常的根源
字符集问题在文本函数使用中经常以诡异的方式出现。最典型的是两个库表之间的关联查询,两边的字段一个用utf8mb4,一个用latin1,JOIN条件里做等值比较,结果该匹配的匹配不上,或者匹配效率极低。这是因为字符集不同导致比较前需要转换,转换后排序规则也可能不一致。
排查这类问题,用CHARSET(col)和COLLATION(col)函数检查字段的实际字符集和排序规则是最快的:
SELECT CHARSET(username), COLLATION(username) FROM user_info LIMIT 1;处理方案是在查询时显式指定统一字符集,比如:
WHERE a.username = CONVERT(b.username USING utf8mb4)另外强烈建议建库建表时统一用utf8mb4字符集,不要图省事混合使用。utf8mb4是utf8的超集,支持完整Unicode,包括emoji和生僻字。很多老库还停留在utf8mb3(也就是我们说的一般utf8),存emoji直接报错或变成乱码。我改造过一个老系统,把用户昵称字段从utf8升级到utf8mb4,那些此前一直存不进去的emoji才正常显示出来。
6.3 函数包裹索引列,查询性能滑坡的典型场景
这是在WHERE条件里使用函数时最常见的性能问题。比如统计某天的订单,很多新手会这么写:
WHERE DATE(create_time) = '2024-01-01'看起来没毛病,但DATE(create_time)这个函数把create_time列包了起来,优化器无法直接使用该列上的索引,只能全表扫描。如果订单表有上千万行,这条SQL能慢到让你怀疑数据库是不是挂了。
正确的做法是把它改写成范围查询,让索引能用上:
WHERE create_time >= '2024-01-01 00:00:00' AND create_time < '2024-01-02 00:00:00'这个原则广泛适用:在WHERE或ON子句里,不要让函数包住被索引的列。如果你真的需要在查询条件里对列做函数运算,可以考虑两个方案。第一是MySQL 8.0.13版本开始支持函数索引,可以给表达式建索引;第二是用生成列,把计算结果存储为一个新列,再给这个生成列建索引。生成列方案兼容性更好,5.7版本就支持了。我在项目里做过一个:给用户的手机号脱敏字段建生成列,然后直接对这个列建索引,查询脱敏手机号时非常快,也不用担心应用层维护数据不一致的问题。
最后分享一个我的排查习惯:写完一条稍微复杂的SQL,先EXPLAIN看一眼,重点关注type、key、rows三个字段。如果type是ALL,说明是全表扫描;如果key是空,说明索引没用到;rows如果比预期大很多,就顺着这个线索去找函数包列、隐式转换这类问题。这种习惯养成之后,很多性能隐患在开发阶段就能提前发现,而不是等上了线被慢查询日志打脸。MySQL内置函数本身没有好坏之分,关键在于用得对、用得巧。把函数的边界条件摸清,把性能陷阱避开,SQL的战斗力会提升一个档次,这也是我能在多年实践里反复验证的一条经验。