聊一个在 MySQL 群里被问烂、但每次都能聊出新花样的需求:计算两个日期之间的间隔天数。你可能会觉得,这还不简单,一个DATEDIFF不就完事了吗。可真到了业务里,你会发现“间隔天数”背后全是细节:是算日历天还是满 24 小时才算一天?参数顺序写反了返回负数算谁的?为什么一个“近 30 天”的统计能让慢查询变全表扫描?
这篇就把DATEDIFF、TIMESTAMPDIFF的差别讲透,把日期类型、字符串转换、索引优化这些坑一次性串起来。适合写业务 SQL 的同学参考,也适合刚把 MySQL 环境搭好、准备开始做查询优化的新手收藏。
1. 核心函数选型:DATEDIFF 与 TIMESTAMPDIFF 的取舍
先说结论:MySQL 里算两个日期间的间隔,官方给的核心工具就是DATEDIFF和TIMESTAMPDIFF这两个函数。很多人只知道前一个,后一个才是应对复杂业务场景的大杀器。
1.1 DATEDIFF:简单直接,但它只认“日历天”
DATEDIFF的语法很简单:DATEDIFF(expr1, expr2),返回expr1 - expr2的天数差。比如:
SELECT DATEDIFF('2024-03-05', '2024-03-01'); -- 结果:4需要注意两点。
第一,参数顺序是前者减后者,写反了会得到负数。我见过不少同事在统计“今天距离合同到期还有几天”时写成DATEDIFF(expire_date, CURDATE()),结果到期日已经过去后,这个数变成负数但又没被过滤掉,前端直接显示“还有 -7 天到期”,相当尴尬。
第二,DATEDIFF比较的是日期部分,也就是“翻日历数格子”,完全不关心时间部分。举个例子:
SELECT DATEDIFF('2024-03-02 07:00:00', '2024-03-03 06:59:00'); -- 结果:1但实际上这两个时间点之间连 24 小时都不到。如果你计算的场景是“满 24 小时才算一天”,用DATEDIFF就会得出与直觉不一致的结果。
所以DATEDIFF适合什么场景?只关心日历上相隔几天的场景,比如“订单创建日期距离今天过了几个自然日”,这种情况下你用 DATE 类型存字段,用DATEDIFF就很可靠。
1.2 TIMESTAMPDIFF:单位灵活,精确到秒也没压力
TIMESTAMPDIFF的语法多了一个单位参数:TIMESTAMPDIFF(unit, datetime_expr1, datetime_expr2),返回的是datetime_expr2 - datetime_expr1的结果,注意方向也是反的。单位支持MICROSECOND、SECOND、MINUTE、HOUR、DAY、WEEK、MONTH、QUARTER、YEAR。
以天为单位来算:
SELECT TIMESTAMPDIFF(DAY, '2024-03-01', '2024-03-05'); -- 结果:4如果要精确到小时:
SELECT TIMESTAMPDIFF(HOUR, '2024-03-01 08:00:00', '2024-03-01 17:30:00'); -- 结果:9这里有个容易忽略的小细节:17:30 - 08:00实际是 9 小时 30 分,但因为返回类型是整数,会向零的方向截断,只返回 9。如果你想保留精度,就得继续往小单位拆,比如用TIMESTAMPDIFF(SECOND, ...)再除以 86400.0,这样才能拿到带小数点的天数:
SELECT TIMESTAMPDIFF(SECOND, '2024-03-01 08:00:00', '2024-03-04 20:00:00') / 86400.0; -- 结果:3.5000这个写法在计算“平均处理时长”“相对精确的跨天时长”时很实用。
1.3 两者对比与选型建议
整理成一张表,看得更清楚:
| 对比维度 | DATEDIFF | TIMESTAMPDIFF |
|---|---|---|
| 语法 | DATEDIFF(expr1, expr2) | TIMESTAMPDIFF(unit, expr1, expr2) |
| 返回值 | 天数(整数) | 指定单位下的整数 |
| 是否忽略时间部分 | 忽略,只看日历日 | 按单位精度参与计算 |
| 支持的计算单位 | 只有“天” | 秒、分钟、小时、天、周、月、季度、年 |
| 参数顺序 | 第一个减第二个 | 第二个减第一个 |
| 适用场景 | 纯日期跨度、自然日统计 | 精确时长、按周/月/年统计、带时间戳的间隔 |
我的选型习惯是:只要业务口径是“过了几个自然日”,两个函数都能用;但为了统一和可扩展,我优先用TIMESTAMPDIFF。原因是它既可以算天,也可以算小时、分钟,万一产品经理明天把需求从“相距几天”改成“相距几小时”,你改一个单位参数就行,不需要重写 SQL。只有一种情况我会特意回来用DATEDIFF:就是被历史 SQL 绑住了,或者写报表时需要非常直白地表达“日历格数”,可读性优先。
小建议:如果你希望 SQL 语义更清晰,可以在查询里给字段起一个带口径的别名,比如
AS days_between,后续维护的人一看就知道这是天间隔,省得猜。
2. 日期数据格式:类型、转换与格式化
计算间隔天数的前提是日期数据本身没毛病。实际工作中一大半的“算错了”,都不是函数的问题,而是数据类型和格式的问题。
2.1 DATE、DATETIME、TIMESTAMP 得先分清
MySQL 里常见的三种日期相关类型,看着都像时间,性格差别很大:
| 类型 | 存储内容 | 支持范围 | 时区关联 | 推荐场景 |
|---|---|---|---|---|
DATE | 年-月-日 | 1000-01-01 到 9999-12-31 | 无 | 生日、合同日期、工作日历 |
DATETIME | 年-月-日 时:分:秒 | 1000-01-01 到 9999-12-31 | 无 | 业务发生时间、操作日志 |
TIMESTAMP | 年-月-日 时:分:秒 | 1970-01-01 到 2038-01-19(UTC) | 会随会话时区变化 | 需要按用户时区换算的场景 |
关键在于TIMESTAMP的时区联动特性。MySQL 会把TIMESTAMP值从会话时区转换到 UTC 存储,查询时再转回当前会话时区。如果你的应用服务器和数据库服务器时区设置不一致,DATEDIFF算出来的结果可能莫名其妙少一天或多一天。如果业务只关心日期,直接用DATE类型最省心,根本不给你时区捣乱的机会。
2.2 字符串转日期:别把格式问题扔给隐式转换
很多表的历史字段是用VARCHAR存日期的,比如'2024/03/05'、'20240305'。MySQL 在大部分场景下会尝试隐式转换字符串为日期,但这不是一个可靠的行为,不同sql_mode、不同 MySQL 版本下结果可能不一致。
最稳妥的做法是先用STR_TO_DATE做显式转换:
SELECT STR_TO_DATE('2024/03/05', '%Y/%m/%d'); -- 结果:2024-03-05STR_TO_DATE的格式符要严格对应字符串:%Y四位年份、%m两位月份、%d两位日、%H小时、%i分钟、%s秒。如果源字符串是'2024-03-05 14:30:00',对应格式就是'%Y-%m-%d %H:%i:%s'。
还有一种常见操作是从DATETIME里提取日期部分,用DATE()函数:
SELECT DATE('2024-03-05 14:30:00'); -- 结果:2024-03-05这样一来,后面接DATEDIFF或者TIMESTAMPDIFF都非常干净。
2.3 格式化输出:DATE_FORMAT 是展示层的活儿
计算完间隔天数,往往还要把日期格式化成特定字符串给前端展示。比如“日期转字符”这种需求:
SELECT DATE_FORMAT(login_time, '%Y-%m-%d %H:%i:%s') AS login_str FROM user_login_log;这里想提醒一句:格式化尽量放在查询的展示层或者说输出层,不要为了对齐格式把日期字段存成字符串。一旦日期字段变成VARCHAR,排序会按字典序排,'2024-02-01'会排在'2024-01-31'前面,索引也会失效,后续所有日期计算都变成先转换再算,性能会很难看。
另外,从 Excel 导入的日期如果变成45000这种五位数,那是 Excel 的日期序列号——代表从 1900-01-01 起算的天数。这种数据进了 MySQL 要么在导入时转成标准YYYY-MM-DD,要么后续处理时手动换算,别指望DATEDIFF替你兜底。
3. 实战:业务中常用的间隔天数统计
理论说完了,看几段能直接抄进项目的 SQL。这些我都实际跑过,覆盖了业务里最常见的三类场景。
3.1 计算年龄、工龄、会员剩余天数
算年龄的标准姿势是用TIMESTAMPDIFF:
SELECT user_name, birth_date, TIMESTAMPDIFF(YEAR, birth_date, CURDATE()) AS age FROM users;为什么这里不用“天数除以 365”?因为涉及到闰年,你自己除很容易在 2 月 29 号前后出错,而且按“已度过完整年数”来算年龄,TIMESTAMPDIFF(YEAR, ...)的语义就是对得上。
同理,算合同剩余天数就是两个日期直接做差:
SELECT contract_no, DATEDIFF(expire_date, CURDATE()) AS remain_days FROM contracts;这里注意,如果remain_days是负数,说明合同已经过期。负天数本身不是 bug,业务上该过滤筛选就能用,别随便套ABS(),否则“过期了几天”和“还剩几天”会被强行混淆成同一个数。
3.2 “近 30 天”统计的正确姿势:别对字段套函数
很多人的第一反应是这么写“近 30 天订单”:
-- 不推荐,索引会失效 SELECT * FROM orders WHERE DATEDIFF(CURDATE(), create_time) <= 30;这个写法逻辑上没错,但它把create_time放进了一个函数里。MySQL 的优化器看到索引字段被函数包住,通常就直接放弃走索引了,数据量大一点就会变成全表扫描。
你应该把日期运算放在等式的一边,让字段保持原样,改成范围比较:
SELECT * FROM orders WHERE create_time >= DATE_SUB(CURDATE(), INTERVAL 30 DAY) AND create_time < DATE_ADD(CURDATE(), INTERVAL 1 DAY);如果你的create_time是DATETIME类型,上界写成“明天零点”非常关键,否则今天 14:00 创建的订单会被漏掉。如果你的字段是DATE类型,那只需要create_time >= DATE_SUB(CURDATE(), INTERVAL 30 DAY)就够了,但加个上界也不会出错。
这个“对字段包一层函数 → 索引用不上 → 慢查询”的套路非常经典。凡是DATE(create_time) = '2024-03-05'、TO_DAYS(create_time) = TO_DAYS(NOW())这类写法,性能都不会好到哪去。宁可多写几行范围条件,也不要图省事在字段上做函数计算。
3.3 用窗口函数统计用户每次登录间隔
统计“每个用户最近两次登录间隔了多少天”,可以直接用 8.0 的窗口函数:
SELECT user_id, login_date, DATEDIFF( login_date, LAG(login_date) OVER (PARTITION BY user_id ORDER BY login_date) ) AS gap_days FROM user_login_log;LAG(login_date)取的是同一用户按登录时间排序后,上一行的登录日期。DATEDIFF(login_date, 上一行日期)算出的就是“距上次登录过了几天”。第一次登录时LAG返回NULL,DATEDIFF结果也是NULL,正好表示没有可比对象。
如果你的库还是 MySQL 5.7,也可以用自连接模拟,不过写法会绕一些:先按用户分组取“比当前登录时间更早的最大登录时间”再关联。窗口函数是更直观的方案,建议新项目直接上 8.0+,这个需求也能顺便验证你的 EXPLAIN 结果是否走到了合理索引。
4. 常见问题排查技巧与踩坑实录
“算日期”最麻烦的不是函数不会用,而是算出来看着对、实际口径不对。这里列几个我真实遇到过的坑。
4.1 为什么结果总比预期少一天?
业务上经常有“活动从 1 月 1 日到 1 月 3 日,一共几天”这种问题。按自然理解,1 日、2 日、3 日是 3 天,但:
SELECT DATEDIFF('2024-01-03', '2024-01-01'); -- 结果:2因为DATEDIFF求的是两个日期之间“跨越”的区间数,不含起点那一天。如果你的业务口径是“包含首尾两天”,比如会员有效期从 1 日到 3 日,用户实际能用 3 天,那就要在 SQL 结果上手动加 1。
这个口径问题,必须在写 SQL 之前问清楚。产品经理说“相差几天”你按函数结果来;产品经理说“能玩几天”,你大概率要加一。建议在代码注释里写明口径,不然三个月后你自己都会忘。
4.2 明明不足 24 小时,却返回间隔 1 天?
这是DATEDIFF只看日期部分导致的。比如订单 3 月 2 日 07:00 创建,3 月 3 日 06:59 完成,实际只花了不到 24 小时,但:
SELECT DATEDIFF('2024-03-03 06:59:00', '2024-03-02 07:00:00'); -- 结果:1如果你的统计口径是“满 24 小时才算 1 天”,那么应该用秒级差再除:
SELECT TIMESTAMPDIFF(SECOND, '2024-03-02 07:00:00', '2024-03-03 06:59:00') / 86400.0; -- 结果:0.9972...这样取整或者四舍五入,才算得出符合物理时间的间隔。做 SLA、服务耗时统计时尤其要注意这一点。
4.3 日期过滤慢到离谱,问题出在函数包裹字段
这个我在前面讲过,这里再次强调。遇到“日期字段加索引了但查询还是慢”的情况,第一时间检查 WHERE 条件里有没有DATE()、DATEDIFF()、YEAR()这类函数直接包住字段。有的话,改成范围比较。用EXPLAIN看一眼就知道:type从ref变成ALL,或者Extra里出现Using where; Using index condition但实际没走索引,基本就是踩到了。
另一个常见原因是日期字段本身是字符串。VARCHAR上建索引虽然也能等值匹配,但范围比较和排序会按照字符串规则来,索引的效用大打折扣。治本的方法是把字段类型改成DATE或DATETIME,早改早省心。
4.4 问题速查表
| 现象 | 可能原因 | 建议处理方式 |
|---|---|---|
| 结果比预期少 1 天 | 业务口径包含首尾 | 按需+1,并写注释记录口径 |
| 返回 1 但实际不足 24 小时 | DATEDIFF只看日历天 | 用TIMESTAMPDIFF(SECOND, ...) / 86400.0 |
结果全是NULL | 参数中有NULL | 用IFNULL或COALESCE兜底 |
| 返回负数 | 参数顺序写反 | 确认是“前者减后者”,必要时统一方向 |
| 日期过滤慢 | 字段被函数包裹 / 字段是字符串 | 改成范围比较,尽量迁移为日期类型 |
| 整体偏移 8 小时 | TIMESTAMP受会话时区影响 | 用CONVERT_TZ显式换算,或改用DATETIME |
只要按照“先确认字段类型,再看有没有隐式转换,最后查时区设置”这个顺序排查,绝大多数“日期算不对”的问题都能定位出来。
5. 顺带聊聊其他数据库的写法差异
很多团队不只用一个数据库。同一套“间隔天数”逻辑,换到别的数据库语法会差很多,迁移时最容易翻车。
| 数据库 | 写法示例 | 说明 |
|---|---|---|
| MySQL | DATEDIFF(d1, d2)或TIMESTAMPDIFF(DAY, d1, d2) | DATEDIFF只算天 |
| SQL Server | DATEDIFF(DAY, d1, d2) | 单位参数在最前面,方向是前减后 |
| Oracle | d2 - d1 | 两个 DATE 直接相减,结果就是天数 |
| PostgreSQL | d2 - d1或age(d2, d1) | 两个 DATE 相减返回整数;age返回interval |
这里尤其要注意 Oracle 和 PostgreSQL 的“减法”写法。你在 MySQL 里写习惯了,到了 Oracle 里也顺手写DATEDIFF,直接报函数不存在。做跨数据库迁移的时候,最好先把这些日期函数拉一个清单,挨个对照替换。
我个人在实际操作里的体会是:“间隔天数”这东西,函数只是最后那一哆嗦,真正花时间的是把日期数据的类型、格式、口径都摆平。日期字段统一用DATE或DATETIME,显示统一走DATE_FORMAT,条件过滤统一写范围比较,再配合TIMESTAMPDIFF应对不同的精度需求,一套组合下来,大部分日期相关的需求都能稳稳接住。
最后再分享一个小技巧:项目里凡是出现日期差值的 SQL,我都会在注释里写清两件事——口径是“跨越区间”还是“包含首尾”,单位是天还是小时。这样半年后自己回头看,或者同事接手改需求,都不用靠猜。先写到这里,希望这些坑你能少踩几个。