🛢️ 第17章 事件
📄 一句话概括

ON SCHEDULE 只有两种写法:AT 一次性、EVERY 周期,两者互斥。 不给 STARTS 时,周期事件的起点是「建事件的那一刻」——这往往不是你想要的。 跑完留不留由 ON COMPLETION 决定;补不补跑,复刻版和 MySQL 分歧最大。

📝 本章和前面几章的关系

这一篇讲 CREATE EVENT 的骨架:ON SCHEDULE 怎么写、STARTS/ENDS 怎么划范围、 ON COMPLETION 怎么定去留。全部在复刻版上真跑,MySQL 原码照贴。 和第 14 章的存储过程对照着看会更清楚:过程是「有人 CALL 才跑」, 事件是「到点自己跑」——差别只在「谁发起」。 所以这两者的「事件体」写法几乎是同一套东西:一段 SQL,或者 BEGIN ... END 里的多段。

一、两种时间表:AT 一次,EVERY 反复

ON SCHEDULE 后面只有两条路,教材各给了一个例子。

-- 一次性:到点跑一次就不管了
CREATE EVENT e_once
ON SCHEDULE AT '2026-11-01 02:00:00'
DO
  INSERT INTO tb_log(note) VALUES ('凌晨清理');

-- 周期:每隔一段时间跑一次
CREATE EVENT e_every
ON SCHEDULE EVERY 1 DAY
STARTS '2026-11-01 03:00:00'
DO
  DELETE FROM tb_stat WHERE id > 100;

AT 是一次性的,EVERY 是周期的;两者互斥,写一个就行。 在复刻版里,AT 对应「只给 at,不给 every」,EVERY 对应「给 every」。 内部就靠 every 是不是 None 来区分——None 就是一次性。

事件 周期 下次时间
e_once 一次性 11-01 02:00
e_week 604800 秒 11-08 00:00

AT 那条的下次时间是 02:00,EVERY 那条按「现在 + 7 天」算。 注意 EVERY 不给 STARTS 时,起点就是创建的那一刻——这往往不是你想要的。 想要「每天凌晨 3 点」,得显式写 STARTS,否则第一次是「建事件的时刻 + 1 天」。 这个坑很常见:下午三点建了个「每天一次」的事件,它从此每天下午三点跑,而你以为它会在凌晨跑。 顺带一句:EVERY 后面除了 1 DAY,还能写 1 HOUR、30 MINUTE、1 WEEK。 复刻版统一折成秒——604800 就是 7 天。 折成秒这件事本身没什么,但它提醒你:周期的粒度最终是「多久一次」,不是「看起来像什么」。

二、STARTS 与 ENDS:给周期事件划个范围

周期事件可以只在一个区间里跑:STARTS 说到点开始,ENDS 说到点为止。 教材的写法是 EVERY 1 HOUR STARTS '...' ENDS '...',复刻版把这两个字段存下来。 先建一个每小时一次、ENDS 在 03:00 的事件,然后把时钟直接推到 05:00:

tick 到 跑了哪几次
01:00 01:00
02:00 02:00
03:00 03:00
05:00 没有(已过 ENDS)

跑完 01:00、02:00、03:00 三次就停了,状态自己变成 DISABLE。 下次时间停在 04:00 没动,因为 04:00 那一刻它发现「已经过了 ENDS」,直接停用。 ENDS 不是「删掉」,是「不再跑」——事件还在,改一下 ENDS 还能接着用。 这个区别在运维上有用:临时停一个周期任务,改 ENDS 比 DISABLE 更能表达「跑到这儿为止」。 反过来,DISABLE 表达的是「现在停,以后再说」,两者语义不一样,别混着用。 混用的后果是交接时说不清:下一个人看到 DISABLE,不知道是「临时停」还是「到期了」。

三、ON COMPLETION:跑完删不删

一次性事件跑完之后,默认做法是「留着」,只把状态改成 DISABLE。 教材里讲 ON COMPLETION PRESERVE 和 NOT PRESERVE,说的就是这件事。

写法 跑完之后还剩什么
PRESERVE(默认) 1 个,状态 DISABLE
NOT PRESERVE 0 个

第二行是 0 个——跑完自己把自己删了。 NOT PRESERVE 适合「只做一次」的活,比如一次性的数据修补。 但风险是:跑完之后连「我干过这件事」的记录都没有了,出问题查不到。 所以实践里更常见的是 PRESERVE 加手动清理,而不是让它自己消失。 这条和 17.4 的 DROP EVENT 是同一件事的两个入口:一个是你删它,一个是它自己走。 共同点是——删完之后,库里就没有「曾经有过这个任务」的证据了。 所以真正的做法是:任务本身可以走,记录要留下。 要么在事件体里先写一行日志,要么把这件事记在运维文档里。

四、补跑还是不补跑:复刻版和 MySQL 的分歧点

时钟一次跨过好几个周期时,中间那几个点要不要补?这一条两边不一样。

做法 一次推到 00:09,跑了几次 下次时间
补跑 9 次 11-01 00:10
不补跑 1 次 11-01 00:10

补跑跑了 9 次(1 到 9 分各一次),不补跑只跑 1 次、直接跳到 00:10。 注意两行的「下次时间」是一样的——差别只在「中间那些点补不补」。 MySQL 的事件调度器更接近右边这一列:它不承诺「错过的多次执行逐一补上」。 数据库停机两小时再起来,「每小时一次」的事件不会补跑两次,而是接着往下一个整点走。 这个差异在业务上很要命:如果一段逻辑假设「每个点都跑过」,停机之后数据就有空档。 所以用事件做什么都要加一道兜底:先查「上次跑到哪儿」,再决定补不补。 这比让调度器替你记住更可靠——它本来就没答应记住。 兜底的具体写法不复杂:建一张「任务进度表」,事件体开头读一行「上次处理到哪」,结尾写回一行。 这样无论漏了几个点,下一次都能从断点接上,跟调度器补不补没关系。

五、动手 Demo:AT、EVERY、ENDS、PRESERVE、补跑,五件事各验一遍

这一段按教材 17.2 的顺序走:先建一次性事件和周期事件,看下次时间怎么算; 再给周期事件加 ENDS,推到边界外看它怎么停;然后验 PRESERVE 和 NOT PRESERVE; 最后把「补跑」和「不补跑」放在一起对照。

import datetime

BASE = datetime.datetime(2026, 11, 1, 0, 0, 0)
s = Sched(BASE)

# ① 两种时间表
s.create("e_once", at=BASE + datetime.timedelta(hours=2))
s.create("e_week", every=7 * 86400)      # 不给 STARTS:起点就是现在
for r in s.rows():
    print(r)          # e_once 11-01 02:00 / e_week 11-08 00:00

# ② ENDS:只在 01:00 到 03:00 之间跑
s.create("e_range", every=3600,
         at=BASE + datetime.timedelta(hours=1),
         ends=BASE + datetime.timedelta(hours=3))
print(s.tick(BASE + datetime.timedelta(hours=5)))   # 三次
print(s.ev["e_range"]["status"], s.ev["e_range"]["runs"])   # DISABLE 3

# ③ ON COMPLETION:跑完留不留
for keep in (True, False):
    a = Sched(BASE)
    a.create("k", at=BASE, preserve=keep)
    a.tick(BASE)
    print(keep, len(a.ev))      # True -> 1,False -> 0

# ④ 补跑 vs 不补跑
for flag in (True, False):
    b = Sched(BASE, catchup=flag)
    b.create("e", every=60)
    print(flag, len(b.tick(BASE + datetime.timedelta(minutes=9))))

实跑输出:

--------------------------------------------------------------------
创建事件:AT 一次,EVERY 反复
--------------------------------------------------------------------


  两种时间表:AT 与 EVERY
  --------------------------------------------------------------------

  [教材原码 · 本沙箱不执行]
  | -- 一次性:到点跑一次就不管了
  | CREATE EVENT e_once
  | ON SCHEDULE AT '2026-11-01 02:00:00'
  | DO
  |   INSERT INTO tb_log(note) VALUES ('凌晨清理');


  [教材原码 · 本沙箱不执行]
  | -- 周期:每隔一段时间跑一次
  | CREATE EVENT e_every
  | ON SCHEDULE EVERY 1 DAY
  | STARTS '2026-11-01 03:00:00'
  | DO
  |   DELETE FROM tb_stat WHERE id > 100;
  AT 是一次性的,EVERY 是周期的;两者互斥,写一个就行。

**在复刻版里,`AT` 对应「只给 `at`,不给 `every`」,`EVERY` 对应「给 `every`」。**
**内部就靠 `every` 是不是 `None` 来区分——`None` 就是一次性。**

  事件    周期       状态    下次时间     已跑
  --------------------------------------------
  e_once  一次性     ENABLE  11-01 02:00  0
  e_week  604800 秒  ENABLE  11-08 00:00  0

**`AT` 那条的下次时间是 02:00,`EVERY` 那条按「现在 + 7 天」算。**
**注意 `EVERY` 不给 `STARTS` 时,起点就是创建的那一刻——这往往不是你想要的。**
**想要「每天凌晨 3 点」,得显式写 `STARTS`,否则第一次是「建事件的时刻 + 1 天」。**

  STARTS 与 ENDS:给周期事件划个范围
  --------------------------------------------------------------------

  事件     周期     状态    下次时间     已跑
  -------------------------------------------
  e_range  3600 秒  ENABLE  11-01 01:00  0

  tick 到 05:00(越过 ENDS)-> e_range@11-01 01:00、e_range@11-01 02:00、e_range@11-01 03:00


  事件     周期     状态     下次时间     已跑
  --------------------------------------------
  e_range  3600 秒  DISABLE  11-01 04:00  3

**跑完 01:00、02:00、03:00 三次就停了,状态自己变成 `DISABLE`。**
**`ENDS` 不是「删掉」,是「不再跑」——事件还在,改一下 `ENDS` 还能接着用。**
**这个区别在运维上有用:临时停一个周期任务,改 `ENDS` 比 `DISABLE` 更能表达「跑到这儿为止」。**

  ON COMPLETION:跑完删不删
  --------------------------------------------------------------------
**一次性事件跑完之后,默认做法是「留着」,只把状态改成 `DISABLE`。**
**教材里讲 `ON COMPLETION PRESERVE` 和 `NOT PRESERVE`,说的就是这件事。**

  写法            跑完之后还剩什么
  ----------------------------------
  `PRESERVE`      1 个,状态 DISABLE
  `NOT PRESERVE`  0 个

**第二行是 0 个——跑完自己把自己删了。**
**`NOT PRESERVE` 适合「只做一次」的活,比如一次性的数据修补。**
**但风险是:跑完之后连「我干过这件事」的记录都没有了,出问题查不到。**
**所以实践里更常见的是 `PRESERVE` 加手动清理,而不是让它自己消失。**

  补跑还是不补跑:这是复刻版和 MySQL 的分歧点
  --------------------------------------------------------------------
**时钟一次跨过好几个周期时,中间那几个点要不要补?这一条两边不一样。**

  做法    一次推到 00:09,跑了几次  下次时间
  ---------------------------------------------
  补跑    9 次                      11-01 00:10
  不补跑  1 次                      11-01 00:10

**补跑跑了 9 次(1 到 9 分各一次),不补跑只跑 1 次、直接跳到 00:10。**
**MySQL 的事件调度器更接近右边这一列:它不承诺「错过的多次执行逐一补上」。**
**数据库停机两小时再起来,「每小时一次」的事件不会补跑两次,而是接着往下一个整点走。**
**这个差异在业务上很要命:如果一段逻辑假设「每个点都跑过」,停机之后数据就有空档。**
**所以用事件做什么都要加一道兜底:先查「上次跑到哪儿」,再决定补不补。**
**这比让调度器替你记住更可靠——它本来就没答应记住。**

第一组的两行下次时间值得记:AT 是「你给的那个时刻」,EVERY 是「现在加一个周期」。 所以周期事件千万别省 STARTS,除非你确实想「从现在开始算」。 第二组是 ENDS 的效果:跑满三次、状态自己变 DISABLE、下次时间停在 04:00。 第三行那个 04:00 容易被误读成「它想在 04:00 再跑」——其实不是, 那是「上一次算出来的下次时间」,只是被 ENDS 挡住了没执行。 第四组两个数(1 和 0)是 ON COMPLETION 的全部差别。 0 那一行意味着连「曾经有过这个事件」都查不到了,这是它唯一的代价,也是最容易被低估的代价。

30 秒实验

  1. 把 e_week 的 every 从 7 天改成 1 天、再给一个 03:00 的 at, 看下次时间是「明天 03:00」还是「现在 + 1 天」。
  2. 把 ENDS 设在两次执行之间(比如 02:30),看它是「跑完 02:00 就停」还是「02:00、03:00 都跑」。
  3. 把 catchup 设成 False,让时钟一次跨过 9 个周期,再把 tick 循环跑两遍, 看第二遍还会不会补。

常见误区

  1. 以为 EVERY 1 DAY 就是「每天零点」。不给 STARTS 时起点是建事件的时刻,可能是下午三点。
  2. 以为 ENDS 会把事件删掉。它只是让事件停用,事件本身还在,改一下 ENDS 就能接着跑。
  3. 以为停机期间错过的事件会在恢复后逐一补上。MySQL 不承诺补跑,错过的点就那么过去了; 要补得自己在事件体里查断点。
自测三问(点开对答案) 1. `AT` 和 `EVERY` 能一起写吗? —— 不能,两者互斥。 `AT` 表示「就这一刻跑一次」,`EVERY` 表示「每隔一段跑一次」,语义上就不可能同时成立。 要「从某刻起周期跑」,用 `EVERY ... STARTS ...`。 2. 周期事件不给 `STARTS`,第一次什么时候跑? —— 创建时刻加一个周期。比如下午三点建「每天一次」,第一次是明天下午三点,不是明天零点。 想要固定时刻必须显式写 `STARTS`。 3. `ON COMPLETION NOT PRESERVE` 的风险是什么? —— 跑完之后事件自己消失,库里再也查不到「曾经有过这个任务」。 出问题想复盘时,连它存在过的证据都没有。

创建讲完了。下一篇讲修改:改状态、改周期、改事件体,以及为什么「改名」只能重建。

相关笔记