数据库审计的核心目标是回答三个问题:谁改了数据、改了什么、什么时候改的。应用层日志常常因为绕过业务接口的直接SQL操作而失效,而SQL触发器工作在数据库引擎内部,无论数据变更来自Web服务、定时任务还是人工客户端,都能被统一捕获。下面以MySQL和SQL Server为例,说明如何借助触发器构建可靠的日志与审计体系。

触发器审计的基础表结构设计
要实现审计,首先必须有一张独立于业务表的日志表。这张表不应该与任何业务表存在外键关联,否则原始数据删除时日志也会受影响。典型的设计包含审计ID、表名、操作类型、发生时间、操作用户、主键旧值、旧数据快照、新数据快照等字段。数据快照可以用JSON或XML格式存储,这样无需为每张业务表建立对应的日志表结构。
在MySQL中,我们可以用JSON类型保存变更前后内容;在SQL Server中则可使用NVARCHAR(MAX)存XML。下面是一个通用的审计表定义示例,适用于多数关系型数据库:
CREATE TABLE audit_log (
log_id BIGINT AUTO_INCREMENT PRIMARY KEY,
table_name VARCHAR(64) NOT NULL,
action_type CHAR(1) NOT NULL COMMENT 'I=insert U=update D=delete',
action_time DATETIME NOT NULL,
db_user VARCHAR(64) NOT NULL,
record_id VARCHAR(64),
old_value JSON,
new_value JSON
);
这种结构的好处是扩展性强。当新增业务表需要审计时,只需为其创建触发器并向audit_log插入数据,而不必修改表结构。同时,由于record_id保存了被操作记录的主键,后续排查能快速定位原数据。需要注意的是,JSON字段在低频写入场景下性能良好,但在超高并发写入时应考虑只记录变更列而非整行。
AFTER触发器捕获增删改的完整实现
触发器分为BEFORE和AFTER两类,审计通常使用AFTER触发器,因为此时数据已经成功写入,能确保日志与实际变更一致。在MySQL中,通过NEW和OLD伪记录访问新值和旧值;在SQL Server中则使用inserted和deleted临时表。下面以MySQL的订单表为例,展示INSERT和UPDATE触发器的写法。
我们首先假设有一张orders表,包含id、amount、status字段。当插入新订单时,把整行作为新值写入日志;当更新时,同时记录旧值和新值。这样审计人员能看到状态从待付款变成已付款的全过程。
DELIMITER $$
CREATE TRIGGER trg_orders_insert
AFTER INSERT ON orders
FOR EACH ROW
BEGIN
INSERT INTO audit_log(table_name, action_type, action_time, db_user, record_id, old_value, new_value)
VALUES ('orders', 'I', NOW(), CURRENT_USER(), NEW.id, NULL, JSON_OBJECT('id', NEW.id, 'amount', NEW.amount, 'status', NEW.status));
END$$
CREATE TRIGGER trg_orders_update
AFTER UPDATE ON orders
FOR EACH ROW
BEGIN
INSERT INTO audit_log(table_name, action_type, action_time, db_user, record_id, old_value, new_value)
VALUES ('orders', 'U', NOW(), CURRENT_USER(), NEW.id,
JSON_OBJECT('id', OLD.id, 'amount', OLD.amount, 'status', OLD.status),
JSON_OBJECT('id', NEW.id, 'amount', NEW.amount, 'status', NEW.status));
END$$
DELIMITER ;
上面的代码展示了如何利用JSON_OBJECT函数把行数据转为JSON。对于DELETE操作,由于没有NEW记录,只需把OLD写入old_value字段且action_type设为D。这种方式的优点是逻辑集中,任何对orders表的修改都会留痕;缺点是每次写业务表会多一次日志表插入,因此在批量导入数据时可临时禁用触发器。
在SQL Server中,由于触发器是集合导向而非行级,需要用游标或把inserted表整体JOIN后批量插入。例如更新触发器可以写成INSERT INTO audit_log SELECT 'orders','U',GETDATE(),SYSTEM_USER,i.id, ... FROM inserted i,这比MySQL的逐行触发效率更高,但也要求日志表能接受批量写入。
性能影响与日志归档策略
触发器审计并非没有代价。每一次数据变更都伴随一次额外的日志写入,如果业务表每秒有上千次更新,日志表会迅速膨胀并拖累主表性能。因此,必须根据业务敏感度决定审计粒度:核心资金表记录全部字段,普通配置表只记录主键和操作类型。另外,在MySQL中可以将审计表改为归档引擎或分到独立数据库,降低主库压力。
日志归档通常采用时间分区或定时转移。以MySQL为例,可以按月份对audit_log做RANGE分区,超过一年的数据迁移到历史库。另一种做法是每天用事件调度器把昨天的日志复制到audit_log_archive并清空原表。下面是一段简单的清理旧日志的存储过程示例:
CREATE PROCEDURE clean_old_audit()
BEGIN
DELETE FROM audit_log
WHERE action_time < DATE_SUB(NOW(), INTERVAL 365 DAY);
END;
除了性能,还要考虑安全问题。拥有DROP权限的用户可能删除审计表来掩盖痕迹,所以应当把审计表的写权限仅限数据库内部触发器,应用账号只授予SELECT。同时开启数据库自身的二进制日志,作为触发器日志的补充。当发生违规操作时,结合二进制日志和审计表就能完整还原现场,真正达到合规审计的要求。