先说一个自己的体会:接触MySQL这么多年,日期格式化是写业务SQL时绕不开的一环,但它又是日常里最容易写飘的地方。项目里只要涉及到报表、统计、日志清理、订单超时判断,就一定会碰到日期和字符串之间来回折腾的问题。最近带一个同事排查一条慢SQL,发现他把DATE_FORMAT(created_at, '%Y-%m-%d') = '2025-03-01'直接写在WHERE条件里,结果几十万行的订单表被搞成全表扫描,那个教训挺典型。所以我想把这一系列的东西完整整理一遍,从函数用法、格式符细节,到常见的性能陷阱和业务场景SQL,一次性讲清楚。
1. 日期格式化需求为什么值得单独开一个系列
1.1 格式化不只是“好看”
很多人以为日期格式化就是把2025-03-01 14:30:00输出成2025/03/01这种倒腾格式的操作,属于锦上添花。但实际在业务里,它承担的功能远比“展示美化”要多:
- 分组统计的基石:报表要按天、按小时、按月聚合,前提就是能把时间戳规整成对应的粒度。没有格式化能力,
GROUP BY根本无从下嘴。 - 数据清洗和转换:从Excel导入、上游接口传过来的日期经常是
20250301、2025/03/01、03/01/2025这类五花八门的格式,必须先统一成标准格式才能落库和比较。 - 查询条件的边界控制:判断“某天的数据”和“某个时间段的数据”,写法上差之毫厘,性能上谬以千里。格式化能力在这里直接决定了你是走索引还是扫全表。
- 业务计算:会员到期、订单超时、年龄计算、活跃天数统计,都依赖日期差值函数和格式转换的组合使用。
所以一个实用系列把这块讲透,是值得的。尤其是主力用MySQL做业务开发的同事,这几个函数就是吃饭的家伙。
1.2 搞懂“日期类型”和“字符串”的本质区别
这是格式化系列里最重要的认知,比记住函数字母都重要。
MySQL里,DATETIME、TIMESTAMP、DATE是真正的日期时间类型,有自己的存储格式和排序规则,占用的字节是固定的,比较大小走的是数值逻辑。而'2025-03-01'这种带引号的写法,本质上是一个字符串。字符串比较大小是按字典序从左到右逐位比,看起来'2025-03-01' < '2025-03-02'能得出正确结果,是因为这个格式本身设计成了字典序=时间序,不是MySQL做了特殊处理。
这个区别在跨格式比较时会出问题。比如'2025/03/01'和'2025-03-01',两个字符串的字典序关系是错乱的,如果你把前者直接和日期列比较,MySQL会尝试做隐式转换,一旦转换规则和预期不一致,结果就很离谱。这也是为什么我强烈建议:能用日期类型就用日期类型,能不用字符串就别用字符串。
2. 核心函数拆解:格式化与解析的看家本领
2.1 DATE_FORMAT:最常用的格式化输出函数
DATE_FORMAT(date, format)接收一个日期时间值和一个格式串,返回按格式串描述的字符串。这是整个系列的核心主角。
格式串里的占位符非常细,我建议直接记下表:
| 格式符 | 含义 | 输出示例(以2025-03-05 14:08:09为例) |
|---|---|---|
%Y | 四位年份 | 2025 |
%y | 两位年份 | 25 |
%m | 月份,补零 | 03 |
%c | 月份,不补零 | 3 |
%d | 日,补零 | 05 |
%e | 日,不补零 | 5 |
%H | 24小时制,补零 | 14 |
%h | 12小时制,补零 | 02 |
%i | 分钟,补零 | 08 |
%s | 秒,补零 | 09 |
%p | AM或PM | PM |
%W | 英文星期全称 | Wednesday |
%a | 英文星期缩写 | Wed |
%M | 英文月份全称 | March |
%b | 英文月份缩写 | Mar |
%w | 数字星期,0为周日 | 3 |
%j | 一年中的第几天 | 064 |
%U | 周数,周日为每周第一天 | 10 |
%u | 周数,周一为每周第一天 | 10 |
%T | 时间,HH:MM:SS | 14:08:09 |
%f | 微秒,6位 | 000000 |
实际使用中,最稳的写法是DATE_FORMAT(now(), '%Y-%m-%d %H:%i:%s'),得到2025-03-05 14:08:09,这也是日志和展示层最通用的格式。
一个容易懵的点:%i是分钟,%s是秒,很多人第一次写会拿%m当分钟用,那就错了。另外%H和%h的区别是24小时制和12小时制,后者配合%p才能准确表达上下午。
2.2 STR_TO_DATE:反向解析字符串
格式化是“日期转字符串”,反过来“字符串转日期”就靠STR_TO_DATE(str, format)。它和DATE_FORMAT是一对镜像操作,格式符的规则完全一致。
-- 解析带斜杠的日期 SELECT STR_TO_DATE('2025/03/05', '%Y/%m/%d'); -- 输出:2025-03-05 -- 解析带毫秒的时间字符串 SELECT STR_TO_DATE('2025-03-05 14:08:09.123456', '%Y-%m-%d %H:%i:%s.%f');这个函数在数据迁移、接口对接时非常关键。外部系统传来的日期格式千奇百怪,不可能要求所有上游都输出标准格式。统一入口用STR_TO_DATE做一次清洗转换,比在应用层用语言自带库处理更贴近数据源头,也能减少一次网络传输。
需要注意的是,如果字符串和格式串对不上,函数会返回NULL而不是报错。这个特性有好有坏:好处是不至于让整个SQL挂掉,坏处是NULL会静默污染下游数据。所以做数据清洗时,最好先SELECT几条抽查一下解析结果,确认没有大面积解析失败再批量执行。
2.3 提取局部信息:YEAR、MONTH、DAY、HOUR
很多时候不需要完整格式化,只要取出年份或月份就够了。MySQL提供了独立的提取函数:
SELECT YEAR('2025-03-05 14:08:09'); -- 2025 SELECT MONTH('2025-03-05 14:08:09'); -- 3 SELECT DAY('2025-03-05 14:08:09'); -- 5 SELECT HOUR('2025-03-05 14:08:09'); -- 14 SELECT MINUTE('2025-03-05 14:08:09'); -- 8 SELECT SECOND('2025-03-05 14:08:09'); -- 9 SELECT QUARTER('2025-03-05 14:08:09'); -- 1 SELECT WEEK('2025-03-05 14:08:09'); -- 10(按当年第几个周)这些函数适合在条件判断和分组中使用,比如“只要年份为2025的数据”“按季度聚合”。但要警惕它们的性能陷阱,这个后面第5部分会展开细说。
2.4 当前时间与时间戳转换
业务开发里经常需要拿“当前时间”作为计算基准,MySQL里主要有三个:
NOW():返回当前日期时间,2025-03-05 14:08:09CURDATE():返回当前日期,2025-03-05CURTIME():返回当前时间,14:08:09
它们都有带F的变体(NOW(3)表示毫秒精度、NOW(6)表示微秒精度),适合做高精度日志记录。
另外两个和Unix时间戳有关的函数也属于这个范畴:
SELECT UNIX_TIMESTAMP('2025-03-05 14:08:09'); -- 返回秒级时间戳,如 1741169289 SELECT FROM_UNIXTIME(1741169289); -- 返回 2025-03-05 14:08:09UNIX_TIMESTAMP在跨语言、跨系统传参时很常用,因为一个整数比一个格式化字符串更容易在不同技术栈之间保持一致。但注意它返回的是秒级,不是毫秒级,和Java里的System.currentTimeMillis()差了1000倍,非常容易踩坑。
3. 日期计算与比较:查询条件的核心难点
3.1 DATE_ADD 与 DATE_SUB:时间加减的标准答案
业务里大量需求是“查最近24小时订单”“查上个月的报表”“给会员加30天有效期”。这类需求用字符串拼接加减法是做不出来的,必须交给MySQL的日期运算函数。
-- 加1天 SELECT DATE_ADD('2025-03-05 14:08:09', INTERVAL 1 DAY); -- 减2个月 SELECT DATE_SUB('2025-03-05 14:08:09', INTERVAL 2 MONTH); -- 加1个季度 SELECT DATE_ADD('2025-03-05 14:08:09', INTERVAL 1 QUARTER); -- 加30分钟 SELECT DATE_ADD('2025-03-05 14:08:09', INTERVAL 30 MINUTE);INTERVAL关键字后面可以接的数字单位包括:YEAR、QUARTER、MONTH、WEEK、DAY、HOUR、MINUTE、SECOND、MICROSECOND。用加间隔的思路,同样可以表达“减间隔”,DATE_SUB本质上是DATE_ADD的负数形式。
-- 以上两种写法等价 SELECT DATE_ADD('2025-03-05 14:08:09', INTERVAL -7 DAY); SELECT DATE_SUB('2025-03-05 14:08:09', INTERVAL 7 DAY);我更推荐统一用DATE_ADD加负数,这样加减操作只用记一个函数,心智负担更小。
有一个小细节:INTERVAL表达式如果直接加在日期字符串上,MySQL会隐式转换。比如'2025-03-05' + INTERVAL 1 DAY也能跑通,但为了可读性,还是显式包一下更稳妥。
3.2 DATEDIFF:两个日期之间隔了多少天
计算两个日期差几天,用DATEDIFF(date1, date2),返回的是date1 - date2的天数。
SELECT DATEDIFF('2025-03-05', '2025-03-01'); -- 4 SELECT DATEDIFF('2025-03-01', '2025-03-05'); -- -4它只关心日期部分,不关心时间部分。即使date1是2025-03-05 23:59:59,date2是2025-03-05 00:00:00,结果依然是0,因为都是在3月5日这一天。
适合的场景:计算用户注册了多少天、距离活动结束还有几天、判断两个日期是否连续等。但要注意它只支持“天”这个单位。
3.3 TIMESTAMPDIFF:任意单位的差值计算
需要按小时、分钟、月份、年份计算差值时,DATEDIFF就无能为力了,要用TIMESTAMPDIFF(unit, begin, end),返回end - begin在指定单位下的差。
-- 两个时刻之间隔了多少小时 SELECT TIMESTAMPDIFF(HOUR, '2025-03-05 08:00:00', '2025-03-05 14:00:00'); -- 6 -- 隔了多少个月 SELECT TIMESTAMPDIFF(MONTH, '2025-01-01', '2025-05-01'); -- 4 -- 隔了多少年 SELECT TIMESTAMPDIFF(YEAR, '2020-03-05', '2025-03-05'); -- 5这个函数在年龄计算、会员剩余时长、订单超时时间等场景里是首选。它比DATEDIFF更灵活,而且能处理精确到月/年的需求。注意参数的顺序是(unit, 起始, 结束),很多人容易写成(unit, 结束, 起始),结果得到负数,调试半天才发现是顺序写反了。
还有一个容易忽略的点:TIMESTAMPDIFF不支持QUARTER单位,如果想算季度差,可以用TIMESTAMPDIFF(MONTH, ...) / 3换算。
3.4 时间比较:直接比还是格式化后比
这是查询条件写法里最值得细说的部分。判断时间范围,优先直接比较日期时间类型,而不是格式化后再比较。
-- 推荐:用范围查询 SELECT * FROM orders WHERE created_at >= '2025-03-01 00:00:00' AND created_at < '2025-03-02 00:00:00'; -- 不推荐:对列做函数操作 SELECT * FROM orders WHERE DATE_FORMAT(created_at, '%Y-%m-%d') = '2025-03-01';第一种写法能利用created_at上的索引,第二种写法因为对列套了函数,索引基本失效,在数据量大时性能差异是数量级的。这是整个系列里最重要的性能铁律:**索引列上不要做函数操作,除非你有十足把握。**这个点我在第5部分还会专门展开。
4. 实操场景:可直接抄作业的SQL
4.1 按天和按小时统计订单
报表场景最典型的就是按时间粒度聚合。假设有一张订单表orders,包含id、amount、created_at。
按天统计销售额:
SELECT DATE_FORMAT(created_at, '%Y-%m-%d') AS day, COUNT(*) AS order_count, SUM(amount) AS total_amount FROM orders WHERE created_at >= '2025-03-01 00:00:00' AND created_at < '2025-04-01 00:00:00' GROUP BY DATE_FORMAT(created_at, '%Y-%m-%d') ORDER BY day;按小时统计某个时间窗口内的下单量:
SELECT DATE_FORMAT(created_at, '%Y-%m-%d %H:00:00') AS hour_slot, COUNT(*) AS order_count FROM orders WHERE created_at >= '2025-03-05 00:00:00' AND created_at < '2025-03-06 00:00:00' GROUP BY DATE_FORMAT(created_at, '%Y-%m-%d %H:00:00') ORDER BY hour_slot;这里有个细节:GROUP BY后面可以直接写DATE_FORMAT(created_at, '%Y-%m-%d')的完整表达式,也可以给表达式起别名后用别名分组。但有些MySQL版本对别名的支持有些差异,我习惯在GROUP BY里写完整表达式,避免歧义。ORDER BY则可以直接用别名,因为排序发生在分组之后。
4.2 月度报表和季度汇总
月度报表比日报少,但难度略高一点,因为要跨月。常见做法是按%Y-%m聚合:
SELECT DATE_FORMAT(created_at, '%Y-%m') AS month, COUNT(*) AS order_count, SUM(amount) AS total_amount FROM orders WHERE created_at >= '2025-01-01 00:00:00' AND created_at < '2026-01-01 00:00:00' GROUP BY DATE_FORMAT(created_at, '%Y-%m') ORDER BY month;季度汇总可以这样写:
SELECT CONCAT(DATE_FORMAT(created_at, '%Y'), '-Q', QUARTER(created_at)) AS quarter, COUNT(*) AS order_count FROM orders WHERE created_at >= '2025-01-01 00:00:00' AND created_at < '2026-01-01 00:00:00' GROUP BY DATE_FORMAT(created_at, '%Y'), QUARTER(created_at) ORDER BY quarter;其实GROUP BY后面可以同时写多个表达式,MySQL会按照组合去重分组,比嵌套子查询要简洁得多。
4.3 本周、本月、近30天数据
这类需求的核心是算出查询边界,也就是“起始时间”和“结束时间”。我推荐先把这个边界算出来,再放进WHERE做范围查询。不建议直接在WHERE里写DATE_FORMAT(created_at, ...)做比较,理由前面已经说过。
本周数据(周一起始):
SELECT * FROM orders WHERE created_at >= DATE_SUB(CURDATE(), INTERVAL WEEKDAY(CURDATE()) DAY) AND created_at < DATE_ADD(DATE_SUB(CURDATE(), INTERVAL WEEKDAY(CURDATE()) DAY), INTERVAL 7 DAY);WEEKDAY()返回周一为0、周日为6,用CURDATE()减去这个天数值,就能回到本周一。这个写法比用DATE_FORMAT(NOW(), '%U')取周数再拼年份更靠谱,因为周数跨年的时候很容易算错。
本月数据:
SELECT * FROM orders WHERE created_at >= DATE_FORMAT(CURDATE(), '%Y-%m-01') AND created_at < DATE_ADD(DATE_FORMAT(CURDATE(), '%Y-%m-01'), INTERVAL 1 MONTH);DATE_FORMAT(CURDATE(), '%Y-%m-01')取当前月份第一天,是一个很实用的小技巧。
近30天数据:
SELECT * FROM orders WHERE created_at >= DATE_SUB(CURDATE(), INTERVAL 30 DAY);注意一个坑:近30天是包含今天往前数的30个自然日,如果有人把它写成INTERVAL 1 MONTH,在2月和3月切换时可能有出入,因为月份天数不一样。业务口径上要提前确认清楚。
4.4 年龄计算和活跃时长
用户表里存了生日birthday,计算当前年龄:
SELECT name, birthday, TIMESTAMPDIFF(YEAR, birthday, CURDATE()) AS age FROM users;这里TIMESTAMPDIFF(YEAR, ...)会按自然年满才算一岁,比用DATEDIFF(..., ...) / 365取整要精确很多。很多人自己写年龄计算时容易忽略闰年的影响,直接除365会积累误差,用TIMESTAMPDIFF是标准答案。
计算两个时间点之间的停留时长,精确到分钟:
SELECT user_id, TIMESTAMPDIFF(MINUTE, start_time, end_time) AS duration_minutes FROM user_sessions;如果是计算订单超时时间,比如“下单后30分钟未支付自动取消”,查询条件是:
SELECT * FROM orders WHERE status = 'PENDING' AND created_at <= DATE_SUB(NOW(), INTERVAL 30 MINUTE);这样写的好处是让MySQL在索引上直接比较,业务端不需要先把所有待支付订单查出来再逐条算时间差,性能好得多。
5. 常见问题与性能陷阱
5.1 索引列上套函数导致全表扫描
这是我在第4部分反复强调的点,值得单独拎出来作为提醒。
-- 反例1:对索引列DATE_FORMAT后等于某个值 SELECT * FROM orders WHERE DATE_FORMAT(created_at, '%Y-%m-%d') = '2025-03-01'; -- 反例2:对索引列YEAR提取后等于某个值 SELECT * FROM orders WHERE YEAR(created_at) = 2025;上面两条SQL,在created_at有索引的情况下,执行计划大概率是ALL全表扫描。因为MySQL要先对每一行的created_at做完函数转换,才能拿结果去和右边的常数比较,转换过程没法走索引。
正确写法是把条件改造成范围查询:
-- 正确写法1:日期范围 SELECT * FROM orders WHERE created_at >= '2025-03-01 00:00:00' AND created_at < '2025-03-02 00:00:00'; -- 正确写法2:年份范围 SELECT * FROM orders WHERE created_at >= '2025-01-01 00:00:00' AND created_at < '2026-01-01 00:00:00';这不是MySQL不给力,而是B+树索引存储的是原始值,能直接比较区间,难以支持对原始值的任意函数变换。理解这一点后,写条件之前就会多想一层:我要不要把函数套在查询值上,而不是套在列上?
比如YEAR(created_at) = 2025可以改写成created_at BETWEEN '2025-01-01 00:00:00' AND '2025-12-31 23:59:59',但这样写有个边界隐患:如果时间戳有毫秒或更细的精度,'2025-12-31 23:59:59'会漏掉最后一秒内的数据。所以我通常用的是前闭后开区间:
WHERE created_at >= '2025-01-01 00:00:00' AND created_at < '2026-01-01 00:00:00'这个写法对任何时间精度都安全。
5.2 时区问题:为什么NOW()和别人看到的不一样
MySQL连接建立时会使用服务端或连接的time_zone设置来决定NOW()的返回值。如果应用服务器、数据库服务器、客户端三方时区不一致,就会出现“明明同一个时间,这边查出来比那边早8小时”的诡异问题。
排查顺序一般是:
- 查服务端时区:
SELECT @@global.time_zone, @@session.time_zone; - 查当前连接时区:
SELECT NOW(); - 比对和业务预期时区的差异。
实践中的稳妥做法是:在连接串里显式指定时区,比如JDBC连接参数serverTimezone=Asia/Shanghai,或者统一让数据库服务和业务服务器的时区保持一致。生产环境里时区不一致比想象中更容易引发数据错乱,尤其是定时任务和跨系统接口对接。
另外,TIMESTAMP类型在存储时会把当前时区转换成UTC存储,读取时再转回连接时区;而DATETIME类型就是原位存储,不做时区转换。如果项目有跨时区需求,选TIMESTAMP更合适,但要接受时区换算带来的额外逻辑复杂度。
5.3 NULL 和非法日期的静默问题
日期解析函数的返回NULL是静默的,很容易在不知不觉中污染数据。
-- STR_TO_DATE解析失败时返回NULL SELECT STR_TO_DATE('2025-13-45', '%Y-%m-%d'); -- NULL非法日期在MySQL的严格模式下写入会直接报错,在非严格模式下可能被存成0000-00-00。如果一个表里出现了0000-00-00,后续用DATE_FORMAT对它做格式化时返回的也是NULL,就会产生一串连锁反应。
建议:
- 建表时保证日期时间字段
NOT NULL,并设置合理的默认值,比如DEFAULT CURRENT_TIMESTAMP。 - 写入前用
STR_TO_DATE做一次解析校验,对解析结果为NULL的数据单独告警。 - 查询时如果判断某个日期可能是非法日期,用
WHERE date_col IS NULL OR YEAR(date_col) = 0这类条件主动排除。
5.4 格式符大小写和拼写问题
格式符虽然多,但最坑的不是记不住,而是大小写混淆。比如:
%m是月份(01-12),%M是英文月份名(January),完全两码事。%d是日(01-31),%D是带后缀的日子(1st),基本用不上但很容易手滑。%H是24小时制,%h是12小时制,凌晨时段最能暴露问题。%i是分钟,%s是秒,很多人写成%S,MySQL里没有%S这个格式符,会原样输出字母S。
我遇到过最离谱的一次事故,是把%i写成了%m,导致所有时间输出里分钟位置被月份顶替,从14:08变成了14:03,排查了半天才发现是格式符写错。所以建议写完SQL后先用一条SELECT DATE_FORMAT(NOW(), '你的格式串')肉眼验证一遍输出,再扔进业务代码。
5.5 字符集与排序问题
DATE_FORMAT返回的是字符串,所以在GROUP BY和ORDER BY时,排序规则受字符集和排序规则影响。大多数情况下%Y-%m-%d这种格式的数字排序结果和日期顺序一致,不容易出问题。但一旦格式串里混入英文月份名,比如%M %d, %Y,排序就会变成按字母序排列月份,直接打乱时间顺序。
如果业务报表对顺序有要求,建议GROUP BY时按照%Y-%m这种可排序格式,或者单独再按原始日期列排序:
GROUP BY DATE_FORMAT(created_at, '%Y-%m') ORDER BY MIN(created_at);使用聚合函数MIN(created_at)取分组内的最早时间作为排序依据,比依赖格式化后字符串的排序可靠得多。
6. 几个实用组合技巧
6.1 动态生成连续日期序列
报表里经常会遇到“某一天没有数据也要显示0”的需求。直接用GROUP BY DATE_FORMAT会漏掉没数据的日期,处理方法是先生成一个日期序列,再左连接业务表。
SELECT date_seq.day, COALESCE(agg.order_count, 0) AS order_count FROM ( SELECT DATE_ADD('2025-03-01', INTERVAL seq.day_offset DAY) AS day FROM ( SELECT 0 AS day_offset UNION ALL SELECT 1 UNION ALL SELECT 2 UNION ALL SELECT 3 ) seq ) date_seq LEFT JOIN ( SELECT DATE_FORMAT(created_at, '%Y-%m-%d') AS day, COUNT(*) AS order_count FROM orders WHERE created_at >= '2025-03-01 00:00:00' AND created_at < '2025-03-05 00:00:00' GROUP BY DATE_FORMAT(created_at, '%Y-%m-%d') ) agg ON date_seq.day = agg.day ORDER BY date_seq.day;这种连续日期序列在金融、运营报表里是刚需。MySQL 8.0可以用递归CTE生成序列,写法更简洁:
WITH RECURSIVE date_seq AS ( SELECT '2025-03-01' AS day UNION ALL SELECT DATE_ADD(day, INTERVAL 1 DAY) FROM date_seq WHERE day < '2025-03-05' ) SELECT * FROM date_seq;不过递归CTE有最大递归深度限制,生成超大日期范围时会报错,实际使用要控制好数据量。
6.2 时间字段自动更新
很多业务表要求“记录创建时间”和“记录最后修改时间”,通常做法是应用层代码里自己填值,或者用MySQL的默认值和ON UPDATE能力:
CREATE TABLE demo_table ( id INT PRIMARY KEY AUTO_INCREMENT, data VARCHAR(100), created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP );这样插入时created_at和updated_at都会自动填充当前时间,之后任何对行的更新操作都会自动刷新updated_at,不用业务代码额外操心。这个能力配合DATE_FORMAT(updated_at, '%Y-%m-%d %H:%i:%s')做展示,很省事。
要注意ON UPDATE CURRENT_TIMESTAMP只对真实发生了行内容变更的更新生效,如果执行UPDATE但值没变化,部分版本可能不会刷新。
6.3 微秒精度与日期截断
日志类系统经常需要记录微秒级时间。CURRENT_TIMESTAMP(6)可以取到6位微秒。但DATE_FORMAT默认格式串里只有%f是微秒,如果不写%f,输出就会丢失精度。做流水记录和时序数据时,建议格式串统一加上%f:
SELECT DATE_FORMAT(NOW(6), '%Y-%m-%d %H:%i:%s.%f');返回类似2025-03-05 14:08:09.123456。如果业务根本不需要微秒,就用NOW()不带参数即可,节省一点性能开销和存储空间。
7. 踩过的坑和现在的习惯写法
写日期格式化相关SQL也踩了不少坑,说几个让我印象深刻的。
第一是关于TIMESTAMPDIFF的参数顺序。有一回做订单超时统计,我写成TIMESTAMPDIFF(MINUTE, NOW(), created_at),结果所有超时订单全变成了负数,排查半天才发现参数反了。这个函数从设计上就是TIMESTAMPDIFF(unit, start, end),要的是end - start,不是start - end。后来我在所有相关代码里统一约定“被减数在前,减数在后”,并且在关键统计SQL上方加注释,才彻底杜绝这类问题。
第二是关于DATE_FORMAT的分组性能。早期做报表时图省事,直接GROUP BY DATE_FORMAT(created_at, '%Y-%m-%d'),数据量小的时候还好,后来订单量上来,这条SQL成了数据库里最明显的热点查询。最终的优化方案是:新建一张预聚合表,定时任务按小时把数据刷进这张表,报表查询只扫预聚合表,不碰原始订单表。格式化函数本身没错,但把高频统计放到明细表上是设计层面的问题。
第三是时区问题的排查。有一次线上接口返回的创建时间和数据库里存的差了8个小时,所有人都在查代码,最后发现是数据库连接参数的serverTimezone没配,导致应用侧读出来之后做了错误的时区转换。自那以后,凡是新建项目,第一件事就是在连接串里把时区写死,而不是依赖环境默认值。
我现在的习惯写法总结一下:
- 取日期部分:只要当天范围,用
created_at >= 'xxx 00:00:00' AND created_at < 'xxx 00:00:00'前闭后开。 - 按日期分组:报表场景用
DATE_FORMAT,但查询条件保持范围查询,不对索引列套函数。 - 天差值:用
DATEDIFF;需要任意单位差值用TIMESTAMPDIFF,一定检查好参数顺序。 - 字符串反解析:统一用
STR_TO_DATE,解析完之后先抽查结果,避免NULL污染。 - 字符格式:写完先
SELECT DATE_FORMAT(NOW(), '格式串')肉眼验证。
MySQL日期格式化这套能力不算深,但用好了能避免很多线上事故。希望这篇整理能让你写日期相关SQL时少走一些弯路。如果有更刁钻的日期处理场景,欢迎一起讨论。