事件是「数据库内部的定时任务」:到点自己跑一段 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 秒实验
- 把
tick的起点从 00:00 改成 00:00:30,再推到 00:03,数一下跑几次, 和正文那张表的第二行对一下,解释差在哪。 - 在
tick里加一句打印,把每次「比较的那两个时间」打出来,观察 00:00:59 那次为什么不跑。 - 把
on关掉、推到 01:00、再打开、推到 01:10,数一下补了几次; 然后把on关掉时的self.now = to删掉再试一次,看结果变不变。
常见误区
- 以为建了事件它就会跑。
event_scheduler默认 OFF,不打开这一层,事件建好了也只是躺在库里。 - 以为
event_scheduler = OFF会把事件删掉。事件还在,只是不执行;开关重新打开之后它接着算。 - 拿「语句没报错」当「功能存在」的证据。不存在的
PRAGMA不报错, 只返回空——这类静默失败最难查。
自测三问(点开对答案)
1. 事件、触发器、约束,三者的触发条件各是什么? —— 约束看「每次写入」,触发器看「某张表被写」,事件看「时钟到了」。 前两个由数据变动驱动,最后一个由时间驱动——这是它们最本质的分界。 2. `event_scheduler` 的 ON、OFF、DISABLED 三个值有什么区别? —— ON 跑、OFF 不跑,这两个都能在运行时改;DISABLED 只能从配置文件来,运行时改不回去。 它表示「这个实例根本不允许用事件」。 3. 事件没执行,排查顺序应该是什么? —— 先看全局 `event_scheduler`,再看这个事件的 `status`,最后才去查事件体里的 SQL。 顺序反了会去改一段本来就没问题的代码。第 17 章的第一篇到这里。下一篇讲怎么建事件:AT 一次、EVERY 反复,以及跑完之后留不留。
- ☐ (本篇完,下一篇 17.2 创建事件)