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

事件是「数据库内部的定时任务」:到点自己跑一段 SQL,不用外面有程序叫它。 它和触发器、约束一起构成「库自己动」的三件事,区别只在「什么时候动」。 沙箱没有调度器,这一篇用 Python 复刻一套;复刻版的每一步真跑,MySQL 原码照贴。

📝 本章和前面几章的关系

第 17 章讲事件。前两章分别讲「事务」(几条语句当一个)和「触发器」(写表时自动跑), 这一章讲的是「按时间自动跑」。 三样东西的共同点是「不由人当场叫」,区别在触发条件:约束看每次写入、触发器看某张表、 事件看时钟。 教材 17.1 有两小节:查看事件是否开启、开启事件。 这一篇把它们拆成五节,前三节讲概念和开关,后两节动手搭一个最小调度器。 必须提前说清楚:SQLite 没有事件调度器,CREATE EVENT 这类语句一条都不通。 所以这一章和第 14 章一样,走「Python 复刻语义 + 贴 MySQL 原码」的路子, 复刻版跑出来的每个数都是真数。

一、事件是什么:把它和三样东西分清楚

事件(event)是「数据库内部的定时任务」:到点就自己跑一段 SQL,不用外面有程序叫它。 教材把它放在事务、触发器之后讲,是有道理的——三样东西都是「自己动」,但触发条件不同。

东西 什么时候动
约束 每次写入都检查
触发器 某张表被写的时候
事件 到了某个时间点

所以它最像的是操作系统的 cron。 区别在于:事件跑在数据库进程里,能直接写 SQL,不需要外面有程序。 代价是它绑死在这个库上:换库、迁移、上云,事件往往是最容易被忘掉的那部分。 这句话不是修辞。数据库迁完了、数据一行不少、应用照常跑, 然后某个夜里发现「月度统计没生成」——查半天才想起来,事件没跟着搬过来。 这也是为什么运维文档里,事件、触发器、存储过程要被单独列一张「库内代码」清单。 它们不在应用仓库里,git log 里找不到。

二、查看事件是否开启:MySQL 的两个开关

事件的执行受两层控制,这是排查「事件为什么没跑」的第一站。 第一层是全局开关 event_scheduler,它是一个系统变量,管「整个调度器跑不跑」。 教材给的三段写法如下,都是 MySQL 原码,沙箱不执行:

-- 看调度器开没开
SHOW VARIABLES LIKE 'event_scheduler';

-- 打开(临时,重启失效)
SET GLOBAL event_scheduler = ON;

-- 写进配置文件,重启也生效
-- my.cnf 里加一行:event-scheduler = ON

event_scheduler 有三个取值:ON、OFF、DISABLED。 默认是 OFF——也就是说,建了事件也不会跑,得先把这层打开。 ON 和 OFF 都能在运行时改;DISABLED 只能从配置文件来,运行时改不回去。 这个三态设计的意思很明确:DISABLED 是「这个实例根本不允许用事件」,运维的一道闸, 防止有人在生产库上随手挂个定时任务。 第二层是每个事件自己的 status:ENABLE 或 DISABLE。

层级 管什么 怎么开
全局 整个调度器跑不跑 event_scheduler 变量
单个 这一个事件跑不跑 事件的 ENABLE/DISABLE

两层都得开:全局关着,单个事件就算 ENABLE 也不会跑。 反过来,全局开着、单个 DISABLE,那一个不跑,别的照跑。 排查顺序也因此定死了:先看全局,再看单个,最后才去怀疑事件体里的 SQL。 顺序反了会浪费很多时间——去改一段本来就没问题的 SQL。

三、沙箱里没有调度器:教材的写法一条不通

SQLite 没有事件这个概念。教材里的写法挨个试,报错位置全落在同一个词上。 注意报错分两类,这个二分法比「都不支持」有用得多:

报错 含义 该怎么办
near "SHOW" 语法不存在 改参数没用
near "EVENT" 对象类型不存在 整条路不通

CREATE EVENT/ALTER EVENT/DROP EVENT 三条报的都是 near "EVENT"。 前半截 CREATE、ALTER、DROP 它是认的,卡在 EVENT 这个词上。 这个细节有意义:说明「建/改/删」这套动作它都有。 缺的只是「事件」这个对象类型,不是缺「建/改/删」的能力。 换句话说,SQLite 不是「做不到定时任务」,而是「没把这件事做进 SQL 层」。 顺带说一个容易误判的地方:PRAGMA job_scheduler 这种不存在的 PRAGMA 不会报错,返回空。 所以不能拿「没报错」当「有这个功能」的证据——得看它返回了什么。 这条经验在别处也用得上:凡「查一下状态」的语句,都要追问一句「查不到的时候它给什么」。 给空、给默认值、给报错,三种情况对应的结论完全不同。

四、复刻一个最小调度器:一张注册表加一个时钟

没有调度器,就自己写一个。它只需要三样东西: 一张注册表(有哪些事件)、一个「下次什么时候跑」的时间戳、一个把时钟往前推的函数。 注册表里每个事件记五个字段:周期、事件体、状态、下次时间、是不是一次性。 tick(to) 把时钟推到 to,途中所有「下次时间 <= to」且 ENABLE 的,按时间先后跑掉。 先建一个每分钟跑一次的事件,然后把时钟推三次:

tick 到 结果
00:00 没有事件被执行
00:00:59 没有事件被执行
00:03 跑了三次(00:01、00:02、00:03)

第三行一次跑出了三个点:00:01、00:02、00:03。 因为时钟从 59 秒直接跳到了 3 分,中间跨过两个周期,两个都补上了。 这个行为叫「补跑」,是复刻版的默认做法。真 MySQL 不承诺补跑,下一篇会专门对照。 注意 59 秒那次为什么一个都没跑:下次时间是 00:01,00:01 <= 00:00:59 不成立。 差一秒就是差一秒,定时任务没有「差不多到了」这回事。

五、总开关:关掉之后一个都不跑

复刻版也做了全局开关 on,对应 MySQL 的 event_scheduler。 把它关掉,再推一小时: 一个都没跑,而且下次时间还停在 00:01 没动。 开关关着的时候,连「该不该跑」都不判断——代码直接返回。 这就是 event_scheduler = OFF 的样子:事件还在,就是不执行。 很多人以为「关了调度器事件就没了」,其实它只是停着。 重新打开之后会接着算:欠下的那些点该不该补,取决于「下一次判断时用哪个时间」。 复刻版是补的,MySQL 更接近不补——这个分歧在 17.2 末尾有实测对照。 实际运维里这件事的分量不小:一次计划内的重启、一次主从切换,都可能让事件停一段时间。 停完之后那些「本来该跑」的点,是补还是不补,得提前想清楚,别等数据缺了口才反推。

六、动手 Demo:搭一个调度器,把开关和补跑都量出来

这一段先跑六条 MySQL 写法看报错,再用一个三十行的 Sched 类把「事件」复刻出来:注册、推进时钟、 看补跑、关总开关。复刻版是真跑的,MySQL 原码只是贴出来对照。

import datetime

class Sched:
    u"""最小事件调度器:注册表 + 下次时间 + 时钟。"""
    def __init__(self, base):
        self.now = base
        self.ev = {}
        self.on = True          # 对应 MySQL 的 event_scheduler

    def create(self, name, every=None, at=None, body=None):
        nxt = at if at is not None else \
              self.now + datetime.timedelta(seconds=every)
        self.ev[name] = {"every": every, "body": body,
                         "status": "ENABLE", "next": nxt,
                         "once": every is None, "runs": 0}

    def tick(self, to):
        if not self.on:         # 总开关关着:连「该不该跑」都不判断
            self.now = to
            return []
        fired = []
        while True:
            live = [(v["next"], k) for k, v in self.ev.items()
                    if v["status"] == "ENABLE" and v["next"] <= to]
            if not live:
                break
            at, name = min(live)
            e = self.ev[name]
            if e["body"]:
                e["body"](at)
            e["runs"] += 1
            fired.append((name, at))
            if e["once"]:
                e["status"] = "DISABLE"
            else:               # 补跑:按周期一格一格往前推
                e["next"] = at + datetime.timedelta(seconds=e["every"])
        self.now = to
        return fired

BASE = datetime.datetime(2026, 11, 1, 0, 0, 0)
s = Sched(BASE)
s.create("e_log", every=60)
print(s.tick(BASE + datetime.timedelta(minutes=3)))   # 一次跨过三个点

s.on = False                # 相当于 SET GLOBAL event_scheduler = OFF
print(s.tick(BASE + datetime.timedelta(hours=1)))     # []
print(s.ev["e_log"]["next"])                          # 还停在 00:01

实跑输出:

--------------------------------------------------------------------
事件概述:数据库自己的定时任务
--------------------------------------------------------------------


  事件是什么:把它和三样东西分清楚
  --------------------------------------------------------------------
**事件(event)是「数据库内部的定时任务」:到点就自己跑一段 SQL,不用外面有程序叫它。**
**教材把它放在事务、触发器之后讲,是有道理的——三样东西都是「自己动」,但触发条件不同。**

  东西    什么时候动
  ------------------------
  约束    每次写入都检查
  触发器  某张表被写的时候
  事件    到了某个时间点

**所以它最像的是操作系统的 `cron`。**
**区别在于:事件跑在数据库进程里,能直接写 SQL,不需要外面有程序。**
**代价是它绑死在这个库上:换库、迁移、上云,事件往往是最容易被忘掉的那部分。**

  查看事件是否开启:MySQL 的两个开关
  --------------------------------------------------------------------

  [教材原码 · 本沙箱不执行]
  | -- 看调度器开没开
  | SHOW VARIABLES LIKE 'event_scheduler';
  | 
  | -- 打开(临时,重启失效)
  | SET GLOBAL event_scheduler = ON;
  | 
  | -- 写进配置文件,重启也生效
  | -- my.cnf 里加一行:event-scheduler = ON
  `event_scheduler` 有三个取值:ON、OFF、DISABLED。DISABLED 只能改配置文件,运行时改不回来。

**这里有个两层结构,很容易混:**

  层级  管什么            怎么开
  --------------------------------------------------
  全局  整个调度器跑不跑  `event_scheduler` 变量
  单个  这一个事件跑不跑  事件的 `ENABLE`/`DISABLE`

**两层都得开:全局关着,单个事件就算 `ENABLE` 也不会跑。**
**这一条是排查「事件为什么没执行」时的第一站——先看全局,再看单个。**

  沙箱里没有调度器:教材的写法一条不通
  --------------------------------------------------------------------
**SQLite 没有事件这个概念。教材里的写法挨个试,报错位置全落在同一个词上。**

  MySQL 的写法                                           沙箱的反应
  ---------------------------------------------------------------------------------
  `SHOW VARIABLES LIKE 'event_scheduler'`                near "SHOW": syntax error
  `SET GLOBAL event_scheduler = ON`                      near "SET": syntax error
  `SHOW EVENTS`                                          near "SHOW": syntax error
  `CREATE EVENT e1 ON SCHEDULE EVERY 1 DAY DO SELECT 1`  near "EVENT": syntax error
  `ALTER EVENT e1 ENABLE`                                near "EVENT": syntax error
  `DROP EVENT e1`                                        near "EVENT": syntax error

**`SHOW` 开头的报 `near "SHOW"`,`SET` 开头的报 `near "SET"`。**
**三条报的都是 `near "EVENT"`。**
**前半截 `CREATE`、`ALTER`、`DROP` 它是认的,卡在 `EVENT` 这个词上。**
**这个细节有意义:说明「建/改/删」这套动作它都有。**
**缺的只是「事件」这个对象类型,不是缺「建/改/删」的能力。**

**顺带说一个容易误判的地方:`PRAGMA job_scheduler` 这种不存在的 PRAGMA 不会报错,返回空。**
**所以不能拿「没报错」当「有这个功能」的证据——得看它返回了什么。**

  复刻一个最小调度器:一张表加一个时钟
  --------------------------------------------------------------------
**没有调度器,就自己写一个。它只需要三样东西:**
**一张注册表(有哪些事件)、一个「下次什么时候跑」的时间戳、一个把时钟往前推的函数。**
**先建一个每分钟跑一次的事件,然后把时钟推三次。**

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

  tick 到 00:00 -> 没有事件被执行
  tick 到 00:00:59 -> 没有事件被执行
  tick 到 00:03 -> e_log@11-01 00:01、e_log@11-01 00:02、e_log@11-01 00:03

**第三条一次跑出了三个点:00:01、00:02、00:03。**
**因为时钟从 59 秒直接跳到了 3 分,中间跨过两个周期,两个都补上了。**
**这个行为叫「补跑」,是复刻版的默认做法。真 MySQL 不承诺补跑,下一篇会专门对照。**

  总开关:关掉之后一个都不跑
  --------------------------------------------------------------------
**把总开关关掉,再推一小时:**
  tick 到 01:00 -> 没有事件被执行

**一个都没跑,而且下次时间还停在 00:01 没动——开关关着的时候,连「该跑了」都不判断。**
**这就是 `event_scheduler = OFF` 的样子:事件还在,就是不执行。**
**很多人以为「关了调度器事件就没了」,其实它只是停着;重新打开之后会接着算。**

第一张表六行,报错落在三个词上:SHOW、SET、EVENT。 前两个是「这类语句不存在」,第三个是「这类对象不存在」——后者更接近真相。 第三行那次 tick 一下子跑出三个点,是「补跑」最直白的样子。 它的好处是不会漏,坏处是会重:如果事件体是「把昨天的数据汇总进统计表」,连跑三次就是算三遍。 所以事件体最好写成「幂等」的——跑一次和跑三次结果一样。 最后两行是总开关的效果:[] 加上一个没动过的时间戳。 这两行值得记:开关关着的时候,调度器连「现在几点」都不拿去和下次时间比。 所以「关了再开,会不会补」这个问题,答案取决于实现,不取决于开关本身。

30 秒实验

  1. 把 tick 的起点从 00:00 改成 00:00:30,再推到 00:03,数一下跑几次, 和正文那张表的第二行对一下,解释差在哪。
  2. 在 tick 里加一句打印,把每次「比较的那两个时间」打出来,观察 00:00:59 那次为什么不跑。
  3. 把 on 关掉、推到 01:00、再打开、推到 01:10,数一下补了几次; 然后把 on 关掉时的 self.now = to 删掉再试一次,看结果变不变。

常见误区

  1. 以为建了事件它就会跑。event_scheduler 默认 OFF,不打开这一层,事件建好了也只是躺在库里。
  2. 以为 event_scheduler = OFF 会把事件删掉。事件还在,只是不执行;开关重新打开之后它接着算。
  3. 拿「语句没报错」当「功能存在」的证据。不存在的 PRAGMA 不报错, 只返回空——这类静默失败最难查。
自测三问(点开对答案) 1. 事件、触发器、约束,三者的触发条件各是什么? —— 约束看「每次写入」,触发器看「某张表被写」,事件看「时钟到了」。 前两个由数据变动驱动,最后一个由时间驱动——这是它们最本质的分界。 2. `event_scheduler` 的 ON、OFF、DISABLED 三个值有什么区别? —— ON 跑、OFF 不跑,这两个都能在运行时改;DISABLED 只能从配置文件来,运行时改不回去。 它表示「这个实例根本不允许用事件」。 3. 事件没执行,排查顺序应该是什么? —— 先看全局 `event_scheduler`,再看这个事件的 `status`,最后才去查事件体里的 SQL。 顺序反了会去改一段本来就没问题的代码。

第 17 章的第一篇到这里。下一篇讲怎么建事件:AT 一次、EVERY 反复,以及跑完之后留不留。

相关笔记