BEFORE 在行操作之前跑、AFTER 在之后跑;
同一时机挂多个时,SQLite 是后建的先跑,MySQL 是先建的先跑。
RAISE 有四种动作,ABORT 整条语句作废、FAIL 保留已处理的行、IGNORE 静默跳过。
触发器维护冗余汇总,前提是基线先对齐,之后所有的写都必须走触发器。
教材 15.3 两个小节:触发器的执行顺序、用触发器维护冗余数据。这一篇把两件都做实。
第 13 章讲约束,那是「数据不合规就写不进去」;
这一篇的 RAISE 更进一步,能写自定义的业务规则,还能带一句中文报错。
第二件「维护冗余数据」是触发器最经典的用法,
也是最容易算错的地方——本篇会用一组对照数据把错法摊开。
一、BEFORE 在前,AFTER 在后
最粗的一层顺序是:BEFORE 触发器 → 行操作本身 → AFTER 触发器。
这句话听着像废话,但能量出来:让两边各去数一下表里有几行。
| 时机 | 看到的行数 |
|---|---|
BEFORE |
2(新行还没进去) |
AFTER |
3(新行已经落地) |
中间那一下「真的把行写进去」就是分界线:BEFORE 拦在门外,AFTER 站在门里。
这解释了两种时机各自的用途:要「拦」用 BEFORE,要「记」用 AFTER。
写反了的后果很具体:在 BEFORE 里记日志,记到的是「还没发生」的状态;
在 AFTER 里拦,行已经进去了,拦也拦不住。
还有一层更细的:BEFORE 能看到并参与「这一行要不要合法」,所以约束检查、
值修正都在这个时机;AFTER 只能看结果,改不了。
SQLite 的 BEFORE 连 NEW 都改不了(下一篇会撞上),所以它更像「检查点」。
二、同一时机挂多个:后建的先跑
同一张表、同一个时机、同一个事件,可以挂好几个触发器。它们谁先跑? 这一条两边正好相反,是本章最容易踩的差异。
| 建成顺序 | SQLite 的执行顺序 |
|---|---|
o1 → o2 |
o2 → o1 |
o2 → o1 |
o1 → o2 |
两行都对调了:先建 o1 后建 o2,跑起来是 o2 先 o1 后。
这台沙箱上量到的是「最近创建的优先」。
但 SQLite 官方文档并没有承诺这个顺序——没承诺的东西,更不该拿来当地基。
MySQL 的文档写的是:同一个时机、同一个事件上的多个触发器,按创建时间正序执行,先建的先跑。
注意这也不是「随便哪个库都这样」,只是 MySQL 明确写了。
所以「靠两个触发器的先后顺序来保证结果」这种写法,换个数据库就翻车。
真要定顺序,只有两条路:把逻辑并进一个触发器,或者在 BEGIN ... END 里按顺序写。
把顺序寄托在「谁先建」上,等于把正确性交给部署脚本的执行次序——这种依赖没人会写在文档里,
也就没人记得住。
三、一条语句,触发几次
「每行跑一次」这句话得亲眼看见才信。
| 执行的语句 | 影响行数 | 触发次数 |
|---|---|---|
INSERT 三行 |
3 | 3 |
UPDATE 全部行 |
5 | 5 |
一次动三行就记三条,一次动五行就记五条——触发器跟着「行」走,不跟着「语句」走。 这也是它慢的原因:批量更新十万行,触发器就要跑十万次。 更麻烦的是这十万次都在同一个事务里,锁持有时间会被拉长,并发上来以后表现是「平时很快、 忙时全堵」。 所以大批量导入、批量修数据这类活,常规做法是先禁用或删掉触发器,导完再补一次全量重算。 这个套路和下一篇要讲的「基线」是一回事。
四、RAISE:在触发器里主动拦下来
SQLite 用 RAISE 抛错,四种作用在一条多值 INSERT 上,结果不一样。
一个触发器拦住中间那行,看前面那行留没留下来。
| 动作 | 结果 | 表里行数 |
|---|---|---|
RAISE(ABORT) |
拦住:价格不能为负 | 2 |
RAISE(FAIL) |
拦住:价格不能为负 | 3 |
RAISE(ROLLBACK) |
拦住:价格不能为负 | 2 |
RAISE(IGNORE) |
没拦住 | 4 |
原来 2 行,一条语句要插 3 行,四种动作的结果:
ABORT 退回 2 行——整条语句作废,一行都没进去。
FAIL 是 3 行——「好一」留下了,从出错那行起不再往下做。
ROLLBACK 也是 2 行,但它连外面已经做过的事一起回滚,比 ABORT 更狠。
IGNORE 是 4 行——跳过违规那一行,其余照插,一句错都不报。
四种里最危险的是 IGNORE:数据悄悄少了一行,日志里什么都没有。
用它的场景只有一个:脏数据里允许挑出好的入库,而且必须另有地方记录被跳过了多少条。
MySQL 那边没有 RAISE,它用 SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '...' 抛错,
语义接近 ABORT。
教材里通常写成 IF ... THEN SIGNAL ... END IF,思路一样,写法完全不通用。
五、WHEN 条件:只拦「降价」这一种改法
WHEN 后面是一个布尔表达式,为真才执行触发器体;为假就整段跳过,不算错。
这个机制最适合做业务规则守卫:价格不许往下调。
| 语句 | 方向 | 结果 |
|---|---|---|
SET price = 420 |
涨价 | 成功 |
SET price = 350 |
降价 | 价格不许往下调 |
SET stock = 66 |
没碰价格 | 成功 |
涨价过了、降价被拦、改库存绕过。这三行正是「守卫」该有的样子。
被拦时抛的是 IntegrityError,消息就是 RAISE 里那句中文。
应用层可以直接把它弹给用户看,不用再翻译一遍——这是 RAISE 比普通约束好用的地方:
约束只会说「CHECK 失败」,RAISE 能说人话。
注意第三行:WHEN 不满足时,触发器体整段不执行,连日志都不记。
所以别把「记流水」和「做拦截」写在同一个带 WHEN 的触发器里——条件不满足时,流水也没了。
六、维护冗余数据:先对齐基线,再靠增量
教材 15.3 第二个小节是本章的重头戏:用触发器维护汇总表。
思路是「不每次去 SUM 十万行,而是把结果存在旁边,每改一行就修一下」。
这招叫冗余存储,省读、费写,也最容易算错。
三个触发器,AFTER INSERT 加、AFTER UPDATE 加差值、AFTER DELETE 减,都只做一件事:
把「变化量」加到汇总表上。
全是 AFTER——得等行真的落下去,才知道 NEW 和 OLD 的最终值。
| 时机 | 汇总表 total/cnt | 真值 total/cnt | 核对 |
|---|---|---|---|
| 刚装好触发器 | 0 / 0 | 8658 / 42 | 差 8658 |
| 插 1 行后 | 90 / 3 | 8748 / 45 | 差 8658 |
| 把库存改成 10 | 300 / 10 | 8958 / 52 | 差 8658 |
| 删掉 1 行 | -3570 / -20 | 5088 / 22 | 差 8658 |
四行全在跑偏,但「核对」那一列值得盯一下:差的一直是同一个数,一分不多一分不少。 原因不是触发器写错了,是「起点」错了。 装上去那一刻表里已经有 2 行、货值 8658,可汇总表是 0。 触发器只记增量,它把这 8658 当成「本来就不存在」,之后每一步都在这条错线上平移。 这类错误最阴的地方是:它不放大、也不报错,就这么一直差着。 对账时看到的是「稳定偏差」,第一反应往往是「阈值算错了」; 真正的原因是「基线没初始化」——这个方向差别,决定了你去改哪里。 正确做法是装完触发器立刻做一次全量重算,把基线对齐:
UPDATE tb_stat
SET total = (SELECT sum(price * stock) FROM tb_g),
cnt = (SELECT sum(stock) FROM tb_g)
WHERE id = 1;
| 时机 | 汇总表 total/cnt | 真值 total/cnt | 核对 |
|---|---|---|---|
| 全量重算后 | 5088 / 22 | 5088 / 22 | 一致 |
| 再插 1 行 | 5248 / 26 | 5248 / 26 | 一致 |
| 再改 1 个价 | 5268 / 26 | 5268 / 26 | 一致 |
| 再删 1 行 | 4968 / 16 | 4968 / 16 | 一致 |
对齐之后就一路跟着走了:插、改、删三种动作,汇总表始终等于真值。
这一正一反两组数据是同一个结论的两面:触发器能把冗余数据维护住,前提是基线必须先对齐。
而且之后所有的写都必须走触发器。
只要有一次绕过——批量导入、手工改一行、别的程序直接 UPDATE——汇总表就又歪了。
歪了还没人知道,这才是最贵的代价。
所以工程上的标准做法是配一个对账任务:每天跑一次全量 SUM 和汇总表比一比,差了就报警、
并重算。
触发器负责「实时近似正确」,对账负责「兜底」,两件都要有。
七、动手 Demo:四种顺序、四次拦截、一组对不上的汇总
这一段分四步:先用「数行数」的办法量出 BEFORE 和 AFTER 的先后;再验同一时机挂多个时谁先跑;
然后把 RAISE 的四种动作用在同一条多值 INSERT 上对照;
最后装三个触发器维护汇总表,看它怎么从跑偏到对齐。
import sqlite3
c = sqlite3.connect(u":memory:", isolation_level=None)
c.executescript(u"""
CREATE TABLE tb_g (id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT, price REAL, stock INTEGER);
CREATE TABLE tb_log (id INTEGER PRIMARY KEY AUTOINCREMENT,
act TEXT, ref INTEGER);
CREATE TABLE tb_stat (id INTEGER PRIMARY KEY, total REAL, cnt INTEGER);
INSERT INTO tb_g(name, price, stock) VALUES ('机械键盘', 399.0, 12);
INSERT INTO tb_g(name, price, stock) VALUES ('无线鼠标', 129.0, 30);
INSERT INTO tb_stat VALUES (1, 0, 0);
""")
# ① 量出 BEFORE 与 AFTER 的先后:两边各数一次行数
c.execute(u"CREATE TRIGGER b_chk BEFORE INSERT ON tb_g FOR EACH ROW "
u"BEGIN INSERT INTO tb_log(act, ref) "
u"VALUES ('BEFORE', (SELECT count(*) FROM tb_g)); END")
c.execute(u"CREATE TRIGGER a_chk AFTER INSERT ON tb_g FOR EACH ROW "
u"BEGIN INSERT INTO tb_log(act, ref) "
u"VALUES ('AFTER', (SELECT count(*) FROM tb_g)); END")
c.execute(u"INSERT INTO tb_g(name, price, stock) VALUES ('丙', 30.0, 1)")
print(c.execute(u"SELECT act, ref FROM tb_log").fetchall())
# ② 同一时机挂多个:后建的先跑
for seq in ((u"o1", u"o2"), (u"o2", u"o1")):
c.execute(u"DELETE FROM tb_log")
for nm in seq:
c.execute(u"CREATE TRIGGER %s AFTER INSERT ON tb_g FOR EACH ROW "
u"BEGIN INSERT INTO tb_log(act) VALUES ('%s'); END" % (nm, nm))
c.execute(u"INSERT INTO tb_g(name, price, stock) VALUES ('丁', 40.0, 1)")
print(u"→".join(seq), u"=>", [x[0] for x in
c.execute(u"SELECT act FROM tb_log")])
# ③ RAISE 四种动作:一条多值 INSERT,中间那行违规
for kw in (u"ABORT", u"FAIL", u"ROLLBACK", u"IGNORE"):
msg = u"" if kw == u"IGNORE" else u", '价格不能为负'"
c.execute(u"CREATE TRIGGER chk_%s BEFORE INSERT ON tb_g FOR EACH ROW "
u"WHEN NEW.price < 0 BEGIN SELECT RAISE(%s%s); END" % (kw, kw, msg))
try:
c.execute(u"INSERT INTO tb_g(name, price, stock) VALUES "
u"('好一', 5.0, 1), ('负', -5.0, 1), ('好二', 6.0, 1)")
except sqlite3.Error as e:
print(kw, u"拦住:", e)
print(u" 行数", c.execute(u"SELECT count(*) FROM tb_g").fetchone()[0])
# ④ 冗余汇总:三个触发器只管增量,基线要另外对齐
for nm, timing, ev, body in (
(u"s_ins", u"AFTER", u"INSERT",
u"UPDATE tb_stat SET total = total + NEW.price * NEW.stock, "
u"cnt = cnt + NEW.stock WHERE id = 1"),
(u"s_upd", u"AFTER", u"UPDATE",
u"UPDATE tb_stat SET total = total + (NEW.price * NEW.stock "
u"- OLD.price * OLD.stock), cnt = cnt + (NEW.stock - OLD.stock) "
u"WHERE id = 1"),
(u"s_del", u"AFTER", u"DELETE",
u"UPDATE tb_stat SET total = total - OLD.price * OLD.stock, "
u"cnt = cnt - OLD.stock WHERE id = 1")):
c.execute(u"CREATE TRIGGER %s %s %s ON tb_g FOR EACH ROW "
u"BEGIN %s; END" % (nm, timing, ev, body))
def pair(label):
s = c.execute(u"SELECT total, cnt FROM tb_stat").fetchone()
t = c.execute(u"SELECT sum(price * stock), sum(stock) FROM tb_g").fetchone()
print(label, s, t)
pair(u"刚装好触发器")
c.execute(u"INSERT INTO tb_g(name, price, stock) VALUES ('丙', 30.0, 3)")
pair(u"插 1 行后")
c.execute(u"UPDATE tb_g SET stock = 10 WHERE id = 3")
pair(u"把库存改成 10")
c.execute(u"DELETE FROM tb_g WHERE id = 2")
pair(u"删掉 1 行")
# 补一次全量重算,把基线对齐,再走一遍
c.execute(u"UPDATE tb_stat SET total = (SELECT sum(price * stock) FROM tb_g), "
u"cnt = (SELECT sum(stock) FROM tb_g) WHERE id = 1")
pair(u"全量重算后")
c.execute(u"INSERT INTO tb_g(name, price, stock) VALUES ('丁', 40.0, 4)")
pair(u"再插 1 行")
实跑输出:
--------------------------------------------------------------------
使用触发器:顺序、行数、拦截、汇总
--------------------------------------------------------------------
执行顺序:BEFORE 在前,AFTER 在后
--------------------------------------------------------------------
**教材 15.3 的第一个小节叫「触发器的执行顺序」,最粗的一层是:**
**`BEFORE` 在行操作之前跑,`AFTER` 在行操作之后跑。**
**这句话听着像废话,但能用一个办法量出来:让两边各去数一下表里有几行。**
mysql> 插一行之前表里有 2 行,插一行之后应该是 3 行:
mysql> SELECT act, ref FROM tb_log
act ref
----------
BEFORE 2
AFTER 3
2 rows in set
**`BEFORE` 看到 2 行,`AFTER` 看到 3 行。**
**中间那一下「真的把行写进去」就是分界线——`BEFORE` 拦在门外,`AFTER` 站在门里。**
**这解释了两种时机各自的用途:**
**要「拦」就用 `BEFORE`(还能读到 `OLD`、能在 MySQL 里改写 `NEW`);**
**要「记」就用 `AFTER`(新行已经落地,连自增号带约束检查都完事了)。**
**写反了的后果很具体:在 `BEFORE` 里记日志,记到的是「还没发生」的状态。**
**在 `AFTER` 里拦,行已经进去了,拦也拦不住。**
同一时机挂多个:后建的先跑
--------------------------------------------------------------------
**同一张表、同一个时机、同一个事件,可以挂好几个触发器。**
**它们谁先跑?这一条两边正好相反,是本章最容易踩的差异。**
建成顺序 执行顺序
------------------
o1 → o2 o2 → o1
o2 → o1 o1 → o2
**两行都对调了:先建 o1 后建 o2,跑起来是 o2 先 o1 后。**
**MySQL 的文档写的是按创建时间正序执行,先建的先跑。**
**SQLite 官方文档没承诺这个顺序,这里量到的是「最近创建的优先」——没承诺的顺序更不该拿来当地基。**
**所以「靠两个触发器的先后顺序来保证结果」这种写法,换个数据库就翻车。**
**真要定顺序,就把逻辑并进一个触发器,或者在 `BEGIN ... END` 里按顺序写。**
行级到底有多「行」
--------------------------------------------------------------------
**「每行跑一次」这句话得亲眼看见才信。一条语句动三行、另一条动五行,各触发几次?**
mysql> 一条 INSERT 插三行:
日志表 3 条,记下的 id 是 3、4、5
mysql> 一条 UPDATE 改所有行的库存:
日志累计 8 条,这轮新增 5 条(表里共 5 行)
**一次动三行就记三条,一次动五行就记五条——触发器是跟着「行」走的,不是跟着「语句」走的。**
**这也是它慢的原因:批量更新十万行,触发器就要跑十万次。**
RAISE:在触发器里主动拦下来
--------------------------------------------------------------------
**SQLite 用 `RAISE` 抛错,四种动作用在一条多值 INSERT 上,结果不一样。**
**一个触发器拦住中间那行,看前面那行留没留下来。**
动作 结果 表里行数
-----------------------------------------------
`RAISE(ABORT)` 拦住:价格不能为负 2 行
`RAISE(FAIL)` 拦住:价格不能为负 3 行
`RAISE(ROLLBACK)` 拦住:价格不能为负 2 行
`RAISE(IGNORE)` 没拦住 4 行
**原来 2 行,一条语句要插 3 行。四种动作的结果:**
**`ABORT` 退回 2 行——整条语句作废,一行都没进去。**
**`FAIL` 是 3 行——「好一」留下了,从出错那行起不再往下做。**
**`ROLLBACK` 也是 2 行,但它连外面已经做过的事一起回滚,比 `ABORT` 更狠。**
**`IGNORE` 是 4 行——跳过违规那一行,其余照插,一句错都不报。**
**这四种里最危险的是 `IGNORE`:数据悄悄少了一行,日志里什么都没有。**
**MySQL 那边没有 `RAISE`,它是用 `SIGNAL SQLSTATE '45000'` 抛错的,语义接近 `ABORT`。**
**教材里这段通常写成 `IF ... THEN SIGNAL ... END IF`,思路一样,写法完全不通用。**
WHEN 条件:只拦「降价」这一种改法
--------------------------------------------------------------------
**`WHEN` 后面是一个布尔表达式,为真才执行触发器体;为假就整段跳过,不算错。**
**这个机制最适合做「业务规则守卫」:价格不许往下调。**
语句 方向 结果
-------------------------------------------
`SET price = 420` 涨价 成功
`SET price = 350` 降价 价格不许往下调
`SET stock = 66` 没碰价格 成功
mysql> 三句跑完,表里实际是:
mysql> SELECT id, name, price, stock FROM tb_g
id name price stock
-----------------------
1 机械键盘 420 66
2 无线鼠标 129 30
2 rows in set
**涨价过了、降价被拦、改库存绕过。这三行正是「守卫」该有的样子。**
**被拦时抛的是 `IntegrityError`,消息就是 `RAISE` 里那句中文。**
**应用层可以直接把它弹给用户看,不用再翻译一遍。**
想在 BEFORE 里改写 NEW?这里不行
--------------------------------------------------------------------
**MySQL 有个很常用的手法:`BEFORE INSERT` 里 `SET NEW.price = 0`,把要写进去的值改掉。**
**SQLite 连 `SET` 这个词都没有,这条路直接堵死。**
写法 结果
------------------------------------------------
`SET NEW.price = 1` near "SET": syntax error
`SELECT NEW.price = 1` 建成功
**第一行是 `near "SET": syntax error`,连解析都过不去。**
**第二行建成功了,但它只是算了一个比较、把结果扔掉,一个字都没改。**
**`NEW` 在 SQLite 的 BEFORE 里是只读的——只能读、不能写。**
**SQLite 要改值,只有一条路:在触发器里 `UPDATE` 同一张表。**
**但这样一来就把「插入时改值」变成了「插入后再改一行」,多了一次写、多了一次触发,代价不一样。**
**这条差异是「同一段业务逻辑换个库就得重写」的典型例子。**
维护冗余数据:先初始化,再靠增量
--------------------------------------------------------------------
**教材 15.3 的第二个小节是本章的重头戏:用触发器维护汇总表。**
**思路是「不每次去 `SUM` 十万行,而是把结果存在旁边,每改一行就修一下」。**
**这招叫冗余存储,省读、费写,也最容易算错。**
**三个触发器都只做一件事:把「变化量」加到汇总表上。**
**注意它们全是 `AFTER`——得等行真的落下去,才知道 `NEW` 和 `OLD` 的最终值。**
时机 汇总表 total / cnt 真值 total / cnt 核对
--------------------------------------------------------------
刚装好触发器 0.0 / 0 8658.0 / 42 差 8658.0
插 1 行后 90.0 / 3 8748.0 / 45 差 8658.0
把库存改成 10 300.0 / 10 8958.0 / 52 差 8658.0
删掉 1 行 -3570.0 / -20 5088.0 / 22 差 8658.0
**四行全在跑偏,但注意「核对」那一列:差的一直是同一个数,一分不多一分不少。**
**原因不是触发器写错了,是「起点」错了。**
**装上去那一刻表里已经有 2 行、货值 8658,可汇总表是 0。**
**触发器只记增量,它把这 8658 当成「本来就不存在」,之后每一步都在这条错线上平移。**
**这类错误最阴的地方是:它不放大、也不报错,就这么一直差着。**
**对账时看到的是「稳定偏差」,第一反应往往是「阈值算错了」。**
**真正的原因是「基线没初始化」——这个方向差别,决定了你去改哪里。**
**正确做法是装完触发器立刻做一次全量重算,把基线对齐:**
时机 汇总表 total / cnt 真值 total / cnt 核对
-------------------------------------------------------
全量重算后 5088.0 / 22 5088.0 / 22 一致
再插 1 行 5248.0 / 26 5248.0 / 26 一致
再改 1 个价 5268.0 / 26 5268.0 / 26 一致
再删 1 行 4968.0 / 16 4968.0 / 16 一致
**对齐之后就一路跟着走了:插、改、删三种动作,汇总表始终等于真值。**
**这一正一反两组数据是同一个结论的两面。**
**触发器能把冗余数据维护住,前提是基线必须先对齐。**
**而且之后所有的写都必须走触发器。**
**只要有一次绕过触发器直接改表——批量导入、手工改一行——汇总表就又歪了。**
**歪了还没人知道,这才是最贵的代价。**
第一节那两行 BEFORE 2、AFTER 3 是整篇最划算的一次测量。
只用「数一下行数」这一个动作,就把「谁在前谁在后」这个抽象问题变成了两个具体数字。
遇到「到底哪一步先执行」这类问题时,这个办法可以反复用。
第二节两次对照,顺序正好对调,说明 SQLite 是按「最近创建的优先」执行的。
单单跑一次看不出这个规律——只跑 o1 后 o2,得到 o2 先,很可能被理解成「碰巧」。
把建成顺序反过来再跑一次,才是真正的证据。
第三节四次的结果:ABORT 2 行、FAIL 3 行、ROLLBACK 2 行、IGNORE 4 行。
ABORT 和 FAIL 都「拦住」了,但留下几行不一样——这才是它们真正的差别。
只看「报没报错」是分不出这两个的。
第四节那四行「差 8658」最容易看漏:数字一直在变,差值却一动不动。
能看出「差值是常量」这一步,才谈得上判断原因——是基线没对齐,不是逻辑写错了。
后面补一次全量重算,四行全部「一致」,反证了前面的诊断。
30 秒实验
- 把
b_chk改成BEFORE UPDATE,a_chk改成AFTER UPDATE, 两边各记OLD.stock和NEW.stock,看BEFORE能不能读到「改之后」的值。 - 建两个同类型触发器,但把其中一个改成
BEFORE、一个AFTER, 看执行顺序是按时机走还是按创建顺序走——这次应该能看出时机的优先级更高。 - 把汇总触发器全删掉,改成每次查询时现算
SUM,对比两种做法的结果是否一致; 再想想「十万行、每秒十次查询」这个场景下分别是什么代价。
常见误区
- 以为同一张表上多个触发器的执行顺序是按名字或按字母排的。SQLite 是后建的先跑, MySQL 是先建的先跑,两边还相反。要定顺序就把逻辑合并。
- 用
RAISE(IGNORE)做「容错导入」。它静默跳过违规行,一句错都不报。 除非另有地方统计「跳过了多少条」,否则数据少了没人知道。 - 装完触发器就开始累加,不做基线初始化。差值是常量、不放大、不报错, 对账时看到「稳定偏差」很容易往阈值上想。 正确顺序永远是:装触发器 → 全量重算对齐 → 之后全靠增量。
自测三问(点开对答案)
1. `RAISE(ABORT)` 和 `RAISE(FAIL)` 到底差在哪? —— `ABORT` 把整条语句的改动全部撤销,表里一行都没进去; `FAIL` 保留已经处理完的行,从出错那行起停手。 一条多值 `INSERT` 插三行、第二行违规,`ABORT` 剩 2 行、`FAIL` 剩 3 行。 2. 汇总表和真值一直差 8658,为什么差值不变? —— 因为触发器只累加增量,而装触发器那一刻表里已有的 8658 从来没被算进基线。 之后的每一步都在正确增量上叠加,所以差值恒定。补一次全量重算即可对齐。 3. 为什么「靠两个触发器的先后顺序保证结果」不可靠? —— 因为顺序由数据库实现决定,SQLite 后建先跑、MySQL 先建先跑,两边相反; 而且这个顺序没人写在文档里,改一次部署脚本就可能悄悄变。要定顺序就把逻辑并进一个触发器。下一篇讲删除:DROP TRIGGER 三种写法、删完怎么验、删之前先查谁挂着,
以及删表会不会把触发器一起带走。
- ☐ (本篇完,下一篇 15.4 删除触发器)