1. INTERVAL 在 MySQL 里的真实身份:它不是一个函数
前阵子帮人看一段报表 SQL,对方咬定自己"只是用了下 INTERVAL,逻辑没问题",结果数据少了整整一天。把 SQL 摊开一看,问题不在 INTERVAL 本身,而在于他把它当成了一个"日期加减函数"来用,忽略了它其实是一套运算符语法里的固定零件。很多写着 MySQL 的人天天在用 INTERVAL,却说不清它到底属于哪个语法位置,这直接导致后面所有的边界问题都变成玄学。
先把定位讲清楚:INTERVAL 是 MySQL 关键字列表里标注为保留字的那一类,它出现在两个完全不同的语法场景中,一个是日期时间算术,一个是事件调度器的调度周期描述。两个场景长得像,规则却不一样。日常写业务 SQL,99% 遇到的是第一种,也就是expr + INTERVAL 数值 单位这种形态。它本质上是DATE_ADD(expr, INTERVAL 数值 单位)的运算符写法,MySQL 内部把它们归一化到同一套执行逻辑上。
这件事为什么重要?因为它决定了两件事:第一,你不能把 INTERVAL 单独拿出来当一个值用,SELECT INTERVAL 1 DAY这种写法是没有意义的;第二,参与运算的左侧表达式必须是能被解析成日期或时间的值,左侧一旦不是合法日期,结果不会报错,而是安静地变成 NULL。这类"安静变 NULL"的行为在生产环境里极其危险,因为 WHERE 条件里出现 NULL 比较,整行数据就被静默过滤掉了,报表少数据但你找不到任何报错日志。
我见过最常见的误解是把它当成"时长"。有同事写WHERE TIMESTAMPDIFF(MINUTE, created_at, NOW()) < INTERVAL 30,然后来问为什么语法报错。这里必须拆开:INTERVAL 用来构造一个"日期时间偏移量",而 TIMESTAMPDIFF 需要的是一个纯数字。判断标准很简单——INTERVAL 的产物是"某个时刻",不是"多少分钟"。单位决定了它是日历语义还是绝对时长语义,这一点在第 3 节会详细展开,因为月末问题全部源于此。
另外一个容易被忽略的点:INTERVAL 是保留字,意味着你在建表的时候如果想用一个叫 interval 的列名,必须加反引号。手写 SQL 时顺手写成select interval from t会直接语法报错。这类小坑不值一提,但在做老系统字段迁移或者从 PostgreSQL 迁过来的时候,确实会绊住人。PostgreSQL 里 interval 是一个真正的数据类型,MySQL 里不是,这个差异导致不少从 pg 迁过来的同事第一次写WHERE duration > interval '1 day'就翻车。
1.1 运算符写法和函数写法的取舍
DATE_ADD(dt, INTERVAL 7 DAY)和dt + INTERVAL 7 DAY在语义上完全等价,执行计划里也看不出差别。那为什么我在团队规范里更推荐运算符写法?原因有三点。
一是可读性。报表条件里经常出现"往前推 7 天"这类表达,created_at >= CURDATE() - INTERVAL 7 DAY的阅读顺序和自然语言一致,从左到右扫一眼就懂;换成created_at >= DATE_ADD(CURDATE(), INTERVAL -7 DAY)就得在心里绕个弯,还要注意那个负号挂在哪里。
二是嵌套组合时不容易写错括号。多个偏移量叠加的场景,比如"本月 1 号往前推 3 天再往后推 12 小时",运算符写法可以链起来:CURDATE() - INTERVAL 3 DAY + INTERVAL 12 HOUR。函数写法则要写成DATE_ADD(DATE_SUB(CURDATE(), INTERVAL 3 DAY), INTERVAL 12 HOUR),括号一多就容易漏。
三是减法语义明确。MySQL 同时提供 DATE_SUB 和 DATE_ADD 配负数两种写法,团队里如果一半人用前者一半人用后者,review 的时候要多花时间确认。统一用+ INTERVAL和- INTERVAL,配上一个明确的规范,能省掉很多沟通成本。
当然,函数写法也不是没有价值。在需要动态拼接单位的时候,比如让上层参数决定是加天还是加月,用字符串拼 SQL 的场景下DATE_ADD(dt, INTERVAL ? ?)反而更难注入,因为单位是标识符不是字符串。这个场景我一般会选择白名单映射,而不是直接把用户输入拼进单位位置。
1.2 左侧表达式的类型决定了结果类型
这一点很多人没注意:INTERVAL 加在 DATE 上和加在 DATETIME 上,结果类型是不一样的;加在 TIME 上又是另一套规则。
给 DATE 加INTERVAL 1 DAY,结果是 DATE。给 DATE 加INTERVAL 1 HOUR,MySQL 会把它提升成 DATETIME,结果是2024-03-01 01:00:00这种形态。这个隐式提升在插入字段的时候会咬人——如果目标列是 DATE,多余的时间部分被截断,可能让你算出来的"当天最后一个时刻"直接掉到前一天。
给 TIME 加INTERVAL 2 HOUR更有意思。'23:00:00' + INTERVAL 2 HOUR得到的是25:00:00,而不是第二天的01:00:00。因为 TIME 类型本身的取值范围是-838:59:59到838:59:59,它表示的是"一段时长"而不是"一天中的某个时刻",所以不存在跨天归零的概念。做工时统计、视频时长累加这类计算时,这个特性其实很好用,你不需要额外写取模逻辑,直接加就行。
反过来,做"营业时间跨零点"这类判断时,如果字段混用了 TIME 和 DATETIME,就会出现"两个都表示 25 点,一个能算一个算不出来"的诡异现象。我的经验是:凡是涉及"某个时刻",一律用 DATETIME;凡是涉及"时长",一律用整型分钟数存储,别用 TIME。TIME 的语义在跨天场景下太容易引起误读。
2. 从微秒到年:单位体系与复合单位的正确打开方式
INTERVAL 的单位不是随便填的字符串,MySQL 有一套固定的关键字集合,写错了直接语法错误,不存在"模糊匹配"这种好事。把单位表背下来不现实,但知道它分成"单一单位"和"复合单位"两大类,遇到问题就能快速定位。
单一单位一共十四个:MICROSECOND、SECOND、MINUTE、HOUR、DAY、WEEK、MONTH、QUARTER、YEAR,再加上后面要说的复合单位。日常业务里真正高频的其实只有 DAY、MONTH、HOUR 和 MINUTE 四个,WEEK 和 QUARTER 在报表里偶尔出现,YEAR 基本只用于清理历史数据。
复合单位是为了描述"1 天 2 小时 3 分 4 秒"这种多层级偏移而存在的,包括 SECOND_MICROSECOND、MINUTE_MICROSECOND、MINUTE_SECOND、HOUR_MICROSECOND、HOUR_SECOND、HOUR_MINUTE、DAY_MICROSECOND、DAY_SECOND、DAY_MINUTE、DAY_HOUR、YEAR_MONTH。命名规则很好记:下划线左右分别是高低两个层级,中间缺的部分由字符串里的冒号补位。
| 单位关键字 | 典型写法 | 结果含义 |
|---|---|---|
| DAY | INTERVAL 7 DAY | 往后 7 天 |
| HOUR_MINUTE | INTERVAL '3:20' HOUR_MINUTE | 3 小时 20 分 |
| DAY_SECOND | INTERVAL '1 02:03:04' DAY_SECOND | 1 天 2 小时 3 分 4 秒 |
| YEAR_MONTH | INTERVAL '1-2' YEAR_MONTH | 1 年 2 个月 |
| SECOND_MICROSECOND | INTERVAL '1.500000' SECOND_MICROSECOND | 1.5 秒 |
2.1 复合单位为什么必须写成字符串
这是个高频踩坑点。单一单位可以直接写数字,INTERVAL 7 DAY没问题;复合单位如果也写数字,MySQL 不会报错,但解释方式会和你的预期完全不同。
我实测过DATE_ADD('2024-03-01', INTERVAL 1 DAY_HOUR),得到的结果和+ INTERVAL 1 HOUR相近,而不是"1 天 0 小时"。原因在于复合单位的解析器期望的是一个形如'D HH:MM:SS'的字符串,你给它一个裸数字,它只能按某种降级规则处理,最终结果既不是你想要的,也没人会在 review 时看出来。
正确写法把数值部分用单引号包起来:
SELECT DATE_ADD('2024-03-01 08:30:00', INTERVAL '1 02:03:04' DAY_SECOND); -- 结果:2024-03-02 10:33:04 SELECT DATE_ADD('2024-01-15', INTERVAL '1-2' YEAR_MONTH); -- 结果:2025-03-15 SELECT DATE_ADD('2024-01-01 00:00:00', INTERVAL '1:30' MINUTE_SECOND); -- 结果:2024-01-01 00:01:30字符串里的位数是有要求的:DAY_SECOND要求天 时:分:秒,YEAR_MONTH要求年-月,少写一段会被当成 0,多写一段会报错。真要精细到这种程度,我一般宁可直接用秒数换算,可读性反而更好。复合单位在业务代码里出现频率极低,它更多是给数据分析场景里手写一次性 SQL 用的。
2.2 负数的位置比数字本身更值得注意
INTERVAL -7 DAY是合法的,等价于往前推 7 天。但把负号放到单位后面、或者在复合单位字符串里塞负号,行为就不那么直观了。复合单位字符串里的负号,MySQL 会解释成"整体取负",INTERVAL '-1 02:00:00' DAY_SECOND得到的是往前 1 天 2 小时,这个逻辑是对的,但写在 SQL 里可读性极差,容易被后来接手的人误改成INTERVAL '1 -02:00:00'然后发现报错。
我的建议很直接:永远不要在复合单位字符串里写负号,也尽量不要用INTERVAL -7 DAY这种写法。要往前就写dt - INTERVAL 7 DAY,减号摆在运算符位置,语义和阅读顺序一致,review 时不容易看漏。
还有一个极其冷门但确实存在的坑:INTERVAL后面的表达式如果是列引用而不是常量,虽然语法上允许,但每行都要单独求值,性能和可读性都会崩。比如WHERE created_at < expire_at + INTERVAL grace_days DAY,这在逻辑上没问题,但你必须清楚这意味着无法用 expire_at 上的索引做范围扫描,因为右侧不是常量。这类查询如果表上了千万级,基本就是事故候选。
3. 月末、闰年、跨年:INTERVAL 的钳制规则
如果只允许记住关于 INTERVAL 的一条知识,我会选"月、季度、年这类日历单位,落到不存在的日期时会钳制到该月最后一天"。这一条解释了大量看起来像 bug 的现象。
MySQL 的行为是这样的:
SELECT DATE_ADD('2024-01-31', INTERVAL 1 MONTH); -- 2024-02-29 SELECT DATE_ADD('2023-01-31', INTERVAL 1 MONTH); -- 2023-02-28 SELECT DATE_SUB('2024-03-31', INTERVAL 1 MONTH); -- 2024-02-29 SELECT DATE_ADD('2024-02-29', INTERVAL 1 YEAR); -- 2025-02-28注意这里的机制不是"溢出到下个月 1 号",而是"退回到当月最后一天"。有些数据库用的是另一种策略,把 1 月 31 日加一个月算成 3 月 2 日或者 3 月 3 日,从 MySQL 迁过来的同事如果没有意识到这个差异,跨库比对数据就会对不上。
3.1 为什么"上月同期"这个需求本身就模糊
业务方说"我要上月同期数据",这句话至少可以有两个完全不同的解读。
第一种是"上一整个自然月",起点是上月 1 号 00:00:00,终点是本月 1 号 00:00:00。这种需求用 INTERVAL 表达最干净:created_at >= DATE_FORMAT(CURDATE(), '%Y-%m-01') - INTERVAL 1 MONTH AND created_at < DATE_FORMAT(CURDATE(), '%Y-%m-01')。左右两端都是常量表达式,索引能正常走,而且不存在闭区间漏掉当天的问题。
第二种是"往前推一个月的同一时刻",比如现在是 3 月 31 日 14:00,往前一个月是哪天?按钳制规则会落到 2 月 29 日 14:00。但从业务角度,人可能期望的是 2 月 28 日,或者干脆期望"2 月 1 日到 3 月 1 日"。这就是我说的那种需求歧义——它不是一个技术问题,是需求本身没定义清楚。
我踩过一次这个坑,当时写了WHERE created_at >= NOW() - INTERVAL 1 MONTH,看起来天经地义,上线后业务反馈"数据少了"。原因是这个条件在 3 月 31 日执行时,起点变成了 2 月 29 日 14:xx,而业务心里的"上月"是 2 月 1 日到 2 月 29 日。两个范围重叠了但都不完整,对不上账。后来我把这类需求全部统一成"自然月区间",用DATE_FORMAT求月初再配 INTERVAL,问题就再没出现过。
3.2 闰日与跨年:别在代码里做假设
闰年问题在 INTERVAL 里只有一种表现:2 月 29 日加年份或者减年份会被钳制。'2024-02-29' + INTERVAL 1 YEAR得到2025-02-28,'2024-02-29' - INTERVAL 1 YEAR得到2023-02-28。这个行为是确定的,不会随机。
真正危险的是数据清洗脚本里的"重建日期"逻辑。我见过一个脚本,把一批订单的"每年续费日"用DATE_ADD(base_date, INTERVAL n YEAR)批量算出来,base_date 里有 2 月 29 日的记录。脚本跑完之后,这些用户的续费日全部变成 2 月 28 日,而且后续每次再算都还是 2 月 28 日,因为钳制结果被写回了库。等业务发现"这批用户怎么都提前一天"的时候,已经累积了两个续费周期。
处理办法分两层:如果业务上确实以 2 月 29 日为续费基准,基础日期就不能被覆盖,每次都用原始 base_date 现算;如果业务上可以接受落在月末,那就要在写入时统一规范化,不要存一个、算一个、再存一个。我在实际项目里的做法是:凡是涉及年月偏移的日期计算,宁可存"基准日 + 偏移量"两个字段,也不存算完的结果日期。这样规则变了随时能重算。
3.3 跨年边界与 QUARTER
INTERVAL 1 QUARTER等价于 3 个月,它同样受钳制规则约束。'2024-11-30' + INTERVAL 1 QUARTER得到2025-02-28,而不是2025-03-02。做季度报表的时候,我习惯用QUARTER()函数配合MAKEDATE求季初,而不是用 INTERVAL 反推,因为前者是显式的、可读的,后者容易在月末日期上出意外。
跨年本身对 INTERVAL 不是问题,'2024-12-31' + INTERVAL 1 DAY干干净净地得到'2025-01-01'。真正需要留意的是字符串拼接出来的日期,比如用CONCAT拼出'2025-13-01'这种非法值再交给 INTERVAL 处理,结果就是 NULL。这类问题不是 INTERVAL 的锅,但只要查询里出现 NULL 结果,第一反应应该去查日期字符串本身是否合法。
4. 报表时间桶:用 INTERVAL 把散乱时间戳对齐
做报表最基础的一件事是把连续的时间戳归到离散的桶里:按天、按周、按小时。MySQL 提供了DATE_FORMAT、YEAR、MONTH这类函数,但用 INTERVAL 做"减法对齐"有一个明显优势——保留原始的日期类型,排序和范围比较都不需要转换。
核心思路是:想对齐到某个周期边界,就把时间戳减去"它距离边界的那段偏移量"。比如对齐到周一,偏移量就是WEEKDAY(dt)天,因为 MySQL 的WEEKDAY()返回 0 表示周一、6 表示周日。
SELECT DATE_SUB(DATE(created_at), INTERVAL WEEKDAY(created_at) DAY) AS week_start FROM orders;对齐到月初:
SELECT DATE_SUB(DATE(created_at), INTERVAL DAYOFMONTH(created_at) - 1 DAY) AS month_start FROM orders;这里有个语法细节必须提醒:INTERVAL后面的表达式部分,如果是一个混合运算,强烈建议加括号。INTERVAL DAYOFMONTH(created_at) - 1 DAY在某些 MySQL 版本上的解析结果不是"减 1 天后再作为单位量",而是被拆成INTERVAL DAYOFMONTH(...)后面接了一个减法。写成INTERVAL (DAYOFMONTH(created_at) - 1) DAY才稳。我在两个不同的小版本上验证过这个差异,虽然官方文档里没强调,但加上括号零成本,不加可能出事故。
4.1 小时和分钟粒度的桶
对齐到整点:
SELECT DATE_SUB(created_at, INTERVAL MINUTE(created_at) * 60 + SECOND(created_at) SECOND) AS hour_bucket FROM orders;这里把分钟和秒折算成总秒数,再用一次 SECOND 单位减掉,比嵌套两次 INTERVAL 更简洁。15 分钟粒度同理,只是要把分钟先对 15 取模:
SELECT DATE_SUB(created_at, INTERVAL (MINUTE(created_at) MOD 15) * 60 + SECOND(created_at) SECOND) AS bucket_15m FROM orders;如果落库的时间戳带微秒,还要再减掉MICROSECOND(created_at) MICROSECOND,否则同一个 15 分钟桶里会出现多个不同的值,分组就散了。这是我第一次做监控大盘时栽的跟头——表里字段是DATETIME(3),我按分钟对齐,结果每个桶都被拆成一千个分片,图表上密密麻麻全是尖刺。排查了半天才想起来是毫秒没清掉。
另一种思路是用UNIX_TIMESTAMP做整数除法再转回来,适合一下子想不清楚偏移量的场景:
SELECT FROM_UNIXTIME(FLOOR(UNIX_TIMESTAMP(created_at) / 900) * 900) AS bucket_15m FROM orders;这种写法的好处是粒度参数 900 可以直接从配置里传进来,一个变量控制所有粒度。代价是丢掉了时区无关性,UNIX_TIMESTAMP和FROM_UNIXTIME都受会话时区影响,如果应用和数据库的时区设置不一致,桶边界会对不上。我的选择是:固定粒度的报表用 INTERVAL 写法,粒度可配置的用时间戳写法,但必须保证两边时区配置一致,并且在文档里写死这一点。
4.2 用递归 CTE 造连续日期再左连接补零
聚合查询有一个永恒的问题:没有数据的日期不会出现在结果里。做趋势图的时候,缺失的点会被前端连成直线,看起来像是"那天数据正常",实际上那天根本没数据。解决办法是造一张连续日期表,再左连接聚合结果。
MySQL 8.0 之后可以用递归 CTE 现造,不需要建物理表:
WITH RECURSIVE seq(n) AS ( SELECT 0 UNION ALL SELECT n + 1 FROM seq WHERE n < 29 ) SELECT CURDATE() - INTERVAL n DAY AS stat_date, COALESCE(SUM(o.amount), 0) AS total FROM seq LEFT JOIN orders o ON o.created_at >= CURDATE() - INTERVAL n DAY AND o.created_at < CURDATE() - INTERVAL n DAY + INTERVAL 1 DAY GROUP BY stat_date ORDER BY stat_date;这段 SQL 里有三个值得说的点。第一,seq的生成上限是 29,所以覆盖 30 天,递归 CTE 默认深度上限cte_max_recursion_depth是 1000,超过要调参或者改成多层交叉连接。第二,JOIN 条件必须是半开区间,用BETWEEN会在有毫秒精度时漏掉当天最后一段时间。第三,GROUP BY里直接用了别名stat_date,MySQL 允许这么做,但如果别名和真实列名冲突,会优先匹配真实列,这是个隐蔽的坑,我一般会给别名加前缀。
顺带说一句排序相关的事。这类报表经常要按日期倒序输出,ORDER BY stat_date DESC是没问题的,但如果把CURDATE() - INTERVAL n DAY这个表达式整个塞进 ORDER BY,就会退化成按表达式排序,索引用不上,数据量大的时候能明显感觉到慢。能提前算成列的,一定提前算成列。
5. 索引生死线:INTERVAL 写在比较式的哪一侧
这一节是我认为 INTERVAL 相关最有价值的实务内容。同样一个"查最近 7 天"的需求,写法差一点点,执行计划可以从 range 变成全表扫,数据量上来之后就是几百倍的性能差距。
判断标准只有一条:能走索引的范围查询,比较条件的左侧必须是纯粹的列,右侧必须是一个在语句执行前就能确定值的常量表达式。
CURDATE() - INTERVAL 7 DAY属于常量表达式。虽然它看起来是个函数调用,但 MySQL 把它归类为常量函数,整个语句执行期间只求值一次,所以下面这种写法能正常走索引:
EXPLAIN SELECT * FROM orders WHERE created_at >= CURDATE() - INTERVAL 7 DAY; -- type: range, key: idx_created_at而下面这种写法就不行:
EXPLAIN SELECT * FROM orders WHERE DATE_ADD(created_at, INTERVAL 8 HOUR) >= NOW() - INTERVAL 7 DAY; -- type: ALL原因是左侧的列被函数包住了,优化器没法用 B+ 树的有序性做区间定位,只能逐行求值。这个例子的业务背景也很常见——数据库存的是 UTC 时间,查询要按东八区算"最近 7 天"。很多人第一反应就是在列上加 8 小时,结果索引直接废掉。
正确的做法是把时区转换挪到常量侧:created_at >= UTC_TIMESTAMP() - INTERVAL 7 DAY - INTERVAL 8 HOUR,或者在会话里设置好时区,让NOW()直接返回本地时间。更进一步,如果这张表规模很大,我会考虑加一个生成列把"逻辑日期"固化下来。
5.1 BETWEEN 的闭区间陷阱
BETWEEN是两端都包含的,这在 DATE 字段上没问题,在 DATETIME 字段上就很容易出事。
-- 看起来是查最近 7 天,实际上漏掉了今天一整天的数据 SELECT * FROM orders WHERE created_at BETWEEN CURDATE() - INTERVAL 6 DAY AND CURDATE();右侧的CURDATE()是今天 00:00:00,等于只查到了昨天的最后一刻。这个问题在没有毫秒精度的时候就已经存在,只是当created_at是DATETIME(3)时更加明显——BETWEEN ... AND '2024-05-01 00:00:00'只能匹配到00:00:00.000那一瞬间的记录。
我在团队里的硬性规定是:日期时间字段一律用半开区间,写法固定为>= 起点 AND < 起点 + INTERVAL 1 单位。
SELECT * FROM orders WHERE created_at >= CURDATE() - INTERVAL 6 DAY AND created_at < CURDATE() + INTERVAL 1 DAY;这种写法有三个好处:区间边界严格,不重不漏;两个边界都是常量,索引友好;加时间粒度的时候不用改结构,把1 DAY改成1 HOUR就行。用BETWEEN的唯一场景是整数主键范围查询,那里没有精度问题。
5.2 生成列这条绕行路线
如果业务上必须按"本地日期"聚合,而且没法改查询写法,生成列是最干净的方案。它把计算成本转移到写入侧,读取侧完全等价于普通列查询。
ALTER TABLE orders ADD COLUMN created_date DATE GENERATED ALWAYS AS (DATE(CONVERT_TZ(created_at, '+00:00', '+08:00'))) STORED, ADD KEY idx_created_date (created_date);然后查询可以写得很直白:
SELECT * FROM orders WHERE created_date >= CURDATE() - INTERVAL 6 DAY AND created_date < CURDATE() + INTERVAL 1 DAY;STORED类型的生成列会占用存储空间,插入和更新时需要额外计算,但换来的是查询侧索引可用。如果只是偶尔查一次,用VIRTUAL加函数索引也能达到类似效果。两种方式的取舍点在于写入频率和查询频率的比例,写多读少的表我倾向 VIRTUAL,读多写少的表直接 STORED。
需要提醒的是,生成列的定义表达式必须是确定性的。NOW()、CURDATE()这类会随时间变化的函数不能用,CONVERT_TZ只有在时区表已经加载的情况下才能正常工作。上生产之前,务必用SHOW CREATE TABLE确认表达式被正确解析了,而不是默默变成空定义。
6. 事件调度器与增量同步:INTERVAL 的另外两个战场
前面讲的都是查询侧的用法,INTERVAL 在写入侧和调度侧同样有位置,只是规则略有差别,容易被忽略。
事件调度器的EVERY子句接受一套和日期算术单位高度重合的关键字:YEAR、QUARTER、MONTH、WEEK、DAY、HOUR、MINUTE、SECOND,以及 DAY_HOUR、DAY_MINUTE 这类复合形式。写起来像是同一个东西,但语义上它是"周期间隔",不是"偏移量"。
SET GLOBAL event_scheduler = ON; CREATE EVENT ev_purge_logs ON SCHEDULE EVERY 1 DAY STARTS (TIMESTAMP(CURRENT_DATE) + INTERVAL 1 DAY + INTERVAL 3 HOUR) ON COMPLETION PRESERVE DO DELETE FROM access_logs WHERE created_at < NOW() - INTERVAL 30 DAY LIMIT 5000;这段里有两处 INTERVAL 在同时工作。STARTS那个表达式用的是日期算术语义,把任务定在明天凌晨 3 点启动;DELETE语句里的 INTERVAL 也是日期算术,保留最近 30 天。EVERY 1 DAY则完全不同,它表示"每隔一个自然日周期触发一次",配合 STARTS 一起决定触发时刻。
这里有个反直觉的点:EVERY 1 DAY单独使用的时候,触发时刻取决于事件创建的时间,不是固定的午夜。如果业务要求"每天凌晨固定跑",必须显式写 STARTS,否则任务会在创建时间对应的那个时刻反复执行。我见过同事写的清理任务在下午 5 点跑,把白天正在写日志的查询压得极慢,查了半天才发现是 STARTS 没写。
另外那个LIMIT 5000是我强加的约定。事件调度器里的 DELETE 如果不限制行数,在积压数据多的时候会长时间持锁,影响正常写入。分批清理、每次少删一点,代价是清理慢,收益是线上不抖动。这个取舍怎么看都划算。
6.1 增量同步窗口的重叠设计
做增量同步任务时,最常见的写法是用updated_at做水位线:
SET @win_end = NOW() - INTERVAL 5 MINUTE; SET @win_start = @win_end - INTERVAL 15 MINUTE; SELECT id, updated_at FROM orders WHERE updated_at >= @win_start AND updated_at < @win_end;为什么窗口不是贴着当前时间?因为存在事务提交延迟。一条记录的业务时间updated_at记的是NOW(),但它的事务可能过几百毫秒甚至几秒才提交,提交那一刻才真正可被其他会话读到。如果同步任务的窗口右端直接用NOW(),这批"时间戳在窗口内、提交在窗口外"的记录就会被永久漏掉,下一次跑的时候窗口已经往前走了。
往前留几分钟是给提交延迟留缓冲,同时窗口本身和上一轮重叠一段,形成"至少一次"投递语义。重叠带来的重复数据靠下游做幂等处理,比如用主键 upsert 或者按 id 去重。这个设计不是 INTERVAL 的专利,但 INTERVAL 让这几行代码变得非常好读,参数也一目了然。
需要留意的是updated_at的精度。如果字段是DATETIME不带小数位,同一秒内更新多行时,边界上可能因为比较运算符的选择产生重复或遗漏。用DATETIME(3)或者直接用自增主键加大于号,能避开这类问题。我在数据管道项目里更倾向后者:用 id 做游标,时间是辅助条件。因为自增 id 严格单调,边界问题最少。
6.2 时间窗口参数的校验不能省
用 INTERVAL 拼出来的窗口,如果参数来自外部配置,必须做校验。INTERVAL 0 MINUTE生成的是空窗口,任务会空跑但不会报错;负数会导致窗口反向,结果集为空;超大值会让窗口覆盖全表,把下游打挂。
我在这类任务里加了一段前置检查:窗口长度必须大于 0 且小于等于一个上限,重叠时间必须小于窗口长度。这些检查写起来就几行,能挡掉绝大多数配置写错导致的线上问题。相比事后排查"为什么同步数据少了一天",前置校验的成本几乎为零。
7. 三次排查实录:INTERVAL 相关的线上问题怎么定位
理论讲完了,说三个我自己遇到过的具体问题,重点在于排查链路,因为线上一旦出状况,比"正确答案是什么"更重要的是"怎么快速缩小范围"。
7.1 案例一:报表少了 8 个小时的数据
现象是运营反馈"昨天的订单报表数量对不上,少了大概三分之一"。数据源是同一个库,另一个报表用同样的时间条件却没这个问题。
排查第一步是确认差异范围:把两个报表的原始 SQL 并排看,一个用了created_at >= CURDATE() - INTERVAL 1 DAY AND created_at < CURDATE(),另一个加了AND status = 1。数量差不可能只由状态过滤带来这么大比例,所以排除业务逻辑。
第二步是看数据本身:SELECT MIN(created_at), MAX(created_at), COUNT(*) FROM orders WHERE created_at >= CURDATE() - INTERVAL 1 DAY AND created_at < CURDATE(),和SELECT COUNT(*) FROM orders WHERE DATE(created_at) = CURDATE() - INTERVAL 1 DAY对比。两者数量一致,说明 SQL 没问题,问题出在时区。
第三步是查会话时区:SELECT @@global.time_zone, @@session.time_zone, NOW(), UTC_TIMESTAMP()。结果发现created_at是 DATETIME 类型,写入时用的是 UTC 时间,而报表连接使用的会话时区是东八区,CURDATE()返回的是东八区的今天。东八区的"昨天"在 UTC 里其实覆盖了别的区间,导致部分数据落在窗口之外。
修复方式有两种:一是把查询条件改成基于 UTC 计算,用UTC_TIMESTAMP()替代NOW();二是统一在写入侧存本地时间。我们最终选了第一种,因为存量数据已经是 UTC,改写入成本太高。改完之后在代码注释里加了一行 "created_at 为 UTC,时间运算统一使用 UTC_TIMESTAMP",避免后来人再踩。
7.2 案例二:清理任务把当天数据删了
现象是每天凌晨有少量当天的日志记录消失,量不大,一开始没人注意。
排查从事件调度器入手。SHOW CREATE EVENT看到条件写的是created_at < NOW() - INTERVAL 1 DAY,语义上应该保留 1 天。看起来没问题,于是去看任务的实际执行时间。
SELECT * FROM information_schema.events里的LAST_EXECUTED字段显示执行时间是每天 00:00:03。问题定位到了:任务在午夜刚过的时候执行,NOW()返回的是新的一天的 00:00:03,减一天就是前天 00:00:03,于是前天 00:00:03 到昨天 00:00:03 之间的记录被保留,但更早的都被删了——这跟"保留 1 天"的预期其实差了将近 24 小时。真正被误删的是那些"业务时间戳在前天之前、但因为补录或者延迟写入,实际插入时间较晚"的记录。
修复很简单,把条件改成< CURDATE() - INTERVAL 1 DAY,以自然日为基准而不是以时刻为基准。改完之后再没出现过。这个案例给我的启发是:清理类任务的条件,尽量用日期边界而不是时刻边界,因为时刻边界会随执行时间漂移,而日期边界是稳定的。
7.3 案例三:加了个 INTERVAL 之后慢查询暴涨
现象是某个接口的 P99 从 200ms 涨到 3s,慢查询日志里出现了一条从来没见过的 SQL。
从慢查询日志里把 SQL 抠出来,发现条件是WHERE DATE_ADD(created_at, INTERVAL 8 HOUR) >= NOW() - INTERVAL 7 DAY。这是典型的"列被函数包住"导致索引失效。
排查步骤很标准:先EXPLAIN看type,从range变成ALL,rows从几千变成几百万,基本可以确认。然后确认索引是否存在:SHOW INDEX FROM orders显示idx_created_at确实在created_at上,说明索引本身没问题,只是没法用。
修复就是把时区偏移挪到常量侧,改成created_at >= UTC_TIMESTAMP() - INTERVAL 7 DAY - INTERVAL 8 HOUR。改完EXPLAIN回到range,接口耗时降回原来的水平。
这次事故之后我加了一条规则:任何出现在 WHERE 条件左侧的列,都不允许被函数或算术表达式包裹。这条规则在代码 review 时用肉眼就能检查,比让人记住 EXPLAIN 的每一种输出要实在得多。
8. 几条可以直接抄走的规则
整理一下我在项目里实际执行的约定,都是踩过坑之后固化下来的,不需要理解全部原理也能照着用。
日期时间字段的范围查询,统一写成半开区间,右侧用起点 + INTERVAL 1 单位表示上界,绝不用BETWEEN加 DATETIME。
所有时间运算的常量侧用CURDATE()、UTC_TIMESTAMP()这类函数,列侧保持裸露。时区转换放在比较式的另一边,或者在生成列里提前固化。
需要按自然周期对齐的时候,用减法对齐而不是格式化转换。对齐到周用WEEKDAY(),对齐到月用DAYOFMONTH(),对齐到小时用分钟和秒折算,表达式外面加括号。
涉及月和年的偏移,永远问清楚是"日历语义"还是"绝对时长语义"。INTERVAL 1 MONTH的钳制行为是确定的,但业务方想要的"一个月"往往不是它。
清理任务和调度任务的条件用日期边界,不用时刻边界。事件调度器里的 STARTS 必须显式写,否则触发时刻会跟着创建时间走。
增量同步的窗口要往前留缓冲并和上一轮重叠,重复由下游幂等消化。窗口参数必须做范围校验,零和负数都要拦掉。
我个人在实际操作中的体会是,INTERVAL 的问题几乎从来不出在语法上,语法错了立刻就发现了,真正麻烦的都是语义层面的误解——把日历单位当时长、把时刻边界当日期边界、把闭区间当半开区间。这些错误不会报错,只会让数据少一点、多一天、慢几百倍,然后在某个不经意的时刻被人发现。所以每次写时间条件的时候,我养成了一个习惯:先在心里把区间的两个端点用具体日期念一遍,比如"从 5 月 1 号零点开始,到 5 月 8 号零点之前",念得通再往下写。这个习惯帮我挡掉的问题,比任何工具和规范都多。