一、问题背景:大事务如何拖垮主从复制

百度分账分成引擎的核心职责是:当一笔广告收入结算发生时,按照预设的分成比例,将金额拆分到数十甚至数百个上下游商户的账户中。一笔结算交易对应的数据库写入量通常在 200-500 行之间,涵盖账户余额更新、流水记录插入、分成明细写入等多张表。

这些写入原本被包裹在一个大事务中执行,以保证分成计算的原子性——要么全部成功,要么全部回滚。业务语义上这完全合理,但在高并发场景下,这个大事务成了系统最大的瓶颈。

现象非常典型:

要解决这个问题,首先需要理解大事务为什么会造成主从延迟

二、MVCC、binlog 与大事务延迟的根因分析

MySQL InnoDB 的主从复制基于 binlog 实现。主库将事务的修改操作写入 binlog(顺序追加的日志文件),从库通过 IO Thread 拉取 binlog 并由 SQL Thread 回放。关键在于:从库的 SQL Thread 是单线程串行回放的(即使 MySQL 5.7+ 引入了多线程复制,也是基于库级别的并行,同一库内仍然是串行的)。

当一个事务包含数百条 DML 语句时,从库需要等主库将整个事务的 binlog 全部写入(包括事务提交时的 XID 事件)后才能开始回放。这导致了两个层面的延迟:

更深层的原因在于 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;
}

经过上述三层优化,效果非常显著:

回顾整个优化过程,核心思路其实很朴素:缩小事务粒度,减少持锁时间,将非核心操作异步化。在分布式系统中,事务的大小往往需要在原子性和性能之间做出权衡,而理解底层原理(MVCC、binlog 复制)是做出正确权衡的前提。