我从实际业务里最常碰到的几个场景开始讲:报表导出的日期格式不对、前端展示的日期带了一堆时分秒、按天分组统计出来的结果对不齐、还有从接口里拿到时间戳需要转成可读格式。这些东西几乎是每个用 MySQL 的团队都会遇到的,但网上的教程要么只给一个函数列表,要么就是扔一句“用 DATE_FORMAT”就完事,根本没说清楚在什么场景下用哪种写法最合适,以及为什么。
这篇就把我在项目里反复用、也反复踩坑的 MySQL 日期格式化方法整理成一条线:从基础格式化、字符串互转、时间戳处理,到日期运算、分组统计的对齐问题,再加上一堆性能敏感时的替代写法,尽量每种都给到能直接抄的示例和背后的理由。
1. DATE_FORMAT 是主力,但格式符的坑比想象中多
先开宗明义地说一句:DATE_FORMAT() 是 MySQL 里处理日期格式化最核心的函数,没有之一。它能把你手头的日期时间值(DATETIME、TIMESTAMP、DATE 都行)按照你指定的格式符拼成一个字符串,这个字符串再用作报表列、统计分组、导出文件名等等。
1.1 格式符完整对照表
很多新手最困惑的就是 %Y 和 %y 的区别、%m 和 %c 的区别、%H 和 %h 的区别。我把常用的格式符按用途整理成一张表,建议直接存下来当速查卡用。
| 格式符 | 含义 | 示例值 |
|---|---|---|
%Y |
四位年份 | 2025 |
%y |
两位年份 | 25 |
%m |
两位月份,不足补零 | 01、12 |
%c |
月份,不补零 | 1、12 |
%d |
两位日,不足补零 | 05、31 |
%e |
日,不补零 | 5、31 |
%H |
24小时制,两位 | 00、23 |
%k |
24小时制,不补零 | 0、23 |
%h |
12小时制,两位 | 01、11 |
%l |
12小时制,不补零 | 1、11 |
%i |
分钟,两位 | 05、59 |
%s |
秒,两位 | 07、59 |
%f |
微秒,六位 | 000123 |
%W |
星期几英文全称 | Monday |
%a |
星期几英文缩写 | Mon |
%w |
一周中的第几天,0是周日 | 0-6 |
%j |
一年中的第几天 | 001-366 |
%p |
AM 或 PM | AM |
%T |
等价于 %H:%i:%s |
23:05:07 |
%r |
12小时制时间 | 11:05:07 PM |
实际项目里最常用的组合是 '%Y-%m-%d %H:%i:%s',这对应 2025-01-31 23:05:07,俗称“标准时间格式”。导出 Excel 的时候推荐用 '%Y%m%d_%H%i%s',比如 20250131_230507,这种格式在文件名里没有冒号和空格,在 Windows 和 Linux 系统中都不会出问题。
1.2 DATE_FORMAT 的几个实战示例
语法本身很简单:DATE_FORMAT(date, format),第一个参数是日期时间值,第二个参数是格式字符串。看几个真实查询:
sql复制-- 最基本的日期部分提取
SELECT DATE_FORMAT(NOW(), '%Y-%m-%d') AS today;
-- 结果:2025-01-31
-- 中文报表里常见的"2025年01月31日"样式
SELECT DATE_FORMAT(NOW(), '%Y年%m月%d日') AS cn_date;
-- 结果:2025年01月31日
-- 只要时分秒
SELECT DATE_FORMAT(NOW(), '%H:%i:%s') AS time_part;
-- 结果:23:05:07
-- 季度,这个用格式符做不了,需要配合 QUARTER() 函数
SELECT CONCAT(DATE_FORMAT(NOW(), '%Y'), '年Q', QUARTER(NOW())) AS quarter_label;
-- 结果:2025年Q1
这里要提醒一个非常容易踩的坑:格式串里的非格式符字符,比如空格、横杠、冒号、中文汉字,MySQL 会原样输出。很多人以为 %Y 和 %y 写作 %yyyy 就能得到四位年份,这就错了,%yyyy 会被解析成 %y 加上两个多余的字符 yy,结果变成 25yy 这种奇怪的东西。这一点在代码评审里我见过无数次。
注意:
DATE_FORMAT()的返回类型是字符串(VARCHAR),不是日期类型。这意味着你一旦对某个日期列做了 DATE_FORMAT,它就不能再参与日期运算、不能直接比较大小(除非你再次用STR_TO_DATE转回来)、在 WHERE 条件里还会让索引失效。后面第 6 部分会专门讲这个性能问题。
1.3 顺手把 NOW、CURDATE、CURTIME 的区别说清楚
很多人分不清这几个“当前时间”函数。我直接在项目里就是因为这个问题排查了半天才发现数据对不上。
NOW():返回当前日期时间,格式是2025-01-31 23:05:07,DATETIME 类型。CURDATE():返回当前日期,2025-01-31,DATE 类型。CURTIME():返回当前时间,23:05:07,TIME 类型。SYSDATE():也是当前日期时间,但它与NOW()有个关键差异——NOW()取的是语句开始执行的时间点,SYSDATE()取的是函数被调用那一刻的时间点。在一条慢 SQL 里,如果两个地方调用 NOW(),结果一样;但调用 SYSDATE(),可能两次结果就不一样。
生产环境里,如果你在一条执行了十几秒的 UPDATE 语句中用了 SYSDATE() 来更新时间戳,那么同一语句内不同行被更新的时间可能不同,这在审计和账单场景里是绝对不允许的。所以统一的规范是:全部用 NOW(),只有在明确需要“从语句开始到执行结束期间动态取当前时间”的变态需求时才用 SYSDATE()。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 字符串与日期的双向转换:STR_TO_DATE 是反向操作的核心
有正向格式化,就必然有反向解析。业务上最典型的场景就是:接口或者 Excel 导入给你一个字符串日期,比如 "2025/01/31" 或者 "31-01-2025",你得先把它转成 DATE 或 DATETIME 类型才能存库、参与运算、和别的日期做比较。
STR_TO_DATE(str, format) 是 DATE_FORMAT 的逆函数,它按格式符把字符串中的日期信息解析出来,返回对应的日期时间值。值得注意的是,如果字符串里带了时间信息,返回的就是 DATETIME;只有日期信息,返回的就是 DATE。
sql复制-- 标准格式解析
SELECT STR_TO_DATE('2025-01-31', '%Y-%m-%d');
-- 结果:2025-01-31
-- 解析斜杠分隔的日期
SELECT STR_TO_DATE('2025/01/31', '%Y/%m/%d');
-- 结果:2025-01-31
-- 解析带时间部分的字符串
SELECT STR_TO_DATE('2025-01-31 23:05:07', '%Y-%m-%d %H:%i:%s');
-- 结果:2025-01-31 23:05:07
-- 解析"日-月-年"这种欧洲风格
SELECT STR_TO_DATE('31-01-2025', '%d-%m-%Y');
-- 结果:2025-01-31
你以为到这就能高枕无忧了,不对。STR_TO_DATE 在解析失败时的行为容易让人栽跟头:它返回 NULL 并产生一个 Warning,但不会报错。比如用户传了一个 '2025-02-31',2 月没有 31 号,MySQL 会给你 NULL,而不是抛异常。如果你的代码没检查返回值,后面用到这个 NULL 去插库或者做条件判断,就会带出一堆莫名其妙的业务问题。
所以实际项目里我会建议做两层校验:
sql复制-- 第一层:解析前先确认字符串是否匹配规范的正则
SELECT '2025-02-31' REGEXP '^[0-9]{4}-[0-9]{2}-[0-9]{2}$' AS is_valid_format;
-- 结果:1,格式对,但日期不一定合法
-- 第二层:交给 STR_TO_DATE 解析时,如果结果是 NULL,说明日期不合法
SELECT STR_TO_DATE('2025-02-31', '%Y-%m-%d') IS NOT NULL AS is_valid_date;
-- 结果:0,说明日期非法
这两层都过了,才能放心入库。如果是导入场景,我会在存储过程或者应用层再加一个计数,把解析失败的原始行记录下来,方便对账和反馈给上游。
2.1 CAST 和 CONVERT:轻量场景的备选方案
如果只是把 '2025-01-31' 这种标准格式字符串转成日期,用 STR_TO_DATE 有点“杀鸡用牛刀”,用 CAST 或者 CONVERT 就够了,代码更简洁。
sql复制SELECT CAST('2025-01-31' AS DATE);
-- 结果:2025-01-31
SELECT CAST('2025-01-31 23:05:07' AS DATETIME);
-- 结果:2025-01-31 23:05:07
SELECT CONVERT('2025-01-31', DATE);
-- 结果:2025-01-31
但注意,CAST 和 CONVERT 对格式是有要求的,它们只认 ISO 标准格式(YYYY-MM-DD 或 YYYY-MM-DD HH:MM:SS)。如果你传 '2025/01/31' 或 '31-01-2025' 进去,在严格模式下会报错,非严格模式下可能返回 0000-00-00 或者 NULL。所以收到非标准格式字符串时,老老实实用 STR_TO_DATE 指定格式解析,别偷懒。
顺便提一句,MySQL 的 CONVERT() 还有个 CHAR 转换的用法,语法是 CONVERT(expr USING transcoding_name),比如转字符集用。这和日期的 CONVERT 不是一回事,别搞混。日期格式化场景里我们用到的主要还是 CONVERT(expr, type) 这种两参数形式。
2.2 DATETIME、DATE、TIMESTAMP 三种类型的区别
写格式化之前,必须先搞清楚你手里的列是什么类型,因为不同类型在存储、读取、时区处理上完全不同。
| 类型 | 存储空间 | 范围 | 时区 | 默认值 |
|---|---|---|---|---|
| DATE | 3 字节 | 1000-01-01 至 9999-12-31 | 不含 | 无 |
| DATETIME | 8 字节 | 1000-01-01 00:00:00 至 9999-12-31 23:59:59 | 不含 | 无 |
| TIMESTAMP | 4 字节 | 1970-01-01 00:00:01 UTC 至 2038-01-19 03:14:07 UTC | 包含 | 随系统时区转换 |
这里最容易出现的问题就是误用 TIMESTAMP。TIMESTAMP 类型在存储时会从当前会话时区转换成 UTC,读取时再从 UTC 转回当前会话时区。如果你的应用服务器和数据库服务器时区设置不一致,同一行数据在不同机器上查出来可能差 8 个小时。DATETIME 类型则完全不涉及时区转换,存什么就是什么,比较适合跨时区的业务场景。
在格式化输出时,TIMESTAMP 会先按当前会话时区转成当地时间再格式化,而 DATETIME 直接按存储值格式化。这就是为什么同一张表里不同列格式化出来时间不一致时,要先检查列类型,而不是急着怀疑 SQL 写错了。
提示:如果是新设计的表,建议业务时间列优先选 DATETIME,除非你确实需要“在不同数据库时区下自动转换显示同一 UTC 时刻”这种特性。选类型远比写格式化函数更根本。
3. 时间戳处理:业务系统里最常见的边界问题
很多老系统或者接口对接场景里,时间是用整数时间戳存的,比如 1738314307 这种,代表从 1970-01-01 00:00:00 UTC 到某个时刻经过的秒数。格式化之前必须先转成日期类型。反过来,你把一个日期值传给前端时,也可能要先转成时间戳。
3.1 FROM_UNIXTIME:时间戳转日期字符串
FROM_UNIXTIME(unix_timestamp, format) 把一个整数时间戳转换成日期字符串。第二个参数可以省略,省略时返回 DATETIME 类型的默认格式;也可以给格式符,作用和 DATE_FORMAT 里一致。
sql复制-- 时间戳 1738314307 对应的是 2025-01-31 23:05:07(UTC+8)
SELECT FROM_UNIXTIME(1738314307);
-- 结果:2025-01-31 23:05:07
SELECT FROM_UNIXTIME(1738314307, '%Y-%m-%d %H:%i:%s');
-- 结果:2025-01-31 23:05:07
SELECT FROM_UNIXTIME(1738314307, '%Y年%m月%d日');
-- 结果:2025年01月31日
注意 FROM_UNIXTIME 的结果是跟随当前会话时区的。如果你的 MySQL 会话时区是 UTC,那么 FROM_UNIXTIME(1738314307) 会返回 2025-01-31 15:05:07。生产环境里为了避免团队内成员因为各自客户端时区不同导致看到不一样的日期,建议统一在初始化连接时执行 SET time_zone = '+08:00',或者直接约定数据库全局时区是某个固定值。
3.2 UNIX_TIMESTAMP:日期转时间戳,注意毫秒陷阱
反向操作是 UNIX_TIMESTAMP(date),对 DATE、DATETIME、TIMESTAMP 类型或者合法格式的日期字符串都可以使用。
sql复制SELECT UNIX_TIMESTAMP('2025-01-31 23:05:07');
-- 结果:1738314307
SELECT UNIX_TIMESTAMP(NOW());
-- 结果:当前时间对应的秒级时间戳
但这里有个非常经典的坑:如果你要转的时间戳是毫秒级(13 位),比如 1738314307123,直接用 FROM_UNIXTIME 会得到一个非常不合理的日期,因为 1738314307123 秒数远超当前时间的数量级。正确做法是先除以 1000。反过来,如果你想把当前时间转成毫秒级时间戳,需要用 UNIX_TIMESTAMP(NOW(3)) * 1000,这个 NOW(3) 里的 3 表示保留三位小数秒。
sql复制-- 毫秒时间戳转可读日期
SELECT FROM_UNIXTIME(1738314307123 / 1000, '%Y-%m-%d %H:%i:%s.%f');
-- 结果:2025-01-31 23:05:07.123000
-- 当前时间转毫秒时间戳
SELECT UNIX_TIMESTAMP(NOW(3)) * 1000;
业务上经常有人在 NOW() 里加参数,NOW(3) 和 NOW(6) 分别表示保留 3 位和 6 位小数秒,也就是毫秒和微秒精度。但是注意,如果你把 NOW(3) 直接存到 DATETIME(0) 列里,小数部分会被截断掉,这个特性在存储设计时也要规划好,到底是建 DATETIME(3) 列还是直接转成字符串统一处理。
3.3 时间戳的标准检查和边界值
接第三方接口时,我还会习惯性先确认对方的秒级时间戳是否带符号、是不是 10 位。有些接口调皮,给你的是字符串 "1738314307",你直接用 FROM_UNIXTIME 虽然也能转,但因为类型转换问题,性能上是吃亏的,而且排查问题时很困惑。建议在入库前统一用 CAST(... AS UNSIGNED) 转成整数再处理:
sql复制SELECT FROM_UNIXTIME(CAST('1738314307' AS UNSIGNED), '%Y-%m-%d %H:%i:%s');
另外,2038 年问题虽然老生常谈,但在 TIMESTAMP 类型和 4 字节整型时间戳场景下依然是真实的——2038-01-19 03:14:07 UTC 之后,4 字节有符号整数时间戳会溢出。如果你的系统还在用 TIMESTAMP 列存业务时间,趁早改造,别拖。
4. 日期运算:格式化只是前菜,真正干活的是运算
日期格式化更多是“展示层”的事情,真正在业务逻辑里算账、做筛选时,经常需要日期加减、求两个日期差、找月初月末。所以我把这部分也算进“实用系列”,因为很少有人会只格式化不做运算。
4.1 DATE_ADD、DATE_SUB 和 INTERVAL
DATE_ADD(date, INTERVAL expr unit) 和 DATE_SUB(date, INTERVAL expr unit) 是最常用的日期加减函数。单位支持 YEAR、MONTH、DAY、HOUR、MINUTE、SECOND,也支持组合。
sql复制-- 明天
SELECT DATE_ADD(CURDATE(), INTERVAL 1 DAY);
-- 结果:2025-02-01
-- 三个月前
SELECT DATE_SUB(CURDATE(), INTERVAL 3 MONTH);
-- 结果:2024-10-31(注意,这里 MySQL 会做溢出处理到当月最后一天)
-- 90 分钟前
SELECT DATE_SUB(NOW(), INTERVAL 90 MINUTE);
-- 也可以直接用加减号写法,效果一样
SELECT NOW() + INTERVAL 1 DAY;
SELECT NOW() - INTERVAL 1 HOUR;
这里要特别讲一下“月份加减”的溢出规则。比如你是 2025-01-31,减一个月,MySQL 会给你 2024-12-31 吗?不,它给的是 2024-12-31 还是 2024-12-30?实际上如果是 2025-03-31 减一个月,结果是 2025-02-28,因为 2 月没有 31 号,MySQL 自动裁剪到当月最后一天。这个行为在某些业务场景下是合理的,但在账单、合同续期这类必须精确的场景下就是致命的。比如每月 31 号扣款的合同,遇到小月就挪到月末,次月又回到 31 号,客户就会困惑。
所以遇到这种“月末对齐”需求,我的做法是:先判断是否月底,再决定加减。通常要么返回 NULL 让业务层处理,要么采用“按月对齐到固定日”算法,用 DATE_FORMAT 取出年份月份,拼接成目标日再转回日期。
4.2 DATEDIFF 与 TIMESTAMPDIFF 的差异
计算两个日期之间差了多少天,首选 DATEDIFF(end_date, start_date),它返回天数差,只看日期部分,忽略时间部分。
sql复制SELECT DATEDIFF('2025-02-01', '2025-01-31');
-- 结果:1
-- 注意它忽略时间,所以下面这个也是 1 而不是 0
SELECT DATEDIFF('2025-02-01 00:00:00', '2025-01-31 23:59:59');
-- 结果:1
如果你要计算“两个时间点之间精确差了多少小时、多少分钟”,DATEDIFF 就不够用了,得用 TIMESTAMPDIFF(unit, start_date, end_date),它支持 SECOND、MINUTE、HOUR、DAY、WEEK、MONTH、QUARTER、YEAR 等单位。
sql复制-- 两个时间点之间差多少小时
SELECT TIMESTAMPDIFF(HOUR, '2025-01-31 20:00:00', '2025-01-31 23:05:00');
-- 结果:3
-- 月数差
SELECT TIMESTAMPDIFF(MONTH, '2024-10-01', '2025-01-31');
-- 结果:3
-- 年龄计算
SELECT TIMESTAMPDIFF(YEAR, '1990-05-15', CURDATE());
注意 DATEDIFF 和 TIMESTAMPDIFF 的参数顺序是反的:DATEDIFF 是 (end, start) 即“第二个减第一个”;TIMESTAMPDIFF 也是 (end, start) 即“后面的减前面的”。这一点容易记混,我建议在语句里加上注释,避免三个月后自己都看不懂。
4.3 本月、上月末、季度初:用 LAST_DAY 和拼接技巧
实际做报表时,经常需要“本月第一天”“上个月最后一天”“本季度第一天”这种边界日期。MySQL 里 LAST_DAY(date) 可以返回某日所在月份的最后一天,用得非常多。
sql复制-- 本月第一天
SELECT DATE_FORMAT(CURDATE(), '%Y-%m-01');
-- 结果:2025-02-01(注意这里直接拼接字符串)
-- 本月最后一天
SELECT LAST_DAY(CURDATE());
-- 结果:2025-02-28
-- 上个月最后一天
SELECT LAST_DAY(DATE_SUB(CURDATE(), INTERVAL 1 MONTH));
-- 结果:2025-01-31
-- 上个月第一天
SELECT DATE_FORMAT(DATE_SUB(CURDATE(), INTERVAL 1 MONTH), '%Y-%m-01');
-- 结果:2025-01-01
-- 本季度第一天
SELECT DATE_FORMAT(DATE_SUB(CURDATE(), INTERVAL (MONTH(CURDATE()) - 1) % 3 MONTH), '%Y-%m-01');
-- 结果:2025-01-01
这里有个非常实用的小技巧:用 DATE_FORMAT 把日期拼到每月 1 号,能规避月份天数不齐带来的各种边界问题,比“先减天数再算”可靠得多。季度第一天那个表达式,(MONTH(CURDATE()) - 1) % 3 计算的是当前月距季度首月过了几个月,再用 DATE_SUB 减去对应月份数,就能回到季度首月,逻辑清晰也容易扩展到“季度末”。
4.4 日期格式化与运算组合的典型报表语句
把这些组合起来,一个典型的月度报表分区条件就可以这样写:
sql复制-- 查询上月整月的数据
SELECT
DATE_FORMAT(create_time, '%Y-%m-%d') AS day,
COUNT(*) AS order_cnt,
SUM(amount) AS total_amount
FROM orders
WHERE create_time >= DATE_FORMAT(DATE_SUB(CURDATE(), INTERVAL 1 MONTH), '%Y-%m-01')
AND create_time < DATE_FORMAT(CURDATE(), '%Y-%m-01')
GROUP BY DATE_FORMAT(create_time, '%Y-%m-%d');
注意这里的边界条件用的是“大于等于月初”和“小于下月初”,这是日期查询里最推荐的区间写法,能正确覆盖上月完整自然月的数据,也避免了 <= LAST_DAY(...) 这种写法可能漏掉当月最后一天 23:59:59 以后的那一秒钟数据。
5. 按日期分组统计:格式化与补零,是报表最常见的诉求
如果说前面的函数都是单个值处理,那么“按日期分组”就是日期格式化在统计分析中最重要的应用场景。比如订单表按天统计、日志表按小时统计、用户表按周统计。这里有几个坑不踩一次,你是不会意识到的。
5.1 按天分组的标准写法
最简单的按天分组直接对日期列做 DATE_FORMAT 然后 GROUP BY:
sql复制SELECT
DATE_FORMAT(create_time, '%Y-%m-%d') AS day,
COUNT(*) AS cnt
FROM orders
WHERE create_time >= '2025-01-01 00:00:00'
AND create_time < '2025-02-01 00:00:00'
GROUP BY DATE_FORMAT(create_time, '%Y-%m-%d')
ORDER BY day;
但问题来了:如果某一天一单都没有,这一天的记录在结果里根本不会出现。这在报表上是有问题的——运营会问“1月15号为什么是空的”,你没法回答“因为没数据”。所以报表场景通常需要“补零”或者“填洞”。
填洞最简单粗暴的办法是利用一个数字表或者递归 CTE 把所有日期生成出来,再左连接聚合结果。MySQL 8.0 支持递归 CTE,可以这样写:
sql复制WITH RECURSIVE date_range AS (
SELECT '2025-01-01' AS d
UNION ALL
SELECT DATE_ADD(d, INTERVAL 1 DAY)
FROM date_range
WHERE d < '2025-01-31'
)
SELECT
d AS day,
COALESCE(t.cnt, 0) AS cnt
FROM date_range dr
LEFT JOIN (
SELECT
DATE_FORMAT(create_time, '%Y-%m-%d') AS day,
COUNT(*) AS cnt
FROM orders
WHERE create_time >= '2025-01-01 00:00:00'
AND create_time < '2025-02-01 00:00:00'
GROUP BY DATE_FORMAT(create_time, '%Y-%m-%d')
) t ON dr.d = t.day
ORDER BY day;
在 8.0 之前的版本(5.7 及更早),没有递归 CTE,一般靠内存临时表或者一个冗余的数字表来生成日期序列。5.7 里如果不想建数字表,可以用 information_schema 里现成的表做笛卡尔积,但那是脏活累活,如果能升级到 8.0 建议优先用递归 CTE,代码可读性高一个量级。
5.2 按小时分组的注意点
除了按天,统计接口日志、服务器监控数据时常常要按小时分组。此时建议把时间调整到整点对齐:
sql复制SELECT
DATE_FORMAT(create_time, '%Y-%m-%d %H:00:00') AS hour_slot,
COUNT(*) AS cnt
FROM access_log
WHERE create_time >= '2025-01-31 00:00:00'
AND create_time < '2025-02-01 00:00:00'
GROUP BY DATE_FORMAT(create_time, '%Y-%m-%d %H:00:00')
ORDER BY hour_slot;
这里的 %H:00:00 非常巧妙,它把 2025-01-31 23:05:07 变成 2025-01-31 23:00:00,这样同一小时内的记录可以归到一个分组里。你也可以先加一个 DATE_FORMAT(create_time, '%Y-%m-%d %H') 再拼接字符串,效果相同。
如果你追求极致的性能,可以考虑用 UNIX_TIMESTAMP(create_time) DIV 3600 * 3600 做整点对齐再转回字符串,这样分组键更快,因为整数除法比字符串格式化开销小得多。但这个写法可读性稍差,我一般只在数据量特别大的时候优化。
5.3 按周分组的两种口径
按周统计有个经典坑:周一作为一周的第一天,还是周日作为一周的第一天?MySQL 的 WEEK(date, mode) 和 YEARWEEK(date, mode) 中的 mode 参数就是控制这个口径的。
sql复制-- mode 1:周一作为一周第一天,返回 1-53 周
SELECT WEEK('2025-01-31', 1);
-- mode 0:周日作为一周第一天,返回值可能为 0
SELECT WEEK('2025-01-31', 0);
-- 推荐用 YEARWEEK 同时取出年份和周数,避免跨年周混淆
SELECT YEARWEEK(create_time, 1) AS year_week, COUNT(*) AS cnt
FROM orders
GROUP BY YEARWEEK(create_time, 1);
如果不带 mode 参数,MySQL 默认按 default_week_format 系统变量处理,不同的服务器配置可能不一样。所以在跨环境迁移或者多人协作时,必须显式指定 mode 参数,否则同一套 SQL 在测试和生产上统计结果可能不同,这是个非常隐蔽的问题。
5.4 分组统计时别忘掉 WHERE 的索引问题
在上面的按天/按小时/按周示例里,我特意把 WHERE 条件写成了 create_time >= ... AND create_time < ... 的范围条件,而不是在 WHERE 里对 create_time 做 DATE_FORMAT 后再比较。
举个例子,下面这个查询在表数据量大时,会走全表扫描:
sql复制-- 性能较差:WHERE 里对日期列包了函数,索引会失效
SELECT DATE_FORMAT(create_time, '%Y-%m-%d') AS day, COUNT(*)
FROM orders
WHERE DATE_FORMAT(create_time, '%Y-%m-%d') BETWEEN '2025-01-01' AND '2025-01-31'
GROUP BY day;
而把条件改成范围查询后,如果 create_time 列上有索引,MySQL 就能走索引 range scan,性能差距在百万级数据量上可以是非常明显的。GROUP BY 子句里允许使用 SELECT 别名 day,但 WHERE 子句里不能引用别名,所以只能硬着头皮在 WHERE 里写原列。正确写法就是我前面给的那种范围条件。
6. 性能防线:格式化函数在 WHERE、JOIN、排序里暗藏的雷
这个部分我特意放到比较靠后,因为它是“日期格式化”从入门到进阶的分水岭。很多人函数用得溜,但一到生产环境 SQL 就慢,一个隐藏原因就是随意在 WHERE 和 JOIN 条件里包函数。
6.1 为什么 WHERE 里用函数会导致索引失效
B+ 树索引的搜索依赖“值比较”。当你在索引列上套一层 DATE_FORMAT 或者 YEAR() 之类的函数后,MySQL 没办法直接利用索引上的有序值进行区间搜索,因为索引里存的是原始值,不是格式化后的字符串。它只能把每个索引值都取出来,先算一遍函数,再去比较结果,这就退化成全表扫描或者全索引扫描。
实际项目里最常见的写法就是:
sql复制-- 错误示范:查某天订单
WHERE DATE_FORMAT(create_time, '%Y-%m-%d') = '2025-01-31';
-- 正确示范:范围条件
WHERE create_time >= '2025-01-31 00:00:00'
AND create_time < '2025-02-01 00:00:00';
如果 create_time 是 DATE 类型(没有时间部分),写法可以简化为:
sql复制WHERE create_time = '2025-01-31';
如果 create_time 是 DATETIME,且你确实只想筛选某一天,就用 >= 当天 0 点 AND < 次日 0 点 这个区间。这样写不仅性能好,语义上也完全正确,还不会漏掉当天最后一秒的数据。
6.2 JOIN 条件里的函数也一样危险
不只是 WHERE,JOIN 的 ON 条件里如果对日期列做了格式化再关联,同样会导致索引失效。比如:
sql复制-- 错误示范:按日期字符串关联两张表
FROM orders o
JOIN dim_date d
ON DATE_FORMAT(o.create_time, '%Y-%m-%d') = d.day;
这里的 dim_date 表如果只有几行,那影响不大;但如果关联双方都是大数据量,就必须尽量避免。正确的做法是先通过范围条件把 orders 的当天数据圈出来,再和 dim_date 做等值关联:
sql复制FROM (
SELECT *
FROM orders
WHERE create_time >= '2025-01-31'
AND create_time < '2025-02-01'
) o
JOIN dim_date d
ON DATE(o.create_time) = d.day;
如果必须直接关联,可以考虑在 dim_date 表里同时维护一个“日期时间区间起点”字段,用 o.create_time >= d.day_start AND o.create_time < d.day_next_start 这种范围关联写。虽然写法复杂,但能保住索引和性能。
6.3 排序时格式化 ORDER BY 也影响性能
如果查询结果需要按格式化后的日期排序,比如 ORDER BY DATE_FORMAT(create_time, '%Y-%m-%d'),同样不利于索引排序。MySQL 在 ORDER BY 里如果发现要对函数结果排序,会生成 filesort(文件排序),而不是直接利用索引顺序。当数据量大时,filesort 可能性能很差。
一般来说,ORDER BY create_time 直接按原始时间排序,和 ORDER BY DATE_FORMAT(create_time, '%Y-%m-%d') 在大多数语义上是一致的——因为时间顺序和日期顺序同向。所以在排序需求里,直接按原始列排序即可,不需要额外格式化。只有在显示层需要展示格式化字符串时才格式化,排序逻辑尽量用原始列。
6.4 对日期格式化的性能测试示例
我习惯在优化前后用 EXPLAIN 对比执行计划,确认有没有从 ALL 变成 range:
sql复制EXPLAIN SELECT *
FROM orders
WHERE DATE_FORMAT(create_time, '%Y-%m-%d') = '2025-01-31';
-- type: ALL,说明全表扫描
EXPLAIN SELECT *
FROM orders
WHERE create_time >= '2025-01-31 00:00:00'
AND create_time < '2025-02-01 00:00:00';
-- type: range,说明走索引范围扫描
如果发现走了 range,再看 key_len 是否正确、rows 估算是否合理。这一步对初学者来说可能过于细节,但哪怕是 50 万行数据的表,这个差异也能从几百毫秒降到几个毫秒。这在报表接口超时优化里经常是立竿见影的。
7. 从 sqlserver 迁移场景看差异:格式化函数不是万能翻译器
在搜索词里反复出现 sqlserver 日期格式化,说明不少朋友是带着从 SQL Server 迁移或者多库兼容的视角来看 MySQL 日期格式化的。这里我单独提一下,因为两者差异极其容易让人写错。
7.1 SQL Server 与 MySQL 格式化函数对照
SQL Server 里的日期格式化最常用的是 CONVERT(varchar, getdate(), 120),其中 120 表示 yyyy-mm-dd hh:mi:ss 格式。MySQL 里没有这种数字风格的格式类型,只有 DATE_FORMAT 这种格式符语义。
| 场景 | SQL Server | MySQL |
|---|---|---|
| 当前时间 | GETDATE() | NOW() |
| 日期部分 | CAST(GETDATE() AS DATE) | CURDATE() |
| 格式化日期时间 | CONVERT(VARCHAR(19), GETDATE(), 120) | DATE_FORMAT(NOW(), '%Y-%m-%d %H:%i:%s') |
| 字符串转日期 | CONVERT(DATETIME, '2025-01-31', 120) | STR_TO_DATE('2025-01-31', '%Y-%m-%d') |
| 日期加减 | DATEADD(DAY, 1, GETDATE()) | DATE_ADD(NOW(), INTERVAL 1 DAY) |
| 日期差 | DATEDIFF(DAY, start, end) 或 DATEDIFF_BIG | DATEDIFF(end, start) / TIMESTAMPDIFF(DAY, start, end) |
最坑的差异就是 DATEDIFF 参数顺序:SQL Server 是 DATEDIFF(interval, startdate, enddate),MySQL 的 DATEDIFF 是 DATEDIFF(enddate, startdate),两个是相反的。别问,问就是我在迁移脚本时翻过车。
7.2 迁移时的常见误区和替代方案
如果公司有从 SQL Server 迁到 MySQL 的规划,日期格式化的迁移工作不能光靠“人工翻译函数”,要系统性梳理三件事:
- 把项目代码里所有的日期格式化调用点列出来,标注采用哪种数据库方言。
- 统一时间标准:源库和目标库的时区、日期时间精度(DATETIME 是否带毫秒)要提前对齐。
- 写自动化脚本做样例数据对比:对同一批日期值,分别用 SQL Server 和 MySQL 跑格式化,比对输出结果字符串是否一致。
这里我建议在 MySQL 里创建一组自定义函数来模拟 SQL Server 的格式风格,比如把 FORMATDATETIME(dt, 'yyyy-MM-dd HH:mm:ss') 作为自定义函数,内部调用 DATE_FORMAT。虽然 SQL Server 的 FORMAT() 函数语法更接近 .NET 风格,但我们在迁移期间统一用这种包装函数,能让应用层代码改动量大幅下降。
8. 日期格式化实战踩坑清单
最后按惯例列一份我在真实项目里踩过、或者在代码评审里看到过的坑清单。这些坑单独看都不大,但组合起来足以让人一个下午耗进去。
8.1 月份和分钟的格式符混淆
%i 是分钟,%m 是月。但很多人因为 Excel 或 Java 的 MM 印象太深,把分钟写成 %M,结果出来的是月份英文全称。
sql复制SELECT DATE_FORMAT(NOW(), '%Y-%M-%d %H:%M:%s');
-- 结果类似:2025-January-31 23:05:07,完全不对
记住:MySQL 里 %M 是月份的英文全称(January),%m 才是两位数字月份。分钟只有 %i 一个写法。
8.2 24 小时制和 12 小时制
%H 是 24 小时制(00-23),%h 是 12 小时制(01-12)。如果你用 %h 拼接 %p,输出会变成 11:05:07 PM,但很多系统不想要这个 AM/PM 标识,直接用 %H 就行。这个错误在从 12 小时制习惯的同事写的代码里很常见。
8.3 日期格式化结果再参与比较
DATE_FORMAT 的结果是字符串,字符串比较和日期比较有时结论相同,但有时完全不同。比如 '2025-2-1' 和 '2025-10-1' 按字符串字典序比较,'2025-10-1' 反而小于 '2025-2-1'。所以在日期比较场景里,要么保持 DATE 类型比较,要么把格式串统一补零规范(比如 %Y-%m-%d),再要么转回 STR_TO_DATE。最忌讳的是收到一个 '2025-2-1' 这种不规则字符串,直接拿去和日期列比较,结果会非常抽象。
8.4 STR_TO_DATE 返回 NULL 时不报错
这个我前面提到过,再强调一次:解析失败只返回 NULL 加 Warning,不抛异常。所以在数据校验场景里,不要只检查“有没有返回结果”,要检查“是不是 NULL”。同时也可以检查 SHOW WARNINGS 的输出,把解析错误明细记到日志里。
8.5 GROUP BY 别名在不同 MySQL 版本的行为
MySQL 5.7 及以后默认开启了 ONLY_FULL_GROUP_BY 模式,对 GROUP BY 的使用更严格。如果你写了:
sql复制SELECT DATE_FORMAT(create_time, '%Y-%m-%d') AS day, COUNT(*)
FROM orders
GROUP BY day;
这个在 MySQL 8.0 里是可以工作的(GROUP BY 可以使用别名),但在某些旧版本或者严格模式下可能报错。稳妥的写法是 GROUP BY 里直接写完整的表达式或列名,不要依赖别名。在不同版本间迁移时,这个问题出现的频率很高。
8.6 CURRENT_DATE 不能加括号
CURRENT_DATE 和 CURRENT_TIMESTAMP 是关键字式的函数,可以不加括号使用。但 CURDATE() 必须加括号。如果有人混用 CURRENT_DATE(),在 MySQL 8.0 下其实也能执行,但容易在别的数据库上踩坑,建议统一风格。
8.7 DATETIME 精度在格式化时的影响
DATE_FORMAT 的 %f 输出微秒,但如果你存进 DATETIME(0) 列,微秒本身已经被丢弃,格式化出来永远是 000000。反过来,如果源数据带了微秒,你用 %s 格式化,微秒部分直接丢掉,不会四舍五入。这里没有四舍五入的行为,是截断。
8.8 数据库连接时区对格式化结果的影响
最后也是最重要的一个坑:同一个 NOW()、同一个 FROM_UNIXTIME(),在 JVM 默认时区、MySQL 会话时区、前端展示时区不一致时,结果可能多差 8 小时。排查这类问题时,先执行:
sql复制SELECT @@global.time_zone, @@session.time_zone;
SELECT NOW(), UTC_TIMESTAMP();
确认数据库和会话时区后,再去看连接串和 JDBC 驱动配置里的 serverTimezone。MySQL Connector/J 8.x 版本如果没有正确配置 serverTimezone,也会导致驱动与服务器之间的时间解析偏差。这个问题的排查链路通常比 SQL 本身要长得多,所以我会在建项目的第一天就把时区策略定死:数据库统一 UTC 存储,应用层统一北京时间展示,或者在数据库统一北京时间存储,展示层不转换。具体选哪一种视团队习惯而定,但不统一的代价是巨大的。
9. 我的一点个人习惯
说了这么多函数和案例,最后分享一个我自己的习惯。写日期格式化 SQL 之前,我会先把“这条 SQL 是给谁看的”问一遍。如果给机器处理(比如下游 ETL、接口入参、文件导出),尽量保持日期类型或标准 ISO 字符串,避免歧义;如果给人看(报表、后台列表),性能允许的情况下可以用 DATE_FORMAT 做成友好格式;如果既要给人看又要参与后续运算,则把格式化和运算分层——原始数据层保持 DATETIME,展示层再做格式化。
日常开发时我还习惯在本地准备一个小库,专门试各种日期场景,建一张全都是日期时间字段的表,插几行含月初、月末、闰年 2 月、中午 12 点、晚上 23:59:59 的边界数据,写 SQL 时顺手跑一下不亏。很多“我以为没问题”的日期 SQL,都是在这种边界数据上暴露出问题的。
日期格式化从来不是背函数表的活,真正的功力体现在知道什么时候该格式化、什么时候不该格式化、以及格式化之后会造成什么连锁反应。希望这篇能帮你少走一些弯路。
