1. 时间处理的需求主线:别把格式化当作全部
我这些年看过的项目里,凡是SQL写到一定复杂度的,几乎都绕不开日期时间处理。很多刚接触MySQL的开发者,上手就记了一堆函数名,真到写业务的时候却不知道该用哪一个——MySQL的日期时间函数不是“取当前时间”这么简单,它覆盖了获取、提取、格式化、计算、聚合、时区转换、性能优化等多层场景,每一层都有对应的函数和坑。
举个例子,一个订单系统里要统计“最近7天每天的订单量”,看起来只是按日期分组,但涉及的核心问题至少有三个:第一,订单创建时间字段是DATETIME还是TIMESTAMP,直接决定了存储和查询的边界;第二,按天分组的边界怎么画,是自然日还是从今天往前推7天;第三,如果没有订单的日期要不要补零。这三件事,随便哪一件没想清楚,报表出来就是错的。
所以这篇文章我不打算按官方文档的目录顺序一个个罗列函数,而是从真实业务的处理链路出发,把MySQL日期时间函数按“日常开发里你真正会用到的方式”重新拆一遍。适合正在写业务SQL的开发者、刚转后端的数据分析师,以及准备MySQL面试但觉得函数部分太零散的人。
我默认你的MySQL版本是5.7或8.0,这两个版本的日期函数行为基本一致,8.0在窗口函数和时区处理上更强,但今天讲的这些函数两者都能跑。如果你的项目还在用5.6,下面个别语法(比如TIMESTAMP的某些边界行为)需要额外留意,我会在对应位置标注。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 获取当前时间与时间部件提取:最常用的一层
先说三个最容易混淆的函数:NOW()、CURDATE()、CURTIME()。它们经常出现在业务代码里,但很多人搞不清什么时候用哪个。
NOW()返回当前完整的日期和时间,格式是YYYY-MM-DD HH:MM:SS,对应DATETIME类型。CURDATE()只返回当前日期,即YYYY-MM-DD,对应DATE类型。CURTIME()只返回当前时间,即HH:MM:SS,对应TIME类型。
这三者的使用场景其实很清晰。插入订单记录时用NOW(),因为需要完整的下单时间;统计“今天注册的用户”时用CURDATE(),因为它可以直接和DATE类型的注册日期比较;计算“从当前时刻起8小时后的提醒时间”时,你可能会用到CURTIME(),但这种情况相对少,更多是配合时间计算函数一起用。
有一点需要特别提醒:NOW()在一条SQL的多个位置调用时,返回的是同一个时间点。这在批量更新、生成临时表时非常重要。比如你执行一条INSERT ... SELECT,在VALUES和SELECT里都调用了NOW(),它们拿到的是SQL开始执行的那个时刻,而不是每行各自的时间。这个特性在很多业务场景里能避免数据不一致,但也有开发者误以为每次调用都是新时间,结果在联表更新时埋了雷。
接下来是时间部件提取。官方函数不少,但日常高频的就这几个:
YEAR(date)、MONTH(date)、DAY(date):分别取年、月、日,这个最好理解。HOUR(time)、MINUTE(time)、SECOND(time):取小时、分钟、秒。DAYOFWEEK(date):返回一周中的第几天,注意默认周日是1,周六是7。DAYOFYEAR(date):返回这一天是当年的第几天。WEEK(date):返回这一天是当年的第几周,默认周日为一周的开始。QUARTER(date):返回季度,1到4。
这里最容易被忽略的是DAYOFWEEK和WEEK的“星期起始日”逻辑。MySQL默认用周日作为一周的开始,但国内业务更习惯周一开始。如果你直接用WEEK()做周统计,得到的结果会和业务方理解的“周”错位。要解决这个问题,需要给函数传第二个参数,比如WEEK(date, 1)表示周一开始算一周,WEEK(date, 0)是默认的周日开始。这个参数我后面讲按周聚合时会再次提到,因为它直接影响报表正确性。
sql复制SELECT
NOW(),
CURDATE(),
CURTIME(),
YEAR(NOW()),
MONTH(NOW()),
DAY(NOW()),
DAYOFWEEK(NOW()),
WEEK(NOW(), 1),
QUARTER(NOW());
这段SQL的执行结果可以帮你快速核对每个函数的返回值。我经常在排查时间相关bug时先跑一遍这条语句,确认数据库服务器的当前时间和业务预期是否一致——很多时候问题根本不是函数用错了,而是服务器时区或系统时间本身就不对。
3. 格式化与字符串互转:决定了代码可读性的关键环节
DATE_FORMAT()大概是MySQL日期函数里使用率最高的一个,因为它能把日期时间输出成任意你想要的格式。但正因为它太常用,很多人格式符记不牢,每次都要查,甚至用拼接字符串的方式去手工格式化,既不优雅又容易出错。
DATE_FORMAT(date, format)的核心是第二个参数里的格式符。我列一份日常够用的对照表:
| 格式符 | 含义 | 示例(2024-05-08 14:23:45) |
|---|---|---|
| %Y | 四位年份 | 2024 |
| %y | 两位年份 | 24 |
| %m | 两位月份 | 05 |
| %c | 月份(不带前导零) | 5 |
| %d | 两位日 | 08 |
| %e | 日(不带前导零) | 8 |
| %H | 24小时制,两位 | 14 |
| %h | 12小时制,两位 | 02 |
| %i | 分钟,两位 | 23 |
| %s | 秒,两位 | 45 |
| %W | 星期英文全称 | Wednesday |
| %a | 星期英文缩写 | Wed |
| %M | 月份英文全称 | May |
| %b | 月份英文缩写 | May(这个例子没区分度,换成March就是Mar) |
| %p | AM或PM | PM |
最常见的用法是按天分组统计时把DATETIME转成日期字符串:
sql复制SELECT
DATE_FORMAT(created_at, '%Y-%m-%d') AS day,
COUNT(*)
FROM orders
WHERE created_at >= DATE_SUB(CURDATE(), INTERVAL 7 DAY)
GROUP BY DATE_FORMAT(created_at, '%Y-%m-%d');
这里有个性能隐患值得说一下:对created_at列套用DATE_FORMAT后,如果表里有索引,索引基本就失效了,因为索引无法对函数处理后的结果进行范围扫描。数据量小时无所谓,数据量上了百万级,这条SQL可能从毫秒级变成秒级。后面我会在第7章专门展开这个问题。
字符串转成日期时间,两个函数:STR_TO_DATE()和CAST()。
STR_TO_DATE('2024-05-08 14:23:45', '%Y-%m-%d %H:%i:%s')可以按指定格式把字符串解析成DATETIME。它的格式符和DATE_FORMAT完全对应,方向相反。
CAST('2024-05-08' AS DATE)则适合处理ISO格式的字符串,MySQL会自动识别,不需要指定格式符。它是标准SQL语法,在Oracle、PostgreSQL等数据库里也通用,如果项目有跨数据库的可能,优先用CAST。
sql复制-- 手动解析非标准格式
SELECT STR_TO_DATE('2024/05/08 14:23:45', '%Y/%m/%d %H:%i:%s');
-- 标准格式自动识别
SELECT CAST('2024-05-08' AS DATE);
还有一类常见场景是前端传参,比如接口收到startDate=2024-05-01和endDate=2024-05-31,后端直接把字符串和DATE列比较。MySQL会隐式转换字符串为日期,所以WHERE create_date >= '2024-05-01'也能跑。但隐式转换有个风险:如果字符串格式不标准,MySQL的解析规则可能和你预期不一致。我的建议是统一用STR_TO_DATE或CAST显式转换后再比较,让SQL意图更明确,也方便未来迁移到其他数据库时排查问题。
4. 时间计算与日期加减:批量回填时间字段的核心
日期加减是业务开发里最常见的需求之一。用户开通了一个月会员,到期时间是多少?任务48小时后自动关闭,关闭时间是什么时候?这些都需要对日期时间做加减运算。MySQL提供了非常优雅的写法。
DATE_ADD(date, INTERVAL expr unit)用于加时间,DATE_SUB(date, INTERVAL expr unit)用于减时间。它们支持的年月日时分秒单位包括:YEAR、MONTH、DAY、HOUR、MINUTE、SECOND,以及组合单位如DAY_HOUR、DAY_MINUTE等,但日常最常用的还是单单位。
sql复制-- 一个月后的今天
SELECT DATE_ADD(CURDATE(), INTERVAL 1 MONTH);
-- 48小时前
SELECT DATE_SUB(NOW(), INTERVAL 48 HOUR);
-- 下个季度的第一天(配合 DATE_ADD 和日期截断)
SELECT DATE_ADD(
DATE_FORMAT(CURDATE(), '%Y-%m-01'),
INTERVAL 3 MONTH
);
这里有个容易被忽略的点:INTERVAL的单位可以是复数形式,INTERVAL 1 DAY和INTERVAL 2 DAY都合法,MySQL不检查单复数语法,写INTERVAL 1 DAYS也能跑,但规范起见还是保持单复数正确。
还有一个面试高频写法是DATE_ADD(date, INTERVAL -1 DAY),不需要用DATE_SUB也能做减法,减一天就是加负的一天。两种写法等价,选择哪种完全看习惯。我个人在做连续日期区间时会统一用DATE_ADD配合负数,因为写循环或生成序列时只改符号更不容易乱。
再往下是时间差计算。DATEDIFF(date1, date2)返回两个日期相差的天数,注意它只比较日期部分,忽略时间部分;TIMESTAMPDIFF(unit, datetime1, datetime2)返回相差的指定单位数量,精确到时分秒。
sql复制SELECT
DATEDIFF('2024-05-08', '2024-05-01') AS diff_days,
TIMESTAMPDIFF(HOUR, '2024-05-01 10:00:00', '2024-05-02 18:30:00') AS diff_hours;
这两个函数的方向很容易搞混,我教自己团队的一个记忆方法:DATEDIFF(date1, date2)等于date1 - date2,所以后面的日期减前面的日期,如果结果为正说明date1更晚。TIMESTAMPDIFF则是datetime2 - datetime1的单位换算,正好相反,第二个参数是“起点”,第三个参数是“终点”。
这个方向问题在面试里经常出题,在真实业务里出错更致命。比如计算“用户从下单到支付用了多少分钟”,正确写法是:
sql复制SELECT TIMESTAMPDIFF(MINUTE, order_time, pay_time) AS cost_minutes
FROM orders;
如果写成TIMESTAMPDIFF(MINUTE, pay_time, order_time),结果就是负数,报表直接花掉。
再给一个我今天特别想分享的实战场景:批量回填时间字段。假设一张users表里每个用户有vip_start,你要初始化vip_end为开通后30天:
sql复制UPDATE users
SET vip_end = DATE_ADD(vip_start, INTERVAL 30 DAY)
WHERE vip_start IS NOT NULL AND vip_end IS NULL;
就这一条SQL,几万行数据秒级完成。很多开发者遇到类似需求会选择在代码里循环处理,为每行数据计算再逐条UPDATE,完全没有必要。日期函数的价值不只是“能算”,更是“能用一条SQL完成一批数据的计算”。
5. 日期间隔场景怎么取值:LAST_DAY、YEARWEEK与更聪明的写法
按月、按周、按季度统计是报表系统的固定套路。这类需求的核心不只是日期函数本身,还有“边界值怎么取”的问题。我见过很多SQL写得非常啰嗦,连用三个DATE_FORMAT拼出月初和月末,其实MySQL提供了更直接的函数。
LAST_DAY(date)返回日期所在月份的最后一天,这是处理月末的利器。配合DATE_FORMAT(CURDATE(), '%Y-%m-01')可以拿到当月第一天。
sql复制-- 当月第一天
SELECT DATE_FORMAT(CURDATE(), '%Y-%m-01');
-- 当月最后一天
SELECT LAST_DAY(CURDATE());
-- 上个月第一天
SELECT DATE_FORMAT(DATE_ADD(CURDATE(), INTERVAL -1 MONTH), '%Y-%m-01');
-- 上个月最后一天
SELECT LAST_DAY(DATE_ADD(CURDATE(), INTERVAL -1 MONTH));
这四个表达式我几乎每个月初都会用一遍,因为它覆盖了“上月整月”“本月至今”这两种最常见的统计周期。
按周聚合时,核心函数是YEARWEEK(date, mode)。它返回一个形如202420的值,表示2024年的第20周。第二个参数mode决定一周从哪一天开始,0是周日,1是周一。国内业务一般用1。
sql复制SELECT
YEARWEEK(created_at, 1) AS year_week,
COUNT(*)
FROM orders
GROUP BY YEARWEEK(created_at, 1)
ORDER BY year_week DESC;
这个SQL能直接按自然周统计订单量。但有一个细节要注意:如果订单表里跨了年份,比如2024年12月31日是周二,这一周在YEARWEEK里可能被归到2025年的第1周,MySQL会按照ISO周或mode参数规则自动换算。如果你的业务对周的定义有特殊要求,最好先在数据里验证一下边界周的归属。
按月统计时的“补零”需求也值得提一句。业务方要求“这个月每一天的订单量”,但某些天没有订单,直接GROUP BY出来的结果是空档的,只有有数据的日期。你需要一张连续的日期表或者用数字辅助表生成连续日期,再左连接原始数据。MySQL 8.0可以用递归CTE生成日期序列,5.7则需要借助数字表或存储过程。日期函数在这里的作用是生成日期序列的起点和终点,以及控制递归的终止条件。
sql复制-- MySQL 8.0 生成从2024-05-01到2024-05-31的连续日期
WITH RECURSIVE date_range AS (
SELECT DATE('2024-05-01') AS day
UNION ALL
SELECT DATE_ADD(day, INTERVAL 1 DAY)
FROM date_range
WHERE day < DATE('2024-05-31')
)
SELECT day FROM date_range;
这个CTE生成的结果再左连接订单表,就能得到带零的每日统计。这种方法看似多写了几行CTE,但比起在代码里循环查询每一天的统计,效率高出一个量级。
6. 用日期函数搞定“近N日统计”这类业务报表
前面几章把MySQL日期时间函数的核心用法拆开了,但光知道单个函数还不够,真实业务里它们总是组合出现。这里我以一个电商项目里真实跑过的报表需求为例,把整套逻辑串一遍,方便你直接抄作业。
需求:统计从2024-05-01到2024-05-07这7天每天的订单量、支付金额,并保证没有订单的日期也显示为0。
第一步,明确时间边界。start_date和end_date是两个日期字符串,支付时间paid_at是DATETIME。比较时要注意:end_date = '2024-05-07'只包含2024-05-07 00:00:00这一分钟,所以结束时间要扩展到2024-05-07 23:59:59。最稳妥的写法是paid_at >= '2024-05-01' AND paid_at < DATE_ADD('2024-05-07', INTERVAL 1 DAY),用半开区间避免漏数据。
第二步,生成连续日期序列。我用8.0的递归CTE:
sql复制WITH RECURSIVE date_range AS (
SELECT DATE('2024-05-01') AS day
UNION ALL
SELECT DATE_ADD(day, INTERVAL 1 DAY)
FROM date_range
WHERE day < DATE('2024-05-07')
)
SELECT
d.day,
COUNT(o.order_id) AS order_cnt,
IFNULL(SUM(o.amount), 0) AS amount_sum
FROM date_range d
LEFT JOIN orders o
ON DATE(o.paid_at) = d.day
AND o.paid_at >= '2024-05-01'
AND o.paid_at < DATE_ADD('2024-05-07', INTERVAL 1 DAY)
GROUP BY d.day
ORDER BY d.day;
第三步,检查边界数据。这里最容易出问题的是跨时区订单。如果数据库存的是UTC时间,而业务方要的是北京时间,DATE(o.paid_at)算出来的日期会偏移8小时。夜间下的订单可能被归到前一天。如果你是做中国大陆业务,服务器时区通常已设置为北京时间,但表里如果混入了历史UTC数据,统计就会出错。可靠的方案是在查询时显式转换:
sql复制SELECT DATE(CONVERT_TZ(paid_at, '+00:00', '+08:00')) AS local_day
FROM orders;
CONVERT_TZ()是MySQL的时区转换函数,但使用时要求MySQL的时区表已经初始化。没初始化的话会返回NULL。很多团队第一次用这个函数就踩到这个坑。
这个报表逻辑跑通后,你就能把同样的套路套到按小时统计、按周统计,区别只在于DATE_FORMAT的格式符从%Y-%m-%d改成%Y-%m-%d %H:00(按小时)或YEARWEEK(按周)。框架不变,变的是对日期时间字符串的切分粒度。
7. 时区与索引两个容易被忽视的瓶颈
日期时间函数用得很熟练之后,有两个“元问题”会浮出水面:一是时区不统一,二是索引失效。
先讲时区。MySQL有全局时区设置和会话时区设置,NOW()、CURDATE()等函数返回的是当前会话时区的时间。如果你的项目部署在多台服务器上,每台服务器的系统时区不一致,或者连接串里没指定时区,同一时刻执行NOW()可能得到不同结果。
更隐蔽的问题出在连接层。一些连接池或ORM框架会在建立连接时发送SET time_zone = '+08:00'这类语句。如果开发环境和生产环境的时区设置不一致,你在本地测试NOW()看到的是北京时间,到生产环境可能变成了UTC,导致所有依赖当前时间的计算全部偏差8小时。
排查方法很简单:
sql复制SHOW VARIABLES LIKE '%time_zone%';
重点看system_time_zone和time_zone两个变量。system_time_zone是MySQL从操作系统读取的时区,time_zone是当前会话使用的时区。如果time_zone是SYSTEM,就用系统时区。验证是否偏移,可以执行SELECT NOW() - INTERVAL 0 SECOND,再和操作系统时间对比。
多区域团队会倾向统一在应用层传入固定时区的时间字符串,而不是依赖数据库的NOW()。这样能保证写入的数据就是业务期望的时间,查询时不涉及转换。但如果历史表已经混入了多种时区的时间,唯一的办法是CONVERT_TZ配合TIMESTAMPDIFF重新清洗数据。
再讲索引失效。这是日期函数里最影响性能的一个坑。条件是范围查询时,对Date列套用了函数,MySQL基本就无法使用B+树索引做范围定位了。最常见的两种写法:
sql复制-- 错误示范:对列套函数
SELECT * FROM orders WHERE DATE_FORMAT(created_at, '%Y-%m-%d') = '2024-05-08';
-- 正确写法:列本身参与范围比较
SELECT * FROM orders WHERE created_at >= '2024-05-08 00:00:00' AND created_at < '2024-05-09 00:00:00';
第一种写法即使created_at上有索引也白搭,因为MySQL需要把每一行的created_at都算一遍DATE_FORMAT再去匹配,扫描全表。第二种写法直接把created_at和常量比较,索引能正常生效。
量小看不出差异,但一张千万级订单表,第一种查询可能跑十几秒,第二种毫秒级返回。这个差距在慢查询日志里是很刺眼的。我见过不止一个团队因为这种写法导致数据库CPU居高不下,最后排查下来就是一条统计SQL的日期函数写法不当。
那有人会问,统计按月分组时,GROUP BY DATE_FORMAT(created_at, '%Y-%m')是不是也必然扫全表?答案是:如果查询只是按月汇总,确实需要扫描这个时间段的所有数据,这无法避免。但你可以通过先加一个日期范围条件,把扫描范围缩小到一个月或几个月,再用DATE_FORMAT聚合,这样索引在范围条件上依然能生效,函数只作用于已经缩小后的数据子集。
sql复制SELECT
DATE_FORMAT(created_at, '%Y-%m') AS month,
COUNT(*)
FROM orders
WHERE created_at >= '2024-01-01'
AND created_at < '2025-01-01'
GROUP BY DATE_FORMAT(created_at, '%Y-%m');
这个写法里,WHERE条件用上了索引,GROUP BY里的函数只处理2024年一整年的数据,工程量小了很多。
8. 常见误区与我的避坑经验
最后把我这些年实际踩过、也看别人踩过的坑集中列一下,每条背后都有真实的线上案例。
| 误区 | 后果 | 正确做法 |
|---|---|---|
用DATE_FORMAT(created_at, '%Y-%m-%d') = '2024-05-08'做等值判断 |
索引失效,全表扫描 | 使用范围比较,created_at >= ... AND created_at < ... |
| 在代码里逐行循环计算日期再更新数据库 | 性能差,连接占用高 | 用DATE_ADD/DATE_SUB写一条UPDATE批量回填 |
| 直接比较字符串日期和DATETIME,不显式转换 | 隐式转换规则复杂,格式不标准时结果出乎意料 | 用CAST或STR_TO_DATE显式转换 |
忽略了NOW()在一条SQL中是固定值 |
批量插入时多行数据的时间完全相同,有些人以为是bug | 接受这个行为,必要时显式传入不同时间 |
用WEEK()统计周数据,但业务方按周一起算 |
周统计错位 | 使用WEEK(date, 1)指定周一为一周开始 |
| 数据库时区未统一,跨区域查询结果混乱 | 报表数据偏移 | 统一用CONVERT_TZ或在连接层固定时区 |
| 每月最后一天的业务算到次月 | 月末账单归属错误 | 用LAST_DAY(date)获取当月最后一天,避免手动计算 |
我的个人经验是,日期时间函数的学习不要按官方文档一个一个背,而是先建立一个“输入-处理-输出”的心智模型:输入是什么类型(DATE/DATETIME/TIME/字符串),要做什么处理(格式化/提取/计算/聚合),输出是什么格式(字符串/数字/日期)。任何时候写日期相关的SQL,先想清楚这三件事,函数选择基本不会错。
还有一个很实用的小技巧:在正式跑大查询之前,先用一条SELECT不带条件地测试日期函数表达式,确认返回结果符合预期,再套到业务查询里。比如怀疑YEARWEEK的周归属不对,可以先把YEARWEEK('2024-12-30', 1)单独查出来看看。这个习惯帮我避免了很多次改完SQL却发现结果不对、又得回头找原因的尴尬。
如果你刚接触MySQL日期时间函数,建议把今天提到的函数每个都执行一遍,看输出,再用真实数据写一个“按天分组统计”的小查询,跑通了之后,再逐步叠加时区、索引优化、补零这些进阶需求。函数这东西,看了不一定会,写了才算真会。
