做后端开发这些年,凡是涉及统计报表、定时任务、数据对账的话,十有八九都要跟 SQL 时间函数匹配打交道。MySQL 里的日期时间函数不算少,但真正用得上的、也最容易出幺蛾子的,基本上就是那套“格式化、转换、加减、求差、比较”的组合拳。很多人背得下 NOW()、DATE_FORMAT、STR_TO_DATE,可真遇到业务需求,照样会写出查不到当天数据、月份范围少了最后一天、或者慢到全表扫描的 SQL。今天不打算把文档里的函数清单抄一遍,而是从实际工作场景出发,聊聊我在写“SQL 的时间函数匹配(MySQL)”时踩过的坑、提炼出来的写法,以及哪些索引陷阱会让你明明函数用得挺熟,线上却慢成蜗牛。适合刚接触 MySQL 的新手,也适合天天写报表的老开发拿来对照自查。
1. 为什么说“匹配”是时间函数的真正难点
1.1 时间数据在表里有好几种形态
很多新手理解的时间匹配,就是拿一个日期去比较另一个日期。但真实表里的时间形态五花八门。
有的是 DATETIME,存的是2024-03-15 10:30:00;有的是 TIMESTAMP,存的值看起来差不多,但底层会带时区转换逻辑;有的干脆用 DATE 只存2024-03-15;还有老系统用 INT 存 Unix 时间戳,或者用 VARCHAR 存2024/03/15 12:30。同一张统计表甚至可能混着好几种形态。
匹配的本质,是把这些不同形态统一到同一个时间粒度上,再做比较。比如页面传过来一个字符串2024-03-15,要和数据库里的 DATETIME 字段“当天是否匹配”,就不能直接写WHERE create_time = '2024-03-15',因为'2024-03-15'会被隐式转成2024-03-15 00:00:00,而记录大多是10:30:00,等于把一个区间问题误当成了等值问题。
所以第一步先想清楚:源数据是什么类型,目标匹配粒度是天、小时、还是分钟?想清楚了,函数选择自然就出来了。
1.2 匹配要区分等值、范围还是模糊
时间匹配不只是=一种,我习惯把它拆成四类:
- 等值匹配:比如查某一条流水是不是恰好落在某一秒,极少用,因为业务几乎不会精确到秒对齐。
- 范围匹配:比如查最近 7 天、本季度、上个月,这是最常用的场景。
- 分段匹配:比如按天、按小时、按周分组统计,这时候匹配的是“日期片段”。
- 文本模糊匹配:比如拿到
'2024-03'这种字符串,想匹配整月数据;或者拿到'20240315'这种紧凑日期,要转换后匹配。
不同的匹配类型,对应的函数选型完全不同。范围匹配强调的是边界控制,分段匹配强调的是格式化输出,文本模糊匹配强调的是入参解析。这也是为什么我不鼓励“背函数”,而鼓励“按场景选函数”。你把需求归类之后,写起来会顺手很多。
2. 常用时间函数与匹配场景速查
2.1 取当前时间与基础拆分函数
最基础的一组是NOW()、CURDATE()、CURTIME()。
NOW()返回当前完整的日期时间,比如2024-03-15 14:30:00。CURDATE()只返回当前日期,等于DATE(NOW())。CURTIME()只返回当前时间,比如14:30:00。
配套的拆分函数有YEAR()、MONTH()、DAY()、HOUR()、MINUTE()、SECOND()。它们的作用是取出时间里的某一个分量,然后拿这个分量去做匹配。
举个常见的例子:外卖系统要统计“午高峰 11 点到 13 点之间下的单”,你可以写:
SELECT COUNT(*) FROM orders WHERE HOUR(create_time) BETWEEN 11 AND 13;这个写法简单直观,适合小表。但如果表很大,HOUR(create_time)在列上套了函数,索引基本就废了,后面我会专门讲这个坑。
还有一组拆分函数容易搞混:DAYOFWEEK()返回本周第几天,DAYOFYEAR()返回一年中的第几天,QUARTER()返回季度,WEEK()返回周序号。这些函数在“周匹配”和“季度匹配”时很有用,但要特别留意周日算 1 还是算 7,不同模式参数下结果可能不一样。
2.2 格式化输出与字符串转日期
真正让时间函数匹配灵活起来的核心,是DATE_FORMAT()和STR_TO_DATE()。
DATE_FORMAT(date, format)把日期时间转换成任意格式的字符串。比如:
SELECT DATE_FORMAT('2024-03-15 14:30:00', '%Y-%m-%d'); -- 2024-03-15 SELECT DATE_FORMAT('2024-03-15 14:30:00', '%Y%m%d'); -- 20240315 SELECT DATE_FORMAT('2024-03-15 14:30:00', '%Y-%m-%d %H:%i'); -- 2024-03-15 14:30格式符里%Y是四位年份,%m是两位月份,%d是两位日期,%H是 24 小时制小时,%i是分钟。注意%M是英文月份名,%D是带序数的日期,容易踩坑。
STR_TO_DATE(str, format)是反过来,把字符串解析成日期时间类型。它是文本匹配场景里的利器:
SELECT STR_TO_DATE('2024/03/15 14:30', '%Y/%m/%d %H:%i'); -- 返回 2024-03-15 14:30:00这个函数解决的是“外部传来的字符串日期和数据库时间字段做匹配”的问题。后面实战部分我会具体演示。
2.3 时间加减、求差与月份边界
DATE_ADD(date, INTERVAL expr unit)和DATE_SUB(date, INTERVAL expr unit)用来做时间加减。支持的单位很多:DAY、MONTH、YEAR、HOUR、MINUTE、SECOND、QUARTER等。
一个常见误区是直接写create_time > NOW() - 7,这是错的,减 7 天要写成:
WHERE create_time > NOW() - INTERVAL 7 DAY;或者用DATE_SUB(NOW(), INTERVAL 7 DAY)。
DATEDIFF(date1, date2)只返回相差的天数,适合算“从注册到现在过了几天”。TIMESTAMPDIFF(unit, start, end)更精细,可以按SECOND、MINUTE、HOUR、MONTH、YEAR返回差值。
月份边界是另一个高频痛点。“上个月月初”“下个月月初”这类需求,最稳妥的写法是用DATE_FORMAT(CURDATE(), '%Y-%m-01')拿到当月第一天,再配合INTERVAL 1 MONTH推进。
-- 上月月初 SELECT DATE_FORMAT(CURDATE() - INTERVAL 1 MONTH, '%Y-%m-01'); -- 本月月初 SELECT DATE_FORMAT(CURDATE(), '%Y-%m-01'); -- 下月月初 SELECT DATE_FORMAT(CURDATE() + INTERVAL 1 MONTH, '%Y-%m-01');为什么要强调“月初”?因为写月末很难写准,2 月只有 28 或 29 天,直接写'2024-02-31'就会出错。用区间右开的方式,永远比精确算月末更安全。
3. 时间匹配的实战写法:从需求到 SQL
3.1 今日、昨日、最近 N 天的范围匹配
这是最最常见的需求。很多人会这么写“今日数据”:
SELECT COUNT(*) FROM orders WHERE DATE(create_time) = CURDATE();这个写法没错,但我在第 4 章会解释它为什么在大表上很危险。更推荐写成范围匹配:
-- 今日 WHERE create_time >= CURDATE() AND create_time < CURDATE() + INTERVAL 1 DAY -- 昨日 WHERE create_time >= CURDATE() - INTERVAL 1 DAY AND create_time < CURDATE()“最近 7 天”这个说法其实有歧义。如果是包含今天在内的最近 7 天,起点是前 6 天:
WHERE create_time >= CURDATE() - INTERVAL 6 DAY如果是要排除今天的过去 7 天完整窗口,起点是前 7 天,终点是今天零点:
WHERE create_time >= CURDATE() - INTERVAL 7 DAY AND create_time < CURDATE()我建议写 SQL 之前,先跟业务方确认清楚“最近 7 天”到底包不包含今天。这种歧义在月报、周报里经常引发数据对不上,别等上线了再排查。
3.2 按年月、周、小时分组统计
分组统计本质上是把每条记录“匹配”到某一个时间桶里。
按天统计订单量:
SELECT DATE_FORMAT(create_time, '%Y-%m-%d') AS day, COUNT(*) FROM orders WHERE create_time >= '2024-03-01' AND create_time < '2024-04-01' GROUP BY DATE_FORMAT(create_time, '%Y-%m-%d') ORDER BY day;按小时统计高峰:
SELECT DATE_FORMAT(create_time, '%Y-%m-%d %H:00') AS hour_bucket, COUNT(*) FROM orders GROUP BY hour_bucket ORDER BY hour_bucket;按周统计时,直接GROUP BY WEEK(create_time)会有跨年问题,因为第 1 周可能横跨两年。更稳妥的是用YEARWEEK(create_time, 3),它返回类似202415这样的复合值,把年份和周序号拼在一起,分组才不会把两年的同一周混进一个桶。
按季度统计类似,如果只GROUP BY QUARTER(create_time),2023 年 Q1 和 2024 年 Q1 会混一起。正确做法是用CONCAT(YEAR(create_time), '-Q', QUARTER(create_time))或者DATE_FORMAT(create_time, '%Y')先区分年份。
3.3 文本日期解析与容错
我在数仓项目里经常碰到这种情况:业务方传过来一个 Excel 导出文件,里面日期格式是2024/3/15 14:30:00,想统计这一天有多少条记录。直接拿这个字符串去和 DATETIME 字段比较,MySQL 很可能转成 NULL 或者隐含转换出错。
这时候先STR_TO_DATE归一化:
SELECT STR_TO_DATE('2024/3/15 14:30:00', '%Y/%m/%d %H:%i:%s');另外一个经典坑是两位年份。字符串'24-03-15',如果用%Y-%m-%d解析,会得到 NULL,因为%Y要求四位年份,两位的要用%y。反过来,DATE_FORMAT输出年份时,%Y显示2024,%y显示24。这两个格式符别搞混。
再就是非法日期。STR_TO_DATE('2024-02-30', '%Y-%m-%d')返回 NULL,这是好事,说明函数会做合法性校验。但要注意 MySQL 的严格模式,在某些非严格模式下,非法转换可能变成0000-00-00而不是 NULL。我建议在转换外面包一层判断:
SELECT CASE WHEN STR_TO_DATE(input_date, '%Y-%m-%d') IS NULL THEN '无效日期' ELSE STR_TO_DATE(input_date, '%Y-%m-%d') END AS valid_date FROM temp_upload;这样可以提前把脏数据暴露出来,而不是等统计结果异常了再去翻原始表。
3.4 数据对账里的精确匹配
对账场景里,经常要把订单表的支付时间,和第三方渠道回调里的时间戳做匹配。这里面有两个问题:一是两边时间精度可能不一样,一个是秒,一个是毫秒;二是两边可能有几秒甚至几十秒的网络延迟。
如果要求严格一致,直接拿完整时间比较会漏掉很多正常订单。我的经验是先约定一个可容忍的时间窗口,比如“支付时间在回调时间前后 60 秒内算同一笔”:
SELECT a.order_id, b.transaction_id FROM orders a JOIN third_party_records b ON ABS(TIMESTAMPDIFF(SECOND, a.pay_time, b.callback_time)) <= 60如果两边系统时间本身差几秒,还可以先统一基准,比如把两边时间各自DATE_FORMAT到分钟粒度,再等值匹配:
DATE_FORMAT(a.pay_time, '%Y-%m-%d %H:%i') = DATE_FORMAT(b.callback_time, '%Y-%m-%d %H:%i')这种方式能避掉秒级误差,但会牺牲一点精确度。对账逻辑最好把这两种策略都提供出来,根据业务容忍度选择。
4. 让匹配不摧毁索引:我踩过的最痛一个坑
4.1 为什么在列上套函数会慢
我见过不少同事写出这样的 SQL:
SELECT * FROM orders WHERE DATE_FORMAT(create_time, '%Y-%m-%d') = '2024-03-15';功能上没错,结果也正确,但一旦orders表有上百万行,这条查询会慢到怀疑人生。
原因在于索引的结构。MySQL 的 B+ 树索引是按照原始列值排序存储的,当你对列套了函数,比如DATE_FORMAT(create_time, '%Y-%m-%d'),优化器在索引里找不到可以直接跳过的区间,只能把每一行都取出来,先算一遍函数,再拿结果去比较。相当于查字典的时候,有人让你“把所有页码对应的第一个字都读一遍”,而不是直接翻到目录里的目标页。
拿WHERE DATE(create_time) = CURDATE()来说,即使create_time上有索引,这个条件也无法走索引范围扫描。表越大,代价越明显。这不是 MySQL 的 bug,而是函数导致索引条件失去了“可比较”的性质。
4.2 把函数挪到等号右侧,而不是套在列上
规范做法是保持“列”裸写,把所有计算都放到等号或不等号右侧。
要查2024-03-15这一天的数据,不写DATE(create_time) = '2024-03-15',而是把整个天区间写出来:
WHERE create_time >= '2024-03-15' AND create_time < '2024-03-15' + INTERVAL 1 DAY要查某个月'2024-03'的数据,同样用区间:
WHERE create_time >= '2024-03-01' AND create_time < '2024-03-01' + INTERVAL 1 MONTH要查本季度,不要写QUARTER(create_time) = 1,而是算好季度的起止边界:
-- 2024 年第一季度 WHERE create_time >= '2024-01-01' AND create_time < '2024-04-01'这样写,create_time上的索引就可以正常使用,优化器能直接定位到一个大范围,再快速扫过满足条件的数据。
如果你手上有个变量@month = '2024-03',也可以写成:
WHERE create_time >= DATE(@month) AND create_time < DATE(@month) + INTERVAL 1 MONTH这比WHERE DATE_FORMAT(create_time, '%Y-%m') = @month快得多,语义也更准确。
4.3 区间匹配要养成“左闭右开”的习惯
时间范围匹配我强烈建议写成:
WHERE create_time >= start_time AND create_time < end_time也就是左边界包含,右边界不包含。很多人习惯写BETWEEN start AND end,但BETWEEN是双闭区间,包含两端的值。如果end是'2024-03-31 23:59:59',一条时间戳为2024-03-31 23:59:59.500的记录就会被漏掉。
DATETIME 在 MySQL 里可以精确到微秒,只要你的表里有任何“非整数秒”的时间,闭区间就有漏数据的风险。更不要写<= '2024-03-31 23:59:59'这种自以为精确的边界,因为 23:59:59 和 23:59:59.500 之间存在无穷多个时刻。
“左闭右开”的另一个好处是边界计算非常干净:本月数据右边界是下月 1 号零点,昨天数据的右边界是今天零点。不需要考虑 2 月多少天、跨年、跨月这些琐碎问题,让INTERVAL去替你做月份推进。
5. 时区与会话设置:时间匹配里最隐蔽的坑
5.1 同一个 NOW(),不同时会区结果不一样
MySQL 的NOW()、CURDATE()、CURRENT_TIMESTAMP都受会话时区影响。如果一个连接是在东八区建立的,NOW()返回的是北京时间;如果数据库默认时区是 UTC,NOW()返回的就是 UTC 时间,和你本地看到的当前时间可能差 8 小时。
我遇到过真实的线上事故:应用服务器配置了serverTimezone=UTC,数据库会话时区没跟着改,结果每天晚上定时任务统计“今日订单”时,取到的时间范围整体偏移,导致凌晨时段的数据被归到前一天。排查方法很简单,先执行这一句:
SELECT @@global.time_zone, @@session.time_zone, NOW();如果NOW()返回的时间和业务方看到的时间不一致,就要统一时区。临时修复可以在连接里设置:
SET time_zone = '+08:00';长期方案是让数据库全局时区、连接串里指定的时区、应用服务器时区三层保持一致。最好统一用Asia/Shanghai或者固定+08:00,不要在 JDBC 连接串里一会儿写 UTC、一会儿写上海,否则对账时日期怎么都对不上。
5.2 TIMESTAMP 与 DATETIME 在匹配时的差异
TIMESTAMP类型存储时会按会话时区转换成 UTC 时间,查询显示时再转换成当前会话时区。也就是说,同一行数据,在 UTC 会话下显示是2024-03-15 06:00:00,在东八区会话下显示是2024-03-15 14:00:00。这会导致按日期做分组匹配的结果随会话环境漂移。
DATETIME不会做时区转换,存了14:00:00,查出来就是14:00:00,跟会话时区无关。
所以我的选型建议是:业务时间字段,比如用户下单时间、支付时间、创建时间,用DATETIME,比较稳定,不会因为换台服务器就变。设备日志、第三方回调时间这种纯粹记录“世界时刻”的,用TIMESTAMP也可以,但匹配时一定要先明确口径。
还有一点:如果字段是字符串类型,但里面存的是 ISO 格式的 UTC 时间带Z后缀,比如2024-03-15T06:00:00Z,MySQL 的STR_TO_DATE不能直接解析T和Z,需要先做字符串替换,或者用REPLACE把T换成空格,这个细节也常被忽略。
6. 时间匹配常见问题排查实录
6.1 典型问题速查表
| 问题表现 | 常见原因 | 排查方法 |
|---|---|---|
| 查“今日数据”结果为 0 | 会话时区和业务时区不一致,CURDATE()取到的是 UTC 日期 | 先执行SELECT NOW(), CURDATE()和业务当前时间对比 |
STR_TO_DATE解析结果全是 NULL | 格式符用错,比如%Y遇到两位年份;或者输入字符串本身带多余空格 | 单独SELECT STR_TO_DATE(输入值, 格式)验证 |
| 月份统计少了最后一天 | 右边界写成了<= 当月最后一天 23:59:59,漏掉带毫秒/微秒的记录 | 改成< 下月一日 00:00:00 |
| 某周的数据分到了错误的年份组 | 直接GROUP BY WEEK()导致跨年问题 | 用YEARWEEK(date, 3) |
| 日期分组结果顺序混乱 | GROUP BY没有配套ORDER BY | GROUP BY后用ORDER BY指定日期字段 |
查询很慢,EXPLAIN显示全表扫 | 列上套了DATE()、DATE_FORMAT()等函数 | 改写为范围匹配,函数挪到等号右侧 |
排查时间函数匹配问题时,我建议不要直接在大 SQL 里反复试。先把函数单独拉出来验证一次,例如:
SELECT NOW(), CURDATE(), DATE_FORMAT(CURDATE(), '%Y-%m-01'), STR_TO_DATE('2024-02-30', '%Y-%m-%d');这一步能帮你快速分清是“函数没写对”,还是“范围边界算错”,还是“时区导致整体偏移”。分别验证完,再套回业务 SQL,定位会快很多。
6.2 我的测试习惯与两个小技巧
第一,测试范围匹配时,别用CURDATE()这种动态值,应该把它替换成固定字符串。比如:
-- 临时固定测试 SET @test_date = '2024-03-15'; SELECT * FROM orders WHERE create_time >= @test_date AND create_time < @test_date + INTERVAL 1 DAY LIMIT 10;固定值的好处是边界可复现,今天测完明天再测结果一样;如果直接写CURDATE(),第二天测试范围就变了,很容易误判。
第二,凡是改了时间 SQL,我都先看一眼EXPLAIN。哪怕只是加了LIMIT 10,执行计划也能告诉你有没有用上索引。我不要求每次都看懂所有输出,但至少要看type和rows。如果rows预估扫了几十万行,而你自己知道目标数据只有几百条,那基本可以断定条件写坏了。
第三,处理导入的脏日期时,我习惯在临时表里先做一遍STR_TO_DATE转换,再检查空值比例。比如:
SELECT SUM(STR_TO_DATE(input_date, '%Y-%m-%d') IS NULL) AS invalid_count, COUNT(*) AS total_count FROM temp_upload;只要invalid_count大于 0,就先把脏数据处理干净再进正式统计,千万别让空值混进GROUP BY或WHERE,否则结果会莫名其妙少一截。
最后说一个我印象特别深的踩坑经历。有一回做跨年报表,我用了YEAR()和MONTH()分组,业务方反馈 1 月份数据少了几天。查到最后发现,那几笔订单的时间不是存成 DATETIME,而是存成了字符串,并且日期格式是2024-1-5这种不补零的写法。MONTH()对这种字符串做隐式转换时,解析规则和预期不一致,导致部分记录被分到了空组。后来我统一在写入层就把日期格式清洗成标准YYYY-MM-DD HH:MM:SS,并在查询层坚持“先STR_TO_DATE,再范围匹配”,这种问题就再没出现过。
时间函数匹配这件事,难点从来不在于记不记得函数名,而在于你有没有把“源数据形态、目标粒度、索引使用、边界口径”这四件事想清楚。把这四件事理顺了,MySQL 里的时间匹配其实是很顺手的一件事。