4. 基于触发器的CDC实现与校验
触发器(Trigger)是数据库里最古老、也最直接的CDC手段。说白了,就是在源表上挂个钩子——数据一变,触发器自动把变更记录写到另一张表里。我早年做银行核心系统迁移时,就靠这招撑过了整整两年的数据一致性审计。今天咱们就把它掰开揉碎了讲清楚。
4.1 审计表的设计与创建
审计表是触发器的“仓库”。设计得好,后面查问题就省心;设计得烂,查个历史记录能把你逼疯。
我个人习惯把审计表分成两类:通用型和业务型。通用型记录所有字段的变更,适合做全量审计;业务型只记录关键字段,适合性能敏感的场景。
先看一个通用型审计表的DDL:
CREATE TABLE orders_audit (
audit_id BIGINT AUTO_INCREMENT PRIMARY KEY,
table_name VARCHAR(64) NOT NULL COMMENT '源表名',
operation_type ENUM('INSERT','UPDATE','DELETE') NOT NULL,
row_id BIGINT NOT NULL COMMENT '源表主键',
old_data JSON COMMENT '变更前的整行数据',
new_data JSON COMMENT '变更后的整行数据',
changed_fields JSON COMMENT '仅变更的字段名列表',
operator VARCHAR(64) COMMENT '操作人/应用名',
client_ip VARCHAR(45) COMMENT '客户端IP',
change_time DATETIME(3) NOT NULL COMMENT '变更时间戳(毫秒精度)',
batch_id BIGINT DEFAULT 0 COMMENT '批量任务ID,用于下游消费',
INDEX idx_change_time (change_time),
INDEX idx_row_id (row_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
这里有几个设计要点:
- 用JSON存old_data和new_data:字段多了不愁,加字段也不用改审计表结构。我在项目中遇到过业务表一年改了三次字段,要是用固定列存,每次都得跑ALTER TABLE,太痛苦了。
- 加batch_id字段:下游消费时用来做幂等去重。没有这个字段,数据重复了你都不知道是触发器写了两遍还是消费端重试了。
- 时间戳精度到毫秒:高并发场景下,秒级精度根本不够用。同一秒内两条记录谁先谁后?说不清楚。
4.2 触发器逻辑编写
触发器逻辑的核心就三件事:捕获变更、组装数据、写入审计表。听起来简单,但坑不少。
先看一个INSERT触发器的例子:
DELIMITER $$
CREATE TRIGGER trg_orders_insert
AFTER INSERT ON orders
FOR EACH ROW
BEGIN
INSERT INTO orders_audit (
table_name, operation_type, row_id,
old_data, new_data, changed_fields,
operator, client_ip, change_time
) VALUES (
'orders',
'INSERT',
NEW.id,
NULL,
JSON_OBJECT(
'id', NEW.id,
'customer_id', NEW.customer_id,
'amount', NEW.amount,
'status', NEW.status,
'created_at', NEW.created_at
),
JSON_ARRAY('id','customer_id','amount','status','created_at'),
COALESCE(@current_operator, USER()),
COALESCE(@current_client_ip, SUBSTRING_INDEX(USER(),'@',-1)),
NOW(3)
);
END$$
DELIMITER ;
UPDATE触发器稍微复杂一点,因为要同时记录旧值和新值:
DELIMITER $$
CREATE TRIGGER trg_orders_update
AFTER UPDATE ON orders
FOR EACH ROW
BEGIN
DECLARE changed JSON;
-- 动态计算哪些字段变了
SET changed = JSON_ARRAY();
IF OLD.customer_id <=> NEW.customer_id THEN
SET changed = JSON_ARRAY_APPEND(changed, '$', 'customer_id');
END IF;
IF OLD.amount <=> NEW.amount THEN
SET changed = JSON_ARRAY_APPEND(changed, '$', 'amount');
END IF;
IF OLD.status <=> NEW.status THEN
SET changed = JSON_ARRAY_APPEND(changed, '$', 'status');
END IF;
INSERT INTO orders_audit (
table_name, operation_type, row_id,
old_data, new_data, changed_fields,
operator, client_ip, change_time
) VALUES (
'orders',
'UPDATE',
NEW.id,
JSON_OBJECT(
'id', OLD.id,
'customer_id', OLD.customer_id,
'amount', OLD.amount,
'status', OLD.status,
'created_at', OLD.created_at
),
JSON_OBJECT(
'id', NEW.id,
'customer_id', NEW.customer_id,
'amount', NEW.amount,
'status', NEW.status,
'created_at', NEW.created_at
),
changed,
COALESCE(@current_operator, USER()),
COALESCE(@current_client_ip, SUBSTRING_INDEX(USER(),'@',-1)),
NOW(3)
);
END$$
DELIMITER ;
<=>(NULL-safe等于)而不是=。为什么?因为NULL = NULL返回NULL,而NULL <=> NULL返回1。我曾经因为这个坑,导致所有NULL字段变更都没被记录,排查了整整一个下午。
DELETE触发器相对简单,只记录旧数据:
DELIMITER $$
CREATE TRIGGER trg_orders_delete
AFTER DELETE ON orders
FOR EACH ROW
BEGIN
INSERT INTO orders_audit (
table_name, operation_type, row_id,
old_data, new_data, changed_fields,
operator, client_ip, change_time
) VALUES (
'orders',
'DELETE',
OLD.id,
JSON_OBJECT(
'id', OLD.id,
'customer_id', OLD.customer_id,
'amount', OLD.amount,
'status', OLD.status,
'created_at', OLD.created_at
),
NULL,
NULL,
COALESCE(@current_operator, USER()),
COALESCE(@current_client_ip, SUBSTRING_INDEX(USER(),'@',-1)),
NOW(3)
);
END$$
DELIMITER ;
4.3 触发器带来的性能开销
触发器不是免费的午餐。它带来的性能开销,我总结为三个维度:
| 开销类型 | 影响程度 | 原因分析 |
|---|---|---|
| 写放大 | 高 | 每笔DML操作额外产生一次审计表写入,IO和日志量翻倍 |
| 锁竞争 | 中 | 审计表上的自增主键和索引会成为热点,高并发下容易锁等待 |
| 事务延长 | 高 | 触发器在源事务内执行,审计表写入失败会导致源事务回滚 |
我在一个日活千万的电商订单系统里做过压测:不加触发器时,单表INSERT的TPS能到8000;加上三个触发器(INSERT/UPDATE/DELETE)后,TPS直接掉到3200,降幅超过60%。
怎么优化?几个实战经验:
- 审计表用独立的表空间:避免和源表争抢磁盘IO。我习惯把审计表放在单独的SSD盘上。
- 减少索引数量:审计表只保留必要的索引。上面那个DDL里我只建了两个索引,多了反而拖慢写入。
- 批量提交:如果业务允许,可以在触发器里把数据先写到内存队列,再批量刷入审计表。但要注意——触发器里做异步操作有风险,事务回滚时队列里的数据就成脏数据了。
4.4 基于触发器的数据校验策略
触发器写进去了,不代表数据就是对的。我见过太多案例——触发器逻辑写错了,审计表里全是垃圾数据,下游消费端还傻乎乎地照着跑。
校验策略我分三层:
- 行级校验:对比源表和审计表的记录数、关键字段值。比如每天凌晨跑一个脚本,统计orders表的总行数和orders_audit里INSERT+UPDATE-DELETE的净增量是否一致。
- 字段级校验:随机抽取1000条记录,对比old_data/new_data里的字段值和源表实际值是否匹配。这个可以用Python脚本配合数据库连接池来做。
- 时序校验:检查审计表的change_time是否严格递增。如果出现时间倒挂,说明触发器或数据库时钟有问题。
我曾经在一个金融项目里遇到过这样的问题:触发器里用了NOW(),但数据库主从切换后,新主库的时间比老主库慢了2秒。结果审计表里的时间戳全是乱的,下游做时序分析时全错了。后来我改成用SYSDATE()(MySQL里SYSDATE是实时获取,NOW是事务开始时间),才彻底解决。
下面这张图概括了基于触发器的CDC校验全流程:
最后说一句:触发器CDC虽然“笨”,但它有一个其他方案都比不了的优势——与数据库版本无关。不管你是MySQL 5.6还是8.0,不管你是RDS还是自建,触发器都能跑。对于老系统迁移、异构数据库同步这类场景,它反而是最稳的选择。