分布式事务解决方案:一个前端转全栈的血泪实践

资深-张娟_极客-工程师
2025-12-15 05:49
阅读 560

大家好,我是一个纯前端出身、最近硬着头皮啃 Node.js 的“伪全栈”选手。坐标上海,住公司附近——不是因为我有多卷,纯粹是怕加班太晚打不到车(别问,问就是被产品经理深夜钉钉轰炸过)。平时除了写 Vue 组件,也喜欢扒一扒开源项目的源码,比如 axios 是怎么发请求的、express 中间件链是怎么跑的。但说真的,直到上个月,我对“分布式事务”这四个字的理解还停留在“听上去很牛逼,但我用不上”的阶段。

直到……我们组接了个新项目。

起因:一个看似简单的“下单”需求

事情是这样的。我们团队在做一套 B2B 电商平台,前端我负责(当然),后端原本是 Java 技术栈,由几位老哥维护。但最近公司搞“技术融合”,要求前后端都能顶上。领导拍了拍我肩膀:“小张啊,你不是学 Node 了吗?这次新模块你来搭个服务端原型,顺便看看能不能落地。”

需求很简单:用户下单时,要同时完成三件事:

  1. 扣减库存(库存服务)
  2. 创建订单(订单服务)
  3. 发送消息通知(消息服务)

听起来人畜无害对吧?但问题来了——这三个操作分别在三个不同的微服务里,各自连着自己的数据库。如果扣库存成功了,结果创建订单时崩了,那库存就白扣了;反过来,如果订单建好了,库存没扣,用户就能白嫖……这要是上线,财务部怕是要提刀来找我。

我当时第一反应是:“这不是该用数据库事务吗?”
然后后端老哥冷笑一声:“兄弟,这是跨服务、跨数据库,你 MySQL 的 BEGIN TRANSACTION 可管不到人家 PostgreSQL 头上。”

那一刻,我意识到:单体应用的幸福时光,一去不复返了。

初探:到底什么是分布式事务?

说实话,一开始我连“分布式事务”和“本地事务”的区别都搞不清。翻了几篇文档,又看了 Seata 的官方 demo,总算明白:分布式事务的核心问题,是如何保证多个独立系统在发生故障时,还能保持数据的一致性。

常见的解决方案有几种:

  • 两阶段提交(2PC):经典但性能差,锁资源时间长
  • TCC(Try-Confirm-Cancel):业务侵入强,但灵活
  • 消息队列+本地消息表:最终一致性,适合非强一致场景
  • Saga 模式:长事务拆解,支持补偿

我们这个下单场景,其实不需要强一致性——用户能接受几秒内库存显示延迟,只要最终一致就行。而且产品那边明确说了:“双11期间别崩就行,慢点可以忍。”(感谢产品大大开恩!)

于是,我们决定采用 “本地消息表 + 消息队列” 的方案。为什么?因为简单、可控、对现有 Java 项目改动小,而且我这个 Node 新手也能快速上手配合。

实战:在 Java 项目中落地本地消息表

我们的主服务是 Spring Boot 写的,数据库是 MySQL。思路如下:

  1. 在订单服务中,开启本地事务
  2. 先插入订单记录
  3. 同时插入一条“待发送”的消息到本地消息表
  4. 提交事务
  5. 后台任务轮询消息表,把状态为“待发送”的消息推到 RabbitMQ
  6. 消费者收到消息后,执行库存扣减等操作

关键点在于:订单和消息的写入必须在一个本地事务里完成。这样即使消息推送失败,也可以通过重试机制最终完成。

数据库设计

-- 订单表
CREATE TABLE `order` (
  id BIGINT PRIMARY KEY,
  user_id BIGINT NOT NULL,
  status VARCHAR(20) NOT NULL,
  created_at DATETIME
);

-- 本地消息表(重点!)
CREATE TABLE `local_message` (
  id BIGINT PRIMARY KEY AUTO_INCREMENT,
  message_id VARCHAR(64) NOT NULL UNIQUE, -- 幂等ID
  topic VARCHAR(100) NOT NULL,
  payload TEXT NOT NULL, -- JSON 字符串
  status TINYINT DEFAULT 0, -- 0: 待发送, 1: 已发送, 2: 失败
  retry_count INT DEFAULT 0,
  created_at DATETIME,
  updated_at DATETIME,
  INDEX idx_status (status),
  INDEX idx_retry (retry_count)
);

注:message_id 用于幂等控制,避免重复消费。这个坑我踩过——测试时没加幂等,结果库存被扣了三次,差点背锅。

Java 代码示例(Spring Boot)

@Service
public class OrderService {

    @Autowired
    private OrderMapper orderMapper;

    @Autowired
    private LocalMessageMapper localMessageMapper;

    @Transactional
    public void createOrder(OrderRequest request) {
        // 1. 创建订单
        Order order = new Order();
        order.setUserId(request.getUserId());
        order.setStatus("CREATED");
        orderMapper.insert(order);

        // 2. 插入本地消息(与订单同事务!)
        String messageId = UUID.randomUUID().toString();
        String payload = JSON.toJSONString(Map.of(
            "orderId", order.getId(),
            "userId", request.getUserId(),
            "items", request.getItems()
        ));

        LocalMessage msg = new LocalMessage();
        msg.setMessageId(messageId);
        msg.setTopic("order.created");
        msg.setPayload(payload);
        msg.setStatus(0); // 待发送
        localMessageMapper.insert(msg);

        // 注意:这里不直接发MQ!事务提交后才安全
    }
}

然后写一个定时任务(或者用 @Scheduled)扫描 local_message 表:

@Scheduled(fixedDelay = 5000) // 每5秒扫一次
public void sendPendingMessages() {
    List<LocalMessage> pendingMsgs = localMessageMapper.selectPending(100);
    for (LocalMessage msg : pendingMsgs) {
        try {
            rabbitTemplate.convertAndSend(msg.getTopic(), msg.getPayload());
            msg.setStatus(1);
            localMessageMapper.updateStatus(msg); // 标记已发送
        } catch (Exception e) {
            log.error("Send message failed, messageId={}", msg.getMessageId(), e);
            msg.setRetryCount(msg.getRetryCount() + 1);
            if (msg.getRetryCount() > 5) {
                msg.setStatus(2); // 标记失败,人工介入
            }
            localMessageMapper.updateRetry(msg);
        }
    }
}

我的 Node.js 服务如何配合?

作为前端转来的“全栈仔”,我的任务是写一个库存服务(用 Node.js + MySQL)。当收到 order.created 消息时,扣减库存:

// inventory-service.js
const amqp = require('amqplib');
const db = require('./db');

async function handleOrderCreated(msg) {
  const data = JSON.parse(msg.content.toString());
  const { orderId, items } = data;

  // 关键:这里也要做幂等!
  const exists = await db.query(
    'SELECT 1 FROM inventory_log WHERE order_id = ?', 
    [orderId]
  );
  if (exists.length > 0) {
    console.log('Already processed order', orderId);
    return; // 直接返回,避免重复扣减
  }

  try {
    await db.beginTransaction();
    
    // 扣减库存(简化逻辑)
    for (const item of items) {
      await db.query(
        'UPDATE product_stock SET stock = stock - ? WHERE product_id = ? AND stock >= ?',
        [item.quantity, item.productId, item.quantity]
      );
    }

    // 记录日志(用于幂等)
    await db.query(
      'INSERT INTO inventory_log (order_id, status) VALUES (?, ?)',
      [orderId, 'SUCCESS']
    );

    await db.commit();
    channel.ack(msg); // 确认消息
  } catch (err) {
    await db.rollback();
    console.error('Inventory deduction failed:', err);
    channel.nack(msg, false, true); // 重回队列重试
  }
}

血泪教训:幂等性不是可选项,是保命符! 上周测试时忘了加 inventory_log,结果 MQ 重试导致库存变成负数,运维大哥差点把我从工位上拎起来。

踩坑实录:那些让我想砸电脑的瞬间

  1. 消息重复消费
    RabbitMQ 在网络抖动时可能重复投递。没做幂等?恭喜你,库存变负数,用户白拿货。

  2. 本地事务和 MQ 发送时机
    一开始我天真地在 @Transactional 方法里直接调 rabbitTemplate.send(),结果事务回滚了,消息却发出去了——数据直接不一致。后来才明白:必须等事务提交后再发消息,所以用后台任务轮询。

  3. 死信队列没配
    消息一直失败也不进死信队列,监控告警全靠肉眼盯日志。后来加上了 TTL 和 DLX,终于能自动隔离异常消息。

  4. Node.js 服务的连接池泄漏
    因为异步没处理好,MySQL 连接池爆了。await 忘了加,try/catch 嵌套太深……前端思维一时半会儿真改不过来。

方案对比:为什么我们没选 Seata?

其实团队也讨论过用 Seata(阿里开源的分布式事务框架)。它支持 AT 模式(自动补偿)、TCC 模式,看起来很香。但最后放弃了,原因很现实:

方案 优点 缺点 适用场景
本地消息表 + MQ 简单、可控、对业务侵入小 需要维护消息表、重试逻辑 最终一致性,如订单、通知
Seata (AT模式) 自动回滚,开发省心 依赖全局锁,性能较差;需改造数据库 强一致性要求高,且能接受性能损耗
TCC 灵活、高性能 业务侵入极强,每个接口要写 Try/Confirm/Cancel 金融级场景

我们评估后认为:在非核心链路(比如发通知、更新积分)用最终一致性完全够用,没必要为了“强一致”牺牲性能和复杂度。

而且……说实话,让一个刚学 Node 的前端去配 Seata 的 TC/TM/RM,怕不是要通宵三天还搞不定。领导也点头:“先跑起来,再优化。”

上线效果 & 心得

上周五晚上,我们灰度发布了这个方案。双11预演压测跑了 10w 单,数据一致性 100% 达标,消息平均延迟 < 800ms(99分位)。最爽的是,运维大哥终于不用半夜打电话骂我了!

这次经历让我深刻体会到:所谓“架构”,不是堆砌高大上的名词,而是在约束条件下做出最合理的权衡。

作为一个前端转全栈的“杂牌军”,我以前总觉得后端高深莫测。但现在明白:很多所谓的“高级方案”,底层逻辑其实很朴素——保证原子性、做好幂等、允许重试、留好退路。

如果你也和我一样,正在从前端走向全栈,别怕!分布式事务听着吓人,但拆开来看,无非就是:

  • 本地事务 + 消息表 → 保证“要么全做,要么全不做”
  • 幂等 ID + 重试机制 → 应对网络不确定性
  • 监控告警 + 死信队列 → 给自己留条活路

最后,给同样在挣扎的你一句鼓励:“代码可以重构,但数据不能乱。” ——这是我贴在显示器边上的话。

共勉。


P.S. 产品今天又提了个新需求:“能不能在下单时顺便给用户发个优惠券?”
我默默打开了 local_message 表的设计文档……

评论 0

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