🛢️ 第15章 触发器
📄 一句话概括

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 秒实验

  1. 把 b_chk 改成 BEFORE UPDATE,a_chk 改成 AFTER UPDATE, 两边各记 OLD.stock 和 NEW.stock,看 BEFORE 能不能读到「改之后」的值。
  2. 建两个同类型触发器,但把其中一个改成 BEFORE、一个 AFTER, 看执行顺序是按时机走还是按创建顺序走——这次应该能看出时机的优先级更高。
  3. 把汇总触发器全删掉,改成每次查询时现算 SUM,对比两种做法的结果是否一致; 再想想「十万行、每秒十次查询」这个场景下分别是什么代价。

常见误区

  1. 以为同一张表上多个触发器的执行顺序是按名字或按字母排的。SQLite 是后建的先跑, MySQL 是先建的先跑,两边还相反。要定顺序就把逻辑合并。
  2. 用 RAISE(IGNORE) 做「容错导入」。它静默跳过违规行,一句错都不报。 除非另有地方统计「跳过了多少条」,否则数据少了没人知道。
  3. 装完触发器就开始累加,不做基线初始化。差值是常量、不放大、不报错, 对账时看到「稳定偏差」很容易往阈值上想。 正确顺序永远是:装触发器 → 全量重算对齐 → 之后全靠增量。
自测三问(点开对答案) 1. `RAISE(ABORT)` 和 `RAISE(FAIL)` 到底差在哪? —— `ABORT` 把整条语句的改动全部撤销,表里一行都没进去; `FAIL` 保留已经处理完的行,从出错那行起停手。 一条多值 `INSERT` 插三行、第二行违规,`ABORT` 剩 2 行、`FAIL` 剩 3 行。 2. 汇总表和真值一直差 8658,为什么差值不变? —— 因为触发器只累加增量,而装触发器那一刻表里已有的 8658 从来没被算进基线。 之后的每一步都在正确增量上叠加,所以差值恒定。补一次全量重算即可对齐。 3. 为什么「靠两个触发器的先后顺序保证结果」不可靠? —— 因为顺序由数据库实现决定,SQLite 后建先跑、MySQL 先建先跑,两边相反; 而且这个顺序没人写在文档里,改一次部署脚本就可能悄悄变。要定顺序就把逻辑并进一个触发器。

下一篇讲删除:DROP TRIGGER 三种写法、删完怎么验、删之前先查谁挂着, 以及删表会不会把触发器一起带走。

相关笔记