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

本文是「架构师的技术基石」系列的第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. 运维能承受多重的对账负担?

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


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

相关推荐
江米小枣tonylua7 小时前
译:设计生产级 RAG 架构
架构
怕浪猫13 小时前
领域特定语言(Domain-Specific Language, DSL)
设计模式·程序员·架构
怕浪猫13 小时前
哪些软件对 Chrome DevTools Protocol 频繁使用
人工智能·架构·前端框架
Jack2020 小时前
HarmonyOS APP事件驱动大揭秘
架构
米丘20 小时前
微前端之 Web Components 完全指南
微服务·html
秋播20 小时前
国内本地WSL2编译rancher源码
云原生
Colin草率地做慢慢地改20 小时前
关于QuickStore这个项目的重构(2)- 数据库建表文件
后端·面试·架构
candyTong1 天前
RTK 技术原理:一次典型会话里,80% 上下文是怎么省下来的
javascript·后端·架构
唐某人丶2 天前
从画架构图开始:架构分析与进阶指南
架构