BEFORE UPDATE/DELETE触发器必须用OLD引用原数据,它代表变更前整行且仅在BEFORE中可用;NEW用于INSERT或UPDATE后的新值,AFTER中查原记录不可靠。

BEFORE UPDATE/DELETE 触发器必须用 OLD 引用原数据
想在更新或删除前保存快照,OLD 是唯一可靠来源。它代表变更前的整行数据,只在 BEFORE UPDATE 和 BEFORE DELETE 中可用;NEW 则用于插入或更新后的新值。别试图在 AFTER 触发器里查原记录——事务还没提交,SELECT 可能读不到一致快照,甚至触发死锁。
常见错误是写成:INSERT INTO history SELECT * FROM main WHERE id = NEW.id(UPDATE 场景),这实际读的是刚被修改但未提交的行,结果存的是“新值”,不是“旧值”。正确做法永远是直接 INSERT INTO history VALUES (OLD.id, OLD.name, ..., NOW(), 'UPDATE')。
历史表字段设计要区分 change_type 和 version 语义
change_type(如 'INSERT'、'UPDATE'、'DELETE')记录操作动作,用于审计和回放;version 字段则表示同一主键下的修订序号,需按主键+时间递增生成,不能简单用 AUTO_INCREMENT 全局计数。
实操建议:
- 为避免并发下
MAX(version)+1重复,改用(SELECT COALESCE(MAX(version), 0) + 1 FROM history_table WHERE user_id = OLD.id)—— 注意加WHERE条件限制范围 - 如果业务允许,更稳妥的做法是让应用层生成
version(如用雪花ID或时间戳前缀),避开数据库级竞争 -
change_type必须设为ENUM或CHAR(10),别用INT编码——后期查日志时,WHERE change_type = 'UPDATE'比WHERE change_type = 2直观且不易出错
避免在触发器里对同一张历史表高频写入
高并发 UPDATE 场景下,多个事务同时执行 BEFORE UPDATE 触发器,争抢向 history_table 插入记录,极易引发行锁升级为表锁,尤其当历史表没建好索引时。
关键规避点:
- 历史表必须在
user_id(或主表外键字段)上建INDEX,否则INSERT前的隐式SELECT MAX()会全表扫描 - 不要在触发器里做复杂计算或跨库查询——触发器执行时间越长,主表 DML 阻塞越久
- 如果单次更新涉及上千行(如批量
UPDATE ... WHERE status IN (...)),触发器会逐行执行,可能拖垮整个事务。此时应改用应用层分批处理 + 显式INSERT ... SELECT存档
NULL 安全比对要用 IS NOT DISTINCT FROM(MySQL 8.0.22+)或 NOT (OLD.col NEW.col)
判断某字段是否真发生了变更,不能写 OLD.col != NEW.col —— 遇到 NULL 时结果恒为 UNKNOWN,导致条件失效。MySQL 的 是 NULL 安全等于运算符,所以否定形式 NOT (OLD.col NEW.col) 才能正确捕获 NULL → 'a'、'b' → NULL、NULL → NULL 等所有变化情形。
例如只存 status 或 amount 变动:
IF NOT (OLD.status <=> NEW.status) OR NOT (OLD.amount <=> NEW.amount) THEN INSERT INTO order_history (...) VALUES (...); END IF;
低版本 MySQL(IS NOT DISTINCT FROM,只能依赖 ;若用其他兼容性方案(如 COALESCE(OLD.col, '') != COALESCE(NEW.col, '')),要注意字符集和截断风险——COALESCE 强转可能隐式截断长文本。
真正麻烦的不是写触发器,而是当主表有 50 个字段、历史表要存其中 30 个,且每个字段都可能为 NULL 时,NOT (OLD.f1 NEW.f1) OR NOT (OLD.f2 NEW.f2) OR ... 这种表达式既难维护又难调试。这时候该考虑把变更检测逻辑下沉到应用层,或者用 JSON 字段存差异,而不是硬扛在触发器里。


















