🦏 第13章 存储程序
📄 一句话概括

CREATE EVENT 名字 ON SCHEDULE EVERY 1 DAY DO …, 到点自己跑,不用人叫、也不用等谁改数据。 最容易忘的一步是 SET GLOBAL event_scheduler = ON: MySQL 的调度器默认关着,事件建好了就是不跑。 本沙箱没有事件,因为它没有后台线程—— sqlite3 只在被调用时活着。 等价物是宿主程序的循环,或者操作系统计划任务。

📝 本章和前面几章的关系

第 13 章最后一篇。触发器是「事件驱动」,这一篇是「时间驱动」。 定时任务在哪里跑,是个比语法重要得多的选择: 放数据库、放应用、放操作系统,三处各有各的坑。 这一篇把三者的取舍摊开讲。

一、MySQL 里长什么样

create event 名字 on schedule every 1 day starts '2026-11-01 03:00:00' do 语句。 三部分:频率(every 1 day)、起点(starts)、要干的事(do)。 at '2026-12-01 00:00:00' 是「只跑一次」, 配合 every 用 starts/ends 划定区间。 所以事件既能当「重复任务」,也能当「定时一次性任务」。 do 后面可以跟一条语句,也可以跟 begin … end 一整段。 这一点和存储过程一样,所以事件体里能写的东西和过程体差不多。

二、最容易忘的一步:调度器默认关着

set global event_scheduler = on;—— 这一句不写,事件建好了、show events 里看得见、status 还是 enabled,就是不跑。 因为它只是「一条登记」,真正掐表的是那个调度线程。 线程不开,登记再多也没人看表。 这个坑每年都要埋一批人,而且症状特别迷惑: 建成功了、没报错、查得到——一切正常,就是数据没变。 所以排查「事件不生效」的第一步永远是查调度器开没开。 顺带一句:event_scheduler 重启后会回到配置文件里的值。 用 set global 改的是运行时状态,写不进配置, 所以要么写进 my.cnf,要么每次启动都执行一遍。

三、本沙箱没有,因为它没有后台线程

create event 在这里报 syntax error。 原因不是「没实现」,是「结构上做不到」: sqlite3 是个嵌入式库,没有常驻进程、没有后台线程。 它只在被 execute 的时候活着, 没人调它的时候,它什么也不做。 所以「到点自己跑」这件事,它压根没有「点」的概念—— 没有自己的时钟,哪来的定时。 这个限制不是 bug,是嵌入式的本质。

四、等价做法一:宿主程序里的循环

最直接的办法:写个函数干那件事,外面套一个循环。 while true: clean(c); time.sleep(3600) 就是每小时一次。 clean(c) 里的 delete … where ts < date('now', '-30 day'), 对应 MySQL 的 now() - interval 30 day。 日期加减法两边写法不同,语义一样。 注意 date('now') 取的是 UTC 日期, 本地时间可能差一天—— 要本地日期得写 date('now', 'localtime')。 这一条在「按天清理」这种任务上会真的差出一天的数据。 循环放应用里的硬伤是「应用得一直活着」: 进程重启、机器重启、发一次版,定时就断了。 而且断了没人知道——它不报错,只是不再跑。

五、等价做法二:交给操作系统

更常见的是交给操作系统:Linux 上写 cron,Windows 上用「任务计划程序」, 到点执行一个脚本,脚本里连数据库、跑一句 delete、断开。 好处是「进程短命」: 跑完就退,不占内存、不会因为长跑而内存泄漏。 代价是每次都要新建连接, 高频任务(每分钟一次)要算一算连接开销。 另一个好处是「好排查」: 日志在文件里、退出码明确、手动跑一次就能复现。 这三样在「应用内循环」里都要额外做,在 cron 里是白送的。 注意 cron 的环境变量是空的: 你在终端里跑得好好的脚本,进了 cron 可能找不到 python—— 因为 PATH 不一样。 所以 crontab 里最好写绝对路径。 这个坑比语法问题更容易踩。

六、定时任务该放哪一层

数据库事件: 纯 SQL 能做完、要求贴着数据跑、不想多一个进程。 不适合要调外部接口、要读文件的活, 而且换了数据库版本就没了。 应用内循环:逻辑复杂、能复用应用里的代码和服务。 不适合会重启、会多实例部署的场景。 操作系统计划: 跑完就退的批处理、备份、清理最合适。 不适合需要精细重试和依赖编排的流程—— 那种得上专门的调度系统。 本沙箱因为「没有事件」这件事,反倒把选择摊开了: 上面三种里,第一种能用的场合其实最少。 多数项目最后落在「操作系统计划 + 一个短命脚本」上。

七、多实例会重复跑

这一条值得单独拎出来:应用部署了三台,三个循环都在跑,同一件事干三遍。 轻的后果是「日志里三倍的量」, 重的后果是「同一条数据被处理三次」——发三次短信、扣三次款。 解法有两个:要么加分布式锁(谁能抢到锁谁跑), 要么干脆只用一台跑定时任务——把它单独做成一个不进负载均衡的进程。 后者更简单也更常见, 代价是「那一台挂了,任务就停」—— 所以还要配个监控:任务超过预期时间没跑,就报警。 定时任务最怕的不是跑错,是「悄悄不跑了」。

八、动手 Demo:试一次 create event,再用手动调用替代它

Demo 三段:先撞一次「这里没有事件」、再把「要定时干的事」写成一个函数并手动调一次、 最后给出两种真正的定时方案(循环与操作系统)。 这里刻意没有真的 sleep——演示定时任务不需要真的等一小时,看清「谁在掐表」就够了。

import time
import _q46 as Q
c = Q.con()

# 这里没有事件
try:
    c.execute("CREATE EVENT ev_clean ON SCHEDULE EVERY 1 DAY "
              "DO DELETE FROM log")
except Exception as e:
    print("CREATE EVENT ->", e)

# 等价物一:一个函数,外面套循环
def clean(conn):
    cur = conn.cursor()
    cur.execute("DELETE FROM log "
                "WHERE ts < date('now', '-30 day')")
    conn.commit()
    return cur.rowcount

while True:
    print("删了 %d 行" % clean(c))
    time.sleep(3600)          # 每小时一次

# 等价物二:交给操作系统,脚本跑完就退
# Linux 的 crontab 里写:0 3 * * *  python /opt/app/clean.py
# Windows 用「任务计划程序」,动作指向同一个脚本

实跑输出:

--------------------------------------------------------------------
第七十一课:事件——数据库里的定时任务
--------------------------------------------------------------------

  MySQL 里长什么样
  MySQL 原码(本沙箱不执行):
  | SET GLOBAL event_scheduler = ON;
  | 
  | CREATE EVENT ev_clean
  | ON SCHEDULE EVERY 1 DAY
  | STARTS '2026-11-01 03:00:00'
  | DO
  |   DELETE FROM log WHERE created_at < NOW() - INTERVAL 30 DAY;
  **查看:`SHOW EVENTS;` 删除:`DROP EVENT IF EXISTS ev_clean;`**
  **`every 1 day` 是频率,`starts` 是第一次什么时候跑,`do` 后面是要执行的语句。**
  **还能写 `at '2026-12-01 00:00:00'` 表示「只在那一刻跑一次」。**
  **第一句 `set global event_scheduler = on` 是最容易忘的一步。** **MySQL 的事件调度器默认是关的**
  ——**事件建好了、看着一切正常,就是不跑。** **这个坑每年都要埋一批人。**

  这里没有 CREATE EVENT
  SQL> CREATE EVENT ev_clean ON SCHEDULE EVERY 1 DAY
  报错:near "EVENT": syntax error
  **`syntax error`。** **`sqlite3` 是个嵌入式库,没有「后台线程」这个概念**——
  **它只在被调用的时候活着,没人调它的时候它什么也不做。**
  **所以「到点自己跑」这件事,它结构上就做不到。**

  等价做法一:宿主程序里的循环
  **最直接的办法:写个循环,睡一会儿跑一次。**
  **`clean(c)` 就是那个「到点要干的事」。**
  **外面套一个 `while True: clean(c); time.sleep(3600)` 就是每小时跑一次。**
  **手动调一次:删了 1 行。**
  SQL> SELECT COUNT(*) AS 剩几行 FROM log
  剩几行
  -------
  0
  (1 行)
  **`date('now', '-30 day')` 是 SQLite 的日期加减法**,
  **对应 MySQL 的 `now() - interval 30 day`。**
  **注意 `date('now')` 取的是 UTC 日期,本地时间可能差一天**——
  **要本地日期得用 `date('now', 'localtime')`。**

  等价做法二:交给操作系统
  **循环放在应用里有个硬伤:应用得一直活着。** **进程重启、机器重启,定时任务就断了。**
  **所以更常见的是交给操作系统**:**Linux 上写 `cron`,Windows 上用「任务计划程序」**,
  **到点执行一个脚本,脚本里连数据库、跑一句 `delete`、断开。**
  **这样做的额外好处是「进程短命」**:
  **跑完就退,不占内存、不会因为长跑而内存泄漏。** **代价是每次都要新建连接**,
  **高频任务(每分钟一次)要算一算连接开销。**

  定时任务该放哪一层
  **数据库事件**:
  **纯 SQL 能做完、要求贴着数据跑、不想多一个进程。** **不适合要调外部接口、要读文件的活**,
  **而且换了数据库版本就没了。**
  **应用内循环**:
  **逻辑复杂、能复用应用里的代码和服务。** **不适合会重启、会多实例部署的场景**——
  **三台机器三个循环,同一件事干三遍。**
  **操作系统计划**:**跑完就退的批处理、备份、清理最合适**,
  **日志在文件里、退出码明确、手动跑一次就能复现。** **不适合需要精细重试和依赖编排的流程。**
  **本沙箱因为「没有事件」这件事,反倒把选择摊开了**:
  **上面三种里,能用第一种的时候最少。** **多数项目最后落在「操作系统计划 + 一个短命脚本」上**,
  **因为它最好排查**:**日志在文件里、退出码明确、手动跑一次就能复现。**
  **「多实例会重复跑」这一条值得单独记一笔**:
  **应用部署了三台,三个循环都在跑,同一件事干三遍**——
  **要么加分布式锁,要么干脆只用一台跑定时任务(单独一个进程)。** **这个是线上真会出的事故。**

CREATE EVENT 报 syntax error,和存储过程、存储函数一样。 clean(c) 手动调了一次,删掉 1 行——那行日志的日期是 2026-01-01,早于 30 天前。 剩下的行数:删完之后 log 里 0 行(原来就只有那一行)。 下面那两行注释是「真方案」:crontab 和任务计划程序,脚本都是同一个 clean.py。

30 秒实验

  1. 把 date('now', '-30 day') 改成 date('now'),那行 2026-01-01 的日志还删得掉吗?
  2. 为什么 set global event_scheduler = on 重启后要重新执行?
  3. 三台机器都跑同一个 cron 任务,会发生什么?怎么防?

常见误区

  1. 建完事件就以为在跑。 调度器默认关着,先 set global event_scheduler = on。
  2. 把定时任务放在会重启的应用里。 重启就断,而且断了不报错。
  3. 多实例各自跑一份定时任务。 同一件事干三遍,要么加锁,要么只留一台跑。
自测三问(点开对答案) 1. MySQL 的事件建好了为什么可能不跑? —— 事件调度器默认关闭。 必须先 `set global event_scheduler = on`,否则事件只是登记在那儿,没人掐表。 2. 为什么本沙箱结构上做不到「事件」? —— sqlite3 是嵌入式库,没有常驻进程和后台线程,只在被调用时活着,没有自己的时钟。 3. 定时任务放应用内循环的两个硬伤是什么? —— 应用重启就断(而且不报错),多实例部署会重复跑同一件事。

第 13 章完了: 存储程序四样,一样真跑、三样讲透加等价物。 下一篇进第 14 章——游标,一次处理一行的那种老写法。

相关笔记