分布式事务:在增长中台,我们如何做到“发出去的内容”和“记录的数据”不打架?

本文是「架构师的技术基石」系列的第1-4篇。查看系列完整路线图与所有文章目录【重磅系列】架构师技术基石全景图:以「增长中台」贯穿16讲硬核实战
当你的操作跨越三个数据库时,"要么全做,要么全不做"从一句口号变成了每晚的噩梦

01 一个看似简单却让团队失眠的需求

凌晨1点,我接到报警:增长中台的"新用户欢迎实验"数据对不上了。

运营后台显示,昨晚有12,854个新用户被分配到了实验组A(应该收到一张8折优惠券)。但优惠券系统后台显示,只有11,920张券被成功发放。差了934张券,消失在哪了?

更诡异的是,用户行为日志显示,有300多个用户既收到了券又点击使用了,但在我们的实验分析报表里,他们却被标记为"未触达用户"。

早上9点,复盘会上,三个团队的负责人面面相觑:

  • 实验团队 :"我们严格按照算法分配了用户,记录都写进allocations表了。"
  • 触达团队 :"我们收到Kafka消息就发券,send_coupon_task表里任务状态都是'成功'。"
  • 数据团队 :"我们每天凌晨跑ETL,从allocations表和券系统的日志表coupon_logs做关联统计......"

问题逐渐清晰:一个用户参与实验的完整生命周期,被我们拆成了三个独立操作,存进了三个不同的数据库,却没有保证它们的一致性。

  1. 步骤1(实验服务) :在MySQL的allocations表记录"用户U进入实验A"
  2. 步骤2(触达服务) :通过Kafka消息触发,在另一个MySQL发券,并写coupon_logs
  3. 步骤3(分析服务):凌晨批量Join两个表的数据,生成报表

当步骤1成功但步骤2失败时 ,用户明明没收到券,却被计为实验参与者。
当步骤2成功但Kafka消息丢失时 ,用户收到了券,却被计为未触达。
当凌晨ETL任务失败时,所有人都不知道数据对不上。

产品经理听完技术复盘,问了一个灵魂问题:"所以,从用户视角看,他到底是'参与实验收到了券',还是'没参与实验'?系统能给个准话吗?"

今天,我们就来解决增长中台中最棘手的分布式一致性问题。不空谈理论,只看在真实业务压力下,我们如何从"数据一团糟"走到"勉强可用",再到"基本可靠"。

02 从业务场景出发,而不是从理论出发

2.1 先问:我们到底需要什么样的一致性?

在讨论技术方案前,必须回到业务场景,问清楚:这个操作,如果中途失败,最坏的影响是什么?

在增长中台,有几种典型的跨服务操作:

操作场景 涉及服务 一致性要求 最坏结果
用户进入实验并实时触达 实验服务 + 触达服务 强一致:用户要么完整进入实验并收到权益,要么完全不进入 用户被错误计入实验样本,影响实验结论科学性
用户行为触发积分奖励 行为采集 + 积分服务 最终一致:积分可以延迟几分钟到账,但不能丢 用户损失积分,可能投诉,但不会影响核心业务
实验配置同步到推荐引擎 实验管理 + 推荐服务 最终一致:配置变更可以分钟级延迟生效 短期内策略不一致,影响用户体验但可接受

我们的"用户进入实验并触达"场景,明显属于强一致要求最高的类别。因为A/B测试的科学性建立在准确的样本分配和效果归因之上。

2.2 再问:我们能接受多大的复杂度与性能代价?

这是架构选择的现实约束:

  • 性能要求:实验分配是用户旅程的关键路径,P99延迟必须<100ms
  • 团队能力:团队熟悉MySQL和Kafka,对分布式事务框架无经验
  • 运维成本:系统已有一定复杂度,新方案必须易于监控和排查

带着这些约束,我们开始评估方案。

03 方案演进史:从"完全不做"到"勉强能用"再到"基本可靠"

阶段一:天真的异步消息(我们曾经这么干过)

这是我们最初的方案,也是问题最多的方案:

java 复制代码
// 实验服务代码(v1.0 - 问题版本)
@Service
public class ExperimentServiceV1 {
    
    @Transactional // 本地事务
    public void enrollUserInExperiment(Long userId, Long experimentId) {
        // 1. 记录分配(本地事务保证)
        allocationRepository.save(new Allocation(userId, experimentId));
        
        // 2. 发送Kafka消息(在事务提交后)
        kafkaTemplate.send("experiment-enrollment", 
            new EnrollmentEvent(userId, experimentId));
    }
}

// 触达服务代码
@KafkaListener(topics = "experiment-enrollment")
public void handleEnrollment(EnrollmentEvent event) {
    // 3. 收到消息,发放权益
    couponService.grantCoupon(event.getUserId(), event.getExperimentId());
}

这个方案的问题像筛子一样多

  1. 消息可能丢失:Kafka发送失败怎么办?
  2. 消息可能重复:实验服务重试导致重复消息怎么办?
  3. 时序可能错乱:消息延迟,用户先收到权益再看到实验记录?
  4. 无法回滚:权益发放失败,实验记录却已提交,怎么办?

我们为此付出的代价是:每周花8小时手动核对数据,运营不再信任系统报表

阶段二:引入本地事务表(可靠消息模式)

我们意识到,不能依赖"发后即忘"的消息。于是引入了本地事务表模式:
实验服务 - 单个数据库事务






用户请求进入实验
开启事务
插入主记录 allocation
插入消息记录 event_message

状态=PENDING
提交事务
事务提交成功?
异步线程扫描 PENDING 消息
整个操作失败, 无副作用
发送消息到Kafka
发送成功?
更新消息状态为 SENT
等待下次重试
触达服务收到消息
发放权益
发放成功?
发送确认消息
标记为需人工处理
实验服务更新消息状态为 CONFIRMED

对应的核心代码实现

java 复制代码
// 实验服务代码(v2.0 - 本地事务表版)
@Service
public class ExperimentServiceV2 {
    
    @Transactional
    public void enrollUserInExperiment(Long userId, Long experimentId) {
        // 1. 记录分配
        Allocation allocation = new Allocation(userId, experimentId);
        allocationRepository.save(allocation);
        
        // 2. 在同一个事务中插入消息记录
        EventMessage message = new EventMessage();
        message.setType("EXPERIMENT_ENROLLMENT");
        message.setPayload(buildPayload(userId, experimentId));
        message.setStatus(EventMessage.Status.PENDING);
        message.setCreatedAt(LocalDateTime.now());
        eventMessageRepository.save(message);
        
        // 事务在此提交,要么两条记录都成功,要么都失败
    }
    
    // 独立的定时任务,扫描并发送消息
    @Scheduled(fixedDelay = 5000)
    public void processPendingMessages() {
        List<EventMessage> pendingMessages = 
            eventMessageRepository.findByStatus(EventMessage.Status.PENDING);
        
        for (EventMessage message : pendingMessages) {
            try {
                kafkaTemplate.send("experiment-enrollment", message.getPayload());
                message.setStatus(EventMessage.Status.SENT);
                message.setSentAt(LocalDateTime.now());
                eventMessageRepository.save(message);
            } catch (Exception e) {
                log.error("发送消息失败,messageId={}", message.getId(), e);
                // 下次重试
            }
        }
    }
}

// 触达服务确认消费后,发回确认消息
@KafkaListener(topics = "experiment-enrollment")
public void handleEnrollment(String payload) {
    EnrollmentEvent event = parsePayload(payload);
    
    try {
        // 发放权益
        couponService.grantCoupon(event.getUserId(), event.getExperimentId());
        
        // 发送确认消息
        kafkaTemplate.send("enrollment-confirmed", 
            new ConfirmationEvent(event.getMessageId()));
    } catch (Exception e) {
        log.error("处理实验参与事件失败", e);
        // 进入死信队列,人工处理
    }
}

这个方案进步了,但仍不完美

  • 保证不丢:只要主记录存在,消息最终会被发出
  • 可重试:发送失败可不断重试
  • 仍可能重复:消息发送后、状态更新前崩溃,会导致重复发送
  • 回滚复杂:权益发放失败后,需要额外机制清理实验记录

阶段三:Saga模式 - 接受最终一致,但明确定义补偿

对于金融级强一致,我们可能要用到Seata这样的分布式事务框架。但在增长中台,我们选择了更务实的Saga模式 :接受最终一致,但为每个正向操作都定义明确的补偿操作






用户请求进入实验
实验服务: 开启Saga
Saga协调器
执行步骤1: 记录实验分配
成功?
执行步骤2: 发放权益
Saga失败, 结束
成功?
Saga成功, 结束
触发补偿: 删除实验分配记录
补偿成功?
Saga失败但已清理, 结束
Saga失败且未清理

标记为需人工干预

Saga协调器的简化实现

java 复制代码
@Component
public class ExperimentEnrollmentSaga {
    
    public SagaResult enrollUser(Long userId, Long experimentId) {
        String sagaId = generateSagaId();
        SagaContext context = new SagaContext(sagaId, userId, experimentId);
        
        try {
            // 步骤1: 预分配实验(可补偿)
            StepResult step1 = stepAllocateExperiment(context);
            if (!step1.isSuccess()) {
                return SagaResult.failed("分配实验失败");
            }
            
            // 步骤2: 预占权益(可补偿)
            StepResult step2 = stepReserveCoupon(context);
            if (!step2.isSuccess()) {
                // 补偿步骤1
                compensateStep1(context);
                return SagaResult.failed("权益预占失败,已回滚");
            }
            
            // 步骤3: 确认两者(不可逆)
            StepResult step3 = stepConfirmAllocation(context);
            if (!step3.isSuccess()) {
                // 严重错误,需要人工介入
                return SagaResult.failedWithManualIntervention("确认失败");
            }
            
            return SagaResult.success();
            
        } catch (Exception e) {
            log.error("Saga执行异常,sagaId={}", sagaId, e);
            return SagaResult.failedWithManualIntervention("系统异常");
        }
    }
    
    private StepResult stepAllocateExperiment(SagaContext context) {
        // 插入一条状态为 PENDING 的实验分配记录
        // 而不是直接 FINISHED
        return experimentService.tentativeAllocate(
            context.getUserId(), 
            context.getExperimentId(),
            context.getSagaId()
        );
    }
    
    private void compensateStep1(SagaContext context) {
        // 删除或标记为 CANCELLED 那条 PENDING 记录
        experimentService.cancelTentativeAllocation(context.getSagaId());
    }
}

Saga模式的核心洞察

  1. 补偿不是回滚:补偿是执行一个对等的业务操作,而不是技术上的事务回滚
  2. 设计可补偿操作:每个步骤在设计时就要想好"如果后悔了怎么办"
  3. 幂等性至关重要:任何操作(包括补偿)都可能被重复执行,必须幂等

04 现阶段的折中选择:分层的混合策略

在实际的智能用户增长中台,我们没有采用单一的方案,而是根据不同的业务场景,选择了混合策略:

场景一:实时实验分配 + 权益发放 → Saga + 本地事务表

  • 用户参与实验需要实时反馈(毫秒级)
  • 但权益发放可以接受秒级延迟
  • 实现:Saga保证分配记录的可补偿性,权益发放通过可靠消息异步完成

场景二:用户行为触发积分 → 纯可靠消息,最终一致

  • 积分到账延迟几分钟完全可接受
  • 实现:行为采集服务写入本地事务表,异步消息触发积分发放

场景三:实验配置同步 → 版本化 + 最终一致

  • 配置变更分钟级同步可接受
  • 实现:每次变更生成唯一版本号,消费方按版本号去重和排序

我们的架构现状

复制代码
智能用户增长中台 - 事务处理分层架构
├── 强一致层(Saga模式)
│   ├── 实验用户分配
│   └── 核心权益预占
│
├── 可靠异步层(本地事务表)
│   ├── 权益实际发放
│   ├── 积分奖励
│   └── 消息通知
│
└── 最终一致层(事件驱动)
    ├── 实验数据同步
    ├── 用户画像更新
    └── 分析报表生成

05 我们还在对抗的剩余问题

即使有了这些方案,分布式事务的世界依然不完美:

  1. 人工对账无法完全避免:每周仍有1%的异常case需要人工核对
  2. 跨系统时钟漂移:不同服务器的时间差异,导致"先有因还是先有果"的判定困难
  3. 补偿本身的失败:补偿操作也可能失败,形成"俄罗斯套娃"式的失败链

我们现在每天还在和这些问题斗争。但相比最初"数据一团糟"的状态,至少现在:

  • 我们知道问题出在哪里
  • 我们有工具定位问题
  • 我们知道如何一步步修复问题

06 总结:分布式事务不是技术问题,是业务权衡

回顾这段历程,我最深的体会是:选择分布式事务方案,本质上是选择忍受哪种业务痛苦。

  • 选择2PC/Seata,是选择忍受性能损耗和架构复杂度,换取强一致。
  • 选择Saga,是选择忍受补偿逻辑的复杂性,换取最终一致和性能。
  • 选择可靠消息,是选择忍受延迟和数据不一致窗口,换取简单性。

在智能用户增长中台,我们最终选择了:核心路径Saga化,非核心路径消息化,重要数据人工对账兜底

这不是最优雅的方案,也不是性能最好的方案,但它是在当前团队能力、业务发展阶段和运维成本约束下,最务实的选择

下一次当你面临分布式事务的选择时,不要只问"哪个技术最牛",而是问:

  1. 业务能接受多长的不一致窗口?
  2. 团队能驾驭多复杂的补偿逻辑?
  3. 运维能承受多重的对账负担?

想清楚这些,答案自然会浮现。


留给你的问题:在你的系统中,有没有一个分布式事务场景,让你在"强一致、高性能、易实现"这个不可能三角中做出了痛苦的权衡?你最终选择了哪一边,又忍受了哪些代价?

相关推荐
是三好2 小时前
分布式事务seata
java·分布式·seata
optimistic_chen3 小时前
【Redis 系列】常用数据结构---Hash类型
linux·数据结构·redis·分布式·哈希算法
yuankunliu3 小时前
【分布式事务】4、分布式事务Seata的高级应用详解
分布式
忧郁蓝调263 小时前
Redis不停机数据迁移:基于 redis-shake 的跨实例 / 跨集群同步方案
运维·数据库·redis·阿里云·缓存·云原生·paas
乾元3 小时前
数据中心流量工程(TE)优化:当 AI 成为解决“维度诅咒”的唯一操纵杆
运维·服务器·网络·人工智能·架构·自动化
java1234_小锋3 小时前
ZooKeeper集群中服务器之间是怎样通信的?
分布式·zookeeper·云原生
云器科技3 小时前
NinjaVan x 云器Lakehouse: 从传统自建Spark架构升级到新一代湖仓架构
大数据·ai·架构·spark·湖仓平台
用户91743965394 小时前
从单系统架构到微服务架构:软件现代化的转型综述
微服务·架构·系统架构
easy_coder4 小时前
从“未知故障”到“自治诊断”:基于双路召回与RAG的智能诊断系统构建
人工智能·云原生·云计算