刚接触MySQL那会儿,我最怕看到一条SQL里塞满各种函数,感觉满屏都是括号,读起来像在做阅读理解。直到后来自己真正动手做报表,才发现函数不是用来炫技的,而是实打实帮你省时间的。同样一份订单量很大的数据库,不懂函数的人可能在程序里写循环一条条查,会MySQL常用函数的人在SQL里写几个聚合和格式化操作,几秒钟就把结果算完。这篇博文就是写给零基础读者的,围绕“常用函数”和“数据操作效率”这两个核心词,把平时用得最多的一批函数讲清楚:它们解决什么问题、语法长什么样、实际怎么用、有什么坑。你不用把MySQL官方文档背下来,先把这些高频函数吃透,日常开发和面试基本就够用了。
后面我会按函数类型拆开讲,每一部分都会配一个能直接跑起来的例子。为了方便演示,我们后面统一用一张订单表 orders,结构如下:
sql复制CREATE TABLE orders (
id INT PRIMARY KEY AUTO_INCREMENT,
user_id INT NOT NULL,
order_no VARCHAR(64) NOT NULL,
amount DECIMAL(10, 2) NOT NULL,
status VARCHAR(20) NOT NULL,
pay_type VARCHAR(20),
created_at DATETIME NOT NULL
);
INSERT INTO orders (user_id, order_no, amount, status, pay_type, created_at) VALUES
(1, 'A001', 199.00, 'paid', 'alipay', '2025-01-05 10:23:00'),
(1, 'A002', 59.90, 'pending', 'wechat', '2025-01-12 14:30:00'),
(2, 'A003', 329.00, 'paid', 'card', '2025-02-03 09:15:00'),
(3, 'A004', 89.00, 'canceled', NULL, '2025-02-18 20:41:00'),
(2, 'A005', 499.00, 'paid', 'alipay', '2025-03-08 11:05:00'),
(3, 'A006', 129.00, 'pending', 'wechat', '2025-03-20 16:22:00');
后面的查询示例,你都可以拿这张表自己跑一遍。
1. 学函数之前,先把这两件事搞清楚
1.1 函数在SQL语句里到底起什么作用
很多人会把“函数”想象得很高深,其实你可以把MySQL函数理解成一个“加工车间”:你给进去一个或者几个值,它按照预先设定好的规则处理一下,再给你吐出一个结果。比如 NOW() 不给参数也能用,返回当前系统时间;ROUND(3.14159, 2) 给进去一个小数,它帮你处理成保留两位小数的结果“3.14”。函数不神秘,它就是帮你把重复性、容易出错的计算逻辑封装起来,让你少写代码。
在实际应用里,函数主要有两个使用位置。一个是在 SELECT 后面,对查询出来的字段做加工,比如把订单创建时间格式化成年月日的样子,或者把手机号中间四位用星号遮挡;另一个是在 WHERE 后面,把字段值做判断和过滤,比如筛选出创建时间在某个范围内的订单。你甚至可以把它嵌套在 INSERT、UPDATE、DELETE 语句里,比如更新时间字段直接写成 updated_at = NOW()。不管放在哪里,本质都一样:让数据库帮我们把数据处理完,而不是把原始数据捞到程序里再慢慢手工处理。
这也解释了一个常被忽视的问题:为什么函数能“提升数据操作效率”?因为SQL语句里写好函数之后,一条查询就能直接返回最终结果,不需要程序端反复请求数据库,也不需要把大量原始数据拉到内存里自己轮询。我见过不少新人查一个“每个月销售额汇总”,程序里写循环,每月跑一条SQL,总共12次请求。换成 GROUP BY 配合日期函数,一条SQL全搞定,这就是效率提升最直观的体现。
1.2 MySQL函数的基本分类
MySQL的函数很多,官方文档能列出几百个,但你根本不需要全记。从零基础日常使用和面试频率来看,按功能可以划分成几大类:聚合函数(COUNT、SUM、AVG、MAX、MIN)、字符串函数(CONCAT、SUBSTRING、REPLACE、LENGTH)、日期时间函数(NOW、DATE_FORMAT、DATE_ADD、DATEDIFF)、逻辑控制函数(IF、IFNULL、CASE WHEN)、数值函数(ROUND、CEIL、FLOOR)。这五类基本覆盖了写业务SQL的90%场景。
学习建议上,我不建议你拿着官方函数列表从头到尾背一遍。你先掌握下面一句话的框架:聚合函数是用来做统计的,字符串函数是用来处理文本的,日期时间函数是处理时间字段的,逻辑控制函数是让SQL能“根据条件走不同分支”的,数值函数是做数字计算的。后面每一章的示例都围绕这张订单表展开,你会慢慢发现它们不是孤立的,经常是“日期函数 + 聚合函数”“逻辑函数 + 聚合函数”混在一起用,那才是真正工作里的常态。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 聚合函数:统计报表里的顶梁柱
2.1 五个聚合函数先记熟
我教新人最省力的办法是,先把聚合函数这五个背下来:COUNT(统计数量)、SUM(求和)、AVG(求平均)、MAX(求最大值)、MIN(求最小值)。它们的特点都是“对多行数据进行计算,最后返回一个值”。比如我们想知道订单表里总共有多少条订单、已支付订单金额合计多少、最大单笔金额多少、平均金额多少,一条SQL就能算完:
sql复制SELECT
COUNT(*) AS order_count,
SUM(amount) AS total_amount,
AVG(amount) AS avg_amount,
MAX(amount) AS max_amount,
MIN(amount) AS min_amount
FROM orders;
执行结果里会出现一行,六个数字分别回答了六个问题。这里面有一个初学者最容易忽略的点:AVG(amount) 只对非NULL的金额求平均,SUM 也只加非NULL值。如果某一行的 amount 是 NULL,它不会被计入,也不会报错。这一点在真实生产环境很重要,因为NULL并不是0,它代表“没有值”。
另外值得说一句,SELECT 后同时出现普通字段和聚合函数时会有一个经典报错,比如下面这句在很多老版本里能跑,但新版本MySQL会直接报错:
sql复制-- 注意:这样写在新版MySQL里可能报 ONLY_FULL_GROUP_BY 相关错误
SELECT user_id, COUNT(*) FROM orders;
原因很简单:user_id 可能有多个不同的值,你要数据库显示哪一行?MySQL没法替你决定,所以要求你必须用 GROUP BY user_id 明确分组规则。这个问题几乎每个零基础学习者在聚合函数阶段都会遇到,不是SQL写错了,是逻辑上确实不自洽。
2.2 COUNT 里的三个细节
COUNT 有好几种写法:COUNT(*)、COUNT(1)、COUNT(字段名)、COUNT(DISTINCT 字段名)。有人说它们性能差别天壤之别,实际上在InnoDB引擎下,COUNT(*) 和 COUNT(1) 差别非常小,你不需要过分纠结,按习惯用 COUNT(*) 就行,它统计的是“表的行数”。真正要留意的区别在第四种写法上。
COUNT(字段名) 只统计该字段非NULL的行数。举个例子,订单表里 pay_type 字段有一行是 NULL,如果你执行 SELECT COUNT(pay_type) FROM orders,结果会是 5,而不是总行数 6。这个细节做统计时经常出问题。再说 COUNT(DISTINCT user_id),它统计“有多少个不同的用户下了单”,比如表里 user_id 有1、2、3三个,结果就是3。如果你想看“哪些天有成交订单”,也可以写 COUNT(DISTINCT DATE(created_at)),先把时间转成日期再去重计数。
这里还有一个面试中特别爱问的坑,放进第5章“逻辑控制函数”部分讲会更顺,因为牵涉到 IF 的返回值写 1 还是写 NULL 会直接影响统计结果。你先记住一个结论:条件统计用 SUM(CASE WHEN ... THEN 1 ELSE 0 END) 最稳,不容易踩空值陷阱。
2.3 GROUP BY 让聚合函数从“一个结果”变成“多个分组结果”
你如果把 SUM(amount) 用在全表上,那只能得到一个总金额。但运营想看“每个用户的订单金额合计”,就需要按用户分组,把相同 user_id 的行归到一组,每一组算一个合计。这就是 GROUP BY 的用途:
sql复制SELECT
user_id,
SUM(amount) AS total_amount,
COUNT(*) AS order_count
FROM orders
GROUP BY user_id;
执行结果会把用户1、2、3各自拉成一行,这正是业务报表里最常见的形态。我们通常把 GROUP BY 看成“分组依据的字段”,它决定了聚合函数在什么粒度上计算。这里要提一个使用经验:GROUP BY 后面除了写字段本身,也可以写表达式。比如你想按“订单年月”分组统计,就可以用后面第4章要讲的 DATE_FORMAT(created_at, '%Y-%m'),MySQL会自动先算出每个订单所属的年月,再按年月分组汇总。
聚合函数和分组还有一个配合点:分组后如果你想过滤“只有下单总额超过500的用户”,WHERE 是不能直接写在 GROUP BY 后面的,因为 WHERE 是在分组之前逐行过滤,过滤时总和还没算出来。这种“对聚合结果做条件过滤”必须用 HAVING:
sql复制SELECT
user_id,
SUM(amount) AS total_amount
FROM orders
GROUP BY user_id
HAVING SUM(amount) > 500;
我给新手一个特别直观的区分方法:WHERE 先删行,GROUP BY 再分组,HAVING 最后干掉不满足条件的分组。三者顺序千万别搞反。
3. 字符串函数:文本清洗的瑞士军刀
3.1 拼接与截取
日常写业务,文本字段处理频率极高。比如用户表里 first_name 和 last_name 分开存储,你想在前端显示完整的姓名“张三”,就可以用 CONCAT:
sql复制SELECT CONCAT('张', '三') AS full_name;
注意字符串要用单引号括起来。这里要顺手记住一个非常实用的变体 CONCAT_WS,它的第一个参数是分隔符,后面参数是要拼接的内容。比如我们要拼一个带引号的完整地址,或者拼几个字段中间加短横线,都可以用:
sql复制SELECT CONCAT_WS('-', '2025', '03', '20') AS date_str;
MySQL不像某些程序语言那样自动帮你把NULL变成空字符串处理,CONCAT 一旦遇到某个参数为 NULL,整个结果都会是 NULL。你在项目里做拼接展示时经常想“某个字段没填就不显示”,这就要配合后面要讲的 IFNULL 一起用,把NULL先转成空串再拼。我见过不止一次因为没处理NULL,前端展示直接出现大片空白的案例。
再说截取。SUBSTRING(str, pos, len) 从字符串的指定位置开始,取指定长度的字符。它和很多编程语言不同,位置从1开始而不是从0开始。比如 SUBSTRING('订单号A001', 4, 3) 返回的是“A00”。这里我不建议零基础去死记第三个参数,可以先掌握“从第几个字符开始取到结尾”的用法:SUBSTRING('订单号A001', 4) 返回“A001”。实际做脱敏的时候,你会频繁用到它。还有一个压缩版截取函数 LEFT(str, n) 从左边取n个字符,RIGHT(str, n) 从右边取,简单易懂。
SUBSTRING_INDEX 是一个很容易被忽略但非常好用的函数,它按指定分隔符拆分字符串并返回第n段。比如有个字段保存“区域-城市-门店”,你想只取中间的城市名,可以这样:
sql复制SELECT SUBSTRING_INDEX('华东-上海-门店A', '-', 2);
返回的不是“华东-上海”吗?对,第二段之前全返回了。想拿最后一段“门店A”,可以写:
sql复制SELECT SUBSTRING_INDEX('华东-上海-门店A', '-', -1);
负数表示从右边数第几个分隔符开始取,很顺手。
3.2 长度、大小写、去空格与替换
字符串长度这块有一个经典知识点必须分清:LENGTH 返回的是“字节数”,CHAR_LENGTH 返回的是“字符数”。在UTF-8编码下,一个中文占3个字节,所以 LENGTH('张') 返回3,而 CHAR_LENGTH('张') 返回1。如果你要判断“用户昵称不能超过20个汉字”,用 LENGTH 很容易误判,别搞混。判断字符串长度优先想 CHAR_LENGTH。
大小写转换在业务里一般用在统一识别上。比如用户录入邮箱或证件号时混入了大写字母,我们可以用 UPPER(str) 全部转大写,或 LOWER(str) 全部转小写,再和库里数据比对,就能避免因为大小写不一致导致匹配不上。TRIM 用来去掉字符串两端的空格,LTRIM 只去左边,RTRIM 只去右边。要注意 TRIM 不会去掉字符串内部的空格,比如“张 三”中间那个空格它管不了。
REPLACE(str, from_str, to_str) 是最常用来做数据清洗的替换函数。比如历史数据里有些备注字段把“支付”写成了“付款”,你想给运营做统一标签,可以:
sql复制SELECT REPLACE(remark, '付款', '支付') AS new_remark FROM orders;
有人会问:“那我只想替换订单号中的某一段,比如把订单号 A001 的 A 换成 B,该不会影响其他字段吗?”放心,SQL是查询语句,它只是生成一个新列,不会直接修改表里的原始数据,除非你把它用在 UPDATE 语句中。这也是新手要建立的安全感:在 SELECT 里用函数反复加工数据,表本身是安全的,不会因为写错了就把数据改坏。
3.3 实际案例:手机号脱敏
我来放一个真实性很高的脱敏需求。运营后台展示客户列表,通常不能明文显示完整手机号,要展示成“138****8000”这种形式。拿到一个手机号“13812348000”,思路是保留前三位和最后四位,中间四位用****代替。用现有的 LEFT、RIGHT、CONCAT 组合起来就能实现:
sql复制SELECT
phone,
CONCAT(LEFT(phone, 3), '****', RIGHT(phone, 4)) AS masked_phone
FROM users;
执行之后每个手机号就变成了“138****8000”。这个写法在真实项目里非常常见,有些公司的做法还会把脱敏写在数据库视图里,让所有下游应用统一复用。这里我想特别补充一个大坑:如果你把这段逻辑写在 UPDATE 语句里,把原字段直接更新成脱敏后的字符串,数据就永久性丢了,到时候想恢复只能靠备份。所以脱敏展示一定只在查询里做,千万别直接覆盖原始数据。
从这几个例子能看出,字符串函数很少孤立使用,经常是几个函数套在一起,这也回扣了前面那句话:函数是加工车间,加工完的输出还能当成另一个函数的输入。这种嵌套思想,是你在后续所有MySQL函数学习中都绕不开的核心技能。
4. 日期时间函数:时间维度的正确打开方式
4.1 获取当前日期时间与格式化
日期时间处理是所有表结构里几乎必然出现的功能,因为每条业务记录通常都带有 created_at、updated_at 这类字段。最基础的是获取当前时间。NOW() 返回当前日期和时间,CURDATE() 只返回当前日期,CURTIME() 只返回当前时间。比如你执行 SELECT NOW();,会得到一个 2025-03-20 16:22:00 样式的值。
拿到时间后,第一件事往往是“按我们想要的格式展示”。DATE_FORMAT(date, format) 是格式化函数中的主力。格式符号你只需要记几个常用的:%Y 代表四位年份,%m 代表两位月份,%d 代表两位日期,%H 代表24小时制小时,%i 代表分钟,%s 代表秒。想把 2025-01-05 10:23:00 显示成 2025/01/05,可以:
sql复制SELECT DATE_FORMAT(created_at, '%Y/%m/%d') AS day_str FROM orders;
很多业务表里的日期字段是 DATETIME 类型,存的是“年月日时分秒”,但分析需求常常只关心“哪一天”。你可以在 GROUP BY 里直接按格式化后的字符串分组,这样每个日期就是一行:
sql复制SELECT
DATE_FORMAT(created_at, '%Y-%m-%d') AS order_day,
SUM(amount) AS day_amount
FROM orders
GROUP BY DATE_FORMAT(created_at, '%Y-%m-%d');
这种“按天统计销售额”的报表几乎是每个系统都逃不掉的需求。月份统计就把格式换成 '%Y-%m'。另外提醒一下,DATE_FORMAT 返回的是字符串,如果你后续还需要对这个值做日期运算,记得转回日期类型,否则可能踩到类型不匹配的坑。
4.2 时间加减与时间差
统计中经常需要“最近7天”“上个月”这类相对时间条件。MySQL提供 DATE_ADD(date, INTERVAL expr unit) 给日期加上一段时间,DATE_SUB(date, INTERVAL expr unit) 给日期减去一段时间。单位可以是 DAY、MONTH、YEAR、HOUR、MINUTE。比如你想查“7天前到今天创建的全部订单”:
sql复制SELECT *
FROM orders
WHERE created_at >= DATE_SUB(NOW(), INTERVAL 7 DAY);
注意这里我用了 >= 而不是 =,因为订单时间通常精确到秒,而查询条件往往只给到“某天开始”,直接用 = 会把那天的大部分订单都漏掉。这段代码天然用到了“日期加减函数 + WHERE过滤”的组合,在后期写后台趋势报表时非常高频。
时间差计算是报表中另一个高频需求,常用于计算两个时间相差几天或几个小时。DATEDIFF(date1, date2) 返回的是 date1 - date2 相差的天数,只看日期部分,不看时间部分。比如你想知道某笔订单从创建到今天过了几天:
sql复制SELECT DATEDIFF(NOW(), created_at) AS days_since_order FROM orders;
如果你的业务需要精确到小时或分钟,那要用 TIMESTAMPDIFF(unit, datetime1, datetime2)。比如计算两个时间点之间间隔多少分钟:
sql复制SELECT TIMESTAMPDIFF(MINUTE, '2025-03-20 10:00:00', '2025-03-20 10:35:00') AS diff_minutes;
结果是35。TIMESTAMPDIFF 的第一个参数能写 HOUR、DAY、MONTH 等。它和 DATEDIFF 最大的不同是:DATEDIFF 只关心日期的差值,哪怕时间只差1秒跨了天也算1天;TIMESTAMPDIFF 按指定单位严格算。计算“用户注册到现在多少个月”这类运营指标时我一般都用 TIMESTAMPDIFF(MONTH, ...)。
4.3 按月统计时的一个性能建议
第4.1节写到了按 DATE_FORMAT(created_at, '%Y-%m') 分组统计,零基础这么写没问题,但你没有必要知道一点更好:在这个写法里,MySQL会对每一行的 created_at 执行一次格式化函数,如果订单表有几十万上百万行,这是一笔不小的开销,而且更关键的是,它对 created_at 字段做函数处理后,数据库很可能没法高效地走这个字段上的索引,查询性能会明显下降。
更推荐的做法是,如果你要统计某个时间段,尽量把查询条件写成范围条件。比如想看2025年1月份的订单,别写 WHERE MONTH(created_at) = 1 AND YEAR(created_at) = 2025,而是写:
sql复制SELECT *
FROM orders
WHERE created_at >= '2025-01-01 00:00:00'
AND created_at < '2025-02-01 00:00:00';
这样 created_at 字段不被函数包裹,MySQL就能用它上面的索引去快速定位,数据量越大,差距越明显。分组展示时用 DATE_FORMAT 没问题,因为这是输出环节,不得不处理;但过滤条件环节,尽量少对字段做函数。这个原则不只在日期字段上成立,在字符串字段转大小写比较、数字字段做运算比较时同样适用,你可以记成一条通用经验:条件里把字段本身“加工”得越少,数据库越容易走索引优化。
5. 逻辑控制函数:让同一条SQL长出不同结果
5.1 从 IF 到 IFNULL 到 NULLIF
程序语言里有 if else,MySQL里的 IF 函数也提供类似能力。语法是 IF(expr1, expr2, expr3):如果 expr1 为真,返回 expr2,否则返回 expr3。举个例子,订单表里 status 字段为 paid 表示已支付,pending 表示待支付,canceled 表示已取消。如果你要在结果里新增一个可读性更好的状态说明,就可以:
sql复制SELECT
order_no,
IF(status = 'paid', '已支付', IF(status = 'pending', '待支付', '已取消')) AS status_text
FROM orders;
这里出现了嵌套 IF,条件多了以后会越来越难读,所以我一般建议逻辑分支超过两个就改用第5.2节的 CASE WHEN。零基础可以先掌握 IF 适合“简单二选一”的场景。
IFNULL(expr1, expr2) 是另一个高频函数,判断 expr1 是否为NULL,如果是就返回 expr2,否则返回 expr1。它在处理展示类需求时非常实用。像订单表里的 pay_type,如果某行是 NULL,我们直接展示出来很不好看,用 IFNULL 转一下:
sql复制SELECT
order_no,
IFNULL(pay_type, '未知支付方式') AS pay_type_text
FROM orders;
这里要理解 IFNULL 和 IF(expr IS NULL, ...) 是等价的,但它写起来更简洁,所以我把它单独拎出来讲。NULLIF(expr1, expr2) 则相反:如果两个表达式相等,返回NULL,否则返回 expr1。它的实用场景很有意思,比如防止除数为0的错误,你可以把“当某字段等于0时当作NULL处理”和 IFNULL 再配合,用来做保护。
5.2 CASE WHEN 的分类汇总逻辑
CASE WHEN 是SQL里表达能力最强的条件表达式。语法有两种,先记住完整写法:
sql复制CASE
WHEN condition1 THEN result1
WHEN condition2 THEN result2
ELSE result3
END
从第一个 WHEN 开始依次判断,一旦某个条件成立就返回对应结果。这个表达式的最大价值在于你可以把它放进 SELECT 的字段列表里,基于每一行数据做一个“分类”,实现纵表数据到横向报表的转换。比如按状态统计每类订单数量原本需要三条SQL,用 CASE WHEN 可以一条搞定:
sql复制SELECT
SUM(CASE WHEN status = 'paid' THEN 1 ELSE 0 END) AS paid_count,
SUM(CASE WHEN status = 'pending' THEN 1 ELSE 0 END) AS pending_count,
SUM(CASE WHEN status = 'canceled' THEN 1 ELSE 0 END) AS canceled_count
FROM orders;
这段SQL的执行逻辑是:遍历每一行,如果状态匹配就给1,否则给0,最后 SUM 把这个“0/1”的数字加起来,得到的自然就是对应状态的记录数。这种技巧在面试里考得非常多,也是一个熟练SQL人员的常见工作手法。它解决的核心问题是:把“某种状态有多少个”这种横向指标在一行里展示出来,配合 GROUP BY user_id,还能做出“每个用户已支付几单、待支付几单”的明细宽表。
CASE WHEN 还能嵌套进聚合函数里参与金额计算,比如分别算“已支付订单金额”和“待支付订单金额”:
sql复制SELECT
SUM(CASE WHEN status = 'paid' THEN amount ELSE 0 END) AS paid_amount,
SUM(CASE WHEN status = 'pending' THEN amount ELSE 0 END) AS pending_amount
FROM orders;
你注意到没有,CASE WHEN 在这里相当于把“行”按条件打标记,然后交给 SUM 去累加,这是它和普通 IF 最大的不同,也是从“只会查数据”到“会做数据分析”的分水岭。
5.3 一个容易踩坑的条件计数写法
面试题里有一道非常经典的逻辑函数陷阱,稍微改改就是这种:统计已支付订单数量,有人会写 COUNT(IF(status = 'paid', 1, 0))。表面看,已支付的时候给1,否则给0,然后 COUNT 统计非NULL值的数量,好像没问题。但实际上这个查询会返回所有订单的数量,而不是已支付的数量。
原因要从 COUNT 的本质说起。COUNT(字段名) 统计的是字段值为非NULL的行数,而这里 IF 的结果要么是1,要么是0,永远都不是NULL,所以每一行都会被算进去,即使状态是’pending‘也会被统计。正确的写法应该是让条件不满足时返回NULL:
sql复制SELECT COUNT(IF(status = 'paid', 1, NULL)) AS paid_count FROM orders;
因为 COUNT 会自动忽略NULL行,这样才真正只统计已支付订单。但是说实话,这个写法容易想不明白,我平时更推荐用前面第2章那种 SUM(CASE WHEN ... THEN 1 ELSE 0 END),它把“符合条件”和“不符合条件”的逻辑写得明明白白,新手看着也顺。SUM(IF(status = 'paid', 1, 0)) 同样可以,因为 SUM 不怕0,它会正常加总。这块一定要亲手跑一遍,观察结果差异,才能真正理解为什么“计数用COUNT,求和用SUM”不能乱套。
6. 数值函数与行转列实战
6.1 常用数值函数别只会 ROUND
数值函数在第一眼看上去比较简单,但零基础最容易只记一个 ROUND,后面做金额和比例计算时才后悔。ROUND(x, d) 将 x 四舍五入保留 d 位小数;CEIL(x) 向上取整,也就是“有小数直接加1到整数”,比如订单金额算出来是99.01元,向上取整就是100;FLOOR(x) 向下取整,直接舍掉小数部分。举一个贴近业务的例子:计算每笔订单金额的“预估积分”,规则是每消费100元积1分,不满100元的部分不积分,那应该用 FLOOR(amount / 100):
sql复制SELECT
order_no,
amount,
FLOOR(amount / 100) AS points
FROM orders;
如果你用 ROUND(amount / 100, 0),则199元会积2分,和业务规则不符。所以这几个函数的差别不单纯是数学概念,直接决定业务计算结果,使用前一定要确认你的需求是四舍五入还是向上/向下取整。
ABS(x) 取绝对值,MOD(x, y) 求余数。求余数有个很实用的地方是做奇偶判断,比如要按用户ID把流量分流到两个版本,可以 MOD(user_id, 2) 判断用户ID是奇数还是偶数。还有格式化数字的 FORMAT(x, d),它会给数字添加千位分隔符并且结果返回字符串,比如展示金额时常看到“1,234,567.89”这种效果。要注意它返回的是字符串,不能直接拿去做数值运算,否则类型上容易出岔子。
这里补充一个与金额相关的实战经验:表里的金额字段通常用 DECIMAL(10,2) 存储,为了避免浮点精度误差写业务SQL时尽量不要把它当普通 DOUBLE 处理。计算订单实付金额、退款金额是否对得上,很多公司直接要求用 DECIMAL 计算,尽量少用 ROUND 反复四舍五入,因为多轮四舍五入可能放大误差。展示层要保留两位小数用 ROUND 没问题,但中间计算不要轻易截断精度。
6.2 GROUP_CONCAT:把多行内容拼成一个字段
“行转列”这个词在不同场合含义不同。有初学者想把同一个用户的多个订单号在结果里合并成一行展示,这是一个高度现实的报表需求。GROUP_CONCAT 就是为了这种情况设计的,它可以把同一组内的多个值拼接成一个字符串:
sql复制SELECT
user_id,
GROUP_CONCAT(order_no) AS order_nos
FROM orders
GROUP BY user_id;
执行出来的结果,每个用户一行,order_nos 字段可能是:“A001,A002”。默认分隔符是英文逗号,你也可以用 SEPARATOR 指定符号,比如希望用 | 分隔,就写:
sql复制SELECT
user_id,
GROUP_CONCAT(order_no SEPARATOR ' | ') AS order_nos
FROM orders
GROUP BY user_id;
如果你想给拼接结果去重排序,GROUP_CONCAT 也支持 DISTINCT 和 ORDER BY 子句,比如 GROUP_CONCAT(DISTINCT status ORDER BY status SEPARATOR ',')。这类语法零基础阶段不一定马上用得上,但你要知道有这么个工具,以后遇到“分组内合成列表”的需求时能想到它。
6.3 CASE WHEN 实现的横向行转列
我前面在5.2节提到的“用 CASE WHEN 把多行状态统计成一行”本质上也是一种行转列。以一个更常见的例子收尾:现在要为每个用户生成一行报表,包含“1月订单金额、2月订单金额、3月订单金额”,每个月份都作为一个列,这就需要先按用户分组,再用 SUM(CASE WHEN 月份条件 THEN 金额 ELSE 0 END) 写出多列:
sql复制SELECT
user_id,
SUM(CASE WHEN DATE_FORMAT(created_at, '%Y-%m') = '2025-01'
THEN amount ELSE 0 END) AS jan_amount,
SUM(CASE WHEN DATE_FORMAT(created_at, '%Y-%m') = '2025-02'
THEN amount ELSE 0 END) AS feb_amount,
SUM(CASE WHEN DATE_FORMAT(created_at, '%Y-%m') = '2025-03'
THEN amount ELSE 0 END) AS mar_amount
FROM orders
GROUP BY user_id;
这样每个用户一行,每个月金额各占一列,做数据透视表一样的效果。有经验的读者会指出,这里 DATE_FORMAT(created_at, ...) 写在 CASE WHEN 里,如果表很大确实可能出现性能问题,但对零基础阶段学习“怎么把行转成列”来说,这是一个最容易理解、可读性最强的写法。等数据量上来了,你完全可以先用 WHERE created_at >= ... AND created_at < ... 处理过滤,再用 SUM 把金额和月份条件拼起来。
GROUP_CONCAT 和 CASE WHEN 这两种“行转列”的区别在于:前者是把多行数据并成一个字符串,后者是把多行数据变成多个统计列,服务不同场景,没有谁替代谁。学习时最好在脑海建一个检索表:看到“每个A对应的一组B要合并”用 GROUP_CONCAT,看到“每个A需要横向展示不同分类的统计量”用 CASE WHEN 加聚合。
7. 新手最容易踩的五个函数坑
7.1 索引字段被函数包裹导致查询变慢
这是我在实际项目中替别人排查过最多的一类问题。假设你有一个线上用户表,手机号字段上建了唯一索引,业务方想按手机号精确查询一条记录,你写 WHERE phone = '13812348000',这能走索引,速度毫秒级。但如果某次你收到需求“按手机号后四位搜索”,新手可能顺手写成:
sql复制SELECT * FROM users WHERE RIGHT(phone, 4) = '8000';
MySQL确实能查出来,可 RIGHT(phone, 4) 意味着每一行的手机号都要先被函数处理一遍才能和条件比较,索引在绝大多数情况下帮不上忙,全表扫描就成了默认结局。这个例子不一定要求你在所有SQL里都躲开函数,而是提醒你:当字段本身被函数包裹时,你要警觉性能风险。数据量小问题不明显,到了几百万行就会变成慢查询。想优化,通常得引入冗余字段、函数索引或用范围条件改写,这超出了零基础范围,但至少有这个意识能少踩很多坑。
7.2 LENGTH 和 CHAR_LENGTH 混用导致判断错误
这个问题在注册系统的昵称、邮箱长度校验里经常暴露。比如你做用户昵称的校验,规则是最多20个字符,代码里可能先查库看看已有昵称是否超长。如果用 WHERE CHAR_LENGTH(nickname) > 20 判断,中文昵称能正确按“字”数计算;如果写了 WHERE LENGTH(nickname) > 20,在UTF-8下一个中文字符占3个字节,10个汉字的长度就会按30算,本来合法的昵称全被判定超长了。
我记得特别清楚,有一次部门做一个用户资料补全活动,需要找出“昵称长度超过50个字符”的用户做特殊处理。数据团队的同学用 LENGTH 查出大量结果,我看了一眼几乎全是中文昵称,几行内就发现问题了。后来把条件里的 LENGTH 改成 CHAR_LENGTH,数据量立刻正常了。LOB字段或中文占多数的文本字段,你想要按人数来数,就坚决用 CHAR_LENGTH。
7.3 NULL 参与运算,结果经常是 NULL
NULL是个很会“传染”的值。NULL + 1 的结果是 NULL,CONCAT('a', NULL) 的结果是 NULL,NULL = 0 的判断结果既不是真也不是假,而是NULL。这个特性会让很多写程序的人不习惯,因为初学SQL时常觉得空值就是0,但数据库世界里完全不是一回事。
举个例子,你想给每笔订单金额统一加10元运费,写成 SELECT amount + 10 FROM orders,在普通订单上没问题,可一旦有订单的 amount 是 NULL(比如被手工插入数据漏填了),这一行的结果照样是 NULL,不会显示成10。所以处理可能为NULL的字段前,先思考是否需要 IFNULL(amount, 0) 兜底。这也是我在第5章把 IFNULL 单独强调的原因,它不只是给展示用的,还能在做数值运算前把空值统一成0,避免运算结果静默变成NULL。
7.4 隐式类型转换带来的“灵异事件”
MySQL在比较不同类型的值时,可能会做自动类型转换,这特别容易踩坑。最常见的是数字与字符串比较。比如订单号 order_no 字段是 VARCHAR 类型,某次查询你图省事写:
sql复制SELECT * FROM orders WHERE order_no = 1001;
MySQL会尝试把字符串类型的 order_no 转成数字去比较,虽然有时候能查出结果,但你可能无意中破坏了索引使用,也可能得到意料之外的结果。更隐蔽的一个场景是用 = 比较日期字符串:WHERE DATE(created_at) = '2025-03-20' 本身没问题,却因为你用了函数包字段带来性能问题(见7.1);如果把 created_at 和 '2025-03-20'直接比较,MySQL大多时候也会做隐式转换,结果未必符合预期。
我的建议很直接:写查询条件时,让字段类型和值类型尽量保持一致。字段是字符串就写字符串,用引号包起来;字段是日期时间就写标准日期时间格式,并且尽量用范围比较而不是函数截断。这样既减少被隐式转换坑到的概率,又能让SQL的意图更清晰。
7.5 WHERE 和 HAVING 用错地方
前面第2.3节已经铺垫过,这里做一个完整总结。WHERE 在 GROUP BY 分组之前逐行过滤,HAVING 在分组之后对分组结果过滤。很多刚学完聚合函数的人想筛选“订单总金额超过500的用户”,却写成:
sql复制SELECT user_id, SUM(amount)
FROM orders
WHERE SUM(amount) > 500
GROUP BY user_id;
这会直接报错,因为 WHERE 执行时还没有分组,SUM(amount) 根本不存在。正确做法是把过滤条件放到 HAVING。反过来也一样:如果条件是“只看2025年1月的订单”,筛订单行应该用 WHERE created_at >= ... AND created_at < ...,在进入分组前就把数据减少,而不是先分组再 HAVING 去过滤时间,那样分组和聚合的开销白白多算一大堆。这个“尽量早过滤”的思想,是SQL优化里特别基本的一条经验,能把单条SQL从慢查询边缘拉回来。
8. 一份零基础到熟练的练习路径
8.1 拿一张表把所有函数过一遍
好多读者看完文章会觉得都会了,一动手又不知道从哪开始。我建议你按一个笨但非常有效的办法来:新建一张几十行数据的订单表,把文章里的示例SQL全部敲一遍,每个函数都看一遍实际输出。比如你先建表、插入上面那6条订单数据,然后依次执行:
sql复制SELECT COUNT(*) FROM orders;
SELECT COUNT(pay_type) FROM orders;
SELECT user_id, COUNT(*), SUM(amount) FROM orders GROUP BY user_id;
SELECT DATE_FORMAT(created_at, '%Y-%m') AS ym, SUM(amount) FROM orders GROUP BY ym;
SELECT user_id, GROUP_CONCAT(order_no) FROM orders GROUP BY user_id;
每执行一条,都停下来问自己三个问题:这个函数接收了什么?它做了什么处理?返回结果为什么是这样?不要急着看答案,尤其要观察 COUNT(*) 和 COUNT(pay_type) 的结果为什么不同,GROUP BY 和没写 GROUP BY 时 SUM 的行为为什么不同。这一步做扎实,函数基础基本就稳了。
8.2 结合业务场景做组合练习
熟练了单个函数后,下一步是“组合拳”。组合能力才是真实工作里拉开差距的地方。我从实际工作中挑几个最常见的练习目标:
- 统计每个用户每个月的支付总额,输出三列:
user_id、月份、当月金额。 - 统计每天的下单单量、支付单量、支付金额,输出一张日报表。
- 把每个用户所有订单号合并成一个字段,用顿号分隔,并且只保留已支付的订单。
- 计算每笔订单从创建到当前时间过去了多少天,按“超过30天、7到30天、7天以内”分桶。
- 用
CASE WHEN生成一个宽表,每一行是一个用户,每一列是1月到3月支付金额的合计。
这些练习能覆盖本章前面学过的绝大部分函数,也基本对应最常见的后台报表逻辑。做的时候你会发现函数往往要组合使用:先过滤,再分组,再加工字段,最后排序或分页。完整跑通一个练习,比背100个函数定义有用得多。
8.3 我给新手的三个习惯建议
写到这里,还有几句操作层面的建议想分享,都是我这些年带新人时反复强调的。第一,写完SQL先跑一个不加条件的版本,确认数据范围和预期一致,再套过滤条件,这样可以避免条件写错后拿到空结果时不知道是没数据还是SQL写错了。第二,能用字段本身比较就别用函数包着字段,从写好习惯开始,你就不会写出那种自己都解释不清的慢SQL。第三,多看执行结果再判断,MySQL返回NULL和返回0是两种不同情况,别急着归因成“数据脏”,先搞清楚函数遇到空值的规则。
MySQL常用函数的学习没有那么多玄学,核心就是把聚合、字符串、日期、逻辑、数值这五类东西用熟。敲代码时多留个心眼,多想想它为什么返回这个值,后面你写报表和排查问题都会顺很多。
