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

CREATE TRIGGER 名字 BEFORE|AFTER INSERT|UPDATE|DELETE ON 表 BEGIN … END, 增删改的时候自动跑,不用人叫。 BEFORE 里改 NEW 能改到真要写进去的值,AFTER 里改没用。 NEW 和 OLD 分别代表「改之后」和「改之前」的行。 WHEN 加条件、RAISE(ABORT, '…') 拦下操作, 这是「数据库层校验」的标准写法。 本沙箱四样存储程序里唯一真跑的就是它。

📝 本章和前面几章的关系

前三篇都在讲「要被调用才动的东西」,这一篇讲「自己会动的东西」。 触发器是这一章唯一能在本沙箱全程真跑的, 所以篇幅也最长、演示也最细。 前面几章其实已经用过它一次: 第 11.1 篇用 instead of 触发器让只读的视图变得可写。

一、一条最小的触发器

create trigger 名字 after insert on 表 begin … end—— 内容就是 Demo 里那条往 log 表记一笔的。 三部分:什么时候(after insert on students)、干什么(begin … end 里那句)。 after insert 意思是「插入成功之后」。 还有 before insert、after update、before delete 等等,三个动作 × 两个时机,一共六种。 里面那句用了 NEW—— NEW 代表「刚插进去的那一行」。 所以触发器能知道「这次动了什么」,而不是只知道「有人动了表」。 注意触发器不需要「谁来调」:插一行,它自己就跑了一遍。 这是它和存储过程最根本的区别。

二、BEFORE 与 AFTER:先后决定用途

同一个动作,before 一定在 after 前面,顺序是固定的。 Demo 里用一张 seq 表记了先后,输出是 before 然后 after。 这个先后决定了两件事:before 里改 NEW 的值,改完的值会真的写进去(还没落盘); after 里改 NEW 没用(已经写完了)。 所以分工很清楚: 「校验并纠正数据」用 before——比如「身高超过 200 就改成 200」、「名字为空就填 '未知'」; 「记日志、同步别的表、发消息」用 after——因为这些是「事情已经发生了」之后的动作。 还有一个实际差别:before 里抛错,那条语句整条不执行; after 里抛错,数据其实已经写进去了(然后随事务一起回滚,前提是你在事务里)。 所以「校验」必须放 before。

三、NEW 与 OLD:三个动作各给什么

insert:只有 NEW(要插的那一行),没有 OLD——还没旧值。 update:OLD 是改之前的行,NEW 是改之后的行。 只有 update 两个都有。 delete:只有 OLD(要删的那一行),没有 NEW——删完就没了。 记住这条就没问题:「update 是唯一能比较前后的动作」, 所以「值变了才记日志」这种需求只能写在 update 触发器里—— where old.height <> new.height 这种条件,别的动作写不出来。

四、WHEN 加条件、RAISE 拦下来

when new.height > 200 是条件,只有满足才执行触发器体,不满足就跳过。 这比「进到里面再 if 判断」简洁,也少一次进入。 raise(abort, '身高超过 200,不合理') 是「中止这次操作,把这句话当报错抛出去」。 Demo 里插 210 被拦,报的就是这句中文;插 176 正常通过。 这就是「数据库层的校验」。 应用忘了校验也没关系,数据库不让进。 适合放这里的是「绝对不能错的规则」:金额不能为负、结束时间不能早于开始时间。 但别什么都往里塞。 check 约束能管的事让它管(第 9.5 篇), 它更快、更简单、报错更清楚;只有「要查别的表」「要算复杂条件」「要改别的表」才用触发器。

五、最正当的用途:维护冗余字段

Demo 里给 classes 加了 total 这一列, 插入学生时 total 加一、删除学生时减一。 两条触发器各盯住一个动作。 为什么要存这一列? total 每次都能 count(*) 算出来, 存一份纯粹是为了「列表页不用每次都数」。 这就是「冗余字段」,用空间换查询速度。 既然存了冗余,就得保证它永远对。 靠应用记得更新是靠不住的—— 总有一条路径忘了写,数据就慢慢歪掉, 而且歪了没人发现,等发现时已经错了一年。 靠触发器才靠得住,因为它是「数据库自己保证的」。 但注意 Demo 里少了一条:学生换班(class_id 从 1 改成 2)时,两个班的 total 都得动, 得写一个 old.class_id 减一、new.class_id 加一的 update 触发器。 漏了这一条,数据就会歪。 这也说明触发器的维护成本:一张表有几个入口,就得几个触发器。

六、建的时候不检查,跑的时候才报

Demo 里建了一个往 nosuch 表插数据的触发器——nosuch 根本不存在。 结果触发器建成功了。 因为建的时候只做语法检查,不检查表存不存在。 一插入才报 no such table: main.nosuch。 这个特性很危险:迁移脚本里少建一张表,触发器照样创建成功, 等到线上第一次写入才炸——而且炸的是「插入学生」这种看起来毫不相干的语句。 所以建完触发器一定要「插一行试试」。 这一步只要两秒,能省掉一次线上事故。 和第 8 章那句「建完表先 select 一下」是同一个习惯。

七、查看、删除、重复建

查看:select name, tbl_name, sql from sqlite_master where type = 'trigger'。 tbl_name 是它挂在哪张表上,sql 列存着完整定义(包括 begin … end 那一段)。 删除:drop trigger 名字,只给名字不给表名——因为触发器名全库唯一, 和 drop index 一个道理(第 12.4 篇)。 删不存在的报 no such trigger,加 if exists 就不报。 重复建会报 trigger … already exists,加 if not exists 就不报。 但这里有个陷阱: if not exists 是「跳过」,不是「替换」—— 你想改触发器逻辑时,它不会帮你改,只会默默不动。 要改就得先 drop 再 create。 MySQL 那边是 show triggers 和 information_schema.triggers, 删除用 drop trigger if exists 触发器名。 语法差不多,但 MySQL 的触发器体里能写 if、能 declare 变量, 这边只有一句句 insert/update/select。

八、递归触发器默认关着

如果触发器 A 改了表、又触发触发器 B、B 又改回去,就死循环了。 所有数据库都在防这件事,只是防法不同。 pragma recursive_triggers 返回 0,意思是关着: 触发器内部引起的改动,不会再触发触发器。 所以「after update 里再 update 同一张表」不会无限循环—— 但反过来说,「指望触发器链式生效」也不会发生。 MySQL 的做法更直接:它压根不许触发器改「正在被触发的这张表」, 直接报 can't update table … already used by statement。 所以 MySQL 上「用触发器维护本表的汇总字段」这条路是走不通的, 得改成「维护另一张表」。 这一点两边行为不同,写跨库代码时要注意。

九、动手 Demo:六种触发器全走一遍:记日志、校验、维护汇总、查看删除

Demo 八段:最小的一条触发器、before 与 after 的先后、when 加 raise 做校验、 两条触发器维护 total、建一个引用不存在表的触发器、查看与删除、最后看递归开关。 第五段「插一行才报错」是这个 Demo 里最该记住的—— 它解释了为什么很多触发器故障都是「上线很久后才突然出现」。

import _q46 as Q
c = Q.con()

# 1) 最小的一条:插完记一笔
Q.run(c, "CREATE TRIGGER tr_log AFTER INSERT ON students "
         "BEGIN INSERT INTO log (msg) VALUES ('新来一个 ' || NEW.name); "
         "END")
Q.show(c, "INSERT INTO students (name, height, class_id) "
          "VALUES ('赵敏', 168.0, 2)")
Q.show(c, "SELECT msg FROM log")

# 2) before 一定在 after 前面
Q.run(c, "CREATE TRIGGER b1 BEFORE INSERT ON t "
         "BEGIN INSERT INTO seq VALUES ('before'); END")
Q.run(c, "CREATE TRIGGER a1 AFTER INSERT ON t "
         "BEGIN INSERT INTO seq VALUES ('after'); END")

# 3) when 加条件、raise 拦下来
Q.run(c, "CREATE TRIGGER tr_tall BEFORE INSERT ON students "
         "WHEN NEW.height > 200 "
         "BEGIN SELECT RAISE(ABORT, '身高超过 200,不合理'); END")
c.execute("INSERT INTO students (name, height, class_id) "
          "VALUES ('巨人', 210.0, 1)")

# 4) 维护汇总表:插一个人,班上人数加一
Q.run(c, "CREATE TRIGGER tr_ins AFTER INSERT ON students "
         "BEGIN UPDATE classes SET total = total + 1 "
         "WHERE id = NEW.class_id; END")

# 5) 查看与删除
Q.show(c, "SELECT name, tbl_name FROM sqlite_master "
          "WHERE type = 'trigger'")
Q.run(c, "DROP TRIGGER IF EXISTS tr_log")

实跑输出:

--------------------------------------------------------------------
第七十课:触发器——增删改的时候自动跑
--------------------------------------------------------------------

  一条最小的触发器
  SQL> CREATE TRIGGER tr_log AFTER INSERT
       ON students BEGIN INSERT INTO log (msg) VALUES ('新来一个 ' || NEW.name); END
  完成
  SQL> INSERT INTO students (name, height, class_id) VALUES ('赵敏', 168.0, 2)
  (无结果集)
  SQL> SELECT msg FROM log
  msg
  --------------
  新来一个 赵敏
  (1 行)
  **`create trigger 名字 after insert on 表 begin … end`。**
  **`after insert` 意思是「插入成功之后」。**
  **里面那句 `'新来一个 ' || NEW.name` 用了 `NEW`**——
  **`NEW` 代表「刚插进去的那一行」。** **所以触发器能知道「这次动了什么」。**

  BEFORE 和 AFTER 的先后
  SQL> SELECT n FROM seq
  n
  -------
  before
  after
  (2 行)
  **先 `before`、后 `after`**,顺序是固定的。
  **这个先后决定了两件事**:
  **`before` 里改 `NEW` 的值,改完的值会真的写进去**(**因为还没落盘**);
  **`after` 里改 `NEW` 没用**(**已经写完了**)。
  **所以「校验并纠正数据」用 `before`,「记日志、同步别的表」用 `after`。**

  NEW 和 OLD:四个动作各能拿到什么

  动作                              OLD                       NEW
  --------------------------------------------------------------------------------------
  insert                            没有(还没旧值)          要插的那一行
  update                            改之前的行                改之后的行
  delete                            要删的那一行              没有(删完就没了)

  **记住这张表就够了**:`insert` 只有 `new`,`delete` 只有 `old`,`update` 两个都有。
  **`update` 是唯一能「比较前后」的动作**,
  **所以「值变了才记日志」这种需求只能写在 `update` 触发器里。**

  WHEN 加条件,RAISE 拦下来
  SQL> CREATE TRIGGER tr_tall BEFORE INSERT
       ON students WHEN NEW.height > 200 BEGIN SELECT RAISE(ABORT, '身高超过 200,不合理'); END
  完成
  SQL> INSERT INTO students (name, height, class_id) VALUES ('巨人', 210.0, 1)
  报错:身高超过 200,不合理
  SQL> INSERT INTO students (name, height, class_id) VALUES ('李四', 176.0, 1)
  (无结果集)
  **`when new.height > 200` 是条件**:
  **只有满足才执行触发器体。** **`raise(abort, '…')` 是「中止这次操作,把这句话当报错抛出去」。**
  **插入 210 被拦了,报 `身高超过 200,不合理`;插入 176 正常通过。** **这就是「数据库层的校验」**
  ——
  **应用忘了校验也没关系,数据库不让进。**
  **`check` 约束能管的事让它管,管不了的(要查别的表、要算复杂条件)才用触发器。**

  用触发器维护汇总表
  SQL> CREATE TRIGGER tr_ins AFTER INSERT ON students BEGIN UPDATE classes SET total = total + 1
       WHERE id = NEW.class_id; END
  完成
  SQL> CREATE TRIGGER tr_del AFTER DELETE ON students BEGIN UPDATE classes SET total = total - 1
       WHERE id = OLD.class_id; END
  完成
  SQL> SELECT id, name, total FROM classes
  id     name     total
  ----------------------
  1      一班     2
  2      二班     1
  (2 行)
  SQL> INSERT INTO students (name, height, class_id) VALUES ('赵敏', 168.0, 2)
  (无结果集)
  SQL> DELETE FROM students WHERE name = '张伟'
  (无结果集)
  SQL> SELECT id, name, total FROM classes
  id     name     total
  ----------------------
  1      一班     1
  2      二班     2
  (2 行)
  **一班原来 2 人,删了张伟变 1 人;二班原来 1 人,加了赵敏变 2 人。**
  **两条触发器各自盯住一个动作。**
  **这就是触发器最正当的用途:维护冗余字段。**
  **`total` 本来每次都能 `count(*)` 算出来,存一份是为了「列表页不用每次都数」。**
  **既然存了冗余,就得保证它永远对**——**靠应用记得更新是靠不住的,靠触发器才靠得住。**
  **注意这里少了 `update` 那条**:
  **如果学生换班(`class_id` 从 1 改成 2),两个班的 `total` 都得动**,
  **得写一个 `old.class_id` 减一、`new.class_id` 加一的触发器。**
  **漏了这一条,数据就会慢慢歪掉。**

  引用未建的表:建的时候不报,跑的时候才报
  SQL> CREATE TRIGGER tr_bad AFTER INSERT ON t BEGIN INSERT INTO nosuch (x) VALUES (1); END
  完成
  **`nosuch` 这张表根本不存在,触发器却建成功了。**
  **因为建的时候只做语法检查,不检查表存不存在。**
  SQL> INSERT INTO t VALUES (1, 'x')
  报错:no such table: main.nosuch
  **一插入才报 `no such table: main.nosuch`。** **这个特性很危险**:
  **迁移脚本里少建一张表,触发器照样创建成功**,
  **等到线上第一次写入才炸。** **所以建完触发器一定要「插一行试试」。**

  查看、删除、重复建
  SQL> SELECT name, tbl_name FROM sqlite_master WHERE type = 'trigger'
  name       tbl_name
  --------------------
  tr_log     students
  (1 行)
  SQL> SELECT sql FROM sqlite_master WHERE name = 'tr_log'
  它的完整定义:
  CREATE TRIGGER tr_log AFTER INSERT ON students
  BEGIN
    INSERT INTO log (msg) VALUES ('新来一个 ' || NEW.name);
  END
  (1 行)
  SQL> DROP TRIGGER tr_log
  完成
  SQL> SELECT name FROM sqlite_master WHERE type = 'trigger' ORDER BY name
  name
  -----
  (0 行)
  SQL> DROP TRIGGER tr_log
  报错:no such trigger: tr_log
  **触发器和索引一样记在 `sqlite_master` 里**,
  **`type = 'trigger'`,`tbl_name` 是它挂在哪张表上。**
  **`sql` 列存着完整的建触发器语句,包括 `begin … end` 那段。**
  **`drop trigger` 和 `drop index` 一样,只给名字不给表名**——
  **因为触发器名也是全库唯一的。** **删不存在的报 `no such trigger`,加 `if exists` 就不报。**
  **MySQL 那边查看用 `show triggers` 或者 `information_schema.triggers`**,
  **删除用 `drop trigger if exists 触发器名`。**
  **语法差不多,但 MySQL 的触发器体里能写 `if`、能 `declare` 变量**,
  **这边只有一句句 `insert`/`update`。**

  递归触发器:默认是关的
  **如果触发器 A 改了表,又触发了表上的触发器 B,B 又改回去呢?** **会死循环。**
  SQL> PRAGMA recursive_triggers
  recursive_triggers
  -------------------
  0
  (1 行)
  **0 表示关着**:
  **触发器内部引起的改动,不会再触发触发器。**
  **所以「`after update` 里再 `update` 同一张表」不会无限循环**——
  **但反过来说,「指望触发器链式生效」也不会发生。**
  **MySQL 有类似的开关 `max_sp_recursion_depth`**,**但那是给存储过程用的**;
  **触发器本身在 MySQL 里就不允许改「正在被触发的这张表」**,
  **直接报 `can't update table … already used by statement`。**
  **两种数据库都在防同一件事:自己咬自己。**

最小那条:插入赵敏成功,log 里记下「新来一个 赵敏」。 before 与 after:seq 表里先 before 后 after。 插 210 被 raise(abort) 拦下,报「身高超过 200,不合理」;插 176 通过。 汇总表:一班从 2 人变 1 人(删了张伟),二班从 1 人变 2 人(加了赵敏)。 引用不存在表那条:建的时候不报,插入时报 no such table: main.nosuch。 查看:sqlite_master 里 type = 'trigger' 只有一条,sql 列存着完整定义。 递归开关 pragma recursive_triggers 返回 0。

30 秒实验

  1. 把「维护 total」的两条触发器删掉, 然后 update students set class_id = 2 where name = '李娜',total 会怎样?
  2. before insert 里写 set new.height = 200,插进去的值是 200 还是原值?换成 after 呢?
  3. 为什么「建触发器时不检查引用的表」这个特性会埋坑?

常见误区

  1. 在 after 里改 NEW 指望影响写入。 已经写完了,要改就得用 before。
  2. 用 if not exists 去「更新」触发器。 它是跳过不是替换,改逻辑得先 drop 再 create。
  3. 建完触发器不试一行。 引用的表不存在也照样建成功,故障要等到第一次写入才出现。
自测三问(点开对答案) 1. `before` 和 `after` 分别适合干什么? —— `before` 适合校验和纠正数据(改 NEW 能生效,抛错能整条拦下); `after` 适合记日志、同步别的表。 2. 三个动作分别能拿到 `NEW` 和 `OLD` 吗? —— `insert` 只有 NEW,`delete` 只有 OLD,`update` 两个都有。 所以「比较前后值」只能写在 update 触发器里。 3. 为什么触发器最适合用来维护冗余字段? —— 冗余字段必须永远准确,靠应用每条写入路径都记得更新不可靠; 触发器由数据库自己保证,任何路径都绕不过去。

触发器是「事情发生时跑」,最后一篇讲「时间到了就跑」的:13.5 事件——数据库里的定时任务。

相关笔记