1. DATEADD 的参数账本:三个参数各自在做什么
上周有位做报表的同学在群里发了一句话:DATEADD(month, 1, '2024-01-31')为什么返回的是 2 月 29 日而不是 3 月 3 日?下面有人回"因为 2 月没有 31 号"。这个答案不算错,但只说对了一半。真正需要搞清楚的是 DATEADD 这个函数在整个日期处理体系里扮演什么角色,它和其他日期函数怎么分工,以及它的三个参数各自会把结果往哪个方向带。
先给结论:DATEADD 是一个"平移"函数,不是"构造"函数。它不会帮你补零、不会帮你格式化、也不会帮你判断月末,它只做一件事——把给定的日期值沿着某条时间轴往前或往后推固定的步数。理解了这一点,后面所有的用法都是它的推论。
它的签名很干净:
DATEADD (datepart, number, date)三个参数,位置固定,少一个都不行。很多人卡住不是因为记不住名字,而是因为不知道这三个位置分别"吃"什么类型、吐什么类型、边界在哪里。下面拆开说。
1.1 第一个参数 datepart:为什么它能写那么多种别名
datepart是你告诉函数"我要按哪个时间单位移动"的地方。它接收的是一个关键字,不是一个数值。SQL Server 允许的取值比大多数人想象的要多,而且同一个单位往往有好几个等价写法,这是历史兼容留下的包袱,记住常用的一两个就够了,但看到别人写的别名不能眼生。
| 时间单位 | 可选写法 | 单次移动的实际跨度 |
|---|---|---|
| 年 | year/yy/yyyy | 按日历年推进,受闰年影响 |
| 季度 | quarter/qq/q | 3 个月 |
| 月 | month/mm/m | 1 个自然月 |
| 年中的天 | dayofyear/dy/y | 1 天 |
| 日 | day/dd/d | 1 天 |
| 周 | week/wk/ww | 7 天 |
| 工作日 | weekday/dw/w | 1 天 |
| 小时 | hour/hh | 1 小时 |
| 分钟 | minute/mi/n | 1 分钟 |
| 秒 | second/ss/s | 1 秒 |
| 毫秒 | millisecond/ms | 1 毫秒 |
| 微秒 | microsecond/mcs | 1 微秒 |
| 纳秒 | nanosecond/ns | 100 纳秒 |
这张表里有两个坑值得单独拎出来说。
第一个坑是m和mi。m是月份,mi是分钟。这两个只差一个字母,含义差了四万多倍。我在代码评审里见过不止一次有人想加一分钟写成DATEADD(m, 1, ...),跑出来日期直接跳到下个月,因为数据量小、现象不明显,上线之后才被业务方发现统计口径错了。
第二个坑是week和weekday。很多人以为weekday是按工作日算的、会自动跳过周末,实际上不是。在 SQL Server 里week是加 7 天,weekday是加 1 天,两者都只是纯天数平移,完全不管这天是不是休息日。如果你想做"跳过周末的五个工作日之后",得自己写循环或者用日历表去关联,别指望weekday帮你办这事。
datepart传字符串也可以,比如DATEADD('month', 1, ...),会被隐式转换。但我不建议这么写,一是没必要多一层转换,二是这种写法在部分工具链里会触发语法高亮错误,白白浪费排查时间。
1.2 第二个参数 number:小数、负数与表达式的结果
number决定"移动多少格"。它接收整数,有三种使用形态经常被忽略。
负数就是往回推。DATEADD(day, -7, ...)拿到的是一周前的同一时刻,不需要换DATEDIFF再减,也不用DATE_SUB这种别的数据库才有的函数。查"近七天"的数据时我基本都这么写边界,可读性比一堆加减号好很多。
小数会被截断,不是四舍五入。这是最容易被误解的一处。实测一下:
SELECT DATEADD(day, 1.9, '2024-01-01') AS a, -- 2024-01-02 DATEADD(day, 1.1, '2024-01-01') AS b, -- 2024-01-02 DATEADD(day, -0.9, '2024-01-01') AS c; -- 2024-01-01三次结果都是往零的方向取整之后再平移。所以如果你在代码里做过总数 / 天数这种除法又把结果直接喂给number,小数部分会静默丢失,不会报错也不会提醒。想保留精度就得先把单位换成更细的粒度,比如把"1.5 小时"改成DATEADD(minute, 90, ...)。
表达式和变量可以直接传。number位置完全可以放一个计算式,比如DATEADD(day, @n * 2, @base),或者放进CASE WHEN里做条件平移。这一点在做"根据用户等级给不同的有效期"这类逻辑时特别顺手,不用在应用层拼字符串。
1.3 第三个参数 date:返回类型的连锁反应
date参数看着最无害,其实它决定了返回值的类型,进而影响后面所有环节——包括能不能塞进目标列、比较时会不会出隐式转换、显示出来带不带时分秒。
规则是这样的:DATEADD 的返回类型跟随第三个参数的输入类型。传进去是date,出来就是date;传进去是datetime2(3),出来还是datetime2(3),精度也保持一致;传进去是smalldatetime,出来就是smalldatetime,而且会被它的精度约束截断到分钟。
最容易出事的是字符串字面量。你写DATEADD(day, 1, '2024-01-01'),这个'2024-01-01'并不会被当成date,而是按datetime解析的,于是结果变成一个带00:00:00.000的datetime。把它插进date类型的列当然没问题,但如果你拿它和另一个date列做比较、或者塞进一个varchar列,结果就会带上那个多余的零点时间,报表上显示出来就不好看。
我的习惯是永远显式转换第三个参数,写清楚我要的类型:
DATEADD(day, 1, CAST('2024-01-01' AS date))多敲几个字符,省掉后面一堆格式排查。这个习惯我保持了几年,确实少踩了不少返工。
2. 按时间粒度逐层加:一份可直接对照的实操清单
把参数讲清楚之后,剩下的就是"哪种场景该用哪个粒度"。这一段我按从大到小的顺序过一遍,每个粒度配一个真实的用法场景,你可以直接抄去改字段名。
2.1 年、季、月:跨度最大的三种加法
按年推最常见的是算周年日、到期日:
SELECT DATEADD(year, 1, CAST('2024-03-15' AS date)); -- 2025-03-15闰日是个特殊情况,2024-02-29往后推一年会落到2025-02-28,函数会自动收敛到该月最后一天,不会报错也不会给你一个 2 月 30 日。这个行为在算"会员一年有效期"时是需要跟业务确认的:到底是给到 2 月 28 日还是 3 月 1 日,两种口径都有人用,函数默认给的是前者。
按季度推等价于按月推 3 个月,DATEADD(quarter, 1, ...)和DATEADD(month, 3, ...)结果完全一样。既然等价,为什么还要留quarter这个关键字?主要是为了代码可读性,写"下个季度"的时候用quarter一眼就懂,用month, 3还得在脑子里换算一下。团队协作里这点可读性是有价值的。
按月推是使用频率最高的一个,也是最容易出坑的一个,具体见后面第 4 章。这里先给一个典型用法:算账单日。
-- 假设账期是从每月 15 号开始,算下一个账期起始日 SELECT DATEADD(month, 1, CAST('2024-01-15' AS date)); -- 2024-02-152.2 周、日、工作日:那个容易被混淆的 wk 与 dw
按天推是最直白的:
SELECT DATEADD(day, 30, CAST('2024-01-01' AS date)); -- 2024-01-31 SELECT DATEADD(day, -1, CAST('2024-03-01' AS date)); -- 2024-02-29注意第二个例子,往回推一天自动跨月跨到了闰日,函数处理得很好,不用你判断上个月有多少天。这也正是我推荐用它而不是自己拼字符串的原因——手写'2024-02-' + (天数-1)这种代码,闰年、大小月、跨年全得自己判断,出错的概率太高。
按周推就是按天推 7 天:
SELECT DATEADD(week, 2, CAST('2024-01-01' AS date)); -- 2024-01-15它和DATEADD(day, 14, ...)完全等价。有人担心week会受数据库的"一周第一天"设置影响,这个担心放在DATEDIFF上是对的,放在DATEADD上就不成立——DATEADD的week是纯粹的 7 天平移,不查任何配置。
weekday则是 1 天平移,和day结果一致。存在这个别名的历史原因我猜是语法完整性,实际写代码时基本不会有人用它,看到了知道是"加一天"就行。
2.3 时、分、秒、毫秒:高频采样场景下的加法
往下走到小时以下,主要出现在两类场景:一类是日志、监控、埋点数据的时间窗口对齐,另一类是定时任务的触发时间计算。
监控数据经常需要"以 5 分钟为粒度聚合",这时候可以用DATEADD把任意时间戳对齐到最近的整 5 分钟:
DECLARE @t datetime2 = '2024-05-20 10:37:42'; SELECT DATEADD(minute, DATEDIFF(minute, 0, @t) / 5 * 5, 0) AS bucket; -- 2024-05-20 10:35:00这段写法的思路值得说一下:DATEDIFF(minute, 0, @t)算出从基准点0(也就是 1900-01-01)到目标时间一共过了多少分钟,得到一个整数;整数除以 5 再做整除,把零头砍掉;乘回 5 得到对齐后的分钟数;最后用DATEADD把这个分钟数加回基准点,还原成一个时间。整个过程没有任何字符串操作,全是整数运算,效率高而且不会因为格式问题出错。
秒和毫秒的用法同理,只是粒度更细:
SELECT DATEADD(second, -30, SYSUTCDATETIME()); -- 30 秒前 SELECT DATEADD(millisecond, 500, @t); -- 加半秒微秒和纳秒需要datetime2类型才撑得住精度,用datetime会被截断回约 3.33 毫秒的精度,这一点在写高精度计时逻辑时必须注意,否则你会觉得"明明加了 100 微秒怎么没变化"。
3. 和 DATEDIFF 搭配:把 DATEADD 用成日期截断器
单独用DATEADD只是平移,把它和DATEDIFF组合起来,就变成了"日期截断"和"区间构造"的利器。这个组合技我认为是日期处理里性价比最高的一个套路,掌握之后很多原本要写一堆CASE WHEN的需求都能一行搞定。
3.1 构造"本月一日零时"这类基准点
需求里出现频率极高的一类表达是"本月一号""本周一""今天零点"。这些都可以用同一个模板套出来:
DECLARE @now datetime2 = '2024-05-20 14:37:42'; -- 今天零点 SELECT DATEADD(day, DATEDIFF(day, 0, @now), 0); -- 本月一号零点 SELECT DATEADD(month, DATEDIFF(month, 0, @now), 0); -- 今年一月一号零点 SELECT DATEADD(year, DATEDIFF(year, 0, @now), 0); -- 本周一零点(需配合 SET DATEFIRST 1) SET DATEFIRST 1; SELECT DATEADD(week, DATEDIFF(week, 0, @now), 0);模板的骨架是固定的:DATEADD(单位, DATEDIFF(单位, 0, 目标时间), 0)。先算出目标时间距基准点差多少个完整单位,再把这些单位原样加回基准点,多出来的零头自然就被丢掉了。
这个写法比CONVERT(varchar(7), @now, 120) + '-01'之类的字符串拼法好在哪?一是快,全是整数运算,不用做类型转换;二是稳,不受语言设置、日期格式设置影响;三是能直接参与比较和索引,因为结果是标准的日期类型而不是字符串。
写这套模板时有个细节要注意:基准点0在 SQL Server 里代表1900-01-01。这个值本身没有特殊含义,它只是"一个干净的、各项都为 0 的起点",用它来做差值计算最方便。但如果你的数据里有早于 1900 年的日期,DATEDIFF会返回负数,这套模板就不适用了,需要换成更早的基准比如'-0001-01-01'。实际业务里极少遇到,知道有这么个前提就行。
3.2 用半开区间替代 BETWEEN 月末
统计月度数据的时候,很多人会这么写:
-- 不推荐 WHERE create_time BETWEEN '2024-05-01' AND '2024-05-31'这里藏着两个问题。第一,'2024-05-31'被当成datetime时会补上00:00:00,导致 5 月 31 日当天 0 点之后的数据全部漏掉。第二,如果create_time是datetime2或者带毫秒,边界上还会再丢一截。
正确姿势是用左闭右开的半开区间:
DECLARE @start date = '2024-05-01'; SELECT ... FROM t WHERE create_time >= @start AND create_time < DATEADD(month, 1, @start);DATEADD(month, 1, @start)自动算出下个月一号,不用你去查这个月是 28 天、30 天还是 31 天,也不用担心闰年。区间左闭右开,任何精度的字段都不会漏数据,也不会重复统计。
这个模式我建议直接固化成团队的编码规范。它的好处不只是正确性,还在于扩展性:如果要改成按季度统计,把month换成quarter;改成按年,换成year;改成按周,换成week。整个查询结构一点不用动。
3.3 生成连续日期序列的写法
做报表时经常需要"补齐没有数据的日期",比如最近 30 天每天的订单量,某几天没有订单也得显示 0。这时候得先造一个连续日期序列出来,DATEADD正好可以当生成器用。
WITH nums AS ( SELECT TOP (30) ROW_NUMBER() OVER (ORDER BY (SELECT NULL)) - 1 AS n FROM sys.all_objects ) SELECT DATEADD(day, -n, CAST(GETDATE() AS date)) AS d FROM nums ORDER BY d;思路是先借一张系统表造出 0 到 29 的序号,再用DATEADD(day, -n, 今天)逐个往前推,得到 30 个日期。TOP (30)控制天数,改数字就行。
如果环境不允许访问系统表,可以用递归 CTE,但要注意默认递归上限是 100,超过就得加OPTION (MAXRECURSION n)。在我做过的项目里,日期序列一般不超过几百行,递归完全够用,但如果是按分钟生成一整年的序列(五十多万行),还是老老实实用数字表或者临时表更稳妥。
4. 实测中最容易翻车的几处:溢出、月末与类型隐式转换
函数本身的语法很简单,出问题的地方基本都集中在边界条件和类型转换上。这一章把我在实际项目里踩过的、以及帮别人排查过的几类问题整理出来。
4.1 月末加法不是简单地"加一个月"
这是DATEADD(month, ...)最反直觉的地方。看下面这组实测:
SELECT DATEADD(month, 1, CAST('2024-01-31' AS date)); -- 2024-02-29 SELECT DATEADD(month, 1, CAST('2024-02-29' AS date)); -- 2024-03-29 SELECT DATEADD(month, 1, CAST('2024-03-31' AS date)); -- 2024-04-30 SELECT DATEADD(month, -1, CAST('2024-03-31' AS date)); -- 2024-02-29四条结果放在一起看,规律就清楚了:目标月份没有那么长的天数时,结果收敛到目标月的最后一天;但一旦收敛过,你再往后加,起点就变成了收敛后的日期,不会自动"弹回"原来的月末。
第二行最能说明问题:2024-02-29加一个月得到2024-03-29,而不是2024-03-31。也就是说,这种加法不具备"往返一致性"——先加一个月再减一个月,不保证回到原点:
SELECT DATEADD(month, -1, DATEADD(month, 1, CAST('2024-01-31' AS date))); -- 2024-01-29,不是 2024-01-31这个特性在算分期、算周期性账单的时候会积累误差。比如每月 31 号扣款,到了 2 月变成 29 号,3 月又变成 29 号,之后一路都是 29 号,永远回不到 31 号。如果业务上要求"始终是月末",正确做法是用锚点日期加序号:
-- 以每月 1 号为锚点,先加月份再减一天,得到目标月的最后一天 SELECT DATEADD(day, -1, DATEADD(month, @n + 1, CAST('2024-01-01' AS date)));这样每一次计算都从固定的2024-01-01出发,误差不会累积。这个技巧我在做账期系统时用过,确实比直接加月份靠谱。
4.2 边界溢出的报错长什么样
date、datetime、smalldatetime各自的取值范围不一样,加超了会直接抛错,不是返回 null,也不是悄悄截断:
SELECT DATEADD(year, 1, CAST('9999-12-31' AS date)); -- 报错:将值添加到 'date' 列导致溢出。datetime的下界更靠前,往回推太远也会炸:
SELECT DATEADD(year, -1, CAST('1753-01-01' AS datetime)); -- 报错:将值添加到 'datetime' 列导致溢出。这类错误在生产环境里通常出现在两种地方:一是用户输入了极端的日期值而前端没做校验,二是循环里用累加的方式推日期结果越推越远。第二种情况尤其隐蔽,因为循环跑到第 9000 次才炸,前面都好好的。
防御办法很简单,在关键路径上加一层范围判断:
CASE WHEN @base < '1901-01-01' THEN NULL ELSE DATEADD(year, 100, @base) END或者在应用层就把输入限制在一个合理区间内。我倾向于两者都做,数据库层兜底,应用层给用户友好提示。
4.3 把函数套在索引列上:性能是怎么掉的
这条是我见过代价最大的一个误用。写法大概长这样:
-- 索引失效 WHERE DATEADD(day, 1, create_time) >= '2024-01-01' -- 索引用得上 WHERE create_time >= DATEADD(day, -1, '2024-01-01')create_time上有索引的情况下,第一种写法会让索引彻底用不上,因为优化器没法为"加了 1 天之后的值"去索引里定位,只能全表扫描逐行算。数据量几十万的时候看不出来,上千万之后差别就是秒级和分钟级的区别。
判断标准很朴素:函数套在列上,索引就废;函数套在常量上,索引照用。所以做日期平移的时候,能挪到等号右边就挪过去。上面第二个写法把-1挪到了常量侧,等价变换之后索引完好。
如果实在没法挪(比如两边都是列),那就得考虑持久化计算列:
ALTER TABLE t ADD next_day AS DATEADD(day, 1, create_time) PERSISTED; CREATE INDEX IX_next_day ON t (next_day);DATEADD是确定性函数,可以做持久化计算列并建索引。这个方案在需要频繁按"偏移后的日期"过滤时很有效,代价是写入时多维护一个索引,以及表结构变得更复杂。用不用取决于读写比例,写多读少的表我不建议加。
5. 换个数据库怎么写:DATEADD 在不同方言里的对应物
DATEADD这个名字严格来说不是 SQL 标准,它更像 SQL Server 家族的方言。同一个需求搬到别的数据库上,写法差异不小,做迁移的时候这一块特别容易漏改。下面按常见的几种库做个对照。
5.1 MySQL 的 DATE_ADD 与 INTERVAL 语法
MySQL 没有DATEADD,对应的是DATE_ADD(date, INTERVAL expr unit):
SELECT DATE_ADD('2024-01-31', INTERVAL 1 MONTH); -- 2024-02-29 SELECT DATE_ADD('2024-01-01', INTERVAL -7 DAY); -- 2023-12-25 SELECT DATE_SUB('2024-01-01', INTERVAL 7 DAY); -- 同上翻写的时候有几个差异必须记住:
单位的位置不一样。SQL Server 是DATEADD(month, 1, d),单位在前;MySQL 是DATE_ADD(d, INTERVAL 1 MONTH),单位和数值一起放在后面。写过一次就不会忘,但批量迁移脚本里如果靠正则替换很容易搞错。
单位名字大小写不敏感,但单复数有区别。DAY和DAYS都行,MONTH和MONTHS都行,习惯上单数更常见。
月末行为基本一致,2024-01-31加一个月同样返回2024-02-29,不会报错。这一点两边是对齐的,迁移时不用额外处理。
DATE_SUB是DATE_ADD加负数的一个语法糖,语义上完全等价,选哪个看团队习惯。
5.2 PostgreSQL 的 interval 运算
PostgreSQL 走的是另一条路——把时间间隔当成一等公民,用interval字面量做加减:
SELECT DATE '2024-01-31' + INTERVAL '1 month'; -- 2024-02-29 SELECT DATE '2024-01-01' - INTERVAL '7 days'; -- 2023-12-25 SELECT TIMESTAMP '2024-01-01 10:00' + INTERVAL '90 minutes';也可以把数值和单位分开写,方便把变量塞进去:
SELECT DATE '2024-01-01' + (n || ' days')::interval FROM t; -- 或者更规范一点 SELECT DATE '2024-01-01' + make_interval(days => n) FROM t;这里有个容易踩的点:PostgreSQL 的interval '1 month'和interval '30 days'不是一回事。前者按日历月推进,后者是固定 30 天,跨月的时候结果会不一样。写月度统计的时候必须用1 month,用30 days会在 2 月出偏差。这个坑我在做跨库口径对比时踩过,两边查同样的条件,结果差了一天,查了半天才发现是间隔单位的问题。
5.3 Oracle 与 SQLite 的替代写法
Oracle 没有专门的日期加减函数,它是用算术运算符直接实现的:
-- 加 1 天 SELECT DATE '2024-01-01' + 1 FROM dual; -- 加 1 个月,用专门的 ADD_MONTHS SELECT ADD_MONTHS(DATE '2024-01-31', 1) FROM dual; -- 2024-02-29 -- 加 1 小时,1/24 天 SELECT DATE '2024-01-01' + 1/24 FROM dual;+ n的单位是"天",所以加小时、加分钟都得换算成分数。这种写法看着简洁,但1/24、1/1440这类魔数散在代码里,读起来很吃力,我通常在项目里包一层自定义函数把语义显式化。
ADD_MONTHS的月末行为和DATEADD(month, ...)一致,也是收敛到最后一天。但要注意 Oracle 的DATE类型自带时分秒,做日期比较时同样要用半开区间,逻辑和前面讲的一样。
SQLite 更简单,它只有文本形式的日期,全靠字符串函数:
SELECT date('2024-01-31', '+1 month'); -- 2024-02-29 SELECT datetime('2024-01-01 10:00', '-30 minutes'); SELECT date('now', '+7 days');修饰符的写法是"符号 + 数值 + 单位",单位用复数英文单词。SQLite 没有真正的日期类型,比较和排序都依赖 ISO 格式字符串的字典序,所以格式必须是YYYY-MM-DD或者YYYY-MM-DD HH:MM:SS,写成别的格式就全乱了。
把这几种方言放在一张表里对照,迁移时对照着改效率最高:
| 需求 | SQL Server | MySQL | PostgreSQL | Oracle | SQLite |
|---|---|---|---|---|---|
| 加 1 天 | DATEADD(day,1,d) | DATE_ADD(d,INTERVAL 1 DAY) | d + INTERVAL '1 day' | d + 1 | date(d,'+1 day') |
| 加 1 个月 | DATEADD(month,1,d) | DATE_ADD(d,INTERVAL 1 MONTH) | d + INTERVAL '1 month' | ADD_MONTHS(d,1) | date(d,'+1 month') |
| 减 30 分钟 | DATEADD(minute,-30,d) | DATE_ADD(d,INTERVAL -30 MINUTE) | d - INTERVAL '30 min' | d - 30/1440 | datetime(d,'-30 minutes') |
| 今天零点 | DATEADD(day,DATEDIFF(day,0,d),0) | CURDATE() | d::date | TRUNC(d) | date('now') |
最后一行值得单独注意:SQL Server 里"取今天零点"需要那个DATEDIFF组合拳,而其他几家基本都有一行搞定的写法。这算是 SQL Server 在日期处理上一个不太方便的地方,但用熟了模板之后也就是肌肉记忆的事。
我在实际做跨库项目时的体会是,别指望一套 SQL 走遍所有数据库。日期这块尤其如此,各家在"月末怎么算""interval 是不是日历月""有没有原生日期类型"这几个点上的取舍完全不同,硬要统一往往会在边界上出问题。比较务实的做法是在数据访问层做一层薄封装,把日期加减抽象成方法,底层按目标库分别实现,业务代码只调方法不直接写函数名。这样即使后面换库,改动量也控制在很小的范围里。另外再分享一个小技巧:写日期相关的 SQL 时,先在测试库里把月末、闰日、跨年这几个边界值都跑一遍,把这些用例固化成单元测试,比事后排查生产数据要省心得多。