不久前,我写过关于 Outbox 模式 的文章。现在,是时候谈谈它不可或缺的搭档:Inbox 模式了。
理解消息投递保证
在深入探讨 Inbox 模式之前,我们先明确分布式系统中三种核心的消息投递语义:
最多一次(At-most-once)
在此模型中,消息最多被投递一次——可能成功,也可能丢失,但绝不会重复。该模型没有确认(ACK)机制,如果消息因崩溃、超时或网络问题未能被处理,就会直接丢失。
这种语义常见于轻量级或内存中的消息系统。例如,NATS(在其核心模式下)默认就采用最多一次的投递方式。
权衡点:实现简单,但可靠性较低——除非发送方自行重试,否则消息可能永久丢失。
至少一次(At-least-once)
该模型保证消息至少会被投递一次。如果消费者未发送 ACK,消息代理(broker)会不断重试投递——可能多次。
这是大多数生产级消息中间件的默认行为,比如 RabbitMQ、AWS SQS、RocketMQ,以及 Kafka(在关闭自动提交 offset 的情况下)。
权衡点:你必须确保消息处理器是幂等的,因为重复投递不可避免——尤其是在处理时间超过可见性超时或 ACK 超时时。
恰好一次(Exactly-once)
这是消息投递的“圣杯”:每条消息恰好被处理一次,既无丢失,也无重复。
尽管 Kafka(配合幂等生产者和事务型消费者)等系统声称支持“恰好一次”语义,但在异构服务之间实现端到端的真正“恰好一次”投递极其困难。它通常需要消息层与应用逻辑紧密协作(例如通过事务性写入和去重机制)。
现实情况:大多数系统实际上是通过“至少一次投递 + 幂等处理”来模拟“恰好一次”——而这正是 Inbox 模式 大放异彩的地方。
问题:可靠消费的挑战
让我们聚焦于广泛使用的“至少一次”投递语义。实践中常遇到两大挑战:
- 处理缓慢导致重复:某条消息处理时间很长。单纯延长可见性超时并不总是可行。一旦处理时间超过超时阈值,消息代理就会重新投递,导致业务逻辑被重复执行。
- 流式消费中的“毒丸”问题:在 Kafka 等流式系统中,一条处理失败的消息(毒丸)可能阻塞整个分区。在解决该消息前,你无法继续消费后续消息,导致整个应用停滞。
如何在这些场景下实现幂等且具有弹性的消息处理?答案就是 **Inbox 模式**。
解法:Inbox 模式
Inbox 模式提供了一种实现幂等消费的机制。其核心思想是:将数据库作为可靠、权威的入站消息日志。
具体工作流程如下:
- 收到消息后,第一步是在数据库(如 PostgreSQL、MySQL)中一个专用的 inbox 表中持久化该消息,且该操作与任何初始业务数据变更处于同一个数据库事务中。
- 消息记录包含一个唯一标识(如 message_id)。
- 在处理消息前,系统先查询 inbox 表,检查该 ID 的消息是否已被处理(或正在处理)。
- 如果是重复消息,则直接确认(ACK)并忽略。
这种方式优雅地解决了前述问题:
- 针对问题 #1(重复投递):inbox 表充当幂等性过滤器。即使同一条消息被多次投递,也只会被处理一次。
- 针对问题 #2(毒丸阻塞):在流式系统中,你可以先 ACK 有问题的消息以解除队列阻塞。待问题修复后,重置流或偏移量进行重放。Inbox 模式会自动跳过之前已成功处理的消息。
权衡与考量
没有银弹。Inbox 模式也带来了一些代价:
- 性能开销:每条消息都需要一次数据库写入和一次查询,增加了延迟。
- 架构复杂性:需额外管理 inbox 表和后台处理器。
- 数据库瓶颈:在极高吞吐量下,数据库可能成为性能瓶颈。
- 存储开销:inbox 表可能迅速膨胀,需制定保留策略或归档机制。
工具与实现
好消息是,Inbox 模式已被广泛支持。你很可能在所用编程语言的生态中找到现成框架。若没有,其核心逻辑也相对简单,可自行实现。
对于 .NET 开发者,Brighter 项目对 Inbox 和 Outbox 模式提供了优秀的内置支持。
结语
Inbox 模式是构建弹性事件驱动系统的重要工具。通过将数据库作为保护层,它确保了幂等处理,并能优雅应对各种故障场景。尽管引入了一定复杂性,但对关键业务流程而言,这种“无重复、不丢失”的处理保障价值巨大。当你使用 Outbox 模式确保可靠发送时,请务必搭配 Inbox 模式以实现可靠消费。
参考资料
- https://event-driven.io/en/outbox_inbox_patterns_and_delivery_guarantees_explained/
- https://newsletter.systemdesignclassroom.com/p/every-outbox-needs-an-inbox
2678

被折叠的 条评论
为什么被折叠?



