一、问题背景:大事务如何拖垮主从复制
百度分账分成引擎的核心职责是:当一笔广告收入结算发生时,按照预设的分成比例,将金额拆分到数十甚至数百个上下游商户的账户中。一笔结算交易对应的数据库写入量通常在 200-500 行之间,涵盖账户余额更新、流水记录插入、分成明细写入等多张表。
这些写入原本被包裹在一个大事务中执行,以保证分成计算的原子性——要么全部成功,要么全部回滚。业务语义上这完全合理,但在高并发场景下,这个大事务成了系统最大的瓶颈。
现象非常典型:
- 主从延迟飙升:正常情况下延迟在 100ms 以内,高峰期飙升至 30s-120s。
- 从库查询读到旧数据:商户在 App 上查询余额时,经常看到的是未更新的旧余额,引发大量客服投诉。
- 锁竞争加剧:大事务持有行锁的时间过长,导致其他并发事务排队等待,TPS 明显下降。
要解决这个问题,首先需要理解大事务为什么会造成主从延迟。
二、MVCC、binlog 与大事务延迟的根因分析
MySQL InnoDB 的主从复制基于 binlog 实现。主库将事务的修改操作写入 binlog(顺序追加的日志文件),从库通过 IO Thread 拉取 binlog 并由 SQL Thread 回放。关键在于:从库的 SQL Thread 是单线程串行回放的(即使 MySQL 5.7+ 引入了多线程复制,也是基于库级别的并行,同一库内仍然是串行的)。
当一个事务包含数百条 DML 语句时,从库需要等主库将整个事务的 binlog 全部写入(包括事务提交时的 XID 事件)后才能开始回放。这导致了两个层面的延迟:
- 等待延迟:从库必须等主库事务提交完毕,才能拉取到完整的 binlog 事件组。大事务执行时间越长,这个等待越久。
- 回放延迟:从库需要逐条回放数百条 binlog 事件,回放期间如果遇到主键冲突或锁等待,延迟会进一步放大。
更深层的原因在于 MVCC(多版本并发控制)机制。InnoDB 的 MVCC 通过在每行数据上维护隐藏的版本号(trx_id)和 undo log 来实现读不阻塞写、写不阻塞读。但在大事务场景下:
// 大事务伪代码示例
@Transactional
public void settle(SettlementOrder order) {
// 1. 更新结算单状态
settlementMapper.updateStatus(order.getId(), "SETTLING");
// 2. 遍历分成规则,生成数百条分成记录
for (ShareRule rule : order.getShareRules()) {
balanceMapper.deduct(rule.getFromAccountId(), rule.getAmount());
balanceMapper.increase(rule.getToAccountId(), rule.getAmount());
flowMapper.insertFlow(buildFlow(rule));
shareDetailMapper.insert(buildDetail(order, rule));
}
// 3. 更新结算单状态为完成
settlementMapper.updateStatus(order.getId(), "SETTLED");
}
// 一个事务中执行 200+ 行写入,持锁时间可能达到数秒
这个大事务在执行期间持有了大量行锁,并且生成了大量的 undo log。从库在回放时,还需要维护同样的 MVCC 版本链,内存和 CPU 开销都不容小觑。
三、优化方案:从拆分到异步的分层治理
经过详细的问题分析,我们制定了分层优化策略,从事务拆分、异步写入、读写分离三个维度逐步治理。
方案一:大事务拆分 -- 保证核心原子性,拆分非核心操作
我们将整个结算流程拆分为两个阶段:核心结算事务和非核心异步任务。核心事务只包含账户余额更新和结算单状态变更,确保资金层面的原子性;分成明细、流水记录等非核心数据通过异步方式写入。
// 优化后:核心事务仅处理余额变更
@Transactional(rollbackFor = Exception.class)
public void settleCore(SettlementOrder order) {
settlementMapper.updateStatus(order.getId(), "SETTLING");
for (ShareRule rule : order.getShareRules()) {
// 核心事务只做余额变动
balanceMapper.deduct(rule.getFromAccountId(), rule.getAmount());
balanceMapper.increase(rule.getToAccountId(), rule.getAmount());
}
settlementMapper.updateStatus(order.getId(), "SETTLED");
}
// 非核心数据通过消息队列异步写入
public void settleAsync(SettlementOrder order) {
List<ShareDetail> details = buildShareDetails(order);
mqProducer.send("share_detail_write_topic", JSON.toJSONString(details));
List<FlowRecord> flows = buildFlowRecords(order);
mqProducer.send("flow_record_write_topic", JSON.toJSONString(flows));
}
拆分后,核心事务的写入行数从 200+ 降到 50 行以内,执行时间从 2-3 秒降到 200ms 左右。
方案二:异步写入与本地消息表
为了确保异步写入不丢失数据,我们引入了本地消息表模式。核心事务提交后,同时写入一条消息记录到本地消息表(与核心事务在同一个事务中),然后由后台任务轮询消息表进行异步投递。
// 本地消息表方案
@Transactional(rollbackFor = Exception.class)
public void settleWithLocalMessage(SettlementOrder order) {
// 1. 执行核心余额变更
settleCore(order);
// 2. 在同一事务中写入本地消息
localMessageMapper.insert(LocalMessage.builder()
.topic("share_settlement")
.payload(JSON.toJSONString(order))
.status("PENDING")
.createTime(new Date())
.build());
}
// 后台定时任务扫描并发送
@Scheduled(fixedRate = 1000)
public void scanAndSend() {
List<LocalMessage> messages = localMessageMapper
.findByStatus("PENDING", 100);
for (LocalMessage msg : messages) {
try {
mqProducer.send(msg.getTopic(), msg.getPayload());
localMessageMapper.updateStatus(msg.getId(), "SENT");
} catch (Exception e) {
localMessageMapper.incrementRetry(msg.getId());
}
}
}
方案三:读写分离与缓存兜底
对于商户余额查询这类强一致性需求的场景,我们在拆分事务的基础上引入了缓存兜底策略:核心事务提交后同步更新 Redis 缓存,从库查询时优先读缓存。当缓存未命中时才查从库,并通过版本号比对判断数据是否新鲜。
// 余额查询:缓存优先 + 版本校验
public BigDecimal queryBalance(String accountId) {
String cacheKey = "balance:" + accountId;
String cached = redisTemplate.opsForValue().get(cacheKey);
if (cached != null) {
return new BigDecimal(cached);
}
// 缓存未命中,查从库
BigDecimal balance = balanceMapper.selectBalance(accountId);
String dbVersion = balanceMapper.selectVersion(accountId);
String redisVersion = redisTemplate.opsForValue()
.get("balance_version:" + accountId);
// 如果版本号不一致,说明有未同步的更新,查主库
if (redisVersion != null && !redisVersion.equals(dbVersion)) {
balance = balanceMasterMapper.selectBalance(accountId);
}
return balance;
}
经过上述三层优化,效果非常显著:
- 主从延迟从 30s-120s 降至 50ms 以内,峰值不超过 200ms。
- 商户余额查询的一致性问题完全消除。
- 系统 TPS 提升了 3 倍,锁等待超时报错从日均 500+ 次降到 0。
回顾整个优化过程,核心思路其实很朴素:缩小事务粒度,减少持锁时间,将非核心操作异步化。在分布式系统中,事务的大小往往需要在原子性和性能之间做出权衡,而理解底层原理(MVCC、binlog 复制)是做出正确权衡的前提。