☰
MySQL DATE_FORMAT函数全解析:格式符、陷阱与性能优化
2026/9/30 8:06:46 网站建设 项目流程

1. 为什么一个日期格式化函数值得单独写一篇

先说一个我真实经历过的场景。凌晨两点被电话叫醒,说线上商城的充值记录时间全乱了,用户看到的支付时间比实际时间晚了整整两天。查了半天,问题出在一条 SQL 上:有人为了把时间转成“2024-05-20”这种格式,用了DATE_FORMAT(created_at, '%y-%m-%e')。看着没毛病对吧?但%y是两位年份,%e是不带前导零的日期,组合出来就是24-5-20。真正的坑是某一天日期变成了24-5-1,应用层拿这个字符串去解析,直接当成 5 月 1 号处理了。就这么一个小小的大小写差异,在低峰期不显眼,一到零点切换就炸。

这就是我要写 DATE_FORMAT 的原因。它不是“会用%Y-%m-%d %H:%i:%s就算会了”,它的完整格式符体系里有大量容易混淆、跨版本行为不一致、跟其他函数配合时会产生隐蔽 bug 的细节。很多新手把它当成“一个简单的转换函数”,真正遇到问题时才发现自己连排查方向都没有。这篇我打算从格式符的底层语义讲起,结合报表统计、日志清洗、导出文件命名这些日常场景,再把几个藏得很深的坑逐个拆开,最后聊一些进阶的组合用法。无论你是刚接触 MySQL 的初学者,还是已经写了好几年 SQL 但没系统梳理过日期函数的老手,这篇都能帮你少走弯路。

2. 格式符体系全拆解:大小写、数字与中文的微妙区别

2.1 函数签名与返回类型

DATE_FORMAT(date, format),两个参数,第一个是日期或日期时间值,第二个是格式串。返回类型是字符串。注意“返回字符串”这件事,后面很多坑都是从这里长出来的。MySQL 官方手册给出的格式符有二三十个,但实际高频用到的也就十来个,大部分人只会%Y-%m-%d %H:%i:%s一条走天下。这本身没问题,问题是当你想表达“中文月日”“12 小时制”“一年中的第几天”“ISO 周”这些需求时,不知道有对应的格式符,就只能绕路,绕路就容易出 bug。

另外要注意,MySQL 对格式串的处理比较宽容:格式串里除了%开头的占位符,其他字符都会原样输出。所以你可以直接塞中文进去,比如DATE_FORMAT(NOW(), '%Y年%m月%d日'),返回的就是2024年05月20日。这个特性在生成报表标题、文件前缀时非常实用,我后面会专门举例。

2.2 一张表看清所有常用格式符

我在维护项目时习惯把格式符分成三组:日期组件、时间组件、星期与文本组件。

格式符含义输出示例(以 2024-05-20 14:30:45 为例)
%Y四位数年份2024
%y两位数年份24
%m月份,带前导零05
%c月份,不带前导零5
%M英文月份全称May
%b英文月份缩写May(注意 MySQL 里缩写也是三位,与全称有时相同)
%d日,带前导零20
%e日,不带前导零20
%H24 小时制小时,带前导零14
%k24 小时制小时,不带前导零14
%h12 小时制小时,带前导零02
%l12 小时制小时,不带前导零2
%i分钟,带前导零30
%s秒,带前导零45
%f微秒,六位数000000
%pAM 或 PMPM
%r12 小时制完整时间02:30:45 PM
%T24 小时制完整时间14:30:45
%W星期英语全称Monday
%a星期英语缩写Mon
%j一年中的第几天,001-366141
%U一年中的周数,周日作为一周起点,00-5320
%u一年中的周数,周一作为一周起点,00-5320
%V周数,周日起点,01-53,与 %X 配合20
%v周数,周一起点,01-53,与 %x 配合20
%x周一作为一周起点对应的年份,四位数2024
%X周日作为一周起点对应的年份,四位数2024
%%转义 %%

注意几个最容易翻车的点。

%M和%m:一个输出英文全称May,一个输出两位数字05。写成小写%m才是数字月份,大写%M是英文单词。如果你在 WHERE 条件里比较月份,用错大小写就会拿May跟05比,永远匹配不上。

%h和%H:小写是 12 小时制,大写是 24 小时制。因为输入的时间是 14 点,用%h输出02、%H输出14。如果业务上要求展示下午 2:30,必须用%h配合%p,而不是把%H减 12。

%i是分钟,不是%m。这个是最经典的笔误:有人想取“14 点 30 分”,写成%H:%m,结果输出14:05——因为%m是月份 05。分钟的正确格式符是%i,秒才是%s。

%c和%e不带前导零,%m和%d带前导零。这个看起来是小差别,但直接影响字符串排序。%m输出的月份序列是01, 02, ..., 10, 11, 12,字典序和数值序一致;%c输出的序列是1, 2, ..., 10, 11, 12,字典序就乱了——10会排在2前面。后文讲排序坑时还会回到这点。

2.3 中文场景下的文本格式符

MySQL 的%W、%M这类格式符输出的是英文,因为 MySQL 服务端的 locale 默认是en_US。如果你想输出“星期一”“五月”这种中文,有两个办法。

第一,手动映射。用ELT(WEEKDAY(date) + 1, '星期一', '星期二', ...)之类的方式自己拼。WEEKDAY()返回 0 代表周一,所以加 1 后ELT的第一个参数正好对应周一。月份同理,用MONTH(date)去ELT映射。

第二,如果你整个项目的展示层都是中文,更推荐把格式化放到应用层做,数据库只负责把原始日期返回。这样既避免 MySQL 端产生“半英文半中文”的尴尬字符串,也让前端有更多控制权。

我见过有人在 SQL 里写CONCAT(DATE_FORMAT(created_at, '%Y年'), ELT(MONTH(created_at), '一月','二月',...)),效果没问题,但 SQL 可读性很差。建议把这种映射抽成视图或者应用层常量。

3. 从查询到报表:DATE_FORMAT 的典型业务落地场景

3.1 按天、按月分组统计的正确姿势

日期格式化的高频使用场景就是分组统计。日志表、流水表、订单表里都有created_at这种精确到秒甚至微秒的时间字段,直接GROUP BY created_at会按秒分组,查出来的结果毫无意义。正确做法是把时间字段格式化成目标精度再分组。

-- 按天统计近 7 天每笔业务流水数量 SELECT DATE_FORMAT(created_at, '%Y-%m-%d') AS day, COUNT(*) AS cnt FROM payment_log WHERE created_at >= NOW() - INTERVAL 7 DAY GROUP BY DATE_FORMAT(created_at, '%Y-%m-%d') ORDER BY day;

月维度的统计只是把格式串换成'%Y-%m':

SELECT DATE_FORMAT(order_time, '%Y-%m') AS month, SUM(amount) AS total_amount FROM orders WHERE order_time BETWEEN '2024-01-01 00:00:00' AND '2024-12-31 23:59:59' GROUP BY DATE_FORMAT(order_time, '%Y-%m') ORDER BY month;

有人会在 SELECT 里写DATE_FORMAT、GROUP BY 里再写一遍DATE_FORMAT,觉得很啰嗦。MySQL 允许 GROUP BY 使用 SELECT 中的别名,所以可以简化成GROUP BY month。但这里有个版本差异:MySQL 5.7.5 之前对 GROUP BY 别名的解析存在歧义,5.7.5 之后默认开启了ONLY_FULL_GROUP_BY,用别名反而更安全。不过 ORDER BY 使用别名一直没问题。我的建议是:小查询怎么顺手怎么来,生产环境的大查询还是把完整表达式写清楚,避免执行计划变化时踩坑。

3.2 导出文件名与流水号生成

做后台系统时经常需要导出数据,文件名要带上时间戳,避免同名覆盖。常见需求是生成流水_20240520_143045.csv这种格式。用 DATE_FORMAT 一行搞定:

SELECT CONCAT('流水_', DATE_FORMAT(NOW(), '%Y%m%d_%H%i%s'), '.csv');

这里没写-和:,是因为 Windows 文件系统不允许文件名里出现冒号,Linux 虽然允许但容易在传输时出问题。所以我通常用%Y%m%d和%H%i%s这种紧凑格式。如果希望带毫秒,可以把%s换成%f。

另一个常见需求是生成短码形式的业务流水号,比如把2024-05-20 14:30:45转成20240520143045:

SELECT DATE_FORMAT(NOW(), '%Y%m%d%H%i%s');

注意%H是 24 小时制,如果是凌晨 3 点,%h会输出03,两者看起来一样,但下午 3 点就不同了。流水号如果混用 12/24 小时制,会出现“上午 3 点 = 下午 3 点”的重复编号风险,所以统一用%H。

3.3 在应用与数据库之间选谁来做格式化

这个问题几乎每个团队都会吵。站在数据库端,DATE_FORMAT 是内置函数,新增一列格式化结果不会带来额外的网络开销,报表查询直接返回展示层可用的字符串。很多 BI 工具查完 MySQL 后直接渲染图表,如果库里返回的是原始时间戳,图表工具的日期解析能力又参差不齐,不如库里一次格式化到位。

站在应用端,格式化逻辑更灵活,而且 Java、Python、Go 的日期库在时区处理和国际化方面远强于 MySQL 内置的英文文本格式符。如果你面向多国用户,%W输出Monday没问题,但想输出понедельник就得靠应用层。

我的经验是:数据库负责粗粒度时间计算和聚合,应用层负责最终展示文案。比如按天统计这种必须发生在数据库端的操作,DATE_FORMAT 当仁不让;而报表表格里“星期四 14:30”这种展示性文案,让应用层去做本地化更合理。不要一把梭让数据库干所有的活。

4. 日期格式化的隐藏陷阱:NULL、隐式转换与性能代价

4.1 DATE_FORMAT 遇上 NULL 的静默消失

DATE_FORMAT(NULL, '%Y-%m-%d') 返回 NULL,不是空字符串,也不是'0000-00-00'。这在统计报表里特别容易造成“某一天数据神秘消失”的现象。比如你想统计本月每天的用户注册数:

SELECT DATE_FORMET(register_time, '%Y-%m-%d') AS day, COUNT(*) AS cnt FROM users WHERE register_time >= '2024-05-01' GROUP BY day;

只要某个用户register_time是 NULL,这一行在 GROUP BY 时不会消失(NULL 会单独成组),但如果你在 COUNT 里只数了非空记录,那 NULL 组显示为 0,很容易被忽略。更好的是在聚合前显式处理:

SELECT COALESCE(DATE_FORMAT(register_time, '%Y-%m-%d'), '未知') AS day, COUNT(*) AS cnt FROM users ...

还有一个关联问题是DATE_FORMAT可能收到非法日期。MySQL 在非严格模式下会把'2024-02-30'这种日期转成'0000-00-00',而DATE_FORMAT('0000-00-00', '%Y-%m-%d')返回 NULL。排查数据质量问题时,如果你看到报表里突然缺了某天,先查源数据是不是有脏日期。

4.2 格式化后再比较:索引失效的经典写法

这是我最想强调的一个坑。很多人想查某一天的数据,习惯性写成:

SELECT * FROM orders WHERE DATE_FORMAT(created_at, '%Y-%m-%d') = '2024-05-20';

逻辑上完全正确,执行效率上却极其糟糕。因为created_at上如果有索引,MySQL 无法对DATE_FORMAT(created_at, ...)这个表达式使用索引查找,只能全表扫一遍,对每一行的created_at做格式化,再跟字符串比较。表一旦上了百万行,这个查询就是灾难。

正确写法是用范围条件:

SELECT * FROM orders WHERE created_at >= '2024-05-20 00:00:00' AND created_at < '2024-05-21 00:00:00';

这个写法的好处是:第一,走了索引,性能数量级的提升;第二,即使created_at是 DATETIME 带小数秒,范围条件也能精确覆盖;第三,可读性也不差。我的习惯是,只要是想筛“某一天”的数据,一律写范围条件,绝不写DATE_FORMAT比较。

如果你非要在比较时用 DATE_FORMAT,至少把等号换成BETWEEN粒度更细的写法,或者考虑在新建列上建函数索引。MySQL 8.0.13 以后支持函数索引,可以直接CREATE INDEX idx_date ON orders ((DATE_FORMAT(created_at, '%Y-%m-%d')));,但说实话,日常业务里没必要为一个格式化条件专门建索引,把查询写法改对就好了。

4.3 隐式转换的连环雷:从字符串到日期再到字符串

DATE_FORMAT 的第一个参数虽然叫 date,但 MySQL 允许传字符串,比如DATE_FORMAT('2024-05-20 14:30:45', '%H')会正常返回14。这个“宽容”特性会导致一个隐蔽的问题:你传入的字符串如果格式不规范,MySQL 不会报错,而是给出一个诡异的结果。

举个例子:

SELECT DATE_FORMAT('2024/05/20', '%Y-%m-%d'); -- 结果:2024-05-20,MySQL 自动把斜杠识别成日期分隔符 SELECT DATE_FORMAT('20240520', '%Y-%m-%d'); -- 结果:2024-05-20?还是 NULL? -- 实测是 2024-05-20,MySQL 能解析纯数字串,但依赖版本和 sql_mode

问题在于这种隐式转换的规则不是标准的,它跟sql_mode、服务端版本都有关系。我在 MySQL 5.7 上测过DATE_FORMAT('20240520', '%Y-%m-%d')能正常输出,但到了 8.0 某些版本就返回 NULL 或者给你一个把20240520当数字计算的结果。所以我的建议是:不要依赖 DATE_FORMAT 去猜你的业务字符串是什么格式,先把字符串用 STR_TO_DATE 显式转成日期,再传给 DATE_FORMAT。

SELECT DATE_FORMAT(STR_TO_DATE('20240520', '%Y%m%d'), '%Y-%m-%d');

这看起来多了一步,但每一步都确定,不会因为版本迁移而爆炸。

4.4 格式化本身的性能代价

DATE_FORMAT 是逐行调用。一张千万级流水表,你 SELECT DATE_FORMAT(created_at, ...) 出来,MySQL 会对每一行执行一次日期格式化。这比直接输出原始 DATETIME 字段要慢,而且慢得不少。

我之前做过一个简单测试(MySQL 8.0.28,单表 500 万行):

查询方式平均耗时
SELECT created_at FROM big_table LIMIT 100000约 80ms
SELECT DATE_FORMAT(created_at, '%Y-%m-%d') FROM big_table LIMIT 100000约 220ms

在有 WHERE 条件过滤后再格式化的场景下差距会小一些,因为参与格式化的行数少了。所以一个基础优化原则是:先 WHERE 后 SELECT,先缩小结果集再格式化。不要在子查询里对全表做 DATE_FORMAT,然后外层再过滤,那等于白干。

还有一个藏在 GROUP BY 里的性能细节:GROUP BY DATE_FORMAT(created_at, '%Y-%m-%d')需要对格式化结果做分组,内存临时表的使用量会上升,因为分组键是变长字符串而不是紧凑的日期。如果数据量巨大,可以考虑先把日期截断到天再存汇总表,或者用CAST(created_at AS DATE)做分组。CAST(created_at AS DATE)的语义是取日期部分,它跟DATE_FORMAT(created_at, '%Y-%m-%d')结果相似,但底层的实现更直接,分组开销也小一些。不过返回类型一个是 DATE、一个是 VARCHAR,如果你只需要“某一天”这种粗粒度,CAST AS DATE往往是更轻的选择。

5. 进阶玩法:动态格式串与日期函数的协同使用

5.1 用一个变量控制统计粒度

报表系统经常遇到“同一条查询,按日、按月、按小时切换粒度”的需求。与其为每个粒度写一条 SQL,不如把格式串变成动态参数。

SET @fmt = '%Y-%m-%d'; -- 动态传给查询 SELECT DATE_FORMAT(created_at, @fmt) AS period, COUNT(*) AS cnt FROM payment_log WHERE created_at >= '2024-01-01' GROUP BY DATE_FORMAT(created_at, @fmt);

在 Java 里可以通过 PreparedStatement 把格式串作为参数传入,这样一条统计接口就能支持日/周/月/小时多种维度。格式串集可以定义成一组常量:日'%Y-%m-%d'、周'%x-%v'、月'%Y-%m'、小时'%Y-%m-%d %H'。

这里需要小心周维度的格式串。%x-%v是 ISO 周(周一起算)的标准组合,输出如2024-20,代表 2024 年第 20 周。如果你用%Y-%U,虽然也能输出年份和周数,但%U是周日起算,跟中国习惯的周一起算不一致,统计结果会整体错位一天。这个坑在跨周统计时最明显——周日的数据会被算到上一周。

5.2 跟 DATE_ADD、LAST_DAY 搭配做自然月统计

DATE_FORMAT 经常要配合日期运算函数一起用。比如统计“上个月的每一天”的销售量,你不想写死月份,可以用 LAST_DAY 定位上个月的最后一天,再往前推一个月:

SELECT DATE_FORMAT(day, '%Y-%m-%d') AS day, COUNT(*) AS cnt FROM sales WHERE day >= DATE_FORMAT(DATE_SUB(CURRENT_DATE(), INTERVAL 1 MONTH), '%Y-%m-01') AND day < DATE_FORMAT(CURRENT_DATE(), '%Y-%m-01') GROUP BY DATE_FORMAT(day, '%Y-%m-%d');

这里的思路是:先用DATE_FORMAT(CURRENT_DATE(), '%Y-%m-01')生成本月 1 号,然后用DATE_SUB减去一个月就得到上月 1 号。这样不管今天是几号、上个月是 30 天还是 31 天,区间都是准确的。

另一个常见组合是“本月累计”和“本月剩余天数”:

SELECT DATEDIFF(LAST_DAY(CURRENT_DATE()), CURRENT_DATE()) AS remaining_days, DATE_FORMAT(LAST_DAY(CURRENT_DATE()), '%Y-%m-%d') AS month_end;

5.3 在存储过程与触发器里生成可读日志时间

如果你在写存储过程或触发器,DATE_FORMAT 通常是用来拼接日志内容或者生成事件描述字段的。比如给订单表建一个审计触发器,把变更时间转成易读格式写进日志表:

CREATE TRIGGER trg_order_audit AFTER INSERT ON orders FOR EACH ROW BEGIN INSERT INTO order_audit(order_id, event_desc, event_time) VALUES ( NEW.order_id, CONCAT('新订单创建于 ', DATE_FORMAT(NOW(), '%Y-%m-%d %H:%i:%s')), NOW() ); END;

注意这里的一个关键抉择:日志表里event_time字段存什么类型?很多人会直接存DATE_FORMAT产生的字符串,省事,但后续如果想做时间区间查询、排序、聚合,字符串会非常痛苦。我的建议是:原始时间值(DATETIME/TIMESTAMP)保留在独立字段,DATE_FORMAT 的产物只用于展示或者冗余的描述字段。永远不要把格式化后的字符串当作主时间字段去查询,否则你会掉进“为了格式化而格式化”的怪圈。

5.4 动态跨年周数的坑:千万不要用 %Y 配合 %v

最后单独把跨周年份这个坑拎出来讲。在周维度统计中,%v返回第几周(1-53),但它表示“哪一年”的周数时,必须配合%x而不是%Y。典型的错误发生在 2024 年 12 月 30 日——这天是周一,属于 ISO 周 2025 年的第 1 周。如果你用DATE_FORMAT(date, '%Y-%v'),会输出2024-01,但事实上这周的“归属年”是 2025。正确写法是%x-%v,输出2025-01。

SELECT DATE_FORMAT('2024-12-30', '%Y-%v') AS wrong_result, -- 2024-01 DATE_FORMAT('2024-12-30', '%x-%v') AS correct_result; -- 2025-01

%x是周一周算的年份,%X是周日周算的年份。如果你公司财务的“周”定义为周日开始,就要用%X-%V,跟用%x-%v的结果会差上几天。这里没有绝对的对错,关键是跟业务定义保持一致,并且在代码注释里写清楚“这里用 ISO 周”还是“这里用自然周”。我见过因为这个分歧,两个团队各执一词吵到领导层去的。数据库层面不解决业务定义问题,但至少你要知道自己用的是哪套规则。

6. 我在实际维护中总结的几条铁律

写了这么多,最后把我这些年实际踩坑后总结的几条操作纪律分享给你,它们不复杂但能挡掉绝大多数 DATE_FORMAT 相关的线上事故。

第一条,筛选永远用范围条件,不用格式化后的等值比对。这是性价比最高的一条。把DATE_FORMAT(created_at, '%Y-%m-%d') = '2024-05-20'改成区间查询后,查询性能和数据准确性同时提升。别贪图写法简单,简单不等于正确。

第二条,分钟是 %i,月份是 %m,时刻区分 %h 和 %H。这三个大小写/字母差异是最容易写错的。我建议你在代码仓库的 SQL 规范文档里放一份常用格式符对照表,新人上手先看表,而不是靠记忆。肉眼 review 不出来拼写错误,只有测试能兜住。

第三条,格式串里出现中文时先确认展示层需求。如果只是内部系统的临时报表,库里格式化成中文没问题。如果是多端共存的产品,把本地化格式化的活留给应用层,数据库只管聚合,展示层管文案,各司其职。

第四条,需要保存时间时存原始类型,格式化字符串可以做冗余字段但绝不能成为查询条件。这个前面说过,再强调一次是因为我见过真的有人把订单表的付款时间字段设成 VARCHAR,里面存2024-05-20 14:30:45,然后每次统计都靠 STR_TO_DATE 转换。一个本可以用索引的范围查询,硬生生变成全表扫描。时间字段就该用 DATE、DATETIME、TIMESTAMP 类型。

第五条,对于周、季度这类非自然粒度,先确认业务口径再写格式串。周一为一周之首还是周日为一周之首?跨年那周算哪一年的?这些问题不是 SQL 能替你决定的。写代码之前先跟业务方对齐,然后把口径注释在 SQL 旁边。等线上统计出来的数字引发了业务争执,再来解释“啊这个 %U 是周日口径”就已经晚了。

我还想提醒一个很容易忽略的小细节:DATE_FORMAT 返回字符串,所以当你对格式化结果做 ORDER BY 时,排序规则是字符串字典序。%Y-%m-%d格式下字典序正好等同于时间顺序,这是个巧合;但如果你用了%Y-%c-%e这种不带前导零的格式,排序就会乱掉。所以在按时间排序时,优先使用带前导零的格式串,或干脆用原始日期字段排序。

DATE_FORMAT 是个小函数,但它连接着业务展示、聚合统计和查询性能三个层面。把它的格式符体系、边界行为和搭配技巧吃透,能省下很多排查数据问题的时间。希望这篇能帮你把日期格式化的基本功打得扎实一点,下次再看到那个%y-%m-%e的写法时,你会第一时间知道问题出在哪。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询