🪟 第12章 触发器
📄 一句话概括

触发器是「挂在表上的自动过程」:不用 EXEC,数据一动它就自己跑。 它靠 inserted/deleted 两张临时表取新旧值;SQLite 用 NEW/OLD 逐行取。 SQL Server 触发器是语句级(一条语句跑一次),SQLite 是行级(一行跑一次)——一次插三行, 日志条数差了 3 倍。

📝 本章和前面几章的关系

第 11 章的存储过程要人 EXEC, 触发器不用人叫——它是「事件驱动」的那一类。从「你主动调」变成「数据一动它就跑」, 这是这一章的分水岭。 教材 12.1 分四小节:概念、优点、类型、inserted 与 deleted 两张临时表。 最后那一小节最容易被跳过,但它是触发器能不能写对的关键。 这一章运气好: SQLite 真有触发器,所以「建一个、看它自动跑、 一次插三行看触发几次」全是真跑的; SQL Server 独有的部分(inserted/deleted、语句级语义)打原码讲清楚。

一、概念:你不用调它,它自己跑

需求:商品价格一改,必须留一条痕迹——从多少改到多少。 如果指望程序员每次都记得写日志,那迟早有人忘。忘了也不报错, 事后查不出来——这种「必须发生」的事,就不能靠自觉。 触发器就是干这个的:

CREATE TRIGGER trg_price_log AFTER UPDATE OF price ON tb_goods
BEGIN
    INSERT INTO tb_log (tbl, act, key_id, memo)
    VALUES ('tb_goods', 'UPDATE', OLD.id,
            '价格 ' || OLD.price || ' -> ' || NEW.price);
END

建完之后什么也不用做。 演示里 UPDATE tb_goods SET price = 5199 一执行, tb_log 里自己多了一条——那条 INSERT 没有任何人敲。 所以触发器和存储过程最本质的区别不是语法,是「谁发起」。 存储过程是「你叫它」; 触发器是「数据动它就动」——你连它存在都可能不知道。

二、inserted 与 deleted:两张只在触发器里存在的表

表 里面是什么 哪些操作有
inserted 新值(插入或修改之后的行) INSERT、UPDATE
deleted 旧值(删除或修改之前的行) DELETE、UPDATE

这两张表只在触发器执行期间存在,触发器一结束就没了。它们不是真表,读不出来也改不了。 UPDATE 两张都有——这是最容易记混的一点。 更新 = 先删旧的、再插新的,所以 deleted 里是改之前的,inserted 里是改之后的。 SQLite 没有这两张表,它用 OLD.列 和 NEW.列 逐行取。 这不是语法糖的差别,它反映了两家完全不同的执行模型——一个是「一批行」,一个是「当前这一行」。

三、语句级还是行级:一次插三行,日志多几条

这是本章最该记住的一条差异,演示里直接量出来了:

一次 INSERT 写了几行 日志表新增几条
3 3

一条语句插三行,日志多了 3 条——因为 SQLite 的触发器是「行级」,每行跑一次。 SQL Server 是「语句级」:一条语句只跑一次触发器,inserted 里躺着三行。 这个差别会让同一段逻辑在两边表现完全不同。 它带来一个经典错误:按「inserted 只有一行」写逻辑。 平时单行插入测得好好的, 一碰上批量导入就漏账——因为触发器只跑了「第一行那一份」。

-- SQL Server 里正确写法:把 inserted 当多行表处理
CREATE TRIGGER trg_goods_ins ON tb_goods AFTER INSERT
AS
BEGIN
    SET NOCOUNT ON;
    INSERT INTO tb_log (tbl, act, key_id, memo)
    SELECT 'tb_goods', 'INSERT', id, gname FROM inserted;
END;
GO

关键在 SELECT ... FROM inserted 这种「集合」写法, 而不是 SET @x = (SELECT ...) 拿一个变量去接。接了就只能拿到一行,剩下的悄悄丢掉。

四、类型:AFTER 与 INSTEAD OF

类型 什么时候跑 典型用途
AFTER(也叫 FOR) 语句执行完之后 记日志、级联更新、做审计
INSTEAD OF 代替原操作执行 给视图做可写、拦截非法数据

AFTER 触发器出错了,整个语句回滚——原操作等于没发生。 这一点很方便:校验不通过就主动报错,插入自然不生效。 INSTEAD OF 是自己接管,原操作压根不执行,全靠你写。 第 10 章给视图挂的那个 INSTEAD OF INSERT 就是这类——因为视图本来写不了,只能代劳。 SQL Server 只有这两类,没有 BEFORE。 SQLite 有 BEFORE, 还能 FOR EACH ROW 显式声明行级——这些差异写 SQL 时要当场查文档,别凭印象。

五、优点与代价:两边都要看

优点 代价
自动执行,不靠人记 逻辑藏起来了,看代码看不出来
跨客户端一致 出问题难排查(不知道是触发器干的)
能做级联和审计 批量操作时开销成倍
权限收得住 触发器里再触发触发器,容易绕晕

第二行那个「难排查」是最要命的。 新人接手一个库,改了张表的数据, 结果另一张表莫名其妙多了记录——查半天才发现有触发器。这种事在真实项目里非常常见。 所以规范一点的团队会要求「触发器清单」进文档:哪张表上有几个、各干什么、 出问题先看这张清单。 没有清单的库,触发器就是埋在土里的雷。 最后一行也值得说: 触发器 A 改表 B, 表 B 上的触发器又改表 C——一条语句能牵出一串。SQL Server 默认允许嵌套触发, 递归触发默认是关的;能不用嵌套就别用。

六、动手 Demo:建第一个触发器,看它自己跑;再量一次行级还是语句级

这一段建一个「改价自动记账」的触发器,然后一句 UPDATE,看日志表自己多出一条。 接着用「一次插三行」量出 SQLite 的行级语义——日志多 3 条,而 SQL Server 只会多 1 条。

import sqlite3

c = sqlite3.connect(u":memory:")
# ... 建六表、装样例数据、建 tb_log 日志表 ...

# 价格一改就记账
c.execute(u"CREATE TRIGGER trg_price_log AFTER UPDATE OF price ON tb_goods "
          u"BEGIN INSERT INTO tb_log (tbl, act, key_id, memo) "
          u"VALUES ('tb_goods', 'UPDATE', OLD.id, "
          u"'价格 ' || OLD.price || ' -> ' || NEW.price); END")
c.execute(u"UPDATE tb_goods SET price = 5199 WHERE id = 1001")
print(c.execute(u"SELECT memo FROM tb_log").fetchall())

# 一次插三行,看触发器跑几次
c.execute(u"CREATE TRIGGER trg_goods_ins AFTER INSERT ON tb_goods "
          u"BEGIN INSERT INTO tb_log (tbl, act, key_id, memo) "
          u"VALUES ('tb_goods', 'INSERT', NEW.id, NEW.gname); END")
before = c.execute(u"SELECT COUNT(*) FROM tb_log").fetchone()[0]
c.execute(u"INSERT INTO tb_goods (gname, type_id, price, stock, is_on) VALUES "
          u"('U盘', 2, 59, 100, 1), ('摄像头', 2, 129, 50, 1), ('音箱', 2, 199, 30, 1)")
after = c.execute(u"SELECT COUNT(*) FROM tb_log").fetchone()[0]
print(u"插 3 行,日志多了 %d 条" % (after - before))

实跑输出:

--------------------------------------------------------------------
触发器是什么:数据库自己在动手
--------------------------------------------------------------------


  ① 一个必须「自动」发生的动作
  --------------------------------------------------------------------
  需求:商品价格一改,必须留一条痕迹——谁改的、从多少改到多少。
  如果指望程序员每次都记得写日志,那迟早有人忘。忘了也不报错,事后查不出来。
  触发器就是为了这种「必须」:你不用调用它,数据库在数据被改的时候替你跑。

  ② 第一个触发器:价格改动自动记账
  --------------------------------------------------------------------

  1> CREATE TRIGGER trg_price_log AFTER
     UPDATE OF price ON tb_goods BEGIN
     INSERT INTO tb_log (tbl, act, key_id, memo)
     VALUES ('tb_goods', 'UPDATE', OLD.id, '价格 ' || OLD.price || ' -> ' || NEW.price); END
  建好了。注意这里一个 EXEC、一个 CALL 都没有——它挂在表上,等事件。

  1> UPDATE tb_goods SET price = 5199 WHERE id = 1001

  1> SELECT tbl, act, key_id, memo FROM tb_log
  tbl       act     key_id  memo
  -----------------------------------------------
  tb_goods  UPDATE  1001    价格 4999.0 -> 5199.0
  (1 行受影响)
  日志自己多了一条。这条 INSERT 不是任何人敲的,是触发器干的。

  ③ inserted 与 deleted:两张只在触发器里存在的表
  --------------------------------------------------------------------
  SQL Server 的触发器里能读两张特殊表,它们只在触发器执行期间存在:
| 表 | 里面是什么 | 哪些操作有 |
|---|---|---|
| inserted | 新值(插入或修改之后的行) | INSERT、UPDATE |
| deleted | 旧值(删除或修改之前的行) | DELETE、UPDATE |

  SQLite 没有这两张表,它用 OLD.列 / NEW.列 直接取——一行一行的,不是表。
  这个区别不是语法糖,它反映了两家的执行模型:SQL Server 语句级,SQLite 行级。

  ④ 语句级还是行级:一次插三行,看日志多几条
  --------------------------------------------------------------------

  1> CREATE TRIGGER trg_goods_ins AFTER INSERT ON tb_goods BEGIN
     INSERT INTO tb_log (tbl, act, key_id, memo)
     VALUES ('tb_goods', 'INSERT', NEW.id, NEW.gname); END

  1> INSERT INTO tb_goods (gname, type_id, price, stock, is_on)
     VALUES ('U盘', 2, 59, 100, 1), ('摄像头', 2, 129, 50, 1), ('音箱', 2, 199, 30, 1)
| 一次 INSERT 写了几行 | 日志表新增几条 |
|---:|---:|
| 3 | 3 |

  ! 一条语句插三行,日志多了 3 条——因为 SQLite 的触发器是「行级」,每行跑一次。
  SQL Server 不一样:它是「语句级」,一条语句只跑一次触发器,inserted 里躺着三行。
  这带来一个经典错误:按「inserted 只有一行」写逻辑,碰上批量插入就漏账。

  [教材原码 · 本沙箱不执行]
  | -- SQL Server 里正确写法:把 inserted 当多行表处理
  | CREATE TRIGGER trg_goods_ins ON tb_goods AFTER INSERT
  | AS
  | BEGIN
  |     SET NOCOUNT ON;
  |     INSERT INTO tb_log (tbl, act, key_id, memo)
  |     SELECT 'tb_goods', 'INSERT', id, gname FROM inserted;
  | END;
  | GO

  关键在 SELECT ... FROM inserted 这种「集合」写法,而不是拿一个变量去接。

  ⑤ 类型:AFTER 与 INSTEAD OF
  --------------------------------------------------------------------
| 类型 | 什么时候跑 | 典型用途 |
|---|---|---|
| AFTER(也叫 FOR) | 语句执行完之后 | 记日志、级联更新、做审计 |
| INSTEAD OF | 代替原操作执行 | 给视图做可写、拦截非法数据 |

  AFTER 触发器出错了,整个语句回滚——原操作等于没发生。这一点很方便。
  INSTEAD OF 触发器是自己接管,原操作压根不执行,全靠你写。第 10 章给视图挂的就是它。

  ⑥ 优点与代价,一条一条列
  --------------------------------------------------------------------
| 优点 | 代价 |
|---|---|
| 自动执行,不靠人记 | 逻辑藏起来了,看代码看不出来 |
| 跨客户端一致 | 出问题难排查(不知道是触发器干的) |
| 能做级联和审计 | 批量操作时开销成倍 |
| 权限收得住 | 触发器里再触发触发器,容易绕晕 |

  第二行是最要命的:新人接手一个库,改了张表的数据,结果另一张表莫名其妙多了记录。
  查半天才发现有触发器——这种事在真实项目里非常常见。所以规范团队会要求「触发器清单」进文档。

第一段返回 ('价格 4999.0 -> 5199.0',)。 注意两端都带 .0——因为 price 是 REAL, SQLite 显示浮点数就是这个样子。SQL Server 的 MONEY 类型会显示成 4999.00。 第二段返回 3。 这就是行级语义:INSERT 一条语句写了三行,触发器跑了三次。 换成 SQL Server,这个数会是 1——因为语句级。 这 3 与 1 的差别,就是「同一段触发器逻辑在两边结果不同」的根源。写跨库的触发器时, 先问一句:这个库的触发器是行级还是语句级? 还有一句:触发器里那句 INSERT INTO tb_log 是「触发器体」,不是「主语句」。 主语句是 INSERT INTO tb_goods,它成功了才会跑触发器体——顺序反了就不会有日志。

30 秒实验

  1. 把第一个触发器改成 AFTER UPDATE ON tb_goods(去掉 OF price),再改一次商品名。 这次也会记一条日志——因为「任何列的更新」都算。
  2. 把 AFTER 改成 BEFORE,看日志里的值是改之前的还是改之后的。 BEFORE 里 NEW 还能被改(改了就真写进去),AFTER 里改不了。
  3. 想一想:如果 tb_log 上也挂一个 INSERT 触发器,往里插一条会怎样? 会触发它,形成「触发器里再触发」——能用,但排查起来非常痛苦。

常见误区

  1. 「触发器就是自动执行的存储过程」。语法像,但发起方完全不同: 存储过程你叫它,触发器是数据动它就动。 后者意味着「你可能会忘了它的存在」——这才是它危险的地方。
  2. 「inserted 里就一行」。SQL Server 里批量插入时 inserted 有多行。 按一行写逻辑,批量导入就漏账。
  3. 「SQLite 和 SQL Server 的触发器一样」。一个行级一个语句级,结果能差好几倍。 inserted/deleted 也只有 SQL Server 有。
自测三问(点开对答案) 1. 触发器和存储过程最本质的区别是什么? —— **谁发起。** 存储过程要人 `EXEC`; **触发器挂在表上,数据一动它自己跑**——**你甚至可能不知道它存在。** 2. `inserted` 和 `deleted` 分别放什么?哪些操作两张都有? —— **`inserted` 放新值,`deleted` 放旧值。** **`UPDATE` 两张都有**——因为更新等于先删旧的、再插新的。 3. SQL Server 和 SQLite 的触发器在触发次数上有什么不同? —— **SQL Server 语句级:一条语句跑一次。SQLite 行级:一行跑一次。** **一次插三行,前者日志 1 条,后者 3 条。**

下一篇 12.2 分别建 INSERT、UPDATE、DELETE 三种触发器。 重点看 UPDATE 那个:怎么只记「真正变了的字段」——WHEN 和 IF UPDATE() 两种写法。

相关笔记