AFTER触发器是审计日志唯一可行方案,因其确保事务提交后记录,避免回滚导致不一致;BEFORE触发器因OLD/NEW缺失及错误阻断事务而不适用;审计表结构需精简,主键独立、字段类型严格匹配,仅存实际变化字段,禁用SELECT与子查询,确保触发器逻辑轻量。

AFTER触发器是唯一可行选择
审计日志必须等数据变更真正落地后才记录,否则事务回滚会导致日志与数据不一致。BEFORE触发器在INSERT时OLD不存在、DELETE时NEW不存在,且一旦触发器内部出错(比如字段超长、JSON构造失败),整个业务事务会直接失败——这不是审计,是阻断。
只用这三种声明方式:
-
AFTER INSERT:只读NEW.*,无需比对 -
AFTER UPDATE:用OLD.col和NEW.col逐字段判断是否变化 -
AFTER DELETE:只读OLD.*,注意外键级联删除后OLD仍可读
一个表每种事件最多定义一个AFTER触发器,重复创建会报错Can't create more than one trigger with the same action time and event for one table。
审计表结构要克制,别照搬原表
审计表不是备份表,字段越多、索引越全,写入放大越严重。主键必须独立(如id BIGINT AUTO_INCREMENT),原表主键作为普通字段存为record_id,类型严格匹配(原表是BIGINT UNSIGNED,这里就不能用INT)。
关键字段建议:
-
table_name VARCHAR(64):方便跨表查询 -
action_type ENUM('INSERT','UPDATE','DELETE'):别用VARCHAR,避免索引失效 -
old_values JSON和new_values JSON:只存实际变化的字段,不是整行 -
changed_by VARCHAR(128):靠应用层设@current_user_id传入,USER()返回的是MySQL账号(如app@10.0.1.5),不可信 -
change_time DATETIME(3) DEFAULT NOW(3):别用CURRENT_TIMESTAMP,它在复制场景下可能取从库时间
禁止在审计表上建除主键外的二级索引,除非你明确需要按table_name + change_time高频查询——否则索引维护开销远高于收益。
UPDATE触发器里必须做NULL安全比对
直接写OLD.email != NEW.email在任一值为NULL时结果是UNKNOWN,条件不成立,字段变更就漏记了。MySQL的(安全等于)才是正确解法。
示例片段:
IF NOT (OLD.email <=> NEW.email) THEN
SET @json_part = JSON_OBJECT('email', JSON_OBJECT('old', OLD.email, 'new', NEW.email));
SET @changed_fields = JSON_MERGE_PATCH(@changed_fields, @json_part);
END IF;
要点:
- 每个字段单独判断,避免无意义全量记录
- 用
JSON_MERGE_PATCH()逐步拼接,别用字符串拼接(易截断、难解析) - TEXT或JSON字段长度要和原表一致,否则
JSON_OBJECT()可能报Truncated incorrect DOUBLE value
触发器体里不能有SELECT、子查询或存储过程调用
触发器运行在DML语句的临界路径上,任何额外IO或锁等待都会拖慢主业务。曾有人在AFTER UPDATE里查user_info表补用户名,导致主表更新卡住、连接池耗尽。
能提前做的都交给应用层:
- 操作人ID、客户端IP必须由应用在SQL执行前通过
SET @current_user_id = '123'注入 - 触发器里只读
@current_user_id,不查表、不调函数 - 禁止
SELECT ... INTO、禁止游标、禁止调用自定义函数
真正低成本的核心,不是少写几行SQL,而是让触发器逻辑足够薄——它只做一件事:把已确定的变更事实,以最小开销落盘。其余溯源、展示、归档,全交给外部系统处理。


















