日期时间函数这块,属于MySQL里"平时觉得会用,真到写复杂统计需求时才发现一堆坑"的知识点。拿我自己举例,早两年刚接手一个电商后台的报表模块时,接到的第一个需求就是"统计最近30天每天的订单数和销售额,按天展示,没有订单的日期也要补0"。当时想着这不简单吗,GROUP BY DATE(create_time) 一把梭,结果发现几个问题:空档日期没了、时区对不上、跨年统计时周数算错。也就是从那时候起,我才把MySQL的日期时间函数完整过了一遍,踩了不少坑,也积累了不少可以直接抄作业的写法。
这篇东西我打算换个讲法,不按官方文档从头到尾列函数,而是按实际业务里最常碰到的几个场景来拆:日期时间的类型怎么选、格式化怎么玩、日期怎么算、各类特殊维度怎么取,还有时区和性能这些容易出事的细节。每个场景都会把函数、语法、坑点串起来,适合正在学MySQL的同学,也适合写了好几年SQL但没系统梳理过日期函数的朋友。
1. 日期时间类型选不对,后面全白费:DATETIME和TIMESTAMP的取舍
学习日期函数之前,先得搞清楚MySQL里存日期时间的那几个类型。类型选错了,后面所有函数的行为都会变得奇怪。很多新手上来就用VARCHAR存时间,这非常不建议,做范围查询没法走索引,做计算还得各种转换,真是给自己找麻烦。
MySQL主要提供 DATE、TIME、DATETIME、TIMESTAMP、YEAR 这几种。我用一个表格总结下,方便大家直接对照:
| 类型 | 存储大小 | 范围 | 格式示例 | 适用场景 |
|---|---|---|---|---|
| DATE | 3字节 | '1000-01-01' 到 '9999-12-31' | 2024-05-20 | 生日、节日、交易日 |
| TIME | 3字节 | '-838:59:59' 到 '838:59:59' | 09:30:00 | 时间段、每日固定时间 |
| DATETIME | 8字节 | '1000-01-01 00:00:00' 到 '9999-12-31 23:59:59' | 2024-05-20 14:30:00 | 业务时间、下单时间 |
| TIMESTAMP | 4字节 | '1970-01-01 00:00:01' UTC 到 '2038-01-19 03:14:07' UTC | 2024-05-20 14:30:00 | 日志时间、更新时间 |
| YEAR | 1字节 | 1901 到 2155 | 2024 | 年份统计 |
很多人纠结DATETIME和TIMESTAMP,我自己的经验是:业务核心时间字段用DATETIME,系统审计类字段用TIMESTAMP。为什么这么分?
TIMESTAMP有个很要命的特性:它存储的是UTC时间,查询时MySQL会根据当前会话的time_zone设置自动转换成当地时间。这意味着如果你的服务器时区变了,或者数据库连接指定了其它时区,读出来的TIMESTAMP值会自动跟着变。对于记录"这条数据什么时候被修改"的场景,这其实是优点,因为无论谁在什么时区看,看到的时间都是那个时区的本地时间。
但如果你存的是"用户下单时间"这种业务事实,事情就微妙了。订单时间是一个客观发生的事件,不应该跟着数据库时区设置变来变去。假设A用户在中国时间14:00下单,管理员在美国用另一个时区连数据库查,看到的下单时间变成凌晨1点,这就很荒谬了。所以业务事实时间我统一用DATETIME,不受时区干扰。
再说说存储和未来风险。DATETIME用8字节,范围到9999年;TIMESTAMP只有4字节,范围到2038年。虽然2038年听起来很远,但有些系统设计寿命长,我在面试别人时也喜欢问这个点,能答上来的人说明真的看过官方文档。
提示:MySQL 8.0.19开始支持
TIMESTAMP的AT TIME ZONE操作,但底层范围没变。涉及百年长周期的表,别用TIMESTAMP。
还有一个小提醒:建表时如果能加上DEFAULT CURRENT_TIMESTAMP和ON UPDATE CURRENT_TIMESTAMP,可以让"创建时间/更新时间"字段自动维护,省掉很多手工SQL。不过要注意,DATETIME类型在MySQL 5.6.5之后也支持这两个语法了,所以用DATETIME同样可以享受自动维护的便利。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 格式化与解析:DATE_FORMAT和STR_TO_DATE,以及那个让人抓狂的分钟符
日期函数里日常用得最狠的就是格式化。DATE_FORMAT负责把日期值转成指定格式的字符串,STR_TO_DATE负责把字符串按指定格式解析成日期值。两者互为逆操作,格式符完全一致,所以只需要记一套。
格式化符里最常用的一套,我给大家列出来:
| 格式符 | 含义 | 示例 |
|---|---|---|
| %Y | 四位年份 | 2024 |
| %y | 两位年份 | 24 |
| %m | 月份(01-12) | 05 |
| %c | 月份(1-12,无前导零) | 5 |
| %d | 日(01-31) | 07 |
| %e | 日(1-31,无前导零) | 7 |
| %H | 小时(00-23) | 14 |
| %h | 小时(01-12) | 02 |
| %i | 分钟(00-59) | 30 |
| %s | 秒(00-59) | 45 |
| %p | AM或PM | PM |
| %W | 星期名(Sunday-Saturday) | Monday |
| %a | 缩写星期名(Sun-Sat) | Mon |
| %M | 月名(January-December) | May |
| %j | 一年中的第几天(001-366) | 141 |
我第一次用的时候,把分钟写成了%m,结果分钟位置一直显示月份,排查了半天才发现问题。%m是月份,%i才是分钟,这个大坑一定要记住。MySQL刻意用了%i表示分钟,只因为%m被月份占了。另外%c和%e这两个不带前导零的格式符,特别适合做分组统计,比如按月份分组后想让"5月"显示成5而不是05,用%c就很舒服。
格式化最容易出错的地方,是把日期时间当作字符串处理。很多人会写成WHERE DATE_FORMAT(create_time, '%Y-%m-%d') = '2024-05-20',这个写法虽然结果对,但性能很差,原因后面专门讲时延展开。优先写成WHERE create_time >= '2024-05-20 00:00:00' AND create_time < '2024-05-21 00:00:00'。
STR_TO_DATE和DATE_FORMAT的格式符是同一套,它是把字符串解析成日期。注意一点:左侧字符串的格式必须和右侧格式串严格匹配,否则返回NULL。比如:
sql复制SELECT STR_TO_DATE('2024/05/20 14:30:45', '%Y/%m/%d %H:%i:%s');
-- 返回 2024-05-20 14:30:45
但如果你写成:
sql复制SELECT STR_TO_DATE('2024-05-20', '%Y/%m/%d');
-- 返回 NULL
因为字符串里是-,格式串里却写了/,对不上。遇到数据清洗场景时,这种"返回NULL但不报错"的行为其实挺坑的,建议解析前先看一眼数据样例,或者用SELECT ... WHERE STR_TO_DATE(列, 格式) IS NULL做一次体检,把脏数据揪出来。
2.1 把时间戳转成可读时间的FROM_UNIXTIME
除了DATE_FORMAT,还有两个常见的转换函数:FROM_UNIXTIME和UNIX_TIMESTAMP。前者把整数时间戳(Unix时间戳,秒级)转成日期时间,后者把日期时间转成整数时间戳。
sql复制SELECT FROM_UNIXTIME(1716197445);
-- 返回 2024-05-20 14:30:45
SELECT UNIX_TIMESTAMP('2024-05-20 14:30:45');
-- 返回 1716197445
这个组合在做跨系统对接时特别有用。比如Java后端传了一个System.currentTimeMillis()出来的长整型,注意那是毫秒,MySQL的UNIX_TIMESTAMP是秒,差了1000倍,直接拿毫秒去FROM_UNIXTIME会导致时间变成1970年附近的一个日期,看起来像1970-04-26 10:25:17左右,非常容易踩坑。毫秒级处理要除以1000:
sql复制SELECT FROM_UNIXTIME(1716197445000 / 1000);
但会丢失毫秒精度,如果业务对毫秒敏感,建议在应用层就做好处理,不要依赖SQL完成这类转换。
2.2 获取当前日期时间的函数选择
从库里取当前时间,主要有NOW()、CURRENT_TIMESTAMP()、SYSDATE()、LOCALTIME()这些。很多初学者搞不清它们有什么区别,其实核心差异就两个:一个是语句开始时间,一个是函数执行时间。
NOW():返回语句开始执行时的时间,同一个SQL里无论调用多少次,值都一样。SYSDATE():返回函数实际被执行那一刻的时间,哪怕是在同一条SQL里,先后调用两次值可能不同。
sql复制SELECT NOW(), SYSDATE(), SLEEP(2), NOW(), SYSDATE();
这条SQL执行结果里,第一个NOW和第二个NOW一样,第一个SYSDATE和第二个SYSDATE差了2秒。在复制场景下,SYSDATE这种"实时时间"可能导致主从数据不一致,所以默认情况下MySQL会警告不要用SYSDATE,具体在sysdate-now-verbatim这个参数控制。日常开发直接用NOW()就够了,别徒增麻烦。
另外,CURDATE()返回当前日期,CURTIME()返回当前时间,用法也很简单:
sql复制SELECT CURDATE(); -- 2024-05-20
SELECT CURTIME(); -- 14:30:45
3. 日期加减和间隔计算:从DATE_ADD到TIMESTAMPDIFF的实战矩阵
日期运算是SQL里最见功力的地方,因为很多复杂的统计需求本质上就是"时间区间"的计算。比如连续登录天数、最近7天、上个月同期、自然周,等等。
3.1 日期加减用DATE_ADD和DATE_SUB,别自己手动算秒
DATE_ADD和DATE_SUB基于INTERVAL来加减时间。语法:
sql复制DATE_ADD(date, INTERVAL expr unit)
DATE_SUB(date, INTERVAL expr unit)
支持的时间单位包括 YEAR、QUARTER、MONTH、WEEK、DAY、HOUR、MINUTE、SECOND、DAY_HOUR、DAY_MINUTE等。我平时最常用的几个例子:
sql复制-- 当前日期加7天
SELECT DATE_ADD(CURDATE(), INTERVAL 7 DAY);
-- 当前时间减3小时
SELECT DATE_SUB(NOW(), INTERVAL 3 HOUR);
-- 下个月第一天:先加1个月,再取月份第一天
SELECT DATE_ADD(DATE_ADD(CURDATE(), INTERVAL 1 MONTH), INTERVAL - DAY(CURDATE()) + 1 DAY);
第二个例子"加1个月后取月初"这种写法,在复核月报时经常用。注意MySQL的日期算术不是简单的"字符串拼接加减",它会自己处理月份天数差异。比如1月31日加1个月,MySQL返回2月29日(闰年)或2月28日,不会抛错。
不过有个小坑:DATE_ADD('2024-01-31', INTERVAL 1 MONTH)返回的是2024-02-29,这符合很多人的直觉。但如果你期望的是"2月最后一天",这个逻辑就对不上了。业务口径不一样,处理方式也不同,需要和产品确认清楚。
3.2 两个日期相差多少天:DATEDIFF和TIMESTAMPDIFF
DATEDIFF只算天数差,它会忽略时间部分,直接用日期相减。TIMESTAMPDIFF更强大,可以按任意单位计算差值,而且会自动做跨单位换算。
sql复制-- 返回 3,因为只算日期
SELECT DATEDIFF('2024-05-23', '2024-05-20');
-- 返回 2,因为 23号14点 和 21号10点 之间不足72小时
SELECT TIMESTAMPDIFF(HOUR, '2024-05-21 10:00:00', '2024-05-23 14:00:00');
TIMESTAMPDIFF的单位可选 FRAC_SECOND、SECOND、MINUTE、HOUR、DAY、WEEK、MONTH、QUARTER、YEAR。TIMESTAMPDIFF会直接忽略小数部分(不是四舍五入),比如上面算小时差,23号14点减去21号10点是52小时,剩下4分钟被忽略,返回52。如果你要精确到小数点后几位,得用TIMESTAMPDIFF(SECOND, ...) / 3600.0自己算。
另外注意TIMESTAMPDIFF的单位参数是英文单词,不用加引号,比如写TIMESTAMPDIFF(MONTH, start, end),不是'MONTH'。
3.3 月初月末、季度第一天这类"骨头"日期怎么算
做报表经常要求"本月第一天""上月最后一天""本季度起始日"这种日期。可以这样写:
sql复制-- 本月第一天
SELECT DATE_ADD(CURDATE(), INTERVAL - DAY(CURDATE()) + 1 DAY);
-- 本月最后一天
SELECT LAST_DAY(CURDATE());
-- 上月第一天
SELECT DATE_ADD(DATE_ADD(CURDATE(), INTERVAL - DAY(CURDATE()) + 1 DAY), INTERVAL - 1 MONTH);
-- 本季度第一天
SELECT DATE_ADD(DATE_ADD(CURDATE(), INTERVAL - MONTH(CURDATE()) + 3 * (QUARTER(CURDATE()) - 1) MONTH), INTERVAL - DAY(CURDATE()) + 1 DAY);
我自己平时很少手写这么长一串,更常用的是"本月月初"用DATE_FORMAT(CURDATE(), '%Y-%m-01')直接生成,简洁直观:
sql复制SELECT DATE_FORMAT(CURDATE(), '%Y-%m-01');
但注意这样返回的是字符串,如果你要和日期列比较,建议外层套一个DATE()转换:
sql复制SELECT DATE(DATE_FORMAT(CURDATE(), '%Y-%m-01'));
3.4 两个日期间隔生成连续日期序列
这是一个很妙但也容易被忽略的能力。MySQL 8.0支持递归CTE,可以用它生成连续的日期序列,这在补全"缺失日期"时非常好用。比如要生成2024年5月1日到2024年5月10日的每一天:
sql复制WITH RECURSIVE date_range AS (
SELECT DATE('2024-05-01') AS d
UNION ALL
SELECT DATE_ADD(d, INTERVAL 1 DAY)
FROM date_range
WHERE d < DATE('2024-05-10')
)
SELECT d FROM date_range;
配合LEFT JOIN可以查出"某段时间内每天的用户数,没数据的日期补0"。这是很多统计报表的刚需,MySQL 8.0以下版本没有递归CTE功能,但可以通过数字辅助表(比如一个包含1到1000的序列)也能达到同样的效果。
4. 取年份、月份、季度、星期,以及那些"从1开始还是从0开始"的陷阱
MySQL提供了一系列提取日期时间组成部分的函数,看起来很简单,但用起来有很多口径细节。以2024-05-20为例,这天是周一。
| 函数 | 返回值 | 说明 |
|---|---|---|
| YEAR(date) | 2024 | 年份 |
| MONTH(date) | 5 | 月份,1-12 |
| DAY(date) / DAYOFMONTH(date) | 20 | 日,1-31 |
| HOUR(time) | 14 | 小时,0-23 |
| MINUTE(time) | 30 | 分钟,0-59 |
| SECOND(time) | 45 | 秒,0-59 |
| DAYOFWEEK(date) | 2 | 周日=1,周一=2,...,周六=7 |
| DAYOFYEAR(date) | 141 | 一年中的第几天 |
| WEEK(date) | 21 | 一年中的第几周 |
| WEEKOFYEAR(date) | 21 | 一年中的第几周(ISO周),周一开始 |
| QUARTER(date) | 2 | 季度,1-4 |
| DAYNAME(date) | Monday | 星期名 |
| MONTHNAME(date) | May | 月份名 |
这里最大的坑是DAYOFWEEK。MySQL的DAYOFWEEK把周日当作1,周一当作2,和很多国家"周一为一周第一天"的习惯相反。如果直接拿DAYOFWEEK=1去判断周一,会得出错误结果。正确做法是使用WEEKDAY()函数,它返回0-6,其中0表示周一,6表示周日:
sql复制SELECT WEEKDAY('2024-05-20'); -- 返回0,周一
SELECT WEEKDAY('2024-05-26'); -- 返回6,周日
如果你判断"是不是周末",用WEEKDAY(date) >= 5才是正经写法,不要用DAYOFWEEK=1或7,因为DAYOFWEEK的语义因地区习惯而异,容易误导。
WEEK函数也有类似的口径问题。WEEK(date)默认模式取决于default_week_format系统变量,而不同模式下"一年的第一周"怎么定义是完全不同的。最常见的兼容场景是使用WEEK(date, 1)把周一作为一周的第一天,或者使用WEEKOFYEAR(date)直接按ISO周标准计算(周一到周日为一周,第一周是包含该年第一个周四的那一周)。在跨年业务里,如果时间区间覆盖了元旦,周数计算必须显式指定mode,不然很容易出现第0周或者上周第52周的情况。
4.1 按周统计时,小心"跨年周"的头疼
按周统计是报表里的经典需求,但跨年时会出各种问题。比如2024年1月1日是周一,这一周其实是2023年的第52周,如果直接用WEEK函数,它会把2024年1月1日归到第1周,但12月31日那几天其实也是第1周(如果那年碰巧周日跨年)。不同系统之间周次对不上,就很麻烦。
一个相对安全的处理思路是统计时带上年份和周次:
sql复制SELECT YEARWEEK(create_time, 1) AS yw, COUNT(*)
FROM orders
GROUP BY YEARWEEK(create_time, 1)
ORDER BY yw;
YEARWEEK的返回值类似202421,表示2024年第21周,这个值在跨年场景下能保证同一周的数据被归到同一年。不过要注意,YEARWEEK的第二个参数同样是模式控制,建议显式传1,保证周一为一周第一天。这个参数在MySQL 8.0.31之后还有更多模式,如果业务复杂,建议查一下官方文档确认。
4.2 月份的第几周:两个函数搞定
偶尔会被产品问到"这个月第几周"。比如5月5日是5月的第几周?可以用:
sql复制SELECT FLOOR((DAY('2024-05-20') - 1) / 7) + 1;
简单粗暴。严格按周来算的话,要结合本月1号的星期:
sql复制SELECT CEIL((DAY('2024-05-20') + WEEKDAY(DATE_FORMAT('2024-05-20', '%Y-%m-01'))) / 7);
这个公式先算出本月1号是周几,再结合当前日期推移,得到"自然周"意义的第几周。实际业务里用哪种口径,得先和产品对齐。
5. 时区黑洞:为什么同一个表,不同环境查出来的时间差8小时
日期时间函数用得再溜,只要时区设置有问题,所有计算结果都会"看起来不对"。这是生产环境最常见的问题之一,我记得有个项目上线后,运营反馈所有订单时间都比实际晚8小时,查到最后就是数据库连接的时区参数没配上。
MySQL的时区体系分三层:
- 服务器系统时区:由操作系统时区决定,一般在安装时设定
- MySQL全局时区:
global.time_zone,默认跟随系统 - 会话时区:
session.time_zone,每个连接可以独立设置,默认跟随全局
查询用的NOW()、CURDATE()都受会话时区影响。而时间字段的存储,TIMESTAMP会被转成UTC存储,查询时再转回会话时区;DATETIME则直接存原样。
常见的"相差8小时"最可能原因:
- JDBC连接串没指定
serverTimezone,而驱动和数据库时区不一致 - 使用Docker容器部署时,容器默认UTC时区,和宿主机东八区不一致
- 云数据库实例创建时没勾选正确的时区
解决方案很直接,在连接串里显式指定:
code复制jdbc:mysql://localhost:3306/db?serverTimezone=Asia/Shanghai
如果不想改连接串,可以在数据库层统一设:
sql复制SET GLOBAL time_zone = '+08:00';
SET time_zone = '+08:00';
我自己比较推荐用+08:00这种偏移量写法,一来不依赖系统时区表,二来任何环境都能生效。Asia/Shanghai这类命名时区需要MySQL加载时区表,很多Docker镜像里根本没加载,设了也会报错。
注意:DATE、TIME、DATETIME类型不受时区影响,只有TIMESTAMP类型会随会话时区自动转换。表和代码里如果混用两种类型,排查时区问题时会非常头疼,建议项目里统一约定用一种。
6. 性能隐患:别在索引列上套函数,以及隐式转换的代价
日期函数用得不对,最致命的问题不是结果错,而是让索引失效,直接把查询变成全表扫描。MySQL的B+树索引是按字段原始值排序的,如果你在WHERE条件里对索引列套了函数,比如WHERE DATE_FORMAT(create_time, '%Y-%m-%d') = '2024-05-20',MySQL就没有办法直接利用create_time的索引去范围扫描,只能先把每行的create_time都算一遍再比较。
正确的写法是使用范围条件:
sql复制-- 差:索引失效
SELECT * FROM orders WHERE DATE(create_time) = '2024-05-20';
-- 优:索引生效
SELECT * FROM orders
WHERE create_time >= '2024-05-20 00:00:00'
AND create_time < '2024-05-21 00:00:00';
这背后的原因要理解一下:函数把原来的值"变形"了,索引里存的还是原始值,B+树的排序规则基于原始值,无法直接定位到"函数结果等于某值"的那些行。这不是MySQL不行,任何B+树数据库都这样(Oracle叫函数索引,MySQL 8.0没有函数索引,只有生成列加索引这种变通方案)。
顺便说下隐式转换。如果时间字段是字符串类型(VARCHAR),你拿一个日期字符串去比较,或者拿时间字段和一个日期字符串做比较,MySQL会做隐式转换,也有可能导致索引失效。比如:
sql复制-- 如果create_time是DATETIME,这个等值比较大概率会触发隐式类型转换,因为右侧的'20240520'不是合法日期格式
SELECT * FROM orders WHERE create_time = '20240520';
正确做法是显式转换:
sql复制SELECT * FROM orders WHERE create_time = STR_TO_DATE('20240520', '%Y%m%d');
6.1 覆盖group by和order by的索引设计
日期函数不仅影响WHERE,还影响GROUP BY和ORDER BY。比如:
sql复制SELECT DATE(create_time) AS d, COUNT(*)
FROM orders
GROUP BY DATE(create_time)
ORDER BY d;
如果要对这个聚合结果做优化,可以建立一个(create_time)的索引。因为GROUP BY date(create_time)理论上不能直接利用普通索引,但只要数据量不大,全表扫描后内存排序也能接受。真正恶劣的场景是数据量达到千万级后,每一条都要走函数转换,聚合效率直线下降。这时可以考虑设计一个"日期冗余列"或者"日期分区表"。
MySQL 8.0支持的功能里,有一种做法是生成列加索引:
sql复制ALTER TABLE orders
ADD COLUMN create_date DATE GENERATED ALWAYS AS (DATE(create_time)) STORED,
ADD INDEX idx_create_date (create_date);
然后查询改成WHERE create_date = '2024-05-20',索引能正常利用。这种方式在报表查询频繁且数据量大的时候非常有效,代价是多出一列存储空间。
6.2 分页/联表场景下重复调用函数导致的开销
联表查询时,如果多次对同一个时间字段做复杂日期函数计算,MySQL可能无法做到高效的连接优化。比较稳妥的做法是,在子查询或者CTE里先把时间字段处理成需要的维度,再进行JOIN,避免连接条件里出现函数计算。比如:
sql复制WITH daily AS (
SELECT user_id, DATE(create_time) AS create_date, COUNT(*) AS cnt
FROM orders
WHERE create_time >= '2024-05-01'
GROUP BY user_id, DATE(create_time)
)
SELECT u.name, d.create_date, d.cnt
FROM users u
JOIN daily d ON u.id = d.user_id;
虽然看起来只是把函数挪了个位置,但对执行计划的影响有时很大。经验是:能提前过滤的数据尽量提前过滤,能尽量少计算就少计算。
7. 面试和实战中绕不开的日期函数场景模板
最后这块,整理几个高频的实战场景,可以直接抄过去改改用。
7.1 连续登录天数
假设有一张登录记录表login_log(user_id, login_date),要查每个用户最近连续登录天数。思路是先算出每行的"登录序号",然后拿登录日期减序号,同一个结果的连续行就是一组连续登录日期。
sql复制WITH t1 AS (
SELECT user_id, login_date,
ROW_NUMBER() OVER(PARTITION BY user_id ORDER BY login_date) AS rn
FROM login_log
),
t2 AS (
SELECT user_id, login_date,
DATE_SUB(login_date, INTERVAL rn DAY) AS grp
FROM t1
)
SELECT user_id, MIN(login_date) AS start_date, MAX(login_date) AS end_date, COUNT(*) AS days
FROM t2
GROUP BY user_id, grp
HAVING COUNT(*) >= 7;
这里面的关键就是用DATE_SUB把"连续递增的行"变成"同一个基准日期"。这个思路在计算连续签到、连续消费、连续活跃时都可以复用。
7.2 每小时的订单量统计
统计订单表里每小时的订单数,空档小时补0:
sql复制WITH RECURSIVE hours AS (
SELECT DATE_FORMAT('2024-05-20 00:00:00', '%Y-%m-%d %H:00:00') AS h
UNION ALL
SELECT DATE_ADD(h, INTERVAL 1 HOUR)
FROM hours
WHERE h < DATE_FORMAT('2024-05-20 23:00:00', '%Y-%m-%d %H:00:00')
)
SELECT hours.h, COUNT(orders.id) AS order_cnt
FROM hours
LEFT JOIN orders
ON DATE_FORMAT(orders.create_time, '%Y-%m-%d %H:00:00') = hours.h
GROUP BY hours.h;
注意这里LEFT JOIN会扫描orders全表,如果数据量大,建议先对orders做时间范围过滤再JOIN:
sql复制WITH orders_filtered AS (
SELECT id, create_time
FROM orders
WHERE create_time >= '2024-05-20 00:00:00'
AND create_time < '2024-05-21 00:00:00'
)
SELECT hours.h, COUNT(orders_filtered.id) AS order_cnt
FROM hours
LEFT JOIN orders_filtered
ON DATE_FORMAT(orders_filtered.create_time, '%Y-%m-%d %H:00:00') = hours.h
GROUP BY hours.h;
虽然对DATE_FORMAT字段做JOIN还是无法直接用索引,但至少不会扫描整表。更好的做法是JOIN到"小时整数"字段,比如把UNIX_TIMESTAMP(create_time) DIV 3600算出来,然后和小时的整数戳对比。
7.3 按自然周对比上周同期
运营常问"本周一到现在比上周同期涨了多少"。计算"上周同期"要结合周函数:
sql复制-- 本周一
SELECT DATE_SUB(CURDATE(), INTERVAL WEEKDAY(CURDATE()) DAY);
-- 上周一
SELECT DATE_SUB(DATE_SUB(CURDATE(), INTERVAL WEEKDAY(CURDATE()) DAY), INTERVAL 7 DAY);
-- 上周同期(本周已过的天数,比如今天是周三,就取上周一到上周三)
SELECT DATE_SUB(CURDATE(), INTERVAL 7 DAY);
"上周同期"的准确语义是:本周一至今(比如周三)对应的去年/上周同一时间范围。用DATE_SUB(CURDATE(), INTERVAL 7 DAY)可以拿到去年的今天,但这样会受节假日影响,如果产品说"做同比,要比较同样工作日",那得用更复杂的"日历表"方案了。
7.4 一个常见的面试题:统计最近30天每天注册人数
这是上面所有函数的综合运用。思路分两步:先生成连续30天的日期序列,再LEFT JOIN用户注册表:
sql复制WITH RECURSIVE dates AS (
SELECT DATE_SUB(CURDATE(), INTERVAL 29 DAY) AS d
UNION ALL
SELECT DATE_ADD(d, INTERVAL 1 DAY)
FROM dates
WHERE d < CURDATE()
)
SELECT dates.d, COUNT(users.id) AS cnt
FROM dates
LEFT JOIN users ON DATE(users.reg_time) = dates.d
GROUP BY dates.d
ORDER BY dates.d;
数据量大的时候,把DATE(users.reg_time) = dates.d改成范围连接更好:
sql复制LEFT JOIN users
ON users.reg_time >= dates.d
AND users.reg_time < DATE_ADD(dates.d, INTERVAL 1 DAY)
这个写法可以让users.reg_time上的索引参与连接,性能好很多。面试时如果能主动说出这个优化点,面试官基本会满意。
8. 一些我踩过坑之后养成的写SQL习惯
本质上,日期时间函数不难,难点在于口径统一和性能意识。把这些年踩过的坑总结一下,变成几个固定的写SQL习惯:
-
所有时间字段建表时统一类型。业务时间用DATETIME,审计时间天然可以用TIMESTAMP,但一个项目里最多两套标准,不要一会儿DATETIME一会儿TIMESTAMP。
-
所有涉及"今天"的查询,都提取一个基准变量再复用。比如
SET @today = CURDATE();,后续所有SQL引用@today,避免每个子查询各自执行一次函数,也防止跨天时边界条件不一致。 -
日期范围查询一律用"左闭右开":
>= 开始时间 AND < 结束时间。这样可以避免毫秒、秒级精度导致的边界数据漏掉或重复。虽然MySQL的DATETIME默认精度到秒,但写入时如果字符串带了小数秒,还是会出问题。 -
格式化符写完后自检一遍:重点检查%m和%i有没有写反,%H和%h有没有混淆。这种错误不报错,结果全是错,还不容易发现。
-
跨年、跨月统计时,先明确周和月的业务口径,再选函数。宁愿多写两行注释,也不要让后人去猜这个周次是"周一为第一天"还是"周日为第一天"。我自己写复杂SQL时,会在SQL前面加注释标注口径来源。
-
时区问题提前约定,别等上线后排查。数据库连接串、Docker环境变量、服务器时区,三者在部署时就要确认一致,写进部署文档比写进代码好使。
-
凡是涉及日期函数的重型报表,先看看执行计划。EXPLAIN一下,确认没有全表扫描。如果发现
type = ALL,优先通过范围过滤缩小数据量。
这些习惯看着简单,但能帮你省下大量排查时间。每次新来的同事写SQL把日期函数放在索引列上,我都是用执行计划跟他讲一遍,把"为什么不能这么写"的原理讲清楚,他后面就不会再犯了。
日期时间函数这类知识,单纯背函数列表意义不大,真正值钱的是知道"什么场景用什么函数,什么写法会踩坑"。遇到复杂的日期统计需求,先拆时间段口径,再选函数,最后用EXPLAIN看一眼执行计划,基本就不会出大问题。希望这篇文章能帮你少走一些弯路。
