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 里其实非常集中:
ErrorHandlingStrategyDefaultErrorHandlingStrategyErrorActionErrorActionAggregator
也正因为它足够集中,这一篇特别适合把设计边界讲透。
一、这篇文章到底想回答什么?
这一篇只回答三个问题:
- 单个 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 后直接打印日志"式的实现很不一样,它至少先把决策语义显式建模出来了。
不过这里要先提前说清一个边界,避免后面读到 ACKNOWLEDGE、RETRY、DEAD_LETTER 这些动作时产生过度联想:
- 当前版本已经把错误结果抽象成了明确的动作语义
DefaultMqttMessageRouter也确实会在结尾调用errorActionAggregator.aggregate(actions)- 但
route(...)目前仍然是void,聚合结果还没有继续反馈到 adapter 或协议层确认逻辑
也就是说,第 5 篇讨论的重点首先是"动作语义如何被建模和聚合",而不是"这些动作已经完整驱动了端到端协议行为"。
三、ErrorAction 为什么要先变成统一语义?
当前 ErrorAction 非常简单,只有 4 个枚举值:
ACKNOWLEDGERETRYMANUAL_ACKDEAD_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、headersThrowable
可以拿到真实异常
这说明它的设计目标很明确:
- 决策必须知道是谁失败了
- 决策必须知道消息上下文是什么
- 决策必须知道具体失败原因
而默认实现 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取最大值
也就是说,当前优先级直接由枚举声明顺序决定:
ACKNOWLEDGERETRYMANUAL_ACKDEAD_LETTER
也可以理解为:
- 越靠后,动作越"严格"
- 最终聚合结果总是取最严格的那个
ACKNOWLEDGE
RETRY
MANUAL_ACK
DEAD_LETTER
aggregate() returns the strictest action by ordinal
从测试也能看出这条规则是被显式验证过的:
ACKNOWLEDGE + RETRY -> RETRYMANUAL_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 | 测试体系:MqttTestTemplate 与 EmbeddedBroker 的设计 |
待更新 |
| 10 | 从内部项目到开源框架:mqtt-plus 的抽取过程与决策 | 待更新 |
上一篇:拦截器链:MqttMessageInterceptor 的扩展点设计(待更新链接)
下一篇:多 Broker 管理:如何让一个应用同时连接多个 MQTT 服务(待更新链接)