我给消息推送加了 Publisher Confirm,然后亲手引入了一个竞态

------ 一次从「异步优化」到「退回同步」的完整复盘

引子:一行不起眼的 INFO 日志

事情的起点是一行日志。不是 ERROR,不是 WARN,是一行淹没在几十行正常输出中间的 INFO:

log 复制代码
11:53:02.560 INFO KernelEmailStateMachineImpl - Executing fireEvent() for email: 2089923568981184512, retry attempt: 3 / 5

retry attempt: 3 / 5

系统运行正常,邮件被成功分析,报告正常生成。如果我当时划过去,这篇文章就不会存在。

但我停下来问了一句:它为什么是 3?正常不应该重试一到两回就好了的吗?

一、背景:一个内核邮件分析服务

我在做一个 Linux 内核邮件列表分析服务,核心链路很简单:

复制代码
IMAP 拉取邮件 → 写库 → 推送到 RabbitMQ → 消费端调用 AI 分析 → 生成报告 → 持久化

每封邮件有一个状态机来跟踪它走到了哪一步:

erlang 复制代码
FETCHED → PUSHED → ANALYSIS_PENDING → ANALYZING → ANALYSIS_SUCCESS → ...

状态流转由 Spring StateMachine 驱动,配合数据库乐观锁:

java 复制代码
@Transactional(rollbackFor = Exception.class)
@Retryable(retryFor = OptimisticLockException.class, maxAttempts = 5, ...)
public KernelEmailStatus fireEvent(long emailId, KernelEmailEvents event) {
    // 查当前状态 → 恢复状态机 → sendEvent → 乐观锁写回
}

看起来挺严谨的,问题出在推送那一步。

二、第一个 Bug:convertAndSend() 返回 ≠ 消息到达

最初的代码是这样的:

java 复制代码
// (1) 把邮件投递到 RabbitMQ
this.rabbitTemplate.convertAndSend(exchange, routingKey, kernelEmail, correlationData);

// (2) 投递成功则变更状态到 PUSHED
this.kernelEmailStateMachine.fireEvent(emailId, KernelEmailEvents.PUSH_SUCCESS);

这段代码有个看不见的假设convertAndSend() 返回了,就代表消息到了 broker。

但这个假设是错的!

我画了张图来理清整个投递过程:

sequenceDiagram participant App as 应用线程 participant Ch as Channel 对象<br/>(JVM 内存内) participant Sk as 内核 Socket 缓冲 participant Net as 物理网络 participant Br as Broker 业务逻辑<br/>(路由/持久化/入队) App->>Ch: ① convertAndSend()<br/>序列化为 AMQP 帧 Ch->>Sk: ② socket.write() Note over App,Sk: convertAndSend() 在这里就返回了<br/>只代表&#34;数据交给了内核&#34; Sk->>Net: ③ 数据上网卡、发往网络 Net->>Br: ④ 到达 Broker Note over App: fireEvent(PUSH_SUCCESS)<br/>此刻 Broker 还没表态! Br-->>App: ⑤ basic.ack / basic.nack<br/>异步到达

convertAndSend() 做的事情是:序列化成 JSON → 封装成 AMQP Frame → 调用 write() 系统调用写进内核 socket 缓冲区。然后就返回了。

此时消息可能还在本机内核里躺着。如果这时候 broker 宕机、网络断开、或者磁盘写满导致 broker 拒绝,消息就丢了------而数据库里这封邮件已经是 PUSHED 状态

结果就是一封永远不会被消费、也永远不会被重试的邮件------一个静默黑洞。

解法:Publisher Confirms

RabbitMQ 基于 AMQP 0-9-1 扩展了 Publisher Confirms 机制。开启后,broker 收到并处理完消息,会异步回一个 basic.ack(或 basic.nack 表示拒绝)。

yaml 复制代码
spring:
  rabbitmq:
    publisher-confirm-type: correlated

这个配置有三档:

  • none:不开启,broker 不回任何确认
  • correlated:每条消息挂一个 CorrelationData,broker 异步回 ack/nack,结果写入该对象
  • simple:老的同步等待模式,性能差,不推荐

现代开发自然是选 correlated,然后我写了第一版实现------这也是问题的开始。

三、第二个 Bug:我自己造的竞态

第一版我用了异步回调,因为「异步显然更高效」:

java 复制代码
private void messagePublisherConfirm(Long emailId, CorrelationData correlationData) {
    correlationData.getFuture()
        .orTimeout(confirmTimeout, TimeUnit.SECONDS)
        .whenCompleteAsync((confirm, throwable) -> {
            if (throwable == null && confirm != null && confirm.isAck()) {
                stateMachine.fireEvent(emailId, PUSH_SUCCESS);   // ← 异步流转
            } else {
                stateMachine.fireEvent(emailId, PUSH_FAILURE);
            }
        }, this.emailServiceExecutor);
}

推送逻辑里注册完回调就返回,不阻塞。看起来很现代,很响应式。

然后就有了那行 retry attempt: 3 / 5

顺着日志挖下去

我把那封邮件的全部日志按时间轴拉出来:

时刻 事件
02.479 fireEvent 开始(线程 A)
02.491 fireEvent 开始(线程 B)← 两个线程同时在流转同一封邮件
02.505 读到 FETCHED,发 PUSH_SUCCESS
02.514 读到 FETCHED ,发 PULL_SUCCESS消费者来了,状态还没变
02.514 FETCHED -> PUSHED 写库成功
02.560 retry attempt: 2 / 5 ← 第一次 PULL_SUCCESS 失败了
02.570 又读到 FETCHED,再发 PULL_SUCCESS
02.594 retry attempt: 3 / 5 ← 第二次又失败
02.600 终于读到 PUSHED
02.604 PUSHED -> ANALYSIS_PENDING

这封邮件是靠重试到第 3 次才侥幸成功的。

根因:两条异步路径在赛跑

问题在于,broker 收到消息之后,会做两件互相独立的事:

sequenceDiagram participant P as 生产者线程 participant Br as Broker participant C as 消费者线程 participant DB as 状态机 / DB P->>Br: convertAndSend() Note over P: 注册 confirm 回调后立即返回 par 路径 A:broker 回 ack Br-->>P: basic.ack P->>DB: fireEvent(PUSH_SUCCESS)<br/>FETCHED -> PUSHED and 路径 B:broker 投递给消费者 Br->>C: deliver C->>DB: fireEvent(PULL_SUCCESS)<br/>需要 PUSHED 状态! end Note over DB: 两条路径无顺序保证<br/>B 先到则流转被拒 → 重试 → 可能进死信

AMQP 协议不保证「回 ack」和「投递给消费者」的先后顺序。 消费者在同一个进程里,max-concurrency 配了 75,完全可能先跑起来。

而我的状态机是严格线性的:FETCHED --PUSH_SUCCESS--> PUSHED --PULL_SUCCESS--> ANALYSIS_PENDING。消费者先到时,状态还是 FETCHEDPULL_SUCCESS 不被接受,sendEvent() 返回 false,抛异常,进入重试。

重试 5 次耗尽后会发生什么? 异常冒泡到消费端的 catch,然后:

java 复制代码
channel.basicNack(deliveryTag, false, false);   // requeue = false

一封完全正常的邮件被丢进死信队列。

关键认知:这个竞态是我自己引入的

原来的同步实现里,这个竞态不存在 ------PUSH_SUCCESSconvertAndSend() 之后立刻同步执行,必然早于消息被消费。

是「优化成异步」这个动作,把一个有序的执行链拆成了两条赛跑的路径。

四、反直觉的一点:低负载反而更容易触发

我一开始以为这是个高并发才会遇到的问题。实际上恰恰相反。

竞态的窗口大小取决于两个事件的时间差 ,而不是每秒处理多少消息。每一封邮件都独立地掷一次骰子------掷骰子的频率低,不代表单次掷出坏结果的概率低

而低负载在这里反倒是是不利因素

  • broker 空闲 → deliver 极快
  • prefetch: 1 + 消费者空闲 → 消息一到队列立刻被取走
  • 高负载反而会让消费者排队,给生产者留出时间

五、修复:退回同步

想清楚之后,我做的决定是------把异步掐掉

java 复制代码
private boolean awaitPublisherConfirm(Long emailId, CorrelationData correlationData) 
    throws InterruptedException 
{
    final long timeoutMillis = properties.getPublisherConfirmTimeout().toMillis();

    try {
        // 阻塞等待 broker 的 ack / nack
        final CorrelationData.Confirm confirm
            = correlationData.getFuture().get(timeoutMillis, TimeUnit.MILLISECONDS);

        if (Objects.isNull(confirm) || !confirm.isAck()) {
            log.error("Broker rejected kernel email (snowflake-id = {}, message-id = {}). Caused by: {}",
                emailId, correlationData.getId(),
                Objects.nonNull(confirm) ? confirm.getReason() : "confirm is null");
            return false;
        }
        return true;
    }
    catch (TimeoutException exception) {
        /*
         * 注意:超时的真实语义是「结果未知」而非「确认失败」,
         * 消息可能已经进入队列、甚至已被消费。
         */
        log.error("Publisher confirm timeout after {} ms, result UNKNOWN for kernel email ...", 
            timeoutMillis, emailId, correlationData.getId());
        return false;
    }
    catch (ExecutionException exception) {
        log.error("Publisher confirm failed ...", emailId, correlationData.getId(), exception.getCause());
        return false;
    }
}

调用侧:

java 复制代码
this.rabbitTemplate.convertAndSend(exchange, routingKey, kernelEmail, 
                                    messagePostProcessor, correlationData);

// 同步等待 broker 确认,拿到结果才流转状态
final boolean acked = this.awaitPublisherConfirm(emailId, correlationData);

this.kernelEmailStateMachine.fireEvent(
    emailId, acked ? PUSH_SUCCESS : PUSH_FAILURE
);

为什么这是对的?

关键不在「同步 vs 异步」,而在于:PUSH_SUCCESS 重新回到了 convertAndSend() 所在的执行链上,排在消息被消费之前。

而且顺带解决了三个我原本没意识到的问题:

  1. successCount 语义恢复真实 ------之前 join() 等到的只是"提交给 socket 成功"
  2. 在途回调不会被 shutdown() 截断 ------之前应用关闭时,悬在空中的回调会被直接丢弃,那批邮件的状态永久停在 FETCHED
  3. 回调内的异常有人接了 ------whenCompleteAsync 里抛的异常会静默消失,日志里都看不见

代价:吞吐

每封邮件串行等一个 confirm RTT。但这个项目跑在虚拟线程上:

java 复制代码
/** 邮件服务专用虚拟线程执行器。*/
@Bean(
    name          = "email-service-executor",
    destroyMethod = "shutdown"
)
public ExecutorService emailServiceExecutor()
{
    final ThreadFactory threadFactory
        = Thread.ofVirtual()
                .name("email-service-", 0)
                .factory();

    return
    Executors.newThreadPerTaskExecutor(threadFactory);
}

get() 阻塞的是虚拟线程,会自动 unmount 释放载体线程。这正是 Loom 想解决的问题------让"同步写法"不再等于"性能惩罚",于是你可以用最简单的顺序代码表达最清晰的时序语义。

六、诚实地说:竞态并没有完全消除

同步化把窗口从「每封必撞」压到了「偶尔撞」,但没有归零

txt 复制代码
t0  broker 收到消息
t1  broker 投递给消费者 ──→ 消费者 fireEvent(PULL_SUCCESS)  ← 仍可能在这里
t2  broker 回 ack
t3  get() 返回
t4  fireEvent(PUSH_SUCCESS) 写库完成

broker 在 t1 投递、t2 回 ack,这两件事的先后顺序协议依然不保证。

真正的治本方案是给状态机加一条边:

java 复制代码
.and().withExternal()
    .source(KernelEmailStatus.FETCHED)
    .target(KernelEmailStatus.ANALYSIS_PENDING)
    .event(KernelEmailEvents.PULL_SUCCESS)

承认「消费先于确认」是合法时序,而不是异常。 加边之后,即使撞上窗口,也不会被拒、不会耗重试、不会误进死信。

这才是闭合的方案:不是靠重试去赌时序,而是让状态机接受两种合法时序。

七、几个衍生的思考

1. 用重试掩盖时序依赖,是把「顺序保证」降级成「概率保证」

我的 @Retryable 混用了两种性质完全不同的失败:

失败类型 触发条件 重试是否有意义
乐观锁冲突 updated == 0 ✅ 能收敛,重试正确
流转被拒 !sendEvent(event) ❌ 收敛与否不由自己决定

第二种重试的本质是「赌别人会在我退避期间把状态推到我需要的位置」。 5 次退避约 75ms 左右,赌不赢就进死信队列。

这两种失败应该用不同的异常类型表达。

2. 超时的语义是「未知」,不是「失败」

这是两将军问题(在不可靠的通信信道上,无法通过消息传递达到绝对一致的共识) 的实例------理论上不可消除。

把未知当失败,同样是在撒谎,只是换了个方向。 如果消息其实到了,这封邮件会「既被正常分析、又被记为推送失败」; 将来若写补偿任务扫 PUSH_FAILED 重推,就会重复推送

正解不是"未知按失败算",而是让状态机能表达"未知" ------加一个 PUSH_UNKNOWN 状态,由对账任务定论。

3. Publisher Confirm 只堵了一半

Confirm 只保证消息到达 exchange。 如果 routing key 匹配不上任何队列,broker 依然回 ack------消息被静默丢弃。

要完整覆盖需要开启下面两个配置:

yaml 复制代码
# 开启返回机制
# 当消息无法被路由到任何队列时,Broker 会将消息返回给生产者。
spring.rabbitmq.publisher-returns: true

# 消息必须被路由到至少一个队列,否则通过 ReturnsCallback 退还消息
spring.rabbitmq.template.mandatory: true

再实现 ReturnsCallback。注意这个必须 用全局回调(它不走 CorrelationData)。

八、结语:这是一个什么问题

严格说,这不是典型的分布式一致性问题(没有多副本、没有共识)。它是更常见的一类:

跨越「事务边界」与「消息边界」的状态协调问题 ------也就是经典的 dual-write

系统里有两个事实来源:数据库里的 status、RabbitMQ 里的消息。两者之间没有原子性,而且不可能有。

我遇到的现象 它的标准名字
未确认就标 PUSHED dual-write 的写入顺序错误
confirm 与 consume 赛跑 缺乏 happens-before 保证
超时后不知道消息是否到达 两将军问题
想用重试掩盖时序依赖 用概率替代顺序保证

彻底的解法是 Transactional Outbox------本地事务写消息表 + 独立投递器,把 dual-write 变成 single-write。

但对这个体量的项目来说 Outbox 过于沉重了,同步确认 + 状态机加边 + 未知状态显式化,是更务实的中间路线。

最后

这次修复里,技术上最有价值的判断不是「怎么用 Publisher Confirm」,而是「这里的异步没有收益」

confirm 回调本身已经是异步的(IO 线程回调),再套一层 whenCompleteAsync 只是把状态流转甩出了原本有序的执行链,换来的吞吐提升被虚拟线程本来就能提供的并发抹平了。

我付了异步的钱,买了个自己不需要的东西,还顺带砸坏了原本好用的东西。

而这一切的起点,是一行 INFO 级别的日志------它夹在几十行正常输出中间,系统运行完全正常。跳过它是 100% 合理的选择。

但是我没跳过。


相关 PR

相关推荐
S3rein1 小时前
JAVA多线程手撕
java·多线程
用户8181870627461 小时前
第7章 死锁排查实录:jstack定位死锁的完整流程
java·后端
喵同志不止步于码农1 小时前
Java Caffeine 快速入门
java·开发语言·后端·并发·缓存一致性
Java成神之路-1 小时前
Java 并发灵魂拷问:notify/signal 唤醒顺序是规范还是实现?
java
Java牛马2 小时前
Caffeine 缓存及其应用相关总结
java·redis·缓存·caffeine·数据一致性·本地缓存
一只叫煤球的猫2 小时前
Spring AI 2.0 源码解析(三):ChatClient 的 Fluent API 如何构建请求?
java·后端·面试
软件开发JR2 小时前
基于Web的足球青训俱乐部管理后台系统的设计与开发
java·前端·spring boot·毕业设计
重生之我是Java开发战士3 小时前
【Java EE】Spring AOP :面向切面编程
java·spring·java-ee
凤山老林3 小时前
基于 Spring Batch 的海量数据迁移与批处理架构:分片、容错与断点续跑
java·spring boot·spring·架构·spring batch