分布式事务实战:我们踩过的坑,都是成长的阶梯

精通-马智-专家
2025-06-19 15:38
阅读 579

开篇:为什么我要写这篇关于分布式事务的文章?

开篇:为什么我要写这篇关于分布式事务的文章?

大家好,我是李工,在后端开发这一行摸爬滚打了快八年,经历过的项目从小型 SaaS 到高并发的金融系统,也从中踩过不少跟“数据一致性”相关的深坑。今天想和大家分享一下我亲身经历过的一个典型案例——在一次支付平台重构中,如何设计并实现一个稳定、高效、可扩展的分布式事务方案

这并不是纸上谈兵的技术选型对比,而是实实在在从生产环境里“打怪升级”一路走来的经验总结。如果你现在正为你的微服务架构下如何处理跨服务的数据一致性而头疼,这篇文章或许能给你一些启发,少踩一点坑。


背景介绍:支付系统中的分布式事务挑战

背景介绍:支付系统中的分布式事务挑战

故事要从两年前开始讲起。

我们当时负责的是公司内部的统一支付平台,原本是一个单体应用,随着业务增长,被拆分为了多个独立服务:支付服务、账户服务、风控服务、对账服务等。由于每个服务都维护自己的数据库,自然就引入了“分布式事务”问题。

比如:用户付款时,需要:

  1. 从账户服务扣款;
  2. 在支付服务记录一笔支付交易;
  3. 向风控服务发送风控请求;
  4. 如果失败还要触发补偿机制或回滚操作。

理想状态下是原子性的流程,但一旦某一步出错(网络超时、服务宕机、异常抛出),整个流程可能会陷入“中间态”,导致数据不一致的风险。

于是我们决定引入一种相对成熟又灵活可控的分布式事务解决方案。


挑战出现:我们遇到了哪些问题?

1. CAP 理论的取舍困境

最开始,我们的目标是追求强一致性。但在实践中发现,如果使用类似两阶段提交(2PC)这样的传统方案,虽然能保证一致性,但性能差得离谱,且容易因为某个节点不可用导致全局阻塞。

2. 跨服务调用的可靠性难题

微服务之间通信依赖 REST + RPC,但我们发现:

  • 接口调用失败频率比预期高;
  • 即使做了重试机制,也可能导致重复操作(比如扣了两次钱);
  • 异常处理复杂度陡增。

举个真实例子:

有一次,我们调风控接口返回了 timeout,但我们不确定这个风控是否真正执行成功了,继续执行扣款就会导致资损风险。如果不继续执行,又会造成支付卡住的问题。

这就倒逼我们需要一套既能容忍失败、又能最终达成一致状态的事务机制。


解决方案:我们为什么选择了“最终一致性 + 补偿事务”模式

考虑到系统的可用性优先于强一致性,并且结合实际业务场景,我们最终采用了 本地消息表 + 状态机驱动 + 定时补偿 的组合方案。这套方案在业界也有较多实践案例,比如京东、淘宝的部分核心链路都有类似的思路。

整体架构设计图(文字描述):

客户端请求 -> 支付服务(发起支付)
                    ↓
    记录支付订单状态(本地事务)
                    ↓
    发送 Kafka 消息给账户服务(异步解耦)
                    ↓
   账户服务消费消息,尝试冻结资金并确认状态
                    ↓
     回写结果到支付服务的回调接口
                    ↓
支付服务更新支付状态,若失败则进入定时补偿队列

核心组件说明:

  1. 支付状态机管理模块:用于维护不同状态之间的流转逻辑(未支付 -> 处理中 -> 已完成/失败)
  2. 本地事务日志表:每次关键变更,先记事务日志,防止状态丢失。
  3. Kafka 消息队列:作为可靠的异步通讯通道,实现解耦与削峰填谷。
  4. 定时补偿 Job:定期扫描未完成的状态,重新驱动流程。

关键代码和配置示例

数据库设计模型-1

以下是一些关键部分的伪代码结构,方便理解:

1. 记录支付状态(本地事务)

public void createPaymentOrder(PaymentRequest request) {
    try {
        // 启动本地事务
        transactionTemplate.execute((status) -> {
            // 1. 写入支付订单
            paymentRepository.save(new Payment(...));


![系统架构设计图-2](https://code-guide.oss.shanghai.autogptai.club/common/file/download?name=date2025061915/78d22fb7-408d-4879-b41d-b6385ceaa6c8.jpg)


            // 2. 写入本地事务日志
            logRepository.save(new TransactionLog("PAYMENT_CREATED", ...));

            // 3. 发送到 Kafka 消息队列
            kafkaProducer.send(buildMessage(request));
            return null;
        });
    } catch (Exception e) {
        // 本地事务会回滚
        throw new BizException("创建支付单失败");
    }
}

2. 账户服务消费 Kafka 消息(幂等处理)

@KafkaListener(topic = "payment_requests")
public void consumePaymentRequest(Message msg) {
    String paymentId = msg.get("paymentId");

    // 先查是否已经处理过这条消息(幂等处理)
    if (compensateService.isAlreadyProcessed(paymentId)) {
        return;
    }

    try {
        accountService.freezeAmount(userId, amount);
        
        // 更新状态为已冻结
        compensateService.markAsProcessed(paymentId);

        // 发送回调事件
        callbackService.notifyFreezeSuccess(paymentId);
    } catch (Exception e) {
        compensateService.markAsFailed(paymentId);
    }
}

3. 支付服务回调接口处理

@PostMapping("/callback/freeze_success")
public Response handleFreezeSuccess(@RequestBody CallbackData data) {
    paymentService.updateStatus(data.paymentId(), Status.FREEZE_SUCCESS);
    // 触发下一步流程,如调用风控服务等
    riskService.checkRisk(data.paymentId());
}

4. 定时补偿任务

@Scheduled(cron = "0/30 * * * * ?")
public void retryUnfinishedPayments() {
    List<Payment> pendingPayments = paymentRepository.findByStatusIn(Arrays.asList(
        Status.PAYMENT_CREATED,
        Status.FREEZE_FAILED
    ));

    for (Payment p : pendingPayments) {
        // 重发 Kafka 消息或手动调用下游接口
        retryService.triggerRetry(p.getId());
    }
}

踩坑经验分享:这些事早知道就好了!

✅ 坑点一:没做幂等处理,导致重复扣款

刚开始上线时,Kafka 消费者没做好幂等检查。某个分区反复 rebalance,导致同一笔支付消息多次被消费。最终造成用户的余额被连续扣除三次。

✨ 解法:

  • 使用唯一标识符+Redis缓存进行幂等校验;
  • 每次消费前先查是否已经执行过。

✅ 坑点二:补偿任务误删了老数据

定时任务本来是用来修复状态的,结果有一次把老数据全清空了……原因就是查询条件太宽泛,没有加时间范围限制。

✨ 解法:

  • 加上“只查最近 7 天内的未完成任务”;
  • 对 SQL 加索引优化,避免扫全表;
  • 用灰度脚本验证任务逻辑再上线。

✅ 坑点三:事务日志表没做监控报警

一开始我们以为事务日志只是用来排查问题,没想到后来有个部署版本忘了初始化这张表,直接导致支付流程失败但完全无记录。

✨ 解法:

  • 给事务日志表加上健康检查;
  • 每天统计新增日志数是否异常;
  • 所有服务的日志行为都要可追踪。

实施效果与收益分析

方案上线后,我们观察了一段时间的数据,效果非常明显:

指标 上线前 上线后
支付成功率 95.2% 98.7%
平均响应时间 800ms 350ms
需人工干预的异常订单数量 每天 100~200 条 几乎归零
数据一致性问题发生率 每周几次 几乎为零

而且整套机制具备良好的可观测性,配合 Prometheus + Grafana 监控,我们可以实时看到每一步的流程进展,极大提升了运维效率。


我的一些建议和注意事项

如果你也在设计或者正在重构你的分布式事务逻辑,这里是我总结的一些实用建议:

💡 1. 不要盲目追求“强一致性”

根据实际业务需求选择合适的事务模型。很多时候,最终一致性 + 补偿机制就能满足业务需求,还能带来更好的性能和容错能力。

💡 2. 做好幂等处理,是分布式系统的基础功

无论是 Kafka 消费、HTTP 接口调用还是数据库写入,都要在关键节点加入幂等判断。否则轻则重复处理,重则造成资损。

💡 3. 设计状态驱动的流程引擎很有必要

把整个流程抽象成状态机,不仅让逻辑更清晰,还能通过状态流转判断当前处于哪个环节,便于自动化补偿和监控。

💡 4. 不要把补偿逻辑做得太复杂

补偿逻辑本身要足够简单、可重复运行、不影响正常流程。复杂的补偿反而会制造新的风险。

💡 5. 可观测性必不可少

日志、监控、告警必须跟上。否则出了问题就像盲人摸象,根本不知道哪里断掉了。


技术趋势下的思考:未来还可能怎么演进?

目前我们这套方案已经在生产跑了两年多,表现稳定。不过站在技术视角看未来,我们也考虑引入更高级的事务框架:

  • Seata:阿里开源的分布式事务框架,适合希望引入 TCC 或 Saga 模式的团队。
  • Dapr:微软支持的云原生开发运行时,内置了状态管理和发布订阅机制,适合作为未来服务网格的一部分。
  • EventStorming + CQRS + EventSourcing:对于更复杂的业务流程,这类事件驱动架构也是趋势之一。

不过说实话,任何新技术引入都需要成本。对于大多数中小型系统来说,基于本地事务 + 状态机 + 异步补偿的设计,已经是足够稳健且易维护的方案。


写在最后:每一次“故障”背后,都藏着成长的契机

其实这篇文章里的每一个细节、每一段代码,都来自于我和团队在深夜里一起调试、一起复盘的日日夜夜。有时候我们会为了一条异常链路讨论好几个小时,甚至争论不休,但正是在这样的碰撞中,我们慢慢找到了属于自己的节奏和方法。

我也希望大家在面对分布式事务这样复杂的问题时,不要怕麻烦,也不要急于照搬网上的“最佳实践”。真正的最佳实践,往往是在你解决自己问题的过程中沉淀下来的。

如果你对这套方案感兴趣,或者也在处理类似的问题,欢迎留言交流。我们可以一起探讨,共同进步。😊


作者:李工|某大型互联网公司资深后端架构师,热爱分享一线实战经验。

评论 0

最热最新
暂无评论
匿名用户Lv.1
0
影响力
0
文章
0
粉丝