☰
SQL日期函数实战指南:格式化、日期计算与性能优化
2026/10/1 3:47:47 网站建设 项目流程

做SQL查询久了,你会发现一个规律:十张业务表里,至少有八张带着日期字段。订单有下单时间,用户有注册时间,日志有写入时间,几乎所有的数据分析、报表统计、数据清洗,最后都绕不开跟日期打交道。而“写SQL时最常用的日期函数”这个话题,看着基础,实际上坑特别多。我见过不少人因为日期格式转换踩坑、因为日期比较写错导致索引失效、因为函数选型不当让慢查询雪上加霜。这篇文章就把我在实际工作中反复用到的日期函数用法、场景和坑一次性说清楚,不管你是刚入门的新手,还是写了一两年SQL的工程师,都能从中找到能直接拿去用的东西。

1. 为什么日期函数是SQL查询的重灾区

1.1 日期数据的真实样子,比你想象的乱

很多人觉得日期字段不就是“2024-01-15”嘛,能有多复杂?等你真正接手一套跑了五六年的业务库,你就知道日期字段能乱成什么样。有的是字符串,存成“2024/01/15”;有的是时间戳,存成1705305600这种;有的精确到毫秒,存成2024-01-15 10:23:45.123;还有的干脆存成“20240115”这种八位数字。你光是把这些格式统一起来,就得费不少功夫。

更重要的是,业务系统不同模块的日期格式还经常不一致。比如订单表用DATETIME,用户表用VARCHAR,日志表用BIGINT时间戳。当你做跨表关联或者汇总统计时,日期函数就成了唯一能把这些格式拧到一块的工具。所以,与其问“为什么日期函数重要”,不如问“没有日期函数我怎么处理这些乱七八糟的日期格式”。

1.2 日期函数解决了哪三类核心问题

实际工作中,日期函数主要解决三类问题,你可以对照自己的业务看看是不是这么回事:

第一类,格式化与解析。把“20240115”变成“2024-01-15”,把字符串变成日期类型,或者反过来把日期按指定格式输出到报表里。这类需求在数据导出、接口对接、报表展示时特别多。比如你导出数据给业务方,对方要求日期必须是“2024年1月15日”这种中文格式,你就得靠格式化函数处理。

第二类,日期计算与偏移。计算两个日期之间差了多少天、多少月;给定一个日期,推算出它7天前是哪天、下个月的第一天是哪天;判断某个日期是星期几、是当年的第几周。这类函数在周期报表、账期计算、会员生命周期分析里用得非常频繁。

第三类,日期截断与聚合。把精确到秒的时间戳,截断到天、月、季度、年,再配合GROUP BY做聚合统计。这就是“按天统计订单量”“按月统计销售额”“按季度统计新增用户”的核心逻辑。没有日期截断函数,你就得自己写一堆CASE WHEN去拼分组逻辑,又丑又容易错。

1.3 不同数据库的日期函数差异,必须心中有数

搞SQL的人最容易忽略的一件事:日期函数在不同数据库里,名字和用法差异很大。SQL Server里有DATEADD,MySQL里叫DATE_ADD;SQL Server用DATEDIFF算天数差,MySQL对应的是DATEDIFF,但算月份差又得用TIMESTAMPDIFF;Oracle里格式化用TO_CHAR,SQL Server用FORMAT或CONVERT,MySQL用DATE_FORMAT。

这意味着,你今天在MySQL上写好的日期逻辑,换到SQL Server或者PostgreSQL上,很可能直接报语法错误。所以,不要问我“哪个数据库的日期函数最好用”,先搞清楚你现在用的是哪个数据库,再去查对应的函数文档。后文我会按主流数据库分别讲,方便你对号入座。

2. 主流数据库日期函数速查与核心逻辑

2.1 SQL Server:DATEADD、DATEDIFF、DATEPART三件套

SQL Server是很多企业级应用的首选数据库,它的日期函数体系比较独立,重点记住三件套就够了。

DATEADD用来做日期加减。比如DATEADD(DAY, 7, GETDATE())就是当前时间加7天,DATEADD(MONTH, -1, GETDATE())是当前时间减一个月。注意单位参数是第一个,别写反了。单位可以是YEAR、QUARTER、MONTH、WEEK、DAY、HOUR、MINUTE、SECOND,基本覆盖所有时间粒度。

DATEDIFF用来算两个时间点之间的差值。DATEDIFF(DAY, 开始日期, 结束日期)返回天数差,DATEDIFF(MONTH, 开始日期, 结束日期)返回月份差。这里有个细节容易忽略:DATEDIFF是按“跨越了多少个边界”算的,不是按整整24小时算的。比如2024-01-01 23:59:59和2024-01-02 00:00:01,虽然实际差了2秒,但DATEDIFF(DAY, ...)结果是1天。这在某些精确计算场景是个大坑,后面我会细说。

DATEPART用来提取日期的一部分。DATEPART(YEAR, 日期)拿年份,DATEPART(WEEK, 日期)拿到当年的第几周,DATEPART(WEEKDAY, 日期)拿星期几。SQL Server里默认一周从周日开始,所以DATEPART(WEEKDAY, '2024-01-15')返回2(周一),如果你希望一周从周一开始计算,要用SET DATEFIRST调整,这个细节在周报统计时非常关键。

另外SQL Server还有一个容易被性能问题缠上的函数:FORMAT。它写法优雅,FORMAT(GETDATE(), 'yyyy-MM-dd')就能格式化日期,中文年月日也能搞定。但FORMAT底层走的是.NET的格式化逻辑,性能比CONVERT差了不止一个数量级。我在后面讲到索引和性能的部分会专门说这个坑。

常用示例:

-- 当前日期时间 SELECT GETDATE(); -- 2024-01-15 10:23:45.123 -- 取当天零点 SELECT DATEADD(DAY, DATEDIFF(DAY, 0, GETDATE()), 0); -- 上个月第一天 SELECT DATEADD(MONTH, DATEDIFF(MONTH, 0, GETDATE()) - 1, 0); -- 当前是几号 SELECT DATEPART(DAY, GETDATE()); -- 字符串转日期 SELECT CONVERT(DATETIME, '2024-01-15 10:00:00', 120);

2.2 MySQL:DATE_FORMAT、DATE_ADD、DATEDIFF组合拳

MySQL在互联网场景用得最多,日期函数也相对友好。几个核心函数我逐个讲。

**NOW()和CURDATE()**分别返回当前日期时间和当前日期。CURDATE()返回的只有日期部分,是“2024-01-15”这种格式。如果只需要当前日期,用CURDATE()比NOW()更语义化,还能避免后续误用到时间部分。

DATE_FORMAT是MySQL里最灵活的格式化函数。DATE_FORMAT(NOW(), '%Y-%m-%d %H:%i:%s')能按你指定的格式输出日期。注意百分号加字母这套格式,跟SQL Server的格式码完全不同。常用的格式码:%Y四位年份,%y两位年份,%m两位月份,%c数字月份(不补零),%d两位日,%e数字日(不补零),%H24小时制,%i分钟,%s秒,%W星期名,%a缩写星期名,%M月份名。这个函数在报表输出中出场率极高。

DATE_ADD和DATE_SUB做日期加减。DATE_ADD(NOW(), INTERVAL 7 DAY)是加7天,DATE_ADD(NOW(), INTERVAL 1 MONTH)是加一个月。INTERVAL后面跟的数字和单位可以组合,支持MICROSECOND、SECOND、MINUTE、HOUR、DAY、WEEK、MONTH、QUARTER、YEAR等。MySQL没有单独的DATESUB,而是通过DATE_ADD传负数实现,比如DATE_ADD(NOW(), INTERVAL -1 DAY)。

DATEDIFF在MySQL里只用来算天数差,DATEDIFF('2024-01-15', '2024-01-10')返回5。如果你想精确计算两个时间戳之间差了几个小时、几分钟,要用TIMESTAMPDIFF(HOUR, 开始, 结束)。TIMESTAMPDIFF比DATEDIFF强大得多,第一个参数可以是SECOND、MINUTE、HOUR、DAY、MONTH、YEAR,而且是按实际长度计算的,不会出现SQL Server那种“跨边界就算一天”的问题。

DATE_TRUNC的MySQL替代法是DATE()函数。比如DATE(NOW())就是截断到天。要截断到月,可以配合DATE_FORMAT实现,比如DATE_FORMAT(NOW(), '%Y-%m-01')就是当月的第一天。要截断到周,用YEARWEEK()函数。

LAST_DAY返回某个月的最后一天。SELECT LAST_DAY('2024-02-05')返回2024-02-29,处理闰年特别省心,不用自己判断2月有28天还是29天。

常用示例:

-- 当前日期时间 SELECT NOW(); -- 2024-01-15 10:23:45 -- 今天的日期 SELECT CURDATE(); -- 2024-01-15 -- 格式化为 2024年01月15日 SELECT DATE_FORMAT(NOW(), '%Y年%m月%d日'); -- 最近7天的订单 SELECT * FROM orders WHERE order_time >= DATE_SUB(CURDATE(), INTERVAL 7 DAY); -- 两个时间戳之间差了多少小时 SELECT TIMESTAMPDIFF(HOUR, '2024-01-15 08:00:00', '2024-01-15 18:30:00'); -- 结果是10 -- 判断某个日期所在月的最后一天 SELECT LAST_DAY('2024-02-05'); -- 2024-02-29

2.3 PostgreSQL与Oracle:各具特色的日期处理方案

PostgreSQL的日期处理能力非常强,它甚至支持直接用日期加减整数。比如CURRENT_DATE + 7就是7天后的日期,CURRENT_DATE - 1就是昨天。这种运算符重载的设计,让PostgreSQL的日期计算写起来特别自然。它还有DATE_TRUNC函数,DATE_TRUNC('month', CURRENT_DATE)返回当月第一天,DATE_TRUNC('week', CURRENT_DATE)返回本周起始日,在做时间序列分析时非常好用。EXTRACT可以从时间戳里提取年、月、日、时、分、秒、季度、周,EXTRACT(YEAR FROM TIMESTAMP '2024-01-15 10:00:00')返回2024。AGE函数用来计算年龄,AGE('2024-01-15', '1990-05-20')会返回33年8月5日这种格式,做用户年龄统计比人工算省事太多。格式化用TO_CHAR,解析字符串用TO_DATE。

Oracle作为老牌商业数据库,日期体系相对封闭但功能完备。核心是TO_DATE和TO_CHAR,一个负责字符串转日期,一个负责日期转字符串。Oracle的日期格式符用单字母或双字母组合,比如YYYY-MM-DD HH24:MI:SS,跟MySQL的%Y%m%d风格完全不同。然后是ADD_MONTHS,专门用来加月份,ADD_MONTHS(DATE '2024-01-31', 1)结果是2024-02-29,Oracle会自动处理月末对齐,这一点比很多数据库做得聪明。MONTHS_BETWEEN返回两个日期之间相差的月份数,可以带小数。EXTRACT和PostgreSQL类似,EXTRACT(YEAR FROM SYSDATE)取年份。还有个TRUNC函数,TRUNC(SYSDATE)截断到天,TRUNC(SYSDATE, 'MM')返回月初,TRUNC(SYSDATE, 'WW')返回周初,功能上类似PostgreSQL的DATE_TRUNC但参数风格不同。

Oracle默认日期格式是DD-MON-YY,比如15-JAN-24,这个格式特别容易让人踩坑,因为你直接用字符串跟日期比较时,Oracle会按这个默认格式解析字符串,导致很多看起来没问题的SQL在Oracle上报“ORA-01843: not a valid month”错误。

2.4 一张表理清日期格式化的标准写法

功能描述SQL ServerMySQLPostgreSQLOracle
当前日期时间GETDATE()NOW()CURRENT_TIMESTAMPSYSDATE
当前日期CAST(GETDATE() AS DATE)CURDATE()CURRENT_DATETRUNC(SYSDATE)
日期加减天数DATEADD(DAY, 7, GETDATE())DATE_ADD(NOW(), INTERVAL 7 DAY)CURRENT_DATE + 7SYSDATE + 7
日期加减月份DATEADD(MONTH, 2, GETDATE())DATE_ADD(NOW(), INTERVAL 2 MONTH)CURRENT_DATE + INTERVAL '2 months'ADD_MONTHS(SYSDATE, 2)
两个日期差(天)DATEDIFF(DAY, 日期1, 日期2)DATEDIFF(日期1, 日期2)日期2 - 日期1日期2 - 日期1
提取年份DATEPART(YEAR, 日期)YEAR(日期)EXTRACT(YEAR FROM 日期)EXTRACT(YEAR FROM 日期)
格式化CONVERT(VARCHAR, 日期, 120)DATE_FORMAT(日期, '%Y-%m-%d')TO_CHAR(日期, 'YYYY-MM-DD')TO_CHAR(日期, 'YYYY-MM-DD')
当月最后一天EOMONTH(日期)LAST_DAY(日期)DATE_TRUNC('month', 日期) + INTERVAL '1 month - 1 day'LAST_DAY(日期)

这张表你可以保存下来,以后在不同数据库之间切换时对照着查,比我上面啰嗦的几千字直观得多。

3. 高频实操:搞定5个真实业务场景

3.1 按天/周/月/季度分组统计

这是报表需求里的王者场景。随便一个运营提需求,就是“给我看下这个月每天的订单数”。常规写法是GROUP BY日期字段,但直接把DATETIME字段放GROUP BY里会精确到时分秒,导致本来想按天聚合,结果同一秒内十几条订单被拆成好几组。

正确做法是先把日期截断到天,再分组。MySQL下用DATE_FORMAT(order_time, '%Y-%m-%d'),SQL Server用CONVERT(VARCHAR(10), order_time, 120),PostgreSQL用DATE_TRUNC('day', order_time)或直接CAST(order_time AS DATE),Oracle用TRUNC(order_time)。

如果是按周统计,就要考虑“一周从哪天开始”的问题。MySQL里YEARWEEK(order_time, 1)表示周一开始的一周,YEARWEEK(order_time, 0)是周日开始。SQL Server里DATEPART(WEEK, order_time)的结果受DATEFIRST影响,你得确认数据库默认的每周起始日是什么。PostgreSQL的DATE_TRUNC('week', 日期)默认从周一开始。按季度统计,MySQL用QUARTER()函数,SQL Server用DATEPART(QUARTER, 日期),PostgreSQL和Oracle用EXTRACT(QUARTER FROM 日期)。

一个典型的月报SQL长这样(MySQL版):

SELECT DATE_FORMAT(order_time, '%Y-%m') AS order_month, COUNT(*) AS order_count, SUM(order_amount) AS total_amount FROM orders WHERE order_time >= '2024-01-01' AND order_time < '2024-04-01' GROUP BY DATE_FORMAT(order_time, '%Y-%m') ORDER BY order_month;

这里要注意WHERE的写法。我见过很多新手喜欢写WHERE DATE_FORMAT(order_time, '%Y-%m') >= '2024-01',看起来没毛病,实际性能很差。因为你在日期字段上套了函数之后,索引基本就废了,后面讲性能的部分我再展开。

3.2 计算同比环比

运营会看去年同期数据、上个月数据,这就是同比环比。核心逻辑是用日期加减函数定位到比较基准期,然后JOIN或子查询对比。

以“本月销售额环比上月”为例,在MySQL下可以这样写:

SELECT DATE_FORMAT(now_month.order_time, '%Y-%m') AS month, now_month.total_amount AS current_amount, last_month.total_amount AS previous_amount, ROUND((now_month.total_amount - last_month.total_amount) / last_month.total_amount * 100, 2) AS mom_ratio FROM (SELECT DATE_ADD(CURDATE(), INTERVAL 1 - DAY(CURDATE()) DAY) AS month_start, SUM(amount) AS total_amount FROM orders WHERE order_time >= DATE_FORMAT(CURDATE(), '%Y-%m-01') AND order_time < DATE_ADD(DATE_FORMAT(CURDATE(), '%Y-%m-01'), INTERVAL 1 MONTH) GROUP BY DATE_FORMAT(order_time, '%Y-%m')) now_month LEFT JOIN (SELECT SUM(amount) AS total_amount FROM orders WHERE order_time >= DATE_ADD(DATE_FORMAT(CURDATE(), '%Y-%m-01'), INTERVAL -1 MONTH) AND order_time < DATE_FORMAT(CURDATE(), '%Y-%m-01') GROUP BY DATE_FORMAT(order_time, '%Y-%m')) last_month ON 1 = 1;

这个SQL有几个关键点值得你仔细看。第一,用DATE_FORMAT(CURDATE(), '%Y-%m-01')拿到当月第一天的日期,再配合INTERVAL加减拿到上个月的第一天。这样写的好处是,不管今天几号,我统计的都是完整月份的数据,不会把“今天以前”和“整月”搞混。第二,LEFT JOIN的条件写成了ON 1 = 1,因为两个子查询都只返回一行,这种写法可以让两个聚合结果直接横向比较。第三,过滤条件都写成了起始日期大于等于、结束日期小于下月月初的开闭区间,不会漏掉边界数据,也不会重复统计。

同比运算逻辑完全一样,无非是把“往前减1个月”改成“往前减12个月”。

3.3 日期区间查询的正确打开方式

最常见的需求是“查最近7天的订单”“查本月的注册用户”“查上一季度的退款单”。这里有个核心原则:能用日期范围比较,就别在日期字段上包函数。

举个例子。想查昨天一整天的订单,最直觉的写法是:

-- 错误示范(MySQL) SELECT * FROM orders WHERE DATE(order_time) = DATE_SUB(CURDATE(), INTERVAL 1 DAY);

这写法看着干净,但如果你在order_time上有索引,ORDER_TIME的索引大概率失效。因为MySQL要对每行的order_time先执行DATE()函数,才能跟后面的日期比较。数据量一大,这条SQL就是全表扫描的命。

正确写法是区间比较:

-- 正确示范(MySQL) SELECT * FROM orders WHERE order_time >= DATE_SUB(CURDATE(), INTERVAL 1 DAY) AND order_time < CURDATE();

这样写,order_time上的索引直接被利用,走得是索引范围扫描,性能差出几十倍不止。

SQL Server下也有同样的问题。很多人在SQL Server里写WHERE CONVERT(VARCHAR(10), order_time, 120) = '2024-01-14',一样让索引失效。正确写法是中间变量或者参数化写法:

DECLARE @start_date DATETIME = '2024-01-14 00:00:00'; DECLARE @end_date DATETIME = '2024-01-15 00:00:00'; SELECT * FROM orders WHERE order_time >= @start_date AND order_time < @end_date;

有人说我用的JDBC、MyBatis,没法声明变量。那也可以直接在SQL里写条件,意思一样。

这里再提醒一个SQL Server特有细节。DATETIME类型的精度约为3.33毫秒,你用order_time < '2024-01-15'去查“1月14日一整天”的订单,看起来没问题,实际上会漏掉2024-01-15 00:00:00.000这个时刻到2024-01-15 00:00:00.003之间产生的订单。所以SQL Server里最稳妥的区间写法是用“大于等于起始日零点”和“小于结束日次日零点”组合,也就是我上面示例写的方式。而在MySQL的DATETIME类型下,精度到秒,直接写DATE(order_time) = 某天的效率虽然差,但不会漏数据。至于DATETIME2类型,精度到100纳秒,但也建议统一用开闭区间写法,省心。

3.4 生日提醒与年龄段统计

系统里经常要算“今天是谁的生日”或者“本月有多少会员过生日”。这种场景别直接去比较完整的出生日期,因为年份肯定不等。

正确思路是提取月和日来比较。MySQL下用MONTH()和DAY()函数,SQL Server下用DATEPART(MONTH, ...)和DATEPART(DAY, ...)。

-- MySQL:查询本月过生日的会员 SELECT * FROM users WHERE MONTH(birthday) = MONTH(CURDATE()) AND DAY(birthday) >= DAY(CURDATE());

如果只查今天过生日的,直接MONTH(birthday) = MONTH(CURDATE()) AND DAY(birthday) = DAY(CURDATE())就行。

这里有个坑,查“今天过生日”时,如果某人是2月29日出生的,平年2月没有29号,业务上怎么处理要提前和产品对齐。有的产品要求按2月28日算,有的要求按3月1日算,别自己拍脑袋决定。

年龄段统计更常见,通常是按年龄段分组看用户分布。这里推荐你把年龄段映射和日期计算分开写,别在一个SELECT里堆一堆CASE WHEN。先把每个人的年龄算出来,再套年龄段,代码可读性会好很多。MySQL下算年龄可以用TIMESTAMPDIFF(YEAR, birthday, CURDATE()),它会自动处理生日没过的情况,比你自己拿年份相减再判断月份靠谱。

3.5 处理字符串日期与时间戳互转

接口对接和数据处理时经常遇到字符串日期和时间戳互转的需求。MySQL下把字符串转日期用STR_TO_DATE('2024-01-15 10:00:00', '%Y-%m-%d %H:%i:%s'),把时间戳转日期用FROM_UNIXTIME(1705305600),把日期转时间戳用UNIX_TIMESTAMP('2024-01-15 10:00:00')。

SQL Server里字符串转日期用CONVERT(DATETIME, '2024-01-15 10:00:00', 120),其中120代表ODBC标准格式yyyy-mm-dd hh:mi:ss。如果把时间戳转日期,SQL Server更常见的做法是DATEADD(SECOND, 1705305600, '1970-01-01')。

PostgreSQL里直接用::date、::timestamp做类型转换,比如'2024-01-15'::date,或者TO_TIMESTAMP(1705305600)函数。Oracle里字符串转日期必须用TO_DATE('2024-01-15', 'YYYY-MM-DD'),直接写'2024-01-15'让Oracle隐式转换是个坏习惯,分分钟给你抛ORA-01861错误。

互转过程中最容易出问题的,是时间戳的精度。有的系统时间戳是秒级,有的是毫秒级。秒级时间戳是10位,毫秒级是13位。如果你拿13位毫秒级时间戳直接转,得到的日期会是1970年附近,离实际日期差了十万八千里。我在实际对接第三方接口时栽过一次,对方文档里没写清楚是毫秒,转出来的日期全是1970年,排查了半天才发现是精度问题。所以,拿到时间戳字段,先确认位数:10位是秒,13位是毫秒,16位是微秒。

4. 性能隐患:日期函数写不对,索引白建

4.1 函数包裹字段,索引立马失效

前面已经提到好几次,在索引列上套函数会导致索引失效。这是个老生常谈的问题,但很多人栽过的坑恰恰就是“知道”和“做到”之间的距离。

举例说明。你在create_time上建了索引,下面这两种写法,性能天差地别:

-- 索引失效写法:对索引字段套了函数 SELECT * FROM orders WHERE DATE_FORMAT(create_time, '%Y-%m-%d') = '2024-01-15'; -- 索引充分利用写法:直接对索引字段做范围比较 SELECT * FROM orders WHERE create_time >= '2024-01-15 00:00:00' AND create_time < '2024-01-16 00:00:00';

在MySQL的InnoDB引擎下,第二种写法会走create_time索引的范围扫描,查询计划里能看到range类型,处理几千条数据可能只需要几毫秒。第一种写法是全表扫描,数据量上百万后,一次查询可能要几百毫秒甚至更久。

SQL Server里同理,WHERE CONVERT(VARCHAR(10), create_time, 120) = '2024-01-15'也是无法利用索引的写法。PostgreSQL里WHERE date_trunc('day', create_time) = '2024-01-15'同样问题。

有个判断索引是否失效的粗暴方法:看看WHERE条件里的字段,如果字段两侧是“函数(字段)”,那索引一定用不上;如果字段是完整裸露的,两边是大于、小于、等于,那索引大概率能用上。

4.2 FORMAT这类函数为什么是性能毒药

SQL Server的FORMAT函数我用过一次就再不敢在大表上用。它看起来太方便了:FORMAT(GETDATE(), 'yyyy-MM-dd')直接输出2024-01-15,FORMAT(GETDATE(), 'yyyy年MM月dd日')输出2024年01月15日,中文格式随便写。

但FORMAT背后是CLR(公共语言运行时),每一行都要启动一次.NET格式化逻辑。在百万行级的数据上做格式化,查询时间可能是CONVERT方案的几十倍。我有一次在一个500万行的表上跑报表,业务方要看格式化后的日期字段,我用FORMAT写的SQL跑了三分钟,换成CONVERT(VARCHAR, 日期, 120)写法后,三秒内出结果。这个对比足够说明问题。

MySQL的DATE_FORMAT虽然性能比SQL Server的FORMAT好,但在大表上同样不建议放在WHERE条件里,能用范围查询就用范围查询,把格式化放到SELECT输出层去做,影响面会小很多。

4.3 日期范围查询的黄金法则:开闭区间

我在前面反复强调区间查询的写法,这里再系统讲一下。判断一个日期属于“某一天”的标准写法,永远推荐“大于等于起始日零点,小于次日零点”的开闭区间。用“大于等于0点且小于等于23:59:59”的写法,看起来更直观,但有两个隐患。

第一,DATETIME类型的精度问题。SQL Server的DATETIME精度是3.33毫秒,你写成<= '2024-01-15 23:59:59',对于精确到2024-01-15 23:59:59.997的数据,能查出来,但23:59:59.998到23:59:59.999的动作会被漏掉。MySQL的DATETIME精度到秒,<= '23:59:59'没有这个问题,但DATETIME2就不行了。

第二,语义不清晰。小于次日零点,哪怕后续有人把数据库精度升级,也不会影响结果。小于等于当天23:59:59则隐含了对精度的假设。习惯了“大于等于起始日零点,小于次日子零点”的写法之后,你写日期过滤条件基本不会再犯边界错误。

4.4 隐式类型转换:日期查询的隐形杀手

还有一种索引失效是隐式类型转换造成的。比如你的create_time字段是VARCHAR类型,存的是'2024-01-15 10:23:45'这种字符串,你在WHERE里写create_time > '2024-01-14',数据库需要把日期字符串和普通字符串做比较,或者反过来把普通字符串当成日期来比较。MySQL在这种情况下,如果字段是字符串,但比较值是日期格式,会尝试把字段值转成日期再比较,一转换,索引又废了。

反过来,如果字段是DATETIME,你写WHERE create_time = '2024-01-15',也会发生隐式转换——把字符串转成DATETIME。这个转换如果发生在字段上,同样导致索引失效。所以,写SQL时尽量显式写清楚类型,不要依赖数据库帮你做类型转换。宁可多写一遍TO_DATE/CONVERT/STR_TO_DATE,把等号左边的字段保持裸露状态。

5. 我踩过的坑:日期函数实战问答与避坑清单

5.1 日期格式转换老是报错,怎么排查

最常见的报错是MySQL的“Incorrect datetime value”和SQL Server的“Conversion failed when converting date and/or time from character string”,Oracle则是ORA-01843/OORA-01861。这类问题十有八九是字符串格式和日期格式不匹配。

我的排查套路是三步。第一步,把出问题的字符串原样打印出来,肉眼检查是什么格式——是'2024/01/15'、'20240115'还是'15-Jan-2024'。第二步,找到你正在用的数据库对应的格式符规则:MySQL用%Y-%m-%d,SQL Server用120样式,Oracle用YYYY-MM-DD。第三步,检查字符串里有没有“意外字符”。我遇到过最气人的是字符串里混了全角空格、制表符、还有不可见字符,肉眼根本看不出来。遇到这种,先用REPLACE函数把看不见的字符清掉,比如REPLACE(date_str, CHAR(9), '')去掉制表符,再转日期。

另外千万警惕一个经典问题:月份简写在英文系统下解析有一套规则,月份名是中文在Oracle里又得改NLS设置。遇到问题先问自己“这个字符串的格式,数据库认识不认识”,别一上来就怀疑函数写错了。

5.2 时区问题怎么处理

时区问题在日志分析和国际化业务里特别明显。数据库存的是UTC时间,业务方要看北京时间,差8个小时,处理不好报表就是错的。

我的经验是遵循“入库统一、展示转换”的原则。数据入库时统一存UTC时间,所有的时间戳字段约定好语义,查询展示时再用日期函数统一转换。MySQL下用CONVERT_TZ(时间, '+00:00', '+08:00')转时区;PostgreSQL可以直接用AT TIME ZONE语法;SQL Server里则用AT TIME ZONE(需要SQL Server 2016以上),格式是时间AT TIME ZONE 'UTC' AT TIME ZONE 'China Standard Time'。

还有个小细节:取当天数据的开始和结束要结合时区算。你在中国写“今天0点到明天0点”,不能拿UTC的CURDATE()去算,否则你查到的“今天”实际上是UTC的今天,比北京时间晚8个小时。正确做法是先把当前时间转到目标时区,再截断到天,然后做区间比较。

5.3 日期为空时的NPE式崩溃

日期字段为NULL是常态,尤其LEFT JOIN关联出不来数据时,日期字段全是NULL。你在日期字段上做DATE_FORMAT、DATEPART、EXTRACT,会得到NULL结果,但如果你在NULL上做比较、聚合,结果会出乎意料。

最典型的是“统计某月有订单的用户”场景。用户没下单时LEFT JOIN后的下单时间为NULL,你要是写WHERE MONTH(order_time) = 1,这个用户就被排除了,但业务想要的可能是“所有用户都统计进来,没下单的显示0”。所以,写日期筛选前,先想清楚NULL要不要过滤,要的话用IS NULL / IS NOT NULL显式处理,别靠日期函数间接过滤。

聚合函数也会被NULL坑。比如你想看用户最后一次下单日期,MAX(order_time)没问题,NULL会自然忽略。但如果业务上要求“没有下单显示1970年”,那就要用COALESCE(MAX(order_time), '1970-01-01')显式兜底。

5.4 闰年、月末和平年的边界

边界问题的典型代表是“上月同日”“下月同日”。比如今天是1月31日,你写DATE_ADD(CURDATE(), INTERVAL 1 MONTH)期望得到2月28日还是3月2日?不同数据库行为不完全一样。

MySQL的DATE_ADD往月加的时候,如果目标月份没有当前日,会顺延到目标月份的最后一天。所以1月31日加一个月,结果是2月28日(平年)或2月29日(闰年)。SQL Server的DATEADD(MONTH, 1, '2024-01-31')则不一样,它返回的是2024-02-29,同样是“尽量往月末对齐”,但和MySQL的行为在细节上仍有差异。PostgreSQL里'2024-01-31'::date + INTERVAL '1 month'返回2024-02-29。Oracle的ADD_MONTHS('2024-01-31', 1)也返回2024-02-29。

所以,涉及月末日期计算的业务逻辑,必须和产品确认清楚:周期计算是基于“自然月”,还是基于“固定天数”。如果是从每月1号开始算30天一个周期,那就用INTERVAL DAY,别用MONTH,否则1月的时间周期和2月的周期会差出几天。

还有闰年的坑,判断2月29日时,DATE_SUB、DATE_ADD都要小心。我遇到过线上系统因为没人处理闰年,在2月底跑批直接报错的事故。后来我养成了一个习惯:写跨月日期计算的SQL,先在某个闰年日期上快速验证一遍,看行为是否符合预期。

5.5 SQL注入与日期参数安全问题

写日期查询时,最容易被忽略的是SQL注入风险。日期参数通常通过前端传进来,比如用户选个日期范围,如果直接拼接字符串SQL,等于把数据库大门敞开。

安全的做法是使用参数化查询。Java的JDBC用PreparedStatement的setDate、setTimestamp方法;Python的SQLAlchemy传datetime对象;MyBatis里用#{}占位符而不是${}拼接。这样既避免了SQL注入风险,也避免了隐式类型转换带来的索引问题,一举两得。

我见过有人写“WHERE create_time >= CONVERT(DATETIME, '2024-01-01') AND create_time <= '{endDate}'”这种代码,前端传入endDate拼进去。攻击者如果在endDate上动点手脚,整条SQL就危险了。记住一条底线:任何SQL参数,一律参数化绑定;日期参数的绑定尤其要注意类型,绑定成时间戳,不要绑定成格式化字符串。

5.6 日期函数在慢SQL优化里的使用建议

最后聊聊日期函数和慢SQL结合的场景。做慢SQL治理时,我几乎每次都遇到日期条件的写法问题。排查步骤大概是:

先看执行计划,确认WHERE条件是否走了索引。其次看条件里有没有对日期字段做函数包装。再看有没有隐式类型转换。最后看是否用了排斥性的写法,比如WHERE order_time <> '2024-01-15'这种,也是直接放弃索引的写法。

如果一定要在WHERE里对日期做函数运算,比如业务就是“按星期过滤”或“按月份过滤”,那可以退而求其次:把日期列冗余出一个“日期字符串”字段,单独建索引,查询时直接对字符串做等值匹配。比如orders表有个create_date_str字段,存的是'2024-01-15'字符串,查询时WHERE create_date_str = '2024-01-15',完美的索引匹配。这种冗余字段在报表库里很常见,算是一种以空间换时间的经典方案。

另外,日期统计类的SQL,尽量用批处理方式跑。别让报表SQL在业务高峰期直接查生产库的大表日期字段。能放到离线数仓或者从库的,绝对不要在主库上做。

6. 一个提高效率的收尾技巧

写了上面这么多,最后再分享一个小技巧,是我最近实践下来觉得特别有用的习惯:在数据量大的场景下,日期字段一律用DATETIME类型存储,别用VARCHAR,也别用BIGINT时间戳。DATETIME类型配合日期函数,几乎不会出现类型转换问题,索引也友好,计算也灵活。字符串日期看着直观,但排序、比较、聚合操作都比DATETIME麻烦,而且隐式转换的坑十个里有八个是从字符串日期来的。

如果历史表里已经用字符串存了日期,也别急着重构存储,先在后加一个真实的日期列,用一条UPDATE语句把旧数据解析进去,再在新列上建索引。之后所有查询都走新列,老列保留一段时间做兼容。这样既不影响存量逻辑,又能让新的统计查询跑得快。

我个人在实际操作中的体会是,日期函数本身不难,难的是在不同场景下选择合适的函数、合适的写法、合适的时机去用。把常用的那几个函数练到条件反射,把边界情况的处理逻辑刻进脑子里,写日期相关的SQL就不会再让你头疼了。希望这篇文章里的踩坑经验和示例代码,能帮你少走几步弯路。

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

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

立即咨询