分布式事务解决方案:一个从测试转开发的远程打工人血泪实战总结

CloudArchitect
2025-12-16 04:23
阅读 417

去年十月,凌晨两点,我蹲在老家县城出租屋的阳台上,一边啃着冷掉的烤串,一边盯着屏幕上不断重试失败的订单。手机震动,产品经理小王发来消息:“哥,线上支付又出问题了,用户付了钱没到账,客服快被投诉炸了。”

我点开日志,一看就知道——又是分布式事务惹的祸。

当时真的想把键盘砸了。不是因为技术难(虽然确实不简单),而是因为我刚从测试岗转开发不到半年,月薪才从15k涨到22k,还没站稳脚跟,就撞上这种“史诗级”线上事故。更糟的是,我老婆还在隔壁房间睡觉,明天她还要早起送孩子上幼儿园——没错,我在老家远程办公,省下了北京3500块的房租,却换来了随时可能背锅的焦虑。


事情是怎么崩的?

我们做的是一款SaaS电商产品,核心流程是:用户下单 → 扣库存 → 创建支付单 → 调用第三方支付 → 支付成功后更新订单状态 + 发券 + 记账

听起来很常规对吧?但问题在于,这些操作分布在四个微服务里:订单服务、库存服务、支付服务、营销服务。每个服务都有自己的数据库,彼此通过 HTTP 或 MQ 通信。

最初,团队为了“快速上线”,用了最朴素的方案:先调A,成功再调B,失败就回滚A。结果呢?支付成功了,但发券服务挂了,用户付了钱却没拿到优惠券;或者库存扣了,支付超时,订单卡在“待支付”,库存却锁死了。

上线第一周,客服工单暴涨300%。老板在晨会上黑着脸说:“这产品做得像个筛子。”


我被迫扛起了“填坑”的担子

作为团队里最“新鲜”的开发(其实就是背锅位),组长老李直接甩给我一句话:“你之前做测试,最懂数据一致性,这事你牵头搞。”

我内心OS:测试懂一致性 ≠ 开发会写分布式事务啊!

但没办法,远程办公的好处就是没人盯着你摸鱼,坏处就是出了事第一个被@。那天晚上,我翻遍了公司代码库、GitHub、Stack Overflow,甚至把《Java并发编程实战》翻出来找灵感——结果发现,书里根本没讲分布式事务怎么落地。

于是,我开始疯狂补课。花了一周时间,把主流方案过了一遍:

  • 2PC(两阶段提交):理论上完美,但性能差、阻塞严重,而且Java生态里成熟的实现(比如Atomikos)和我们Spring Boot + MyBatis的架构集成起来巨麻烦。
  • TCC(Try-Confirm-Cancel):灵活,但每个业务都要写三套逻辑,开发成本爆炸。我们一个小团队,哪有精力给每个接口都写Cancel?
  • 本地消息表:靠谱,但需要额外建表、轮询、处理幂等,运维复杂度高。
  • RocketMQ事务消息:看起来最香,但公司用的Kafka,临时切中间件?做梦。

最后,结合产品现状(中小规模、业务迭代快、团队人少),我拍板:用“可靠事件模式 + 幂等 + 补偿任务”组合拳


实战:我是怎么在Java里落地的?

第一步:拆解核心链路,识别“关键事务边界”

我把整个支付流程画成一张图,标出哪些操作必须“全成功或全失败”。最终锁定两个核心事务组:

  1. 创建订单 + 扣库存(必须原子)
  2. 支付成功 + 发券 + 记账(必须原子)

注意:不要试图让所有操作在一个事务里完成。这是新手最容易踩的坑。分布式系统里,事务边界越小,越可控。

第二步:用“本地事务表”保证第一步的一致性

在订单服务里,我新建了一张 order_event 表,结构很简单:

CREATE TABLE order_event (
  id BIGINT PRIMARY KEY,
  order_id VARCHAR(64),
  event_type VARCHAR(32), -- e.g., "DEDUCT_STOCK"
  status TINYINT,         -- 0: pending, 1: sent
  payload JSON,
  create_time DATETIME
);

当用户下单时,我这么做(伪代码):

@Transactional
public void createOrder(OrderRequest req) {
    // 1. 插入订单
    orderMapper.insert(order);
    
    // 2. 插入库存扣减事件(状态为pending)
    OrderEvent event = new OrderEvent();
    event.setOrderId(order.getId());
    event.setEventType("DEDUCT_STOCK");
    event.setPayload(...);
    event.setStatus(0);
    eventMapper.insert(event);
}

然后,启动一个后台线程(或定时任务),扫描 status=0 的事件,调用库存服务。只有库存服务返回成功,才把事件状态改为1

如果库存服务挂了?没关系,下次轮询继续重试。因为事件是持久化的,不会丢。

第三步:支付回调用“幂等 + 补偿”兜底

支付成功后,第三方会回调我们的接口。这里的关键是:回调可能重复、可能乱序、可能延迟

所以,我在回调入口加了幂等锁:

public void handlePaymentCallback(PaymentCallbackDTO dto) {
    String lockKey = "pay_callback:" + dto.getTradeNo();
    
    if (redis.setNx(lockKey, "1", 300)) { // 5分钟过期
        try {
            // 检查是否已处理
            if (orderService.isOrderPaid(dto.getOrderId())) {
                return; // 已处理,直接返回
            }
            
            // 执行发券、记账等操作
            couponService.issueCoupon(dto.getUserId(), ...);
            accountingService.record(dto.getAmount(), ...);
            
            // 更新订单状态
            orderService.updateStatus(dto.getOrderId(), "PAID");
        } finally {
            redis.del(lockKey);
        }
    }
}

但如果发券服务在回调过程中挂了怎么办?这时候,我加了一个补偿任务:每天凌晨跑一个Job,找出“已支付但未发券”的订单,重新触发发券逻辑。


开发心得:别信“银弹”,信“合适”

折腾了两周,线上问题基本止住。客服工单降回正常水平,老板在周会上难得夸了句:“这次搞得很稳。”

但我知道,这不是技术多牛,而是选对了方案

很多教程一上来就吹Seata、吹Saga,但现实是:小团队用不起重型框架,也扛不住复杂的运维成本。我们用最土的办法——本地表 + 轮询 + 幂等,解决了80%的问题。

另外,Java程序员容易陷入“框架依赖症”。总想着找个starter一键集成。但分布式事务的本质是业务设计 + 异常处理 + 监控告警,不是换个中间件就能搞定的。

我现在的原则是:

  • 能同步就同步(比如订单+库存在同一DB)
  • 必须异步的,就用事件驱动
  • 所有下游操作必须幂等
  • 关键链路加监控(比如事件积压告警)

远程办公的“副作用”:逼我学会独立思考

说实话,如果我在北京办公室,遇到这种问题,大概率会等架构师出方案,或者拉个会吵半天。但在老家,没人可问,只能自己啃。

反而逼出了成长。

上周五晚上,老婆看我对着屏幕傻笑,问我:“又涨工资了?”
我说:“没,但今天优化了一个补偿任务,把重试次数从10次降到3次,省了服务器资源。”
她翻了个白眼:“你是不是没救了?”

但我知道,这种“解决问题的爽感”,比涨薪还上头。


给同行的建议:从你的产品出发

如果你也在做类似系统,别急着抄作业。先问自己几个问题:

  1. 你的产品能容忍多久的数据不一致?(比如发券延迟5分钟行不行?)
  2. 团队有没有人力维护复杂的事务框架
  3. 下游服务是否支持幂等和补偿

如果答案是否定的,那就从最简单的本地消息表开始。分布式事务不是技术秀场,而是产品稳定性的底线


最后一点思考

从测试转开发三年,我越来越觉得:最好的开发,是带着产品思维写代码的人

测试教会我“用户视角”——用户不在乎你用了TCC还是Saga,只在乎付了钱能不能拿到东西。

而远程办公让我明白:技术方案没有高下,只有合不合适。省下的房租,不该用来买昂贵的中间件许可证,而该用来买时间,去打磨真正影响用户体验的细节。

现在,我的监控面板上,“事务异常率”稳定在0.02%以下。虽然偶尔还会半夜被报警叫醒,但至少,我能安心吃完那串烤串再处理。

共勉。

评论 0

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