mqtt-plus 架构解析(五):错误处理与 ErrorAction 聚合策略

mqtt-plus 架构解析(五):错误处理与 ErrorAction 聚合策略

摘要

在很多消息框架里,错误处理真正困难的地方,不是"一个 listener 抛异常怎么办",而是"同一条消息命中多个 listener 时,结果不一致怎么办"。mqtt-plus 当前的做法,是把单个 listener 的失败交给 ErrorHandlingStrategy 决策,再把多个结果交给 ErrorActionAggregator 聚合。本文会结合真实源码,拆解 ErrorAction、默认错误策略、严格优先级聚合规则,以及这一版实现目前的边界在哪里。

项目地址

项目地址:

https://github.com/mqttplus/mqtt-plus

配套的示例工程:

https://github.com/mqttplus/mqtt-plus-examples

如果你对这个方向感兴趣,欢迎关注、试用,也欢迎一起交流 issue 和 PR。

如果这篇文章对你有帮助,欢迎点赞、收藏,也欢迎给项目一个 Star。


前面几篇已经把主链路铺开了:

  • 第 2 篇讲的是一条消息如何走到 @MqttListener
  • 第 3 篇讲的是 payload 为什么拆成入站/出站两条链
  • 第 4 篇讲的是 MqttMessageInterceptor 为什么只做前后观察,不做错误决策

那错误到底是谁来决定?

答案在 mqtt-plus-core 里其实非常集中:

  • ErrorHandlingStrategy
  • DefaultErrorHandlingStrategy
  • ErrorAction
  • ErrorActionAggregator

也正因为它足够集中,这一篇特别适合把设计边界讲透。

一、这篇文章到底想回答什么?

这一篇只回答三个问题:

  • 单个 listener 失败之后,框架如何把异常转换成统一动作语义
  • 同一条消息命中多个 listener,结果不一致时如何做最终聚合
  • 当前实现为什么说已经有了清晰的错误决策模型,但还不是一个完整的端到端重试框架

如果只记住一句话,那就是:

mqtt-plus 当前把"错误动作语义"抽象清楚了,但把"这个动作如何真正反馈到协议层"刻意收得比较克制。

二、先看错误处理主链路

先看一条消息在路由阶段发生异常时,框架内部会走什么路径。

先看全局路径:
Inbound MQTT Message
DefaultMqttMessageRouter.route()
for each matched listener
listener handling block
collect ErrorAction
ErrorActionAggregator.aggregate(actions)

再把单个 listener 的处理块展开:
No
Yes
beforeHandle()
convertPayload()
listenerInvoker.invoke()
exception thrown?
actions.add(ACKNOWLEDGE)
errorHandlingStrategy.onError(...)
actions.add(ErrorAction)
afterHandle()

这条链路的关键点只有两个:

  • 单个 listener 的失败,不会直接在路由器里写死成某种行为,而是委托给 ErrorHandlingStrategy
  • 多个 listener 的结果,不是边跑边决定,而是先收集到 actions,最后统一交给 ErrorActionAggregator

也就是说,错误处理在 mqtt-plus 里是两层结构:

  • 第一层:单个 listener 的错误决策
  • 第二层:同一条消息的多 listener 结果聚合

这和很多"catch 后直接打印日志"式的实现很不一样,它至少先把决策语义显式建模出来了。

不过这里要先提前说清一个边界,避免后面读到 ACKNOWLEDGERETRYDEAD_LETTER 这些动作时产生过度联想:

  • 当前版本已经把错误结果抽象成了明确的动作语义
  • DefaultMqttMessageRouter 也确实会在结尾调用 errorActionAggregator.aggregate(actions)
  • route(...) 目前仍然是 void,聚合结果还没有继续反馈到 adapter 或协议层确认逻辑

也就是说,第 5 篇讨论的重点首先是"动作语义如何被建模和聚合",而不是"这些动作已经完整驱动了端到端协议行为"。

三、ErrorAction 为什么要先变成统一语义?

当前 ErrorAction 非常简单,只有 4 个枚举值:

  • ACKNOWLEDGE
  • RETRY
  • MANUAL_ACK
  • DEAD_LETTER

这个设计最重要的意义,不是"枚举值够不够多",而是它把错误处理从"异常对象"转换成了"动作语义"。

异常对象适合诊断,但不适合聚合。因为:

  • 不同 listener 抛出的异常类型可能完全不同
  • 业务最关心的不是异常类名,而是"接下来该怎么处理这条消息"
  • 只有先把异常映射成统一动作,后面才有可能做一致的聚合规则

换句话说,ErrorAction 是一层抽象压缩:

  • 上游保留异常上下文做诊断
  • 下游只关心动作语义做决策

设计决策: mqtt-plus 没有直接拿异常对象去做聚合,而是先把失败结果映射成 ErrorAction。这样做的重点不是简化异常,而是让"多 listener 的最终动作决策"成为可能。

四、单个 listener 失败后,谁来决定动作?

这一层由 ErrorHandlingStrategy 负责。

接口非常克制:

java 复制代码
public interface ErrorHandlingStrategy {

    ErrorAction onError(MqttListenerDefinition definition, MqttContext context, Throwable error);
}

这里有三个输入特别重要:

  • MqttListenerDefinition
    可以知道是哪个 listener 出错了
  • MqttContext
    可以拿到 brokerId、topic、payload、headers
  • Throwable
    可以拿到真实异常

这说明它的设计目标很明确:

  • 决策必须知道是谁失败了
  • 决策必须知道消息上下文是什么
  • 决策必须知道具体失败原因

而默认实现 DefaultErrorHandlingStrategy 更克制,直接返回:

java 复制代码
return ErrorAction.ACKNOWLEDGE;

这意味着当前默认策略并不是"失败就重试",而是:

默认不把单个 listener 失败升级成全局阻断行为。

这背后体现的是一个很现实的取舍:

  • 如果框架默认就倾向 RETRY,那很多业务异常都会把消息消费链拖进重复处理
  • 如果框架默认 ACKNOWLEDGE,那框架会更偏向"不中断主链路,把是否重试交给业务自行覆盖"

设计决策: 默认错误策略返回 ACKNOWLEDGE,不是因为失败不重要,而是因为框架默认选择"保持消费链连续",把更强的重试、人工确认或死信策略留给自定义 ErrorHandlingStrategy 去决定。

五、多个 listener 结果不一致时,为什么必须做聚合?

这才是第 5 篇真正的主问题。

一条 MQTT 消息在 mqtt-plus 里可能命中多个 listener。于是就会出现这种情况:

  • Listener A 成功,结果相当于 ACKNOWLEDGE
  • Listener B 失败,自定义策略返回 RETRY
  • Listener C 成功,结果相当于 ACKNOWLEDGE

那这条消息最终应该按什么处理?

  • 跟多数派走,选 ACKNOWLEDGE
  • 只要有一个失败就 RETRY
  • 交给某个 listener 的优先级?

mqtt-plus 当前选的是非常明确的一条路:

把所有 listener 的动作收集起来,再按"最严格动作优先"做聚合。
Same MQTT Message
Listener A -> ACKNOWLEDGE
Listener B -> RETRY
Listener C -> ACKNOWLEDGE
ErrorActionAggregator
Final Action = RETRY

这种模型的最大好处是:

  • 不会因为"多数成功"就把少数失败吞掉
  • 能用统一规则覆盖多 listener 场景
  • 最终决策保守而明确,不依赖业务侧自己拼装推理

这也是为什么这里不能简单用多数决。

因为在消息处理这种场景里,多数决很容易掩盖真正失败的 listener,而一旦失败的 listener 承担的是关键业务逻辑,多数决反而会制造"看起来成功、实际上不完整"的结果。

六、ErrorActionAggregator 的聚合规则到底是什么?

当前实现比很多人想象得更简单。

ErrorActionAggregator.aggregate(actions) 的实现,本质上就是:

  • 空集合时返回 ACKNOWLEDGE
  • 非空时按 enum ordinal 取最大值

也就是说,当前优先级直接由枚举声明顺序决定:

  1. ACKNOWLEDGE
  2. RETRY
  3. MANUAL_ACK
  4. DEAD_LETTER

也可以理解为:

  • 越靠后,动作越"严格"
  • 最终聚合结果总是取最严格的那个

ACKNOWLEDGE
RETRY
MANUAL_ACK
DEAD_LETTER
aggregate() returns the strictest action by ordinal

从测试也能看出这条规则是被显式验证过的:

  • ACKNOWLEDGE + RETRY -> RETRY
  • MANUAL_ACK + DEAD_LETTER -> DEAD_LETTER

这种实现的优点很明显:

  • 规则非常稳定
  • 聚合逻辑几乎零歧义
  • 后续测试也很好写

当然,这种做法也有一个前提:

枚举顺序本身就是架构规则。

也就是说,ErrorAction 的声明顺序不是随便排的,而是直接决定聚合优先级。这是一种很轻量的实现方式,但也要求维护者对枚举顺序非常谨慎。

七、为什么不用"多数决"或者"第一个失败说了算"?

这里其实有三种常见思路:

  • 多数决
  • 第一个失败说了算
  • 最严格动作优先

mqtt-plus 当前选择第三种,是因为它最适合"同一条消息被多个 listener 独立消费"的模型。

如果用多数决,会出现一个很危险的场景:

  • 3 个 listener
  • 2 个成功
  • 1 个关键 listener 失败
  • 最终却被判成 ACKNOWLEDGE

这样表面上系统吞吐更顺,但架构语义其实变模糊了,因为失败被"票数"稀释了。

如果用"第一个失败说了算",问题又会变成:

  • 结果依赖 listener 执行顺序
  • 顺序不同,最终动作可能不同
  • 这会破坏行为稳定性

所以"最严格动作优先"看起来保守,但它有两个非常重要的优点:

  • 不依赖 listener 顺序
  • 不会把失败隐藏在多数成功里

这正是消息中间件和事件系统常见的一种架构倾向:

宁可偏保守,也不要把不一致结果包装成成功。

八、这一版实现的边界在哪里?

这一节很重要,因为如果不把边界讲清楚,读者很容易把 mqtt-plus 想象成一个完整的"错误动作执行引擎"。

当前真实实现里,DefaultMqttMessageRouter 在结尾确实会调用:

  • errorActionAggregator.aggregate(actions)

但它当前的 route(...) 方法返回值是 void,聚合出来的最终 ErrorAction 也没有继续传递给 adapter 或协议层确认逻辑。

这意味着当前版本已经有了:

  • 单 listener 错误决策模型
  • 多 listener 结果聚合模型
  • 明确的动作语义

但还没有完全打通:

  • RETRY 如何映射到真实重投或重试行为
  • MANUAL_ACK 如何映射到协议层确认控制
  • DEAD_LETTER 如何映射到真正的死信投递链路

所以更准确的说法应该是:

当前 ErrorAction 更像一个已经建好的架构接缝,而不是一个已经完整闭环的协议执行层。

这不是坏事,反而说明 mqtt-plus 在这一版里把"决策语义"先定义清楚了,但没有急着把所有协议层行为一次性做满。

对于一个还在持续演进的框架来说,这种节奏其实是合理的。

九、小结

第 5 篇真正想讲清楚的,不是"异常怎么 catch",而是两层设计:

  • 单个 listener 的失败,由 ErrorHandlingStrategy 决定动作
  • 多个 listener 的结果,由 ErrorActionAggregator 做最终聚合

而聚合规则也非常明确:

  • 空集合默认 ACKNOWLEDGE
  • 非空集合按枚举优先级取最严格动作

从架构上看,这套设计最大的价值,是它把"错误处理"从模糊的异常传播,收敛成了可讨论、可扩展、可聚合的动作语义。

但同时也要如实看到它当前的边界:

  • ErrorAction 已经存在
  • 聚合规则已经清楚
  • 端到端的协议层动作闭环,还没有完全打通

这也正好为后面的主题留出了空间。下一篇会进入另一条更偏结构层的问题:一个应用同时连接多个 broker 时,隔离与共享是如何并存的。

系列导航

本文是 mqtt-plus 架构解析 系列的第 5/10 篇。

# 主题 链接
1 总览:分层架构与设计哲学 待更新
2 消息路由:一条 MQTT 消息如何到达你的 @MqttListener 待更新
3 Payload 序列化与反序列化:双链设计的取舍 待更新
4 拦截器链:MqttMessageInterceptor 的扩展点设计 待更新
5 错误处理:ErrorAction 聚合策略的设计逻辑 本文
6 多 Broker 管理:如何让一个应用同时连接多个 MQTT 服务 待更新
7 动态订阅与重连恢复:Reconciler 的协调机制 待更新
8 Spring Boot 自动装配:零件是怎么被粘合起来的 待更新
9 测试体系:MqttTestTemplateEmbeddedBroker 的设计 待更新
10 从内部项目到开源框架:mqtt-plus 的抽取过程与决策 待更新

上一篇:拦截器链:MqttMessageInterceptor 的扩展点设计(待更新链接)

下一篇:多 Broker 管理:如何让一个应用同时连接多个 MQTT 服务(待更新链接)

相关推荐
SmartBrain2 小时前
AI智能体:MCP模型上下文管理设计及实现
人工智能·spring cloud·架构
呼啦啦5612 小时前
C++vector
java·c++·缓存
花千树-0102 小时前
MCP + Function Calling:让模型自主驱动工具链完成多步推理
java·agent·react·mcp·toolcall·harness·j-langchain
Benszen2 小时前
Linux容器:轻量级虚拟化革命
java·linux·运维
凸头2 小时前
Lombok 包底层浅析
java
不懂的浪漫2 小时前
mqtt-plus 架构解析(三):Payload 序列化与反序列化,为什么要拆成两条链
java·spring boot·物联网·mqtt·架构
Aray12342 小时前
论Serverless架构模式及其应用实践
云原生·架构·serverless
卷福同学2 小时前
去掉手机APP开屏广告,李跳跳2.2下载使用
java·后端·算法
天马行空_kk3 小时前
从 Skill 聊到多模态的一个下午
架构