1. 日期时间字段选型:一开始选错,后面函数再好也白搭
很多朋友一上来就急着背函数,这个 DATE_FORMAT 怎么用、那个 TIMESTAMPDIFF 怎么算。但根据我带项目和帮人排查问题的经验,绝大多数日期时间相关的坑,根子不在函数用错,而是字段类型选错了。类型选不对,后面用什么函数都别扭,甚至可能埋下性能隐患和数据精度隐患。
MySQL 里常见的日期时间类型就几个:DATE、DATETIME、TIMESTAMP,偶尔还有人用 VARCHAR 存日期,这是最要命的。
- DATE:只存日期,格式 YYYY-MM-DD,范围 1000-01-01 到 9999-12-31,占用 3 字节。适合只需要记录生日的场景,精确到天就够了。
- DATETIME:存日期和时间,格式 YYYY-MM-DD HH:MM:SS,范围同上,占用 8 字节。适合业务里需要精确到秒、且希望范围宽松的场景。
- TIMESTAMP:也是存日期和时间,但它实际存的是 UTC 时间戳,显示时根据会话的 time_zone 参数转成当地时区。范围只有 1970-01-01 到 2038-01-19,占用 4 字节。它跟 DATETIME 最大的区别是会跟着时区变。
实际开发中我最推荐的是:业务时间字段优先用 DATETIME,除非你明确知道这个时间需要跟随客户端时区自动切换,才考虑 TIMESTAMP。原因有这么几条:
第一,TIMESTAMP 的范围太窄了,2038 年问题虽然在今天看还远,但业务系统往往要跑很多年。你存一个"2050 年会员到期日"就会直接报错或者异常。第二,TIMESTAMP 依赖数据库时区参数,如果哪天服务器时区配置变了或者迁移机房,历史数据的显示就全乱了。第三,DATETIME 更直观,查出来是什么就是什么,排查问题的时候不容易产生误解。
顺便说一句,千万不要用 VARCHAR 存日期时间。我接过一个项目,老系统里所有时间字段都是 VARCHAR,导致排序按字符串排,'2024-01-02' 和 '2024-1-2' 混在数据里,查询结果怎么排都不对,后面改数据简直噩梦。日期时间就用专门的类型。还有人问用 INT 存时间戳行不行,可以但不推荐,因为可读性太差了,查一条数据出来还要在脑子里换算,debug 效率极低。
字段类型定下来之后,函数才有讨论的基础。下面我把 MySQL 日期时间函数按使用频率和应用场景分成几类,每一类都讲清楚原理、常用写法和我实际踩过的坑。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 获取当前时间的函数:NOW() 和 SYSDATE() 的细微差别,关键时刻会坑你
获取当前时间大概是每天写 SQL 都会碰到的需求。这一类里最常见的函数是 NOW()、CURDATE()、CURTIME()、CURRENT_TIMESTAMP、SYSDATE(),还有一个 CURRENT_DATE。
- NOW():返回当前日期时间,格式 YYYY-MM-DD HH:MM:SS。
- CURDATE():返回当前日期,格式 YYYY-MM-DD。
- CURTIME():返回当前时间,格式 HH:MM:SS。
- CURRENT_TIMESTAMP 和 NOW() 基本等价。
- CURRENT_DATE 和 CURDATE() 等价。
这些函数看起来简单,但有一个点很多人不知道:NOW() 返回的是语句开始执行时的时刻,而 SYSDATE() 返回的是函数被调用到的那一刻。什么意思?如果你在一条 UPDATE 语句里对多行数据调用 NOW(),所有行拿到的都是同一个时间,这符合业务直觉——这条语句是"同时"执行的。但如果你用 SYSDATE(),每一行执行到那里时取当前时间,可能前后差个零点几秒。在数据量大的批量更新里,这就导致同一条语句写入的时间戳不一致。
我的习惯是:凡是要记录"操作时间"的场景,统一用 NOW(),别用 SYSDATE()。虽然大部分业务场景差值极小,但数据一致性这种东西,没必要为了一点"看起来更实时"去埋隐患。MySQL 官方也明确建议用 NOW(),因为 SYSDATE() 会破坏基于语句的复制(statement-based replication)的一致性——主库上语句执行了,从库重放时 NOW() 还是同一条语句的时间,SYSDATE() 却可能因为重放延迟取到不同的时间,主从数据就对不上了。
关于时区,还有一个坑值得提:你的数据库连接串里有没有设置 serverTimezone?如果 Java 应用连接 MySQL,连接串里写 serverTimezone=Asia/Shanghai,那 CURDATE()、NOW() 等函数返回的时间就是东八区时间。如果不写,MySQL 默认可能用系统时区,或者用连接时区,结果就是你程序里打印出来的时间和数据库里存的时间差了 8 小时。这种情况我在排查问题的时候遇到不止一次,应用日志明明是上午 10 点,数据库里却显示的凌晨 2 点,查了半天发现是时区根本没对齐。
sql复制SELECT NOW(), CURDATE(), CURTIME();
-- 2025-04-01 14:23:45 | 2025-04-01 | 14:23:45
如果你只需要当前日期,比如生成当天的业务日期,用 CURDATE()。如果要在查询条件里用"今天零点到当前时刻"的时间范围,可以直接写:
sql复制WHERE create_time >= CURDATE()
这个写法比 WHERE create_time >= '2025-04-01 00:00:00' 强的地方在于它每天自动更新,不需要跑定时任务去改 SQL。
3. 格式化与解析函数:DATE_FORMAT 的格式符一错,结果能差出十万八千里
日期时间函数里,DATE_FORMAT 应该是被问到最多的。它的作用是把日期时间按照指定格式转成字符串。格式符很多,但日常开发真正高频的其实就那几个,我把容易混的列成一张表:
| 格式符 | 含义 | 示例(2025-04-01 14:05:08) | 容易混淆的写法 |
|---|---|---|---|
| %Y | 四位年份 | 2025 | %y 是两位年份 25 |
| %m | 两位月份 | 04 | %c 是数字月份不带前导零,即 4 |
| %d | 两位日 | 01 | %e 是日不带前导零,即 1 |
| %H | 24 小时制的小时 | 14 | %h 是 12 小时制,即 02 |
| %i | 分钟 | 05 | 注意不是 %M |
| %s | 秒 | 08 | 注意不是 %S 也行,MySQL 里 %S 和 %s 等价 |
| %W | 星期几英文全称 | Tuesday | %a 是英文缩写 Tue |
| %M | 月份英文全称 | April | %b 是英文缩写 Apr |
| %p | AM 或 PM | PM | 配合 %h 用,比如 %h:%i %p |
最大的坑就是 %i 代表分钟,很多人写 DATE_FORMAT(now(), '%Y-%m-%d %H:%M') 然后发现分钟变成 00,因为 %M 是英文月份全称,根本不是分钟。我当时第一次用的时候也踩过这个坑,查了一个小时数据,怎么统计结果都不对,后来才发现是 %M 和 %i 的问题。
还有一个常见的需求是把字符串转成日期时间,用 STR_TO_DATE()。它和 DATE_FORMAT() 正好是互逆操作,格式符一样。
sql复制-- 把字符串 '2025/04/01 14:30:00' 解析成日期时间
SELECT STR_TO_DATE('2025/04/01 14:30:00', '%Y/%m/%d %H:%i:%s');
这个函数在导入外部数据的时候特别有用。比如你从 Excel 或 CSV 文件里导出的时间是 '2025/04/01 14:30',但数据库里的字段是 DATETIME 类型,你直接插入会报错,就得先用 STR_TO_DATE 转成合法格式再插入。
关于隐式转换,我要特别提醒一句:MySQL 对 DATE_FORMAT 的返回结果是字符串,对字符串和日期时间做比较时,MySQL 会尝试把字符串转成日期时间。但如果字符串格式不标准,转换结果可能不是你想的。比如 WHERE date_str >= '2025-04-01',date_str 是 DATETIME 类型,这个条件实际会被优化成 date_str >= '2025-04-01 00:00:00',也就是只会包含 4 月 1 日零点之后的数据——如果你以为这一句会包含 4 月 1 日全天,那就错了,你漏掉了 4 月 1 日 0 点之前的数据和 4 月 1 日 0 点后到 23:59:59 的数据,等等,这个例子说反了。准确地说,查询"某天"的数据,正确写法是 create_time >= '2025-04-01' AND create_time < '2025-04-02',不要写 create_time >= '2025-04-01' AND create_time <= '2025-04-01'。因为如果是 <= '2025-04-01',那 4 月 1 日 10 点这条记录会被漏掉。这是新手最容易写错的地方,没有之一。
还有格式化的时候要注意性能。在 WHERE 条件里对日期字段使用 DATE_FORMAT,会让索引失效。因为 MySQL 必须对每一行的 date 字段先做格式化再比较,datetime 字段自己的 B+ 树索引就用不上了。比如:
sql复制-- 错误示范:哪怕 create_time 上有索引,DATE_FORMAT(create_time) = '2025-04-01' 也全表扫描
SELECT COUNT(*) FROM orders WHERE DATE_FORMAT(create_time, '%Y-%m-%d') = '2025-04-01';
-- 正确写法:使用范围比较,create_time 上的索引能正常走
SELECT COUNT(*) FROM orders WHERE create_time >= '2025-04-01' AND create_time < '2025-04-02';
这是我在实际性能调优里经常看到的问题。数据量小的时候没感觉,等单表几百万行的时候,全表扫描和索引扫描的差距是几十倍的。
4. 日期运算函数:DATE_ADD、DATEDIFF、TIMESTAMPDIFF 的边界条件和反直觉行为
这一类函数在统计"近 7 天""上个月""去年同一天"这类需求时特别常用。
4.1 DATE_ADD 和 DATE_SUB:三种写法,注意单位参数类型
DATE_ADD 的用途是在日期时间上加上一个时间间隔。写法:
sql复制SELECT DATE_ADD('2025-04-01', INTERVAL 7 DAY);
-- 返回 2025-04-08
INTERVAL 后面跟的单位可以有很多种:SECOND、MINUTE、HOUR、DAY、WEEK、MONTH、QUARTER、YEAR,还有组合型如 '1_1' 之类。网上有些教程写成 DATE_ADD('2025-04-01', INTERVAL '1-1' YEAR_MONTH),这种写法对应的是加 1 年 1 个月,但格式容易写错,我建议日常少用组合间隔,分开写更简单。
sql复制-- 加一个月
SELECT DATE_ADD('2025-04-01', INTERVAL 1 MONTH);
-- 减两周
SELECT DATE_SUB('2025-04-15', INTERVAL 2 WEEK);
DATE_SUB 和 DATE_ADD 互为逆运算,也可以用 DATE_ADD 传负数来实现。
边界条件最典型的例子是 1 月 31 号加一个月:
sql复制SELECT DATE_ADD('2025-01-31', INTERVAL 1 MONTH);
-- MySQL 返回 2025-02-28
很多人以为会报错,其实 MySQL 会自动"截断"到 2 月的最后一天。但这正是隐含的风险——你写一个"每月最后一天续期"的逻辑,1 月 31 号执行后变成 2 月 28 号,下次执行到 3 月 28 号,日期就悄悄往前走了。如果你需要"月底自动补到月底"的规则,就得单独用 LAST_DAY() 或判定下个月是否存在这一天。
4.2 DATEDIFF 与 TIMESTAMPDIFF:单位不同,语义也不同
DATEDIFF 返回两个日期之间相差的天数,只精确到天,后面的时分秒会被忽略。
sql复制SELECT DATEDIFF('2025-04-10 23:59:59', '2025-04-01 00:00:00');
-- 返回 9
TIMESTAMPDIFF 则更灵活,第一个参数指定单位(SECOND、MINUTE、HOUR、DAY、WEEK、MONTH、YEAR),它返回的是整数值,并且是向下取整。
sql复制SELECT TIMESTAMPDIFF(HOUR, '2025-04-01 00:00:00', '2025-04-02 00:00:00');
-- 返回 24
SELECT TIMESTAMPDIFF(MONTH, '2025-01-31', '2025-02-28');
-- 返回 0
注意到没有?TIMESTAMPDIFF 的第二个坑就是 它统计的是跨越的月数,不看"是否满一个月"。两个日期不是同一个月的月末,它返回 0 还是 1,取决于跨了多少个完整的月边界。很多人用 TIMESTAMPDIFF 计算工龄或者会员时长,1 月 31 号到 2 月 28 号满打满算 28 天,返回 0,这完全符合函数设计——它数的是"跨过了多少个月",而不是你直觉里"满没满一个月"。所以如果你要的语义是"差一天都不算一个月",那用 TIMESTAMPDIFF 没问题;如果你要的是"过了 28 天就算一个月",那应该用 DATE_ADD 去判断。
DATEDIFF 和 TIMESTAMPDIFF(DAY) 结果应该一致,但有一个细节:DATEDIFF 只取日期部分,而 TIMESTAMPDIFF(DAY) 取两个时间点之间跨过的天数。比如从 '2025-04-01 23:59:59' 到 '2025-04-02 00:00:01',DATEDIFF 返回 1,TIMESTAMPDIFF(DAY) 也返回 1,因为跨过了 4 月 2 日的零点。但如果反向计算,DATEDIFF('2025-04-02 00:00:01', '2025-04-01 23:59:59') 返回 1,而 TIMESTAMPDIFF(DAY, '2025-04-01 23:59:59', '2025-04-02 00:00:01') 返回 0?不对,这个也返回 1,因为跨过了零点。更准确地说,TIMESTAMPDIFF 是按整数边界数跨过的时间。举个能体现差异的例子:从 '2025-04-01 00:00:00' 到 '2025-04-02 00:00:00' 是 24 小时,DATEDIFF 和 TIMESTAMPDIFF(DAY,HOUR) 都能算出 1 和 24,但 '2025-04-01 12:00:00' 到 '2025-04-02 12:00:00',DATEDIFF 返回 1,TIMESTAMPDIFF(DAY) 也是 1,因为跨过了一天;而 TIMESTAMPDIFF(HOUR) 返回 24。大体上 DATEDIFF 适合快速看相差天数,TIMESTAMPDIFF 适合精确到小时分钟秒的跨度和跨月季度计算。
4.3 EXTRACT 和 DATE_PART:按字段抽取日期部件
EXTRACT 是标准 SQL 的一部分,用来从日期时间中抽取 YEAR、MONTH、DAY、HOUR、MINUTE、SECOND、WEEK、QUARTER 等。
sql复制SELECT EXTRACT(YEAR FROM '2025-04-01 14:30:00');
-- 返回 2025
SELECT EXTRACT(MONTH FROM '2025-04-01 14:30:00');
-- 返回 4
其实 MySQL 里的 YEAR(date)、MONTH(date)、DAY(date) 这些独立函数也能干同样的事,日常用哪个都行。但 EXTRACT 更适合"组合条件"的场景,比如分组统计时:
sql复制SELECT EXTRACT(YEAR_MONTH FROM create_time) AS ym, COUNT(*)
FROM orders
GROUP BY EXTRACT(YEAR_MONTH FROM create_time);
EXTRACT(YEAR_MONTH FROM ...) 的结果是 202504 这样的整数,直接分组很方便,不用再拼接。这个用法在报表统计里很常见。
4.4 LAST_DAY、DATE_FORMAT 组合的"月初月末"计算
拿到某天的日期,怎么快速算这个月的第一天和最后一天?我的写法是:
sql复制-- 本月第一天
SELECT DATE_FORMAT(NOW(), '%Y-%m-01');
-- 本月最后一天
SELECT LAST_DAY(NOW());
-- 上个月第一天
SELECT DATE_FORMAT(DATE_SUB(NOW(), INTERVAL 1 MONTH), '%Y-%m-01');
-- 上个月最后一天
SELECT LAST_DAY(DATE_SUB(NOW(), INTERVAL 1 MONTH));
注意:DATE_FORMAT(NOW(), '%Y-%m-01') 返回的是字符串,如果你要 DATETIME 类型做范围比较,建议在它外面套一层 CAST 或者直接写成 DATE_FORMAT(NOW(), '%Y-%m-01') 的字符串再比较也行,MySQL 会自动把合法日期字符串转日期。但为了严谨,我一般写成:
sql复制WHERE create_time >= DATE_FORMAT(NOW(), '%Y-%m-01')
AND create_time < LAST_DAY(NOW()) + INTERVAL 1 DAY;
LAST_DAY(NOW()) + INTERVAL 1 DAY 得到下个月 1 号零点,用左闭右开区间,把整个月的数据都框进去。这是边界最稳的写法。
5. 星期与月份索引函数:统计里最常见的一类需求,也是最容易算错的一类
统计"今天是周几""这个月第几周""周环比、月环比"这类需求,经常会用到 WEEKDAY()、DAYOFWEEK()、DAYOFYEAR()、WEEK()、MONTH()、QUARTER() 这几个函数。
5.1 WEEKDAY() 和 DAYOFWEEK():星期天的位置不一样,老搞混
sql复制SELECT WEEKDAY('2025-04-06'); -- 2025-04-06 是周日
-- 返回 6
SELECT DAYOFWEEK('2025-04-06');
-- 返回 1
SELECT WEEKDAY('2025-04-07'); -- 2025-04-07 是周一
-- 返回 0
SELECT DAYOFWEEK('2025-04-07');
-- 返回 2
WEEKDAY() 返回 0 到 6,0 代表周一,6 代表周日;DAYOFWEEK() 返回 1 到 7,1 代表周日,2 代表周一。这两个方向是反的,我当年查资料的时候每次都要翻文档。现在给个记忆口诀:WEEKDAY 从周一开始数 0,DAYOFWEEK 从周日开始数 1。
实际统计中,如果我想按自然周分组(周一到周日为一周),我通常会用:
sql复制SELECT DATE_SUB(create_time, INTERVAL WEEKDAY(create_time) DAY) AS week_start_day
FROM orders;
这个表达式算出来的是每条记录所在周的周一日期,后面 GROUP BY 这个字段就是按周一归类,报表里再拼一个"YYYY-MM-DD~YYYY-MM-DD"区间,一目了然。
5.2 WEEK() 的参数 mode 列表:统计口径的隐藏杀手
WEEK(date[, mode]) 函数返回一年中的第几周,第二个参数 mode 可以指定一周从哪一天开始、第一周怎么算。最常用的两个:
- mode 0(默认):周日为一周的第一天,第一周至少包含 1 天。
- mode 1:周一为一周的第一天,第一周至少包含 4 天(ISO 标准)。
举个实际例子,同样是 2025-01-01:
sql复制SELECT WEEK('2025-01-01', 0); -- 返回 0,因为 2025-01-01 是周三,按周日为一周的规则,第一周还没满
SELECT WEEK('2025-01-01', 1); -- 返回 1,因为 2025-01-01 所在周是 2025 年的第一周(周一到周日)
同一个日期,不同 mode,返回的周次不一样。如果你的报表里"周次"的列和另一个系统或 Excel 里的周次对不上,大概率就是 mode 不一致。做周报之前,先确定所有人和所有下游系统都用同一个 mode。
5.3 DAYOFYEAR、MONTH、QUARTER:做同比环比时的分组依据
同比环比都绕不开按日、按月、按季度分组:
sql复制SELECT
MONTH(create_time) AS m,
QUARTER(create_time) AS q,
COUNT(*)
FROM orders
GROUP BY MONTH(create_time), QUARTER(create_time);
这几个函数返回值都是整数,性能也很好,只要注意别在 WHERE 条件里包着日期字段做比较,而是在 SELECT 或 GROUP BY 用,数据库就能更好地利用覆盖索引。
5.4 当前周的周一、周末:报表任务最常见的模板
写日报和周报的时候,我经常要取"本周一到今天"的数据:
sql复制-- 本周一
SELECT DATE_SUB(CURDATE(), INTERVAL WEEKDAY(CURDATE()) DAY);
-- 本周日
SELECT DATE_ADD(DATE_SUB(CURDATE(), INTERVAL WEEKDAY(CURDATE()) DAY), INTERVAL 6 DAY);
-- 本月已过天数
SELECT DAY(CURDATE());
-- 本周一到当前时刻的数据
SELECT COUNT(*)
FROM orders
WHERE create_time >= DATE_SUB(CURDATE(), INTERVAL WEEKDAY(CURDATE()) DAY)
AND create_time < DATE_ADD(CURDATE(), INTERVAL 1 DAY);
注意上面的"本周日"如果用 DATE_ADD(本周一, INTERVAL 6 DAY) 得到的是本周日零点,如果按左闭右开区间,取数据到"下周一零点"更稳。我习惯写成:
sql复制WHERE create_time >= DATE_SUB(CURDATE(), INTERVAL WEEKDAY(CURDATE()) DAY)
AND create_time < DATE_ADD(DATE_SUB(CURDATE(), INTERVAL WEEKDAY(CURDATE()) DAY), INTERVAL 7 DAY);
这样"本周"被完整覆盖,不需要考虑周日晚上 23:59:59 这种边界。
6. 实际排错记录:一个"周活跃用户统计"的问题,排查了整整一下午
前面讲了很多函数,但最有说服力的还是真实排错过程。有一次我做增长看板,需要统计"最近 7 天活跃用户数",口径是按天去重然后累加。我第一版 SQL 写的是:
sql复制SELECT COUNT(DISTINCT user_id)
FROM user_login_log
WHERE login_time >= DATE_SUB(CURDATE(), INTERVAL 6 DAY)
AND login_time < DATE_ADD(CURDATE(), INTERVAL 1 DAY);
这个写法本身没问题,但我的同事在此基础上加了一句"只看工作日活跃用户",写成:
sql复制SELECT COUNT(DISTINCT user_id)
FROM user_login_log
WHERE login_time >= DATE_SUB(CURDATE(), INTERVAL 6 DAY)
AND login_time < DATE_ADD(CURDATE(), INTERVAL 1 DAY)
AND WEEKDAY(login_time) NOT IN (5, 6);
问题就来了。这张表有几百万行,user_id 上有索引,但 login_time 上没索引,结果这个查询跑了十几秒,看板直接超时。
当时排查的思路是这样的:先看执行计划,发现 type 是 ALL,全表扫描。然后我们把 login_time 加上索引,但发现执行计划还是没走索引,因为 WEEKDAY(login_time) 把 login_time 的索引又给"废"了。其实加索引不是重点,重点是 WHERE 条件里的 WEEKDAY(login_time) 破坏了索引使用。凡是 WHERE 函数(字段) 的写法,MySQL 就很难用上字段上的普通索引(除非用生成列或函数索引)。
最终我改成了把工作日判断拿到子查询里,在外面用范围条件:
sql复制SELECT COUNT(DISTINCT user_id)
FROM (
SELECT user_id, login_time,
WEEKDAY(login_time) AS wd
FROM user_login_log
WHERE login_time >= DATE_SUB(CURDATE(), INTERVAL 6 DAY)
AND login_time < DATE_ADD(CURDATE(), INTERVAL 1 DAY)
) t
WHERE t.wd NOT IN (5, 6);
这样内层虽然也要扫描 7 天的全部数据,但可以走 login_time 索引,只取 7 天的数据量,外层再过滤工作日,性能从十几秒降到几十毫秒。
这个问题的本质是:不要对 WHERE 条件中的字段套函数,能先缩范围就先缩范围。这不是 MySQL 的 bug,而是 B+ 树索引的有序性决定的。你一旦对字段做变换,本来有序的值就变成新的分布,天然无法二分查找。
还有一次是统计"本月每天的新增用户数",我一开始用:
sql复制SELECT DATE_FORMAT(create_time, '%Y-%m-%d') AS day, COUNT(*)
FROM users
WHERE create_time >= '2025-04-01'
GROUP BY DATE_FORMAT(create_time, '%Y-%m-%d');
跑起来没问题,但后来数据量大了,也出现同样的问题:GROUP BY 里的表达式不能走索引,慢。改成:
sql复制SELECT DATE_FORMAT(create_time, '%Y-%m-%d') AS day, COUNT(*)
FROM users
WHERE create_time >= '2025-04-01'
AND create_time < '2025-05-01'
GROUP BY DATE(create_time);
其实 DATE(create_time) 也一样会阻止索引下推,正确做法是在 WHERE 就先裁掉时间范围,然后 GROUP BY 直接对日期字段求表达式,或者更彻底一点,建一个"日期"字段(不带时间),在插入时就冗余存一份。冗余一个日期字段,是最简单也最有效的报表优化手段,没有之一。你可以在原表上新增一列 create_day DATE,写入数据的时候同步填好,然后给 create_day 建索引,后面所有日报、周报、月报查询都走这个字段,速度和逻辑都清晰。
7. 时区与闰秒:少数派但一旦遇到就是灾难
日期时间函数使用过程中,时区问题最常见,闰秒则几乎没人会遇到,但真实业务里一旦出现数据不一致,往往就是这类问题。
时区问题我在前面提过一嘴,这里展开讲清楚。MySQL 会话有一个 time_zone 参数。它对 TIMESTAMP 类型特别敏感:TIMESTAMP 在存储时把会话时区的时间转成 UTC,在读取时把 UTC 再转回会话时区。也就是说,同一个时刻,你换一个 time_zone 的会话去查,显示出来的本地时间不一样。
sql复制SET time_zone = '+00:00';
SELECT NOW(); -- 显示 UTC 时间
SET time_zone = '+08:00';
SELECT NOW(); -- 显示东八区时间
DATETIME 不随会话时区变化,存什么就是什么。这就带来一个很微妙的场景:应用服务器在东八区,数据库服务器时区设成 UTC,你插入一条 create_time = NOW() 的数据,如果字段是 TIMESTAMP,数据库会把它当成 UTC 存进去,但你的应用日志显示的是东八区时间,两边差 8 小时。很多 Alibaba 云上部署的应用莫名其妙"慢 8 小时",多半就是这个原因。
解决思路有几个:
- 统一约定:应用连接串、MySQL 实例、容器时区全部设置为 Asia/Shanghai。
- 如果确实需要存"用户本地时间",可以考虑 DATETIME + 明确设计时区偏移量字段,而不是依赖 TIMESTAMP 自动转换。
- 如果已经在线上踩了 8 小时的坑,别盲目改数据,先搞清楚每一列是 TIMESTAMP 还是 DATETIME,再决定怎么迁移。
闰秒的问题就更冷门了。Unix 时间戳没有闰秒,而一些时间源(如 GPS 时间)包含闰秒。MySQL 官方对闰秒的处理是"忽略",即它不认为存在 23:59:60 这种秒,遇到这种输入会报错或者被截断。对绝大多数业务系统来说,这辈子都不会碰到闰秒,但如果你在做天文学或对时系统相关的数据采集,就要知道 MySQL 本身不处理它,你得在应用层做补偿。这一般涉及不到日常后端开发,知道有这回事就行。
8. 日期时间函数和索引的配合:一个字段的索引,怎么才能让函数不"吃"掉它
前面提到过 WHERE DATE_FORMAT(create_time, '%Y-%m-%d') = '2025-04-01' 会让索引失效。关于这一点,再往深里挖一下:MySQL 8.0 支持函数索引,也叫表达式索引。如果你不得不频繁使用函数,可以在 8.0 里给函数表达式建索引。
sql复制ALTER TABLE orders ADD INDEX idx_date( (DATE_FORMAT(create_time, '%Y-%m-%d')) );
注意 MySQL 8.0 的函数索引语法需要把表达式加两层括号。有了这个索引,WHERE DATE_FORMAT(create_time, '%Y-%m-%d') = '2025-04-01' 就能走索引。但说实话,我不太建议一上来就用函数索引,因为它在写入时有额外开销,而且语法和普通索引不一致,对团队里维护的人来说理解成本高。更稳的做法是:
- 在 WHERE 条件里避免对日期字段做函数变换,改成范围查询。
- 查询需求确实只能按日期精简时,检查是否漏掉了"插入时冗余日期字段"的方案。
- 万不得已才用函数索引。
另外,范围查询有一个常见经验:区间不要太宽。比如跑月报,直接 create_time >= '2025-04-01' AND create_time < '2025-05-01' 没问题;如果需求是"最近 90 天到当前小时",那也是范围查询,索引能帮忙;但如果业务上不得不"全表扫描"才能算出来的指标,比如"过去所有时间里,每个用户的首次登录时间",那就不是函数问题了,而是你得考虑预聚合表或汇总表,不要每次实时去算。
索引配合的另一个点:如果排序用到了日期时间字段,索引也能加速 ORDER BY。比如 ORDER BY create_time DESC LIMIT 20,在 create_time 有索引的情况下可以走索引反向扫描。但如果你写成 ORDER BY DATE(create_time) DESC,索引又用不上了,只能文件排序。所以我写列表查询的时候,能用原始时间字段排序就绝不包一层函数。
9. 常用组合场景速查:直接拿去用的 SQL 片段
整理几个我经常写、也经常教别人的组合场景,都是可以直接复制改表名就能用的。
9.1 按小时统计全天的数据分布
sql复制SELECT
HOUR(create_time) AS hour_no,
COUNT(*) AS cnt
FROM orders
WHERE create_time >= CURDATE()
AND create_time < DATE_ADD(CURDATE(), INTERVAL 1 DAY)
GROUP BY HOUR(create_time)
ORDER BY hour_no;
注意我这里 WHERE 已经用 CURDATE() 把范围缩到"今天 00:00:00 到明天 00:00:00",GROUP BY 里的 HOUR() 就不会造成全表扫描,因为数据量先被范围条件限定住了。这是一种很实用的"先窄后宽"思路:先把范围缩到最小,再在结果集上用函数做分组、格式化和计算。
9.2 判断某个时间是否在"最近 N 小时/ N 天"内
sql复制-- 最近 24 小时
WHERE create_time > DATE_SUB(NOW(), INTERVAL 24 HOUR)
-- 最近 7 天(含今天,按自然日算)
WHERE create_time >= DATE_SUB(CURDATE(), INTERVAL 6 DAY)
这两句的区别在前者精确到秒,后者从今天零点往前推 6 天。如果要求"最近 7 天"包含当前时刻往前推 168 小时,那就用 NOW();如果要求"最近 7 个自然日",就用 CURDATE()。口径不同,SQL 也不同,做需求之前先和产品对齐口径。
9.3 按月份分组并补全缺少的月份
报表里经常遇到"某个月没有数据,直接缺行"的问题。解法是用一个数字辅助表或者递归 CTE 生成连续月份:
sql复制WITH RECURSIVE months AS (
SELECT DATE_FORMAT('2024-01-01', '%Y-%m-01') AS month_start
UNION ALL
SELECT DATE_ADD(month_start, INTERVAL 1 MONTH)
FROM months
WHERE month_start < DATE_FORMAT('2024-12-01', '%Y-%m-01')
)
SELECT
months.month_start,
COUNT(orders.id) AS order_cnt
FROM months
LEFT JOIN orders
ON orders.create_time >= months.month_start
AND orders.create_time < DATE_ADD(months.month_start, INTERVAL 1 MONTH)
GROUP BY months.month_start;
这里的重点是 LEFT JOIN 的 ON 条件里用了左闭右开区间,能保证每个月的数据都归属到正确的月份,同时即使某个月没有订单,也能因 LEFT JOIN 保留该月行。
9.4 计算两个时间之间的小时数/分钟数/秒数
sql复制SELECT
TIMESTAMPDIFF(SECOND, start_time, end_time) AS total_seconds,
TIMESTAMPDIFF(MINUTE, start_time, end_time) AS total_minutes,
TIMESTAMPDIFF(HOUR, start_time, end_time) AS total_hours
FROM user_session;
这个场景在统计在线时长、任务耗时的时候特别常用。TIMESTAMPDIFF 的返回值是二者之间的整数跨度,结果永远是正数(如果 end_time 在 start_time 之前会返回负数),所以调用时注意顺序。
9.5 判断今天是今年的第几周、第几天
sql复制SELECT
WEEK(CURDATE(), 1),
DAYOFYEAR(CURDATE());
如果你要在页面上显示"2025 年第 14 周",建议在 SQL 里直接用 WEEK(CURDATE(), 1) 拿到周次,不要在应用层自己算,因为应用层语言对周起始日的默认规则可能和 MySQL 不一样,容易对不上。
10. 函数性能备忘:什么时候该用、什么时候不该用
日期时间函数虽然方便,但我觉得有必要给一张"使用原则清单",帮助大家避免常见的性能坑。
| 场景 | 推荐做法 | 不推荐的做法 |
|---|---|---|
| 查询某一天的数据 | create_time >= '2025-04-01' AND create_time < '2025-04-02' |
DATE(create_time) = '2025-04-01' |
| 查询某一个月的数据 | create_time >= '2025-04-01' AND create_time < '2025-05-01' |
DATE_FORMAT(create_time, '%Y-%m') = '2025-04' |
| 按天分组统计 | 先 WHERE 缩范围,再 GROUP BY 日期字段(可冗余) |
全表 GROUP BY DATE_FORMAT(create_time, '%Y-%m-%d') |
| 按小时统计 | WHERE 先缩到一天,再 GROUP BY HOUR(create_time) |
大范围 GROUP BY HOUR(create_time) |
| 排序 | ORDER BY create_time DESC |
ORDER BY DATE_FORMAT(create_time, '%Y-%m-%d') DESC |
这些原则背后的底层逻辑都是一个:B+ 树索引支持范围扫描和有序扫描,不支持对字段做任意表达式计算后的索引查找。MySQL 8.0 的函数索引虽然有,但维护成本和使用限制决定了它不是首选方案。
关于"冗余日期字段",我再展开一句。假设订单表已经有几百万行,你要做"近 30 天每日订单量"的可视化。你当然可以每天写 SQL 实时查,但更稳的方案是在表里加一列 create_day DATE,应用写入订单时同步填入 DATE(create_time),然后加一个 idx_create_day(create_day)。查询时用 create_day >= '2025-04-01' 范围条件,索引走得很稳。代价只是多一个字段的存储和写入时一点计算量,换来的是报表查询性能的稳定和可预期。这种"以空间换时间"的思路,在处理海量数据的时候远比抠函数写法更有效。
不过要注意,冗余字段要保证数据一致性。如果是已有的历史数据,需要跑一次 UPDATE 回填:
sql复制UPDATE orders SET create_day = DATE(create_time) WHERE create_day IS NULL;
如果这个回填数据量很大,建议分批处理,避免一次性 UPDATE 锁表太久影响线上业务。分批的方式可以按主键范围切段,比如每次处理 1 万行:
sql复制UPDATE orders SET create_day = DATE(create_time)
WHERE id > ? AND id <= ? AND create_day IS NULL;
这也是运维调优里很常见的"小步快跑"策略。
11. 从一个实际的小技巧说起:日期时间函数的默认值、显式默认值和触发器
最后再说一个很多人会忽略的细节。建表时,DATETIME 字段如果想自动写入当前时间,可以用 DEFAULT CURRENT_TIMESTAMP 和 ON UPDATE CURRENT_TIMESTAMP:
sql复制CREATE TABLE orders (
id INT PRIMARY KEY AUTO_INCREMENT,
create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
update_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
);
这样插入数据时不用显式写入 create_time,数据库会自动填入当前时间,update_time 会在每次更新时自动刷新。这个功能在绝大多数业务表里都适用,能省掉应用层手动维护时间的代码。
但要注意几个坑:
- DEFAULT CURRENT_TIMESTAMP 对 TIMESTAMP 是可以的,对 DATETIME 在 MySQL 5.6.5 之后也支持了。如果你的 MySQL 版本比较老,可能 DATETIME 不支持默认值,只能用 TIMESTAMP。
- ON UPDATE CURRENT_TIMESTAMP 是"每次这条记录发生 UPDATE 时自动更新",但有个前提:如果 UPDATE 语句把某个字段的值改成和原来一样,MySQL 不会认为记录更新了,update_time 自然也不会变。这是判断"记录是否真的被改过"的一个小技巧。
- 如果某些业务字段(比如状态字段)经常被更新,但你又不想每次都刷新 update_time,那这个字段就不能用 ON UPDATE CURRENT_TIMESTAMP,需要应用层手动维护一个 logic_update_time 之类的字段。
实际工作中,我建表默认都会带上 create_time 和 update_time,而且都让数据库自己维护。这样做的好处是任何一门后端语言写插入语句时都不用关心时间,减少出错面。对于分库分表场景,这些字段同样建议保留,因为很多数据同步、增量拉取任务会拿 update_time 作为增量字段,没有它,数据对账会非常痛苦。
12. 结合实例:写一份"最近 30 天活跃用户"看板 SQL 的完整推导
这里我想用一个完整的小案例,把前面讲到的函数串起来。假设我们要做一个看板,展示最近 30 天每天的活跃用户数(按 user_id 去重)。表结构:
- id BIGINT
- user_id BIGINT
- login_time DATETIME
- device VARCHAR(32)
第一个版本,新手最容易写成的:
sql复制SELECT DATE_FORMAT(login_time, '%Y-%m-%d') AS login_day, COUNT(DISTINCT user_id)
FROM user_login_log
WHERE DATE_FORMAT(login_time, '%Y-%m-%d') >= DATE_FORMAT(DATE_SUB(NOW(), INTERVAL 29 DAY), '%Y-%m-%d')
GROUP BY DATE_FORMAT(login_time, '%Y-%m-%d');
这个写法在数据量小的时候能跑,但有几个问题:
- WHERE 里对 login_time 套了 DATE_FORMAT,索引失效。
- DATE_FORMAT(login_time) 在 GROUP BY 里重复计算。
- 用户在多个设备上登录,同一天内重复登录,COUNT(DISTINCT user_id) 可以正确去重,但如果有跨天重复登录,按天分组是对的;如果产品要求的是"整个 30 天内去重"的口径,这个 SQL 就完全错了。
正确的写法是:
sql复制SELECT
DATE_FORMAT(login_time, '%Y-%m-%d') AS login_day,
COUNT(DISTINCT user_id) AS active_users
FROM user_login_log
WHERE login_time >= DATE_SUB(CURDATE(), INTERVAL 29 DAY)
AND login_time < DATE_ADD(CURDATE(), INTERVAL 1 DAY)
GROUP BY DATE_FORMAT(login_time, '%Y-%m-%d')
ORDER BY login_day;
改进点:
- WHERE 用 CURDATE() 做起点,左闭右开区间覆盖完整 30 天。这里的 29 天加上今天,正好 30 个自然日。
- 由于 WHERE 已经把数据范围缩小到一个相对可控的区间,GROUP BY 里的 DATE_FORMAT 性能影响就很小了,不用过度优化。
- 如果数据量非常大,仍然建议用冗余 create_day 字段替代 DATE_FORMAT(login_time, '%Y-%m-%d')。
如果还要展示环比,可以再加一个 LAG 函数(MySQL 8.0 及以上版本):
sql复制WITH daily AS (
SELECT DATE_FORMAT(login_time, '%Y-%m-%d') AS login_day,
COUNT(DISTINCT user_id) AS active_users
FROM user_login_log
WHERE login_time >= DATE_SUB(CURDATE(), INTERVAL 29 DAY)
AND login_time < DATE_ADD(CURDATE(), INTERVAL 1 DAY)
GROUP BY DATE_FORMAT(login_time, '%Y-%m-%d')
)
SELECT
login_day,
active_users,
LAG(active_users, 1) OVER (ORDER BY login_day) AS prev_day_users,
active_users - LAG(active_users, 1) OVER (ORDER BY login_day) AS day_over_day
FROM daily;
窗口函数 LAG 可以很方便地算出"前一天的用户数",再相减得到日环比变化。这里注意一点:如果某一天没有数据,active_users 是 0,prev_day_users 可能是 NULL 或上一行值,处理时要根据业务口径决定是否填充为 0。一般报表习惯是把 NULL 显示成 0 或空,在查询里用 COALESCE 处理:
sql复制COALESCE(active_users - LAG(active_users, 1) OVER (ORDER BY login_day), 0) AS day_over_day
这样才能保证前端图表不会出现"空值断线"的情况。
13. 个人踩坑清单:这些错误我几乎每个月都在各种项目里看到
整理一些我平时做代码评审时经常圈的"日期时间函数反模式",给大家做个避坑参考。
- 在 WHERE 条件里对日期字段套函数,导致索引失效。最常见的就是 DATE_FORMAT、DATE()、YEAR()、MONTH()、WEEK()。
- 用等于号判断某一天。
WHERE create_time = '2025-04-01'只能查到零点零分零秒的数据,必须是>= '2025-04-01' AND < '2025-04-02'。 - DATE_FORMAT 的格式符记错,%i 是分钟,%H 是 24 小时制。每年都会有人拿 %M 当分钟用,结果分钟数全部变成 December 这种英文单词,报错的时候才反应过来。
- TIMESTAMP 和 DATETIME 混用,导致时区错乱、2038 年溢出等问题。建议统一用 DATETIME,除非有强时区需求。
- 用字符串比较日期。Python、Java 里拿到 '2025-04-01' 当字符串传给 MySQL,和 DATETIME 字段比较,MySQL 会尝试把字符串转日期。如果字符串格式规范(YYYY-MM-DD HH:MM:SS)还好,不规范就容易出问题,比如 '2025/04/01' 能转,'20250401' 也能转,但某些格式会转成 NULL,直接导致条件失效。
- 忽略 CURDATE() 和 NOW() 的时分秒差异。
WHERE create_time >= CURDATE()是从今天零点开始算,WHERE create_time > NOW()是当前时刻,两者在当天数据统计里差一截,别混用。
这些坑,几乎每个都让我在线上环境付出过代价。尤其是索引失效那条,数据量大了以后,SQL 从毫秒级变成秒级,页面直接卡死,运营同学盯着大屏干着急。这种时候我一般会先查慢查询日志,定位到具体 SQL,再展开看执行计划,基本一眼就能看出是函数套了索引列。
14. 关于 SQL 审核和规范的一些习惯
项目做到一定规模后,一个人怎么写 SQL 不代表整个团队怎么写。我建议在团队内推行几条简单的日期时间规范,能避免大量线上问题:
- 所有表新增日期时间字段时,默认带 create_time 和 update_time,类型 DATETIME,非空,默认 CURRENT_TIMESTAMP。
- 查询"某一天/某月/某时间段"一律用范围条件,禁止用函数包裹字段。
- 需要按天/月/季度统计的报表,优先考虑冗余日期字段,避免在数仓明细表上实时做格式化分组。
- 应用层接 MySQL 时,连接串显式设置 serverTimezone,避免时区差 8 小时。
- SQL Review 时把日期时间相关的检查项单列出来,每周抽查一次慢查询日志。
这些规范看起来简单,但落地之后,我明显感觉线上日期时间相关的 bug 少了很多。尤其是"范围查询不用函数"这一条,对性能提升立竿见影。
说到底,MySQL 的日期时间函数并不难,难的是在真实业务场景下把字段类型、函数选择、索引利用、时区约定串起来,形成一个稳定的体系。函数本身只是工具,怎么组合使用、什么时候避开、什么时候冗余,才是经验所在。
希望这篇整理能帮你在遇到日期时间相关的需求时少走点弯路。如果你在项目里也踩过什么有意思的坑,或者有更好的写法,欢迎一起交流。
