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 秒实验
- 把「维护
total」的两条触发器删掉, 然后update students set class_id = 2 where name = '李娜',total会怎样? before insert里写set new.height = 200,插进去的值是 200 还是原值?换成after呢?- 为什么「建触发器时不检查引用的表」这个特性会埋坑?
常见误区
- 在
after里改NEW指望影响写入。 已经写完了,要改就得用before。 - 用
if not exists去「更新」触发器。 它是跳过不是替换,改逻辑得先drop再create。 - 建完触发器不试一行。 引用的表不存在也照样建成功,故障要等到第一次写入才出现。
自测三问(点开对答案)
1. `before` 和 `after` 分别适合干什么? —— `before` 适合校验和纠正数据(改 NEW 能生效,抛错能整条拦下); `after` 适合记日志、同步别的表。 2. 三个动作分别能拿到 `NEW` 和 `OLD` 吗? —— `insert` 只有 NEW,`delete` 只有 OLD,`update` 两个都有。 所以「比较前后值」只能写在 update 触发器里。 3. 为什么触发器最适合用来维护冗余字段? —— 冗余字段必须永远准确,靠应用每条写入路径都记得更新不可靠; 触发器由数据库自己保证,任何路径都绕不过去。触发器是「事情发生时跑」,最后一篇讲「时间到了就跑」的:13.5 事件——数据库里的定时任务。
- ☐ (本篇完,下一篇 13.5 事件)