做后端开发这些年,我发现一个特别有意思的现象:不管项目多大多小,跟数据库打交道总绕不开日期。订单表要记录下单时间,报表要按天、按月统计,前后端联调要统一时间格式,日志要落本地时间。而 MySQL 的日期格式化,恰恰是面试里平平无奇、实际动手却最容易翻车的一块。这篇文章我从实际项目出发,把日期格式化的函数用法、格式符细节、常见问题、性能优化一次性理清楚。无论你是刚接触 SQL 的新手,还是工作几年的后端开发,都可以当一份速查手册来用。
1. 整体思路:MySQL 日期格式化到底在解决什么问题
1.1 三个逃不掉的业务场景
日期格式化在业务里基本可以概括成三类需求。
第一类是最终展示。前端要显示 2024年06月13日,报表导出要让运营看到 2024-06-13 14:30,这些都需要把数据库里的日期时间值转成人话。第二类是统计聚合。按天统计订单量、按小时统计访问量、按周或按月输出报表,这背后都是对日期时间做分组格式化。第三类是数据清洗。外部系统导过来的数据可能是 2024/06/13、20240613、13-06-2024 这种千奇百怪的格式,你要先转成 MySQL 认得的日期类型,才能进一步比较和计算。
理解了这三类场景,再看函数就顺了:DATE_FORMAT 负责输出格式,STR_TO_DATE 负责把字符串解析成日期,GROUP BY 配合日期表达式负责聚合,DATE_ADD、DATEDIFF 这类函数负责计算。整篇文章的主线就是这三件事。
1.2 先搞清楚存的是什么类型
很多日期格式化的坑,源头其实不是函数不会用,而是没搞懂 MySQL 到底存的是什么类型。MySQL 里常见的日期时间类型有五种:
| 类型 | 格式 | 存储空间 | 说明 |
|---|---|---|---|
| DATE | YYYY-MM-DD | 3 字节 | 只存日期,不存时间 |
| DATETIME | YYYY-MM-DD HH:MM:SS | 8 字节 | 存日期和时间,不受时区影响 |
| TIMESTAMP | YYYY-MM-DD HH:MM:SS | 4 字节 | 自动按会话时区转换,范围 1970-2038 |
| TIME | HH:MM:SS | 3 字节 | 只存时间 |
| YEAR | YYYY | 1 字节 | 一般很少单独用 |
我见过不少同事把 TIMESTAMP 和 DATETIME 混着用,结果前端经常报“日期差 8 小时”。原因很简单:TIMESTAMP 底层存的是 UTC 时间,查询时 MySQL 会把它转成当前会话时区的时间;DATETIME 存的就是字面值,你存进去 2024-06-13 14:30:00,查出来还是这个字面值。所以如果你只需要记录业务时间,不牵扯全球多时区用户,DATETIME 通常更省心;如果你的业务明确要做时区转换,或者想省点存储空间,TIMESTAMP 才真正有优势。
做格式化之前,先在心里过一遍:这列是 DATE 还是 DATETIME?如果是 TIMESTAMP,当前连接的时区是什么?有没有可能需要 CONVERT_TZ?这一步想清楚,后面少踩一半坑。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. DATE_FORMAT 格式符完全拆解
2.1 格式符速查表
DATE_FORMAT(date, format) 是整个日期格式化里最重要的函数,第二个参数是格式字符串,由一堆 % 开头的格式符组成。我第一次用的时候也懵,怎么那么多 % 符号?其实规则不复杂,记住关键几个就行。
| 格式符 | 含义 | 示例(以 2024-06-13 14:05:03 为例) |
|---|---|---|
| %Y | 四位年份 | 2024 |
| %y | 两位年份 | 24 |
| %m | 两位月份,带前导零 | 06 |
| %c | 月份数字,不带前导零 | 6 |
| %M | 英文月份全称 | June |
| %b | 英文月份缩写 | Jun |
| %d | 两位日期,带前导零 | 13 |
| %e | 日期数字,不带前导零 | 13 |
| %H | 24 小时制,00-23 | 14 |
| %h | 12 小时制,01-12 | 02 |
| %i | 分钟,00-59 | 05 |
| %s / %S | 秒,00-59 | 03 |
| %p | AM 或 PM | PM |
| %T | 等价于 %H:%i:%s | 14:05:03 |
| %r | 12 小时制,等价于 %h:%i:%s %p | 02:05:03 PM |
| %W | 星期几英文全称 | Thursday |
| %a | 星期几英文缩写 | Thu |
| %w | 星期几数字,0 表示 Sunday | 4 |
| %j | 一年中的第几天,001-366 | 165 |
| %u | 一年中的周数,00-53,周一为第一天 | 24 |
| %v | 一年中的周数,01-53,周一为第一天,常与 %x 配合 | 24 |
| %x | 周所在的四位年份,常与 %v 配合 | 2024 |
这里面最容易记混的是 %i 代表分钟,而不是 %m。因为 %m 已经被月份占用了,MySQL 只能拿 i 来表示 minute。我第一次写 %M 想表示分钟,结果查出来一列月份的英文全称,当场意识到问题。
2.2 常见模板与反直觉坑
实际项目里最常用的输出格式,我给你整理成可以直接抄的模板:
sql复制-- 标准时间,最常见
SELECT DATE_FORMAT(NOW(), '%Y-%m-%d %H:%i:%s');
-- 结果:2024-06-13 14:05:03
-- 只要日期
SELECT DATE_FORMAT(NOW(), '%Y-%m-%d');
-- 结果:2024-06-13
-- 中文习惯格式
SELECT DATE_FORMAT(NOW(), '%Y年%m月%d日 %H:%i');
-- 结果:2024年06月13日 14:05
-- 年月日压缩成年月日数字,适合做文件名或分区字段
SELECT DATE_FORMAT(NOW(), '%Y%m%d');
-- 结果:20240613
-- 12 小时制
SELECT DATE_FORMAT(NOW(), '%Y-%m-%d %h:%i:%s %p');
-- 结果:2024-06-13 02:05:03 PM
有几个坑需要单独提。第一,%Y 和 %y 的区别是四位年份和两位年份,粗心写错直接导致数据错一年。第二,%H 是 00-23 的 24 小时制,%h 是 01-12 的 12 小时制,如果业务明确是下午 14 点,用 %h 显示出来就是 02,容易看错。第三,%c 和 %e 是不带前导零的数字,如果 2024-06-05 被你拼成 2024-6-5,排序和展示都会有问题。第四,想输出字面的百分号,要写 %%,比如 DATE_FORMAT(NOW(), '进度100%%')。
还有一点,我从 SQL Server 转过来的朋友特别容易踩:SQL Server 里用 FORMAT(GETDATE(), 'yyyy-MM-dd HH:mm:ss'),格式符是 yyyy、MM、dd 这种 .NET 风格。但 MySQL 的 FORMAT 函数是格式化数字的,比如 FORMAT(12345.67, 2) 会得到 12,345.67,跟日期一点关系没有。所以在 MySQL 里想格式化日期,老老实实用 DATE_FORMAT,别拿写 SQL Server 的习惯来套。
3. 字符串转日期:STR_TO_DATE 与隐式转换的边界
3.1 STR_TO_DATE 的用法
从各种数据源往里导数据时,最头疼的就是日期字符串五花八门。STR_TO_DATE(str, format) 就是用来把字符串按指定格式解析成日期时间类型的。
sql复制-- 标准格式
SELECT STR_TO_DATE('2024-06-13 14:05:03', '%Y-%m-%d %H:%i:%s');
-- 结果:2024-06-13 14:05:03
-- 斜杠分隔
SELECT STR_TO_DATE('2024/06/13', '%Y/%m/%d');
-- 结果:2024-06-13
-- 紧凑数字格式
SELECT STR_TO_DATE('20240613', '%Y%m%d');
-- 结果:2024-06-13
-- 英文月份缩写
SELECT STR_TO_DATE('13-Jun-24', '%d-%b-%y');
-- 结果:2024-06-13
需要注意,STR_TO_DATE 的 format 参数必须和字符串结构对应。MySQL 对分隔符的容忍度有点弹性,但最稳妥的做法还是严格匹配。如果解析失败,在非严格模式下会返回 NULL 并产生 warning,在严格 SQL 模式下可能直接报错,比如 Incorrect datetime value。所以做数据导入时,我习惯先跑一条查询验证一下:
sql复制SELECT STR_TO_DATE('2024-13-45', '%Y-%m-%d');
-- 结果:NULL(非严格模式)或报错(严格模式)
如果源数据里混入了坏格式,建议在导入前用 WHERE STR_TO_DATE(column, '%Y/%m/%d') IS NOT NULL 把脏数据筛出去,而不是让导入任务中间挂掉。
3.2 CAST、CONVERT 和日期上下文
除了 STR_TO_DATE,MySQL 还提供了 CAST 和 CONVERT。但它们只能解析标准格式,灵活性远不如 STR_TO_DATE:
sql复制SELECT CAST('2024-06-13' AS DATE);
-- 结果:2024-06-13
SELECT CONVERT('2024-06-13 14:05:03', DATETIME);
-- 结果:2024-06-13 14:05:03
如果字符串是 2024/06/13,CAST 是转不出来的,这种情况必须用 STR_TO_DATE。反过来,从日期时间类型里提取某一部分,也不需要先转字符串,直接用 DATE()、YEAR()、MONTH()、DAY() 这些函数更高效:
sql复制SELECT DATE('2024-06-13 14:05:03');
-- 结果:2024-06-13
SELECT YEAR('2024-06-13 14:05:03');
-- 结果:2024
另外要小心隐式转换。比如你写 WHERE date_col = '2024-06-13',如果 date_col 是 DATETIME 类型,MySQL 会把这个字符串解释成 2024-06-13 00:00:00,所以 14 点那条记录不会被匹配到。这不是 bug,是日期时间语义的问题。反过来,如果你拿一个不规则字符串去跟日期列比较,MySQL 的隐式转换规则不一定能猜中你的意图,很容易出结果偏差。我的建议是:能用标准 YYYY-MM-DD 字符串的地方就用标准格式,不能纯靠隐式转换的地方就显式 STR_TO_DATE。
4. 日期格式化在统计与分组中的实战
4.1 按天、小时、周、月分组的正确写法
统计报表最常用的场景,是把 DATETIME 字段格式化到不同的时间粒度,然后分组聚合。拿订单表举例,按天统计订单数:
sql复制SELECT DATE_FORMAT(created_at, '%Y-%m-%d') AS day,
COUNT(*) AS order_cnt
FROM order_info
WHERE created_at >= '2024-01-01 00:00:00'
AND created_at < '2024-02-01 00:00:00'
GROUP BY DATE_FORMAT(created_at, '%Y-%m-%d')
ORDER BY day;
这里有个细节:SELECT 的列和 GROUP BY 的表达式要一致。如果开了 ONLY_FULL_GROUP_BY(MySQL 5.7+ 默认开启),你只写 GROUP BY day 或只写 GROUP BY created_at 都可能报错。最好的写法是 GROUP BY 里直接写完整的格式化表达式。如果想简洁一点,可以用 CTE 先格式化再聚合,逻辑会更清晰:
sql复制WITH daily AS (
SELECT DATE_FORMAT(created_at, '%Y-%m-%d') AS day,
order_id
FROM order_info
WHERE created_at >= '2024-01-01 00:00:00'
AND created_at < '2024-02-01 00:00:00'
)
SELECT day, COUNT(*) AS order_cnt
FROM daily
GROUP BY day
ORDER BY day;
按小时统计也很常见,核心是格式化到小时粒度:
sql复制SELECT DATE_FORMAT(created_at, '%Y-%m-%d %H:00') AS hour,
COUNT(*) AS cnt
FROM order_info
WHERE created_at >= '2024-06-13 00:00:00'
AND created_at < '2024-06-14 00:00:00'
GROUP BY DATE_FORMAT(created_at, '%Y-%m-%d %H')
ORDER BY hour;
按周统计稍微讲究一点。MySQL 里 %u 表示一年中的周数,周一作为一周的第一天,范围是 00-53;%v 也是周一作为第一天,但范围是 01-53,通常和 %x 搭配使用,%x 是周所在的年份。如果你是按周日作为一周的第一天,就用 %V 和 %X。直接 DATE_FORMAT(created_at, '%Y-%u') 会有个坑:跨年那几天,比如 2024-12-30 按 ISO 周算是 2025 年的第一周,用 %Y 拼出来的年份可能是 2024,导致分组错位。正确做法是:
sql复制SELECT DATE_FORMAT(created_at, '%x-%v') AS week,
COUNT(*) AS cnt
FROM order_info
GROUP BY DATE_FORMAT(created_at, '%x-%v')
ORDER BY week;
按季度统计没有现成的 %q 格式符,需要拼一下:
sql复制SELECT CONCAT(YEAR(created_at), '-Q', QUARTER(created_at)) AS quarter,
COUNT(*) AS cnt
FROM order_info
GROUP BY CONCAT(YEAR(created_at), '-Q', QUARTER(created_at))
ORDER BY quarter;
QUARTER() 返回 1-4,这种写法在报表里很直观。
4.2 日期计算与格式化联动
格式化不是只能用在输出,跟日期计算配合起来能解决很多实际问题。比如要取当月第一天的格式化结果:
sql复制SELECT DATE_FORMAT(CURDATE(), '%Y-%m-01');
-- 结果:2024-06-01
当月最后一天,用 LAST_DAY:
sql复制SELECT DATE_FORMAT(LAST_DAY(CURDATE()), '%Y-%m-%d');
-- 结果:2024-06-30
加上一个月、减去一天,这种时间窗口计算也很常用:
sql复制SELECT DATE_FORMAT(DATE_ADD('2024-01-31', INTERVAL 1 MONTH), '%Y-%m-%d');
-- 结果:2024-02-29(MySQL 自动处理月末问题)
这里我要单独点一下 INTERVAL。MySQL 的月份加减不是简单的字符串拼接,它会把结果归一到合法日期。2024-01-31 加一个月是 2024-02-29,不是 2024-02-31,也不会直接报错。这个行为其实挺贴心,但有些业务预期是“加一个月保持日期不变”,如果原日期是 31 号,加一个月后可能会变成 2 月底,逻辑上是不是符合预期,需要业务方确认。
计算两个日期差多少天,用 DATEDIFF:
sql复制SELECT DATEDIFF('2024-06-13', '2024-06-01');
-- 结果:12
更通用的间隔计算用 TIMESTAMPDIFF:
sql复制SELECT TIMESTAMPDIFF(HOUR, '2024-06-13 10:00:00', '2024-06-13 14:30:00');
-- 结果:4
TIMESTAMPDIFF 支持 MICROSECOND、SECOND、MINUTE、HOUR、DAY、WEEK、MONTH、QUARTER、YEAR,能处理更丰富的时间间隔。如果你只需要看整天数,DATEDIFF 更直接,但要注意它只算天数差,不考虑时间部分,比如 DATEDIFF('2024-06-13 23:59:59', '2024-06-12 00:00:00') 结果是 1 而不是 1.99。
4.3 时区、TIMESTAMP 展示和格式化
如果你用了 TIMESTAMP 类型,格式化之前得先想时区。MySQL 默认会按 time_zone 变量显示 TIMESTAMP。经常出现的情况是:数据库实例时区是 UTC,业务在东八区,你用 DATE_FORMAT(timestamp_col, '%Y-%m-%d %H:%i:%s') 查出来比北京时间慢 8 小时。
排查时先执行三条命令,确认当前状态:
sql复制SELECT NOW();
SELECT UTC_TIMESTAMP();
SELECT @@global.time_zone, @@session.time_zone;
如果 NOW() 和 UTC_TIMESTAMP() 一样,说明当前会话时区就是 UTC。想要转成东八区展示,可以临时设置会话时区:
sql复制SET time_zone = '+08:00';
Java 应用通过 JDBC 连接 MySQL 时,连接串里也经常出现时区问题。现在比较规范的做法是在 JDBC URL 里明确指定时区,例如 serverTimezone=Asia/Shanghai 或 serverTimezone=GMT%2B8。如果应用层用 DATETIME,只要应用传的参数和数据库连接时区一致,问题一般不大;用 TIMESTAMP 就会更敏感。我的建议是,项目里统一一种日期时间类型,避免 DATETIME、TIMESTAMP、VARCHAR 混着存。
5. 日期格式化和索引优化:别让函数毁掉查询
5.1 WHERE 条件里套函数导致索引失效
我在评审代码时见过不少这样的 SQL:
sql复制SELECT *
FROM order_info
WHERE DATE_FORMAT(created_at, '%Y-%m-%d') = '2024-06-13';
这条语句表面上没有毛病,但它对每一行 created_at 都先做一次格式化,再跟字符串比较。即使 created_at 上建了索引,MySQL 也没法用索引去定位,只能全表扫描。这不是 MySQL 笨,是函数套在列上之后,索引的有序性被破坏了。
正确的写法是让它变成一个范围查询:
sql复制SELECT *
FROM order_info
WHERE created_at >= '2024-06-13 00:00:00'
AND created_at < '2024-06-14 00:00:00';
这样 created_at 上的索引就能直接用来定位到 6 月 13 日的记录。如果只想查今天的数据,可以写:
sql复制SELECT *
FROM order_info
WHERE created_at >= CURDATE()
AND created_at < CURDATE() + INTERVAL 1 DAY;
只要记住一个原则:在索引列上做任何运算、函数、隐式类型转换,大概率会让索引失效。优化思路不是去修函数,而是把条件改写成对索引列本身的比较。
5.2 大表按天分组统计的索引策略
按天分组统计这个大场景,GROUP BY DATE_FORMAT(created_at, '%Y-%m-%d') 虽然方便,但同样是因为对列做了函数处理,无法直接用索引做分组。如果表不大,几千几万条数据完全无所谓;如果表是几百万、上千万的流水表,每次报表查询都把全表扫一遍,扛不住。
第一招是缩小扫描范围。报表通常只查某一天或某几天的数据,利用 created_at 上的索引把范围缩到很小,再对结果集做格式化分组,性能能提升一个量级。但如果你经常要做全时间范围的日报聚合,这招就不够用了。
第二招是加冗余字段。MySQL 5.7 支持生成列,可以直接在表结构里加一个 DATE 类型字段,由 created_at 自动生成,并建索引:
sql复制ALTER TABLE order_info
ADD COLUMN created_date DATE
GENERATED ALWAYS AS (DATE(created_at)) STORED,
ADD INDEX idx_created_date (created_date);
之后按天统计的 SQL 可以直接这样写:
sql复制SELECT created_date AS day,
COUNT(*) AS order_cnt
FROM order_info
WHERE created_date = '2024-06-13'
GROUP BY created_date
ORDER BY day;
这条查询完全走 idx_created_date,不需要对每行做函数计算。缺点是生成列会占存储空间,索引也要占空间,所以不要什么列都加,只在查询压力很大的核心表上加。如果你的数据是从应用层写进去的,在插入时顺手填一个独立的 created_date 字段,效果一样。不要小看这个优化,报表类查询从十几秒降到几百毫秒,案例我见过不少。
5.3 排序混乱问题
日期格式化还经常掉进排序的坑。比如你想按月日输出 12-25 这种格式,然后按它排序:
sql复制SELECT DATE_FORMAT(created_at, '%m-%d') AS md
FROM order_info
ORDER BY md;
因为 md 是字符串,排序按字符顺序走:01-01、01-15、02-03……如果月份不补零,比如 2024-6-5 和 2024-11-5 排在一起,6-5 会在 11-5 后面,因为字符比较先看第一位 6 > 1。解决方案有两个:要么输出的时候用 %m、%d 保证补零;要么干脆 ORDER BY created_at 按原始时间排,显示层再格式化。只要排序字段能用原始列,尽量别用格式化后的字符串。
6. 常见问题与排查技巧实录
6.1 格式符引起的返工现场
把格式符写错,在开发期看起来只是数据不对,上线后往往就是事故。最典型的几个:
%y-%m-%d输出24-06-13,年份变成两位,导出的报表被下游系统当成 2024 年解析失败。%i和%m搞混,统计分钟数变成月份数,监控指标直接飘了。- 用
%h:%i显示 14 点变成 02 点,没有%p的话,给人感觉是凌晨两点。 - 写 SQL Server 习惯,把
FORMAT用在日期列上。MySQL 的FORMAT是数字格式化函数,FORMAT(created_at, 'yyyy-MM-dd')会返回数字,结果完全变味。 STR_TO_DATE解析失败返回 NULL,但没加IS NOT NULL过滤,脏数据悄悄进了统计。
排查技巧很简单:先拿一条固定日期字符串跑一遍 SELECT DATE_FORMAT('2024-06-13 14:05:03', '...'),看输出是否符合预期。所有格式符之间微小的字母大小写差异,都能在这条语句里暴露出来。
6.2 时区不同导致前端展示不一致
线上出过好几次类似问题:数据库存的时间看着没错,前端展示总是差 8 小时。排查路径建议固定下来:
第一,先确认应用连接数据库的会话时区,看看是不是 UTC。用 SELECT NOW(), UTC_TIMESTAMP() 就能对比。第二,检查 JDBC URL 里的 serverTimezone 参数。第三,确认这张表用的是 TIMESTAMP 还是 DATETIME。TIMESTAMP 很容易受会话时区影响,DATETIME 则是“所见即所得”。如果只是展示差 8 小时,而业务又不需要跨时区,最好在连接层统一好时区;如果必须保留 UTC,那么展示层或 SQL 里加 CONVERT_TZ 转换。
这里还要提醒一点:不要为了省事,把时间列改成 VARCHAR 存格式化好的字符串。表面上问题“解决”了,后面所有跟日期相关的计算、比较、排序都会变得非常痛苦。日期时间就交给日期时间类型,格式化放到查询或者展示层来做。
6.3 其他我踩过的隐蔽坑
JSON 字段里的日期字符串。MySQL 的 JSON_EXTRACT 会把值带上双引号,你不能直接拿它和日期函数做运算。要先 JSON_UNQUOTE(JSON_EXTRACT(json_col, '$.create_time')),再用 STR_TO_DATE 转成真正的日期类型,才能继续计算。这个过程容易漏,每次写都要多看类型。
ONLY_FULL_GROUP_BY 下报错。MySQL 5.7 之后这个模式默认开启,如果你写了 SELECT DATE_FORMAT(created_at, '%Y-%m-%d') AS day, COUNT(*) ... GROUP BY day,有些版本会报错,因为 GROUP BY 后面用了别名,而 SELECT 中的别名在 GROUP BY 解析顺序里不一定可见。稳妥做法是 GROUP BY 里写表达式,或者用子查询/CTE 先格式化。
月末加一月的坑。DATE_ADD('2024-01-31', INTERVAL 1 MONTH) 返回 2024-02-29,看起来合理,但业务如果预期是“月底加一个月还是月底”,你要额外用 LAST_DAY 再处理一下。更常见的是写定时任务时,用 DATE_ADD(CURDATE(), INTERVAL -1 MONTH) 计算上个月的今天,结果在 3 月 31 日会得到 2 月 29 日,再往后推几天又会漂移。这类问题我在任务调度里遇到过好几次,做月度统计时建议使用 DATE_FORMAT(DATE_SUB(CURDATE(), INTERVAL 1 MONTH), '%Y-%m-01') 取上个月第一天,或者用 LAST_DAY 取上个月最后一天,避免月底边界。
我现在写日期相关的 SQL,动手前会先默念三句:源字段是什么类型?能不能走索引?输出要哪种格式?把这三件事想清楚,基本上不会翻车。日期格式化本身不难,难的是对类型、时区和索引的敏感度。这也是我把它放进实用系列第一篇的原因,后续还会继续补 MySQL 其他高频场景,欢迎一起交流。
