做支付或账户类系统时,余额数据的一致性是生命线。余额表记录每个账户的当前余额,流水表记录每一笔资金变动,理论上任意时刻满足:当前余额 = 初始余额 + 收入流水总和 - 支出流水总和。一旦这个等式被打破,说明某次更新出了问题,可能是并发覆盖、代码bug,也可能是手工改库留下的坑。与其等用户投诉后再排查,不如在数据写入的瞬间就做校验,触发器正是实现这一思路的合适工具。

一、对账相关的表结构设计
先准备两张核心表和一张异常记录表。余额表account_balance存放每个账户的当前余额和版本号,流水表account_flow记录每一笔变动,对账异常表check_error_log用于捕获触发器发现的问题。合理的表结构是触发器方案的基础,建议流水表只插入不更新,保证不可篡改的审计特性。
CREATE TABLE account_balance ( account_id BIGINT PRIMARY KEY, balance DECIMAL(18,2) NOT NULL DEFAULT 0 COMMENT '当前余额', init_balance DECIMAL(18,2) NOT NULL DEFAULT 0 COMMENT '初始余额', version INT NOT NULL DEFAULT 0 COMMENT '乐观锁版本号', update_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP ) ENGINE=InnoDB; CREATE TABLE account_flow ( flow_id BIGINT PRIMARY KEY AUTO_INCREMENT, account_id BIGINT NOT NULL, amount DECIMAL(18,2) NOT NULL COMMENT '正为收入负为支出', balance_after DECIMAL(18,2) NOT NULL COMMENT '该笔发生后的余额快照', biz_no VARCHAR(64) NOT NULL, create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, KEY idx_account (account_id) ) ENGINE=InnoDB; CREATE TABLE check_error_log ( id BIGINT PRIMARY KEY AUTO_INCREMENT, account_id BIGINT NOT NULL, flow_balance DECIMAL(18,2) COMMENT '流水算出的余额', actual_balance DECIMAL(18,2) COMMENT '余额表实际值', err_msg VARCHAR(500), create_time DATETIME DEFAULT CURRENT_TIMESTAMP ) ENGINE=InnoDB;
这里有个细节值得注意:流水表里冗余了一个balance_after字段,记录该笔流水落库后的余额快照。这个字段是事后对账的关键证据,有了它,即便触发器当场校验通过,也可以在事后用定时任务逐条回放验证。金额统一使用DECIMAL而不是FLOAT,浮点数在金融场景下的精度丢失问题必须从源头杜绝。
二、编写流水插入触发器实现实时校验
核心思路是在account_flow上创建AFTER INSERT触发器,每当新流水插入后,自动用流水累计值重算一遍理论余额,与余额表的实际值比对。对不上就把差异写入异常表。
DELIMITER $$
CREATE TRIGGER trg_flow_check AFTER INSERT ON account_flow
FOR EACH ROW
BEGIN
DECLARE v_init DECIMAL(18,2);
DECLARE v_actual DECIMAL(18,2);
DECLARE v_calc DECIMAL(18,2);
SELECT init_balance, balance
INTO v_init, v_actual
FROM account_balance
WHERE account_id = NEW.account_id;
SELECT IFNULL(SUM(amount), 0) + v_init
INTO v_calc
FROM account_flow
WHERE account_id = NEW.account_id;
IF v_calc <> v_actual THEN
INSERT INTO check_error_log(account_id, flow_balance, actual_balance, err_msg)
VALUES(NEW.account_id, v_calc, v_actual, 'balance mismatch on flow insert');
END IF;
END$$
DELIMITER ;这个触发器每次插入流水都会执行一次SUM聚合,逻辑直观但性能会随流水量增长而下降,因为全量求和的代价越来越大。对于流水不多的账户体系可以直接使用;如果单账户流水可能达到十万级以上,建议改成只校验“本次快照是否连续”的轻量版本:即判断新流水的balance_after减去本次amount,是否等于上一笔流水的balance_after。这种增量校验只查一条记录,代价几乎可以忽略。
三、余额更新触发器防止流水遗漏
反过来还有一种出问题的可能:余额被更新了,但流水忘记插入,或者插入失败。为此可以在account_balance上也加一个BEFORE UPDATE触发器,要求每次余额变动必须携带匹配的流水快照,形成双向约束。
DELIMITER $$
CREATE TRIGGER trg_balance_check BEFORE UPDATE ON account_balance
FOR EACH ROW
BEGIN
DECLARE v_expected DECIMAL(18,2);
SELECT balance_after INTO v_expected
FROM account_flow
WHERE account_id = NEW.account_id
ORDER BY flow_id DESC LIMIT 1;
IF NEW.balance <> v_expected THEN
SIGNAL SQLSTATE '45000'
SET MESSAGE_TEXT = 'balance not match latest flow, update rejected';
END IF;
END$$
DELIMITER ;这里用了SIGNAL主动抛错,直接拒绝不合规的更新,比记日志更严格,适合对一致性要求极高的核心账务表。需要注意的是,这种强约束要求数据库事务里必须先插流水再更新余额,且两者在同一事务提交,否则触发器会把合法操作也拦下来。落地前务必与应用层的写入顺序对齐,否则会出现大量误拦截。
四、性能影响与整体方案建议
触发器是隐式执行的,它带来的开销容易被忽视。每次流水插入都会附加一次到两次额外查询,在单账户高并发写入时可能放大行锁持有时间,吞吐量下降一到两成是常见现象。所以核心账户表如果写入量极大,建议只在流水侧做增量快照校验,全量SUM校验交给低峰期的定时任务,例如每天凌晨用一条SQL批量找出不一致的账户:
SELECT b.account_id, b.balance, f.total + b.init_balance AS calc_balance FROM account_balance b JOIN ( SELECT account_id, SUM(amount) AS total FROM account_flow GROUP BY account_id ) f ON f.account_id = b.account_id WHERE b.balance <> f.total + b.init_balance;
触发器实时拦截加定时任务兜底复核,两者配合起来既保证问题能第一时间暴露,又不至于让线上写入背上过重的负担。另外提醒一点,触发器对应用是透明的,团队协作时要在文档中明确说明表上存在触发器,避免有人绕过流水直接改余额却找不到报错原因。只要设计得当,这套方案能以极低的开发成本,为账务数据再加一道实时的安全防线。