------ 一次从「异步优化」到「退回同步」的完整复盘
引子:一行不起眼的 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。
但这个假设是错的!
我画了张图来理清整个投递过程:
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 收到消息之后,会做两件互相独立的事:
AMQP 协议不保证「回 ack」和「投递给消费者」的先后顺序。 消费者在同一个进程里,max-concurrency 配了 75,完全可能先跑起来。
而我的状态机是严格线性的:FETCHED --PUSH_SUCCESS--> PUSHED --PULL_SUCCESS--> ANALYSIS_PENDING。消费者先到时,状态还是 FETCHED,PULL_SUCCESS 不被接受,sendEvent() 返回 false,抛异常,进入重试。
重试 5 次耗尽后会发生什么? 异常冒泡到消费端的 catch,然后:
java
channel.basicNack(deliveryTag, false, false); // requeue = false
一封完全正常的邮件被丢进死信队列。
关键认知:这个竞态是我自己引入的
原来的同步实现里,这个竞态不存在 ------PUSH_SUCCESS 在 convertAndSend() 之后立刻同步执行,必然早于消息被消费。
是「优化成异步」这个动作,把一个有序的执行链拆成了两条赛跑的路径。
四、反直觉的一点:低负载反而更容易触发
我一开始以为这是个高并发才会遇到的问题。实际上恰恰相反。
竞态的窗口大小取决于两个事件的时间差 ,而不是每秒处理多少消息。每一封邮件都独立地掷一次骰子------掷骰子的频率低,不代表单次掷出坏结果的概率低。
而低负载在这里反倒是是不利因素:
- 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() 所在的执行链上,排在消息被消费之前。
而且顺带解决了三个我原本没意识到的问题:
successCount语义恢复真实 ------之前join()等到的只是"提交给 socket 成功"- 在途回调不会被
shutdown()截断 ------之前应用关闭时,悬在空中的回调会被直接丢弃,那批邮件的状态永久停在FETCHED - 回调内的异常有人接了 ------
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: