有个线上问题我印象特别深:凌晨定时任务丢了一秒的数据,排查到最后,问题出在我写SQL时用了SYSDATE()而不是NOW()。这两个函数绝大多数场景下看起来完全一样,可一旦任务横跨午夜,语句开始执行的时间点和函数实际执行的时间点就出现了偏差。MySQL的日期时间函数是很多人眼里“最简单、不用专门学”的部分,等线上出了问题才发现,真正坑人的全是类型、时区、格式化边界这些最基础的东西。
这篇文章想把我这些年踩过的日期时间函数相关的坑系统梳理一遍。不管你是写业务SQL的日常开发,是要做报表统计的分析同学,还是正在准备面试,应该都能从里面找到可以直接拿去用的写法和排查思路。内容不追求把官方文档里的函数罗列一遍,而是把生产环境真正高频的部分讲透:DATE、DATETIME、TIMESTAMP三兄弟的区别,当前时间与字段拆分,格式化与解析,日期加减和区间差值,再落到报表分组和几道容易在面试里被追问的细节上。
1. 先搞清DATE、DATETIME、TIMESTAMP三兄弟:日期函数跑偏,多半是类型认知错位
1.1 三种类型的最小生存指南
很多开发写日期函数是从NOW()开始的,写了半年可能都没认真看过表结构里到底用的是DATE、DATETIME还是TIMESTAMP。这个认知缺口平时不爆发,一旦遇到时区、范围、默认值相关的场景,SQL怎么写都不对。
用一张表把这三种核心类型梳理清楚,之后再查资料也有一个基本坐标系。
| 类型 | 存储范围 | 存储空间 | 时区行为 | 适合场景 |
|---|---|---|---|---|
| DATE | 1000-01-01 到 9999-12-31 | 3字节 | 不随时区变化 | 只关心“哪天”,不需要时刻,比如生日、账单日 |
| DATETIME | 1000-01-01 00:00:00 到 9999-12-31 23:59:59 | 8字节 | 不随时区变化,存什么就是什么 | 业务上应该记录本地时间或字面时间的场景 |
| TIMESTAMP | 1970-01-01 00:00:01 UTC 到 2038-01-19 03:14:07 UTC | 4字节 | 底层按UTC存,查询时按会话时区转 | 需要跟着数据库/应用时区自动换算的跨时区场景 |
怎么理解“DATETIME不随时区变化”?我经常举这个例子:你把2024-06-01 12:00:00存进DATETIME,无论在哪个时区的会话里查出来,都是2024-06-01 12:00:00。数据库不关心这个时刻对应UTC是几点,它只负责原样存取。TIMESTAMP不一样,它内部存的是UTC时间戳,查询时MySQL会拿会话的time_zone配置做一次换算。同样存一个时间点,在+08:00会话里查是12点,换到+00:00会话里查就变成凌晨4点。
1.2 8小时时差到底是怎么来的
“为什么我查出来少8小时”是日期时间问题里出现频率最高的求助帖之一。多数情况不是函数用错,而是TIMESTAMP的时区换算链路出了问题。
MySQL的TIMESTAMP类型在存储时会先把会话时区下的时间转成UTC,查询时再转回会话时区。如果你的MySQL实例、连接串、操作系统时区三个环节不在同一个时区,时间就会在某一个环节被“静默平移”。常见场景是用Docker启动MySQL,容器默认时区经常是UTC,而业务方在代码里用了Asia/Shanghai,于是TIMESTAMP字段写入后查出来就会和预期相差8小时。
排查链路可以用下面几步:
sql复制-- 查看当前会话时区
SHOW VARIABLES LIKE 'time_zone';
-- 查看系统时区和UTC偏移
SHOW VARIABLES LIKE 'system_time_zone';
如果发现time_zone是SYSTEM,MySQL会跟着操作系统时区走,容器时区是UTC,你的时间就会比东八区慢8小时。在连接建立后临时修正,可以执行:
sql复制SET time_zone = '+08:00';
需要永久生效,就在配置文件[mysqld]段下加:
ini复制default-time-zone = '+08:00'
注意8.0.19之后default-time-zone仍然可用,但是命名时区需要提前加载MySQL时区表,用+08:00这种偏移量格式通常最省事。
1.3 存量表已经用了TIMESTAMP怎么办
如果表已经建好了,字段类型是TIMESTAMP,历史数据也已经写入,不建议直接改字段类型。ALTER TABLE ... MODIFY COLUMN虽然能转,但转换时具体怎么解释已有值,和当时写入时的时区、会话配置都强相关,一旦处理不当,历史数据会集体偏几个小时。
更稳妥的做法是先确认业务到底需要“记录某个本地时刻”还是“记录某个全球统一的时刻点”。如果业务系统只需要记录订单创建时间,用户和后台都在东八区,那DATETIME往往更直观;如果是一个全球用户都能访问的系统,希望不同地区看到的是各自时区下的同一时刻,TIMESTAMP配合正确的连接时区就是合适的方案。对已经上线的表,我会优先通过下面这条语句观察一下字段在不同时区下的查询结果,再决定是改配置还是改查询逻辑:
sql复制SELECT
ts_col,
CONVERT_TZ(ts_col, '+08:00', '+00:00') AS utc_time
FROM your_table
LIMIT 5;
CONVERT_TZ本身也是常用日期时间函数之一,它不会修改列的值,只是按你指定的两个时区做换算。这种查询在排查时区问题时,比直接在代码里打印时间更可靠。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 拿当前时间与拆字段:NOW、SYSDATE、WEEK这些函数各有分工
2.1 NOW()和SYSDATE()的语句级时差
网上很多文章会把NOW()和SYSDATE()混着说,因为它们返回的格式几乎一样。生产环境里这两者有本质区别:
NOW()返回的是当前语句开始执行的时间点。一条SQL不管跑多久,内部的NOW()都保持一致。SYSDATE()返回的是该函数实际被执行到那一刻的时间点。
这个区别在单条快速查询里根本看不出差异,但遇到两种情况就会踩坑。第一种是凌晨批处理任务,一条SQL执行时间横跨了零点,NOW()会稳定地用语句启动那一刻的时间,SYSDATE()则会因为SQL执行到中间,时间已经跳到后一秒,导致边界数据被算漏或算重。第二种是在一条INSERT SELECT里批量插入多行,如果写入的值来自SYSDATE(),不同行可能拿到不同的秒级时间,数据看起来就像“时间漂移”。
想直观观察差异,可以在同一会话里执行:
sql复制SELECT NOW(), SLEEP(2), SYSDATE();
一般能看到第二个时间比第一个大约晚2秒。这里NOW()不会因为SLEEP而推进,SYSDATE()会。这也是为什么在批量写数据、审计字段、报表计算这些场景里,我默认使用NOW()或CURRENT_TIMESTAMP而不是SYSDATE()。
2.2 从时间字段里取年、月、日、时、分、秒
拿到一个日期时间值之后,第一件事往往是拆字段。MySQL提供了一组很直接的提取函数:
sql复制SELECT
NOW() AS now_time,
DATE(NOW()) AS only_date,
TIME(NOW()) AS only_time,
YEAR(NOW()) AS year_part,
MONTH(NOW()) AS month_part,
DAY(NOW()) AS day_part,
HOUR(NOW()) AS hour_part,
MINUTE(NOW()) AS minute_part,
SECOND(NOW()) AS second_part;
这些函数比较好理解,直接返回日期的对应部分。要提醒的是,DATE()和前文说的DATETIME类型是两个概念,DATE()是函数,负责把带时间的值截断成纯日期;DATE是类型,表示只存储日期。别在文档里看到DATE就以为是在说同一件事。
还有两组容易混淆的写法:
sql复制SELECT
DAYOFMONTH('2024-06-15') AS day_of_month, -- 15
DAYOFYEAR('2024-06-15') AS day_of_year, -- 167
DAYOFWEEK('2024-06-15') AS day_of_week; -- 7,周日是1,周六是7
DAYOFWEEK的返回结果是从1到7,1代表周日,这个和美国人的周历习惯有关,国内业务要判断“今天是周几”时经常被它坑到。如果希望周一作为一周的第一天,用WEEKDAY()更顺手,它返回0到6,0代表周一,6代表周日。
2.3 WEEK函数的mode参数和几个“冷门但好用”的函数
WEEK()函数很多人都见过,但很少有人认真研究第二个参数mode。同一个日期,mode不同,返回的周数可能差出整整一周,尤其在跨年边界容易出现“第0周”还是“第53周”的争议。
sql复制SELECT
WEEK('2024-01-01', 0) AS week_sun_start,
WEEK('2024-01-01', 1) AS week_mon_start;
mode=0代表周日作为一周的第一天,mode=1代表周一作为一周的第一天。国内业务统计如果按自然周对齐,绝大多数情况的预期是周一到周日算一周,建议统一用mode=1,并且在SQL注释里写明这个约定,否则下一任维护的人看到两个数字会非常懵。
除了WEEK,下面这几个函数在报表场景里也很有用,只是平时容易被忽略:
sql复制SELECT
QUARTER('2024-06-15') AS quarter_part, -- 2,代表第二季度
LAST_DAY('2024-06-15') AS month_last_day, -- 2024-06-30,当月最后一天
YEARWEEK('2024-06-15', 1) AS year_week_no; -- 年份和周数组合
YEARWEEK()返回的是一个数字,比如202424,表示2024年的第24周。在按周聚合并想保持周排序时,比单独用YEAR()和WEEK()两个字段更省事。
3. DATE_FORMAT与STR_TO_DATE:格式化占位符、字符串转日期、老int表时间戳
3.1 占位符就是一张对照表,重点记容易混的几组
DATE_FORMAT是MySQL里使用频率最高的日期时间函数之一,几乎每个报表SQL都会出现。它的基本用法是把日期时间按指定格式转成字符串:
sql复制SELECT DATE_FORMAT('2024-06-15 14:30:45', '%Y-%m-%d %H:%i:%s');
-- 结果:2024-06-15 14:30:45
新手最常见的困境是记不住占位符,我整理了一张高频对照表,平时写SPL业务时放在旁边对照。
| 占位符 | 含义 | 示例 |
|---|---|---|
| %Y | 4位年份 | 2024 |
| %y | 2位年份 | 24 |
| %m | 2位月份 | 06 |
| %c | 月份,不补零 | 6 |
| %d | 2位日期 | 15 |
| %e | 日期,不补零 | 6 |
| %H | 24小时制 | 14 |
| %h | 12小时制 | 02 |
| %i | 2位分钟 | 30 |
| %s | 2位秒 | 45 |
| %W | 英文星期名 | Saturday |
| %a | 英文星期缩写 | Sat |
| %M | 英文月份名 | June |
| %b | 英文月份缩写 | Jun |
| %p | AM或PM | PM |
| %j | 一年中的第几天 | 167 |
| %U | 周数,周日为一周起点 | 23 |
| %u | 周数,周一为一周起点 | 24 |
哪些容易混?一个是%H和%h,前者是24小时制,后者是12小时制。下午两点想显示成“14:00”,写错成%h就会得到“02:00”。另一个是%i和%m,分钟是%i不是%m,月份才是%m。把DATE_FORMAT(now(), '%Y-%m-%d %H:%i:%s')写成%H:%m:%s,分钟位会直接变成月份数字,线上排查过的人应该都记得这种离谱现象。
3.2 字符串转日期:对不上格式返回NULL,不会报错
字符串转日期是另一条主链路。MySQL对标准格式字符串有很强的隐式转换能力,比如把'2024-06-15 14:30:45'直接拿来和DATETIME列比较,通常没问题。但只要你处理的是用户上传、Excel导入、第三方接口返回的日期字符串,格式就可能变成20240615、2024/06/15、15/06/2024 14:30这些五花八门的样子。
推荐的做法是使用STR_TO_DATE()显式解析:
sql复制SELECT
STR_TO_DATE('15/06/2024 14:30', '%d/%m/%Y %H:%i') AS parsed_datetime,
STR_TO_DATE('20240615', '%Y%m%d') AS parsed_date;
这里要特别提醒:STR_TO_DATE()遇到解析不了或者日期不合法时,往往不是直接报错,而是返回NULL并产生一个warning。比如STR_TO_DATE('2024-02-30', '%Y-%m-%d'),2月30日本身不存在,函数会返回NULL。这在批量导入数据的场景里非常危险,因为你可能导入了一堆“静默丢失”的行。
所以在用STR_TO_DATE做数据清洗时,可以先执行一条SELECT验证返回结果里有几个NULL,再决定要不要直接入库:
sql复制SELECT
COUNT(*) AS total_rows,
COUNT(STR_TO_DATE(raw_date, '%Y-%m-%d')) AS valid_rows
FROM stage_table;
如果valid_rows明显小于total_rows,就说明原字符串里有脏数据,需要进行归一化处理,不能直接往里灌。
3.3 老业务里int时间戳的互换
存量系统里经常见到用int或bigint存时间戳的表,可能是历史原因,也可能是当初为了省存储空间、方便排序。这类表和日期时间函数打交道时,主要会用到两个函数:
sql复制-- 整数时间戳转可读时间
SELECT FROM_UNIXTIME(created_at) FROM order_table LIMIT 1;
-- 可读时间转整数时间戳
SELECT UNIX_TIMESTAMP('2024-06-15 14:30:45');
FROM_UNIXTIME()的输出会受会话时区影响,服务器和客户端时区不一致时,同一串时间戳显示出来的本地时间可能不同。所以排查相关问题时,不要只看一条SQL的返回,要看会话的time_zone配置。
UNIX_TIMESTAMP()在把日期字符串转成时间戳时,同样会按会话时区解释这个字符串。如果你的MySQL连接串没有明确指定serverTimezone或connectionTimeZone,Java应用连数据库时经常会出现“传入的本地时间被当成UTC再转回东八区”的偏差。如果项目里已经有这种老int表,我的建议是在SQL查询中只在右侧做时间戳和可读时间的转换,让索引列保持裸列。
3.4 一次下午时间显示成凌晨的排查小记
有一次我帮同事排查一个前端时间显示问题,页面上展示的活动时间是凌晨02:30,但业务方坚持说后台录入的是下午14:30。数据表里存的DATETIME值是对的,直接命令行查询也显示2024-06-15 14:30:00,问题出在接口层的查询SQL写成了:
sql复制SELECT DATE_FORMAT(activity_time, '%Y-%m-%d %h:%i') AS show_time FROM activity;
%h是12小时制,下午2点自然显示成“02”。加上前端又拼了个“凌晨”的文案,时间就彻底错了。改成%H:%i之后显示恢复正常。
这种问题函数本身没报错,数据也没坏,纯粹是格式化占位符用错了位置。所以每当你看到某个时间显示和预期差12小时、差8小时,不要先怀疑数据库和时区,先看一眼SQL里的格式化占位符是不是混用了12小时制和24小时制。
