RabbitMQ 实现微服务分布式事务

一、一次删除跨三个服务

设三个服务:商品服务 A 保存商品主体数据,优惠服务 B 和文件服务 C 分别保存这个商品的附属数据。删除由 A 发起,A 删掉本地数据后调用 B 和 C 的删除接口。

两次远程调用都成功,数据一致;都失败,A 回滚本地事务后也一致。不一致出现在一成一败:B 的删除已经提交,C 的调用超时失败。此时 A 可以回滚本地数据,B 也可以回滚自己尚未提交的事务,但 C 那边无论删没删,A 都已经失去了对它的控制。

根因在于事务的原子性依赖一个能覆盖所有参与者的资源管理器。单库事务里这个角色是数据库连接——同一条连接上的 SQL 要么一起提交要么一起回滚。跨服务之后,每个服务各自持有一条连接、各自提交,没有任何一个资源管理器能同时管住三条连接。A 的 rollback 只作用于自己那条连接。

远程调用返回成功,意味着对方已经提交。这一点是后面所有方案的出发点:跨服务的操作一旦提交就无法撤销,只能补偿。

二、强一致性路线:Seata

Seata 的做法是引入一个事务协调器(TC,即 Seata Server),把跨服务的多个本地事务纳入一个全局事务。业务发起方开启全局事务时从 TC 拿到一个全局唯一的事务 ID(XID),这个 XID 通过调用链一路透传到下游服务,下游每个本地事务作为"分支事务"注册到 TC 上。所有分支执行完后由发起方向 TC 发起全局提交或全局回滚,TC 再逐个通知各分支。

以最常用的 AT 模式为例,分支侧的机制是拦截业务 SQL、解析出修改前后的数据镜像(before image / after image),连同行主键一起写进业务库的 undo_log 表,然后提交本地事务并释放本地锁,但全局锁由 TC 持有到全局事务结束。二阶段如果是全局提交,各分支异步删除 undo_log 即可;如果是全局回滚,分支用 undo_log 里的 before image 把数据改回去。

这里有一个常被忽略的细节:AT 模式一阶段就提交了本地事务,所以它并不是"所有分支都先不提交、等协调者发话"的经典两阶段提交。真正不提前提交的是 XA 模式,代价是锁一直持有到二阶段,吞吐更低。AT 模式用"提前提交 + 全局锁隔离 + undo_log 补偿"换取了更短的锁持有时间,因此它的隔离级别默认是读未提交——分支提交之后、全局回滚之前,中间状态对其他事务是可见的,需要读已提交语义时要额外加 @GlobalLock 配合 select for update。

接入成本主要体现在三处:需要独立部署和维护 TC;每个参与方的库都要加 undo_log 表并接入 SDK;调用链上要透传 XID。它的适用场景是链路较短、对中间态零容忍、且能接受全局锁带来的吞吐下降的核心操作,比如资金类业务。把它用在一次删除上,收益和成本不成比例。

三、最终一致性路线:RabbitMQ 的基本做法与它的窗口

另一条路允许中间态存在,只要求最终收敛。做法是把"远程要做的删除"从一次同步调用改成一条消息:A 删除本地数据,同时向 MQ 发一条删除消息;B 和 C 各自监听这条消息,收到后删自己的数据。消费失败有重投机制,重投到成功为止。

这条路线不需要协调器,也不需要全局锁,中间态就是"消息还在重投,B 删了 C 没删"。它解决的是一类具体问题:消息投递成功之后,消费端内部的异常。 比如 B 的数据库正在重启,消费时抛异常,消息被重投,等 B 恢复后处理成功。

但把"发消息"直接写进业务事务,会留下一个方向相反的窗口。@Transactional 的回滚范围只覆盖当前数据库连接上的 SQL,发消息不走这条连接,不在回滚范围内。于是:

@Transactional
public void removeSpuInfo(List<Long> spuIds) {
    // 业务数据的删除
    // ...

    // 这一行不在事务范围内
    rabbitTemplate.convertAndSend("product-event-exchange", "product.deleted", to);

    // 后面的步骤失败,本地事务回滚
    someLaterStep();
}

消息发出去的一瞬间 broker 就把它投给了消费者,消费者开始删;而本地事务随后因为 someLaterStep() 失败而回滚。结果是本地数据还在,远程已经删了——不一致的方向和手工调用相反,性质完全一样。

把发送挪到方法最后也躲不开,只是把窗口缩小:事务提交发生在方法返回时,而发送语句在提交之前,两者之间始终隔着"发送成功、提交失败"这一段。

反方向还有一个窗口:本地事务提交成功,进程在提交之后、发送之前被 kill 掉。消息从未发出,远程永远不会删,而且没有任何地方记录过"这里本该发一条消息"。这个窗口比前一个更危险,因为它是静默的——不会报错,只会一直不一致下去。

两个窗口的根源相同:数据库事务和消息投递是两个独立的资源,没有一个共同的事务边界能把它们绑在一起。

四、本地消息表:把"要发消息"变成一条数据库记录

本地消息表(outbox)的思路是改变发送时机:不在事务里发消息,而是在事务里记录一条待发的消息。

业务数据的删除和这条消息记录的 insert 在同一个库、同一个事务里,于是要么一起提交、要么一起回滚。事务提交成功就意味着两件事同时成立——数据删了,且库里躺着一条待投递的消息。没有中间态,也没有"本该发一条消息"这种只存在于内存里的事实。

表结构只需几个字段:

CREATE TABLE `mq_message` (
  `message_id`     char(32)     NOT NULL COMMENT '同时作为 MQ 的 CorrelationData id',
  `content`        text                  COMMENT '消息体 JSON',
  `to_exchange`    varchar(255),
  `routing_key`    varchar(255),
  `class_type`     varchar(255)          COMMENT '消息体全限定类名,重投时反序列化用',
  `message_status` int DEFAULT 0         COMMENT '0-待投递 1-已发送 2-错误抵达',
  `create_time`    datetime,
  `update_time`    datetime,
  PRIMARY KEY (`message_id`),
  KEY `idx_status_create_time` (`message_status`, `create_time`)
);

routing_key 这一列不能省。只有交换机名是发不出去消息的,而重投是由定时任务发起的,那时没有业务上下文可以推断路由键。class_type 存全限定类名,是为了让重投能把 JSON 反序列化回对象。

写入和投递分开在两处:

@Transactional
public void removeSpuInfo(List<Long> spuIds) {
    // 1. 业务数据的删除(多张表的 delete)
    // ...

    // 2. 同一个事务里落一条待投递消息
    String messageId = mqMessageService.savePending(
            RabbitMQConfig.PRODUCT_EVENT_EXCHANGE,
            RabbitMQConfig.PRODUCT_DELETED_ROUTING_KEY,
            new ProductDeletedTo(existingSpuIds, skuIds, imageUrls));

    // 3. 提交之后再投递
    if (TransactionSynchronizationManager.isSynchronizationActive()) {
        TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() {
            @Override
            public void afterCommit() {
                mqMessageSender.send(messageId);
            }
        });
    } else {
        mqMessageSender.send(messageId);
    }
}

第 3 步用 afterCommit 而不是直接调用,是为了让投递严格发生在提交之后。事务回滚时 afterCommit 不执行,消息记录也跟着回滚,不会出现"远程删了、本地没删"。

投递结果由 broker 的回调决定,不由 convertAndSend 的返回决定。发送是异步的,方法正常返回只代表消息交给了客户端:

private void onConfirm(CorrelationData correlationData, boolean ack, String cause) {
    String messageId = correlationData.getId();
    if (ack) {
        mqMessageService.markSent(messageId);           // 0 → 1
    } else {
        mqMessageService.markError(messageId, "broker nack: " + cause);   // → 2
    }
}

状态机只有三个状态:0 待投递、1 已发送、2 错误抵达。语义上只需要区分两件事——还要不要再投一次,以及为什么没成功。

兜底的是一个定时任务,它扫出状态为 0 或 2、且创建时间已满一定间隔的记录重投:

@Scheduled(fixedDelay = 60_000, initialDelay = 60_000)
public void resendPending() {
    // 只捞创建满 1 分钟的记录:刚提交的那条正由 afterCommit 投递,
    // 和它抢同一条只会产生一次无谓的重复投递
    int count = mqMessageSender.resendPending(RESEND_BATCH);
    if (count > 0) {
        log.info("重投本地消息表的待投递消息 {} 条", count);
    }
}

到这里,第三节的两个窗口都被堵住了。窗口一"消息早发":消息在提交后才发,事务回滚时记录一起回滚,消息不会出去。窗口二"消息漏发":进程在提交后挂掉,记录还在库里且状态为 0,定时任务会把它捡起来重投。

剩下唯一的窗口是"broker 已确认,但本地状态还没改回 1 就崩了"。这个窗口关不掉,也不需要关——它导致的后果是重复投递而不是消息丢失。这是 at-least-once 语义,重复由消费端幂等兜住。

还有一个语义边界要划清:状态 1 的含义是"broker 收下了",不是"消费端处理完了"。消息进了 broker 之后,可靠性由 broker 的持久化和消费端的 ack 接手。要做到"消费完成",得再加一轮消费端回执,成本高一个量级,通常不值得。

五、消费端的幂等

at-least-once 必然带来重复,来源不止一处:定时任务和 afterCommit 可能投递同一条;broker 确认后本地状态未更新时进程崩溃会导致重投;消费端处理完但 ack 丢失,消息也会重投。所以消费逻辑必须可重复执行。

最省事的是业务本身天然幂等。删除操作就属于这一类:DELETE FROM t WHERE id = ? 删一条不存在的记录返回影响行数 0,不报错;对象存储的 removeObject 删一个不存在的 key 同样返回成功。项目里 coupon 侧清理优惠数据就是两条纯 DELETE,天然幂等,因此没有建去重表。

需要去重时,常规做法是用 message_id 建一张去重表并在处理前先 insert,主键冲突说明已处理过,直接跳过。另一种更轻的做法是把幂等条件写进更新语句本身,用前置状态做约束:

// 条件里带 status = 0:重复执行时影响行数为 0,不会二次生效
this.update(new LambdaUpdateWrapper<MqMessageEntity>()
        .eq(MqMessageEntity::getMessageId, messageId)
        .eq(MqMessageEntity::getMessageStatus, PENDING.getCode())
        .set(MqMessageEntity::getMessageStatus, SENT.getCode()));

去重表有一个容易踩的点:它的键必须能唯一标识"业务事件",而不是消息实例。同一次业务变更如果被重新生成了消息,message_id 是新的,去重表挡不住,只有用业务主键才能识别出这是同一件事。

六、死信队列:给重试一个出口

消费失败之后怎么处理,直接决定这套机制是可靠还是危险。

把消息塞回队列重投是最直观的做法,basicReject(tag, requeue=true) 和 basicNack(tag, false, true) 都是这个效果。它的问题是把消息塞回队头并立即重投:没有退避、没有上限、没有出口。一条永远处理失败的消息会让消费者陷入死循环,日志刷满,而且完全静默——没有任何地方能看出有一条消息卡住了。

合理的方式是让失败路径也走队列,用死信交换机加 TTL 组成延迟重试:

业务队列 ──消费失败 basicNack(requeue=false)──> DLX
                                                │
                                                ▼
                                    重试队列(TTL 1 分钟)
                                                │ 到期死信
                                                ▼
                                    回到业务交换机 ──> 业务队列(下一轮)

业务队列声明时挂上自己的 DLX;重试队列不设消费者,只设 x-message-ttl,消息在里面躺够时间后按 x-dead-letter-* 死信回业务交换机,于是又回到业务队列:

@Bean
public Queue productDeletedQueue() {
    Map<String, Object> args = new HashMap<>();
    args.put("x-dead-letter-exchange", PRODUCT_DELETED_DLX);
    args.put("x-dead-letter-routing-key", RETRY_ROUTING_KEY);
    return new Queue(PRODUCT_DELETED_QUEUE, true, false, false, args);
}

@Bean
public Queue productDeletedRetryQueue() {
    Map<String, Object> args = new HashMap<>();
    args.put("x-dead-letter-exchange", PRODUCT_EVENT_EXCHANGE);      // 回到业务交换机
    args.put("x-dead-letter-routing-key", PRODUCT_DELETED_ROUTING_KEY);
    args.put("x-message-ttl", 60_000);                               // 躺 1 分钟
    return new Queue(PRODUCT_DELETED_RETRY_QUEUE, true, false, false, args);
}

这样每轮重试之间至少有 1 分钟间隔,足以熬过数据库重启、依赖服务抖动这类短暂故障。

重试次数不能记在消费端内存里——服务重启就丢了。broker 会把"这条消息被死信过几次"写进消息头 x-death,并且是持久化的,重启不丢。它的结构是一个数组,元素形如 {queue, reason, count, exchange, routing-keys, time}。这里有个细节:一次重试会在数组里留下两条记录,业务队列一条(reason=rejected),重试队列一条(reason=expired),所以必须按队列名过滤后累加,不能直接取 x-death[0].count:

public static int attemptCount(Message message, String queue) {
    Object xDeath = message.getMessageProperties().getHeader("x-death");
    if (!(xDeath instanceof List<?> deaths)) {
        return 0;
    }
    int count = 0;
    for (Object death : deaths) {
        if (death instanceof Map<?, ?> entry
                && queue.equals(entry.get("queue"))
                && entry.get("count") instanceof Long times) {
            count += times;
        }
    }
    return count;
}

监听器据此决定是继续重试还是收进死信队列:

@RabbitHandler
public void onProductDeleted(ProductDeletedTo to, Message message, Channel channel) throws IOException {
    long deliveryTag = message.getMessageProperties().getDeliveryTag();
    try {
        productCleanupService.cleanupProduct(to.getSpuIds(), to.getSkuIds());
        channel.basicAck(deliveryTag, false);
    } catch (Exception e) {
        int retried = MqRetryUtils.attemptCount(message, PRODUCT_DELETED_QUEUE);
        if (retried >= MAX_RETRY) {
            // 到上限:投到死信队列让人看见,别再无限打转
            rabbitTemplate.convertAndSend(PRODUCT_DELETED_DLX, DLQ_ROUTING_KEY, to);
            channel.basicAck(deliveryTag, false);
        } else {
            // requeue=false:交给队列自己的 DLX → 重试队列延迟 1 分钟
            channel.basicNack(deliveryTag, false, false);
        }
    }
}

死信队列不挂任何消费者,消息进去就停在那里等人处理。它的价值在于把"重试"这件事从无限循环变成有限次尝试加一个明确的终点:能自愈的故障在重试中恢复,不能自愈的故障(数据问题、代码 bug、永远删不掉的地址)被保留下来而不是被丢弃。

需要注意,重试上限不是越高越好。能靠重试解决的通常是一两次就解决了,走满三四次还在失败,基本可以判定不是抖动而是数据或代码问题,继续重试只是延迟暴露。

七、完整链路

把上面的部分合起来,商品删除这条链路是这样的:

product 服务
  事务开始
    删除 7 张表的商品数据
    insert 一条 mq_message(状态 0,含交换机、路由键、消息体、类名)
  事务提交
    afterCommit → 投递到 product-event-exchange,routing key = product.deleted
    broker confirm(ack) → 状态 0 改 1

coupon 服务                         third-party 服务
  coupon.product.deleted.queue        thirdparty.product.deleted.queue
    消费成功 → basicAck                 消费成功 → basicAck
    消费失败 → nack → DLX → 重试队列     消费失败 → nack → DLX → 重试队列
    重试满 3 次 → coupon...dlq          重试满 3 次 → thirdparty...dlq

兜底:product 的定时任务每分钟扫状态 0/2 的记录重投

有两个设计取舍值得单独说明。

队列和绑定由消费方自己声明,product 只声明交换机。 这样 product 不需要知道存在哪些消费者,以后新增一个消费者不用改 product 的代码。代价是如果某个消费者从未启动过,它的队列和绑定就不存在,消息到了交换机之后不可路由。这种情况下 broker 照样会 ack,消息会被直接丢弃——ack=true 只代表"交换机收下了",不代表"有队列接"。要兜住这个缺口,需要开启 publisher-returns 并设置模板的 mandatory=true,让不可路由的消息被退回:

private void onReturn(ReturnedMessage returned) {
    String messageId = returned.getMessage().getMessageProperties().getCorrelationId();
    mqMessageService.markError(messageId, "消息不可路由: exchange=" + returned.getExchange()
            + ", routingKey=" + returned.getRoutingKey()
            + ", replyCode=" + returned.getReplyCode());
}

退回后状态变成 2(错误抵达),定时任务持续重投,等消费方起来、队列和绑定建好之后自然就投进去了。confirm 和 returns 必须分别处理,只处理 confirm 会把"消息已被丢弃"误判成"发送成功"。

每个消费方各自一个队列。 coupon 和 third-party 都绑在同一个交换机上,各自监听自己的队列,互不阻塞——coupon 挂了不影响清理文件,反过来也一样。如果共用一个队列,两个服务就成了竞争消费者,同一条消息只会被其中一个处理,清优惠和清文件必然漏掉一个。

收尾

三条连接、三个数据库,没有一个资源管理器能同时管住它们,这是分布式事务问题的全部起点。Seata 用协调器和补偿日志把跨服务操作重新组织成一个全局事务,代价是全局锁、额外组件和每个库的 undo_log。RabbitMQ 这条路放弃中间态的一致性,只保证最终收敛,代价转移到了消息的可靠投递和消费端的幂等上。

本地消息表解决的是"可靠投递"这一半:把消息记录和业务数据放进同一个事务,让"数据改了"和"有一条消息要发"成为同一个原子事实,剩下的交给提交后投递和定时重投。幂等解决的是另一半:at-least-once 消不掉重复,只能在消费端把重复执行变成无害操作。死信队列则是给重试划定边界,让无法自愈的失败停在一个人能看到的地方,而不是消失在无限循环里。

posted @ 2026-09-24 09:24  PC2005-cloud  阅读(13)  评论(0)    收藏  举报