让我们来谈谈通信工具和模式。随着 Redis 中 Streams 的引入,除了 Redis Pub/Sub 和像 Kafka、RabbitMQ 这样的其他工具之外,我们现在又多了一种通信模式需要考虑。在本文中,我将引导您了解各种通信模式的定义特征,并简要介绍实现每种模式最流行的工具。最后,我将为您提供一个小的启示,希望它能帮助您更快地构建更好的解决方案。
在此上下文中,同步意味着所有参与方需要同时处于活动状态才能进行通信。最简单的形式是服务 A 和服务 B 执行直接的远程过程调用(RPC),例如,服务 A 调用服务 B 的 HTTP REST 端点。如果服务 B 脱机,服务 A 将无法与 B 通信,因此服务 A 需要实现内部故障恢复过程,这在大多数情况下意味着进行优雅降级。例如,如果推荐服务无法访问,Netflix 的“下一集观看”部分可能会显示随机的节目样本。
这些服务仅执行优雅降级,因为对于更敏感的用例(例如,支付服务要求订单服务开始处理已支付的订单),我将在下面描述的其他异步机制更常见。虽然 RPC 范例适用于一对一通信,但您偶尔需要支持一对多或多对多。此时,您有两个主要选项:无代理工具或有代理工具。
无代理意味着参与者仍然直接连接,但可以选择采用与 RPC 不同的模式。在此类别中,我们有像 ZeroMQ 和较新的 nanoMsg 这样的库。它们被恰当地描述为“加强版的 TCP socket”。在实践中,您将库导入到代码中,并使用它实例化一个连接,该连接可以使用各种内置的消息路由机制,例如 Pub/Sub、Push/Pull、Dealer/Router 等。
有代理意味着参与者连接到同一个服务,顾名思义,该服务充当中心代理来实施整个消息路由机制。虽然这种架构通常被描述为星形,代理是星形的中心,但代理本身可以是(而且通常是)一个集群系统。
据我所知,在此类别中,Redis Pub/Sub 独树一帜。您仍然可以使用带有持久性的工具,如 NATS 或 RabbitMQ,来实现此用例,因为它们允许您关闭持久性,但我知道的唯一纯同步消息代理是 Redis。区别不仅在于持久性,还在于可靠交付(即应用层确认)与即发即弃(fire-and-forget)的一般概念。RabbitMQ 默认采用前一种行为,而 Redis Pub/Sub 则专注于为即发即弃执行最少的工作量。您可以想象,这会影响性能(毕竟没有免费的午餐),但可靠交付确实适用于更广泛的用例。
由于 Streams 在 Redis 5 版本之前不可用,有些人在本应倾向于更好的交付保证的情况下选择了使用 Pub/Sub,现在他们正在进行切换。因此,如果您正在构建新应用程序或对当前使用 Pub/Sub 的应用程序感到不满意,如果您的需求是“像 Pub/Sub,但能在断开连接后恢复且不丢失消息”,请考虑使用 Redis Streams。
无代理工具是您可以想到的最快的通信方法,甚至比 Redis Pub/Sub 更快。不幸的是,它们无法抽象掉所有复杂性——例如每个参与者需要知道所有其他参与者的位置才能连接到他们,或者在有代理系统中通常无需处理的复杂故障场景(例如,参与者在扇出过程中死亡的情况)。
在这种情况下,使用 Redis Pub/Sub 的妙处在于不必牺牲太多吞吐量,同时换来一个简单、普遍存在的、集成界面小的基础设施。您只需要一个适用于您的语言的 Redis 客户端,就可以使用 PUBLISH 和 (P)SUBSCRIBE 来传递消息。
当然,异步意味着即使并非所有参与者同时在场,通信仍然可以发生。为了实现这种模式,持久化消息是强制性的,否则在面对故障时将无法保证交付。此类别中的工具主要包括基于队列或基于流的解决方案。
这是进行异步通信的“传统”方式,也是大多数面向服务的架构(SOA)的基础。其思想是,当一个服务需要与另一个服务通信时,它将消息留在中央系统中,供另一个服务稍后提取。实际上,这些消息收件箱就像任务队列一样。
这些系统的另一个期望是任务彼此独立。这意味着它们可以(而且几乎总是)由多个相同的消费者并行处理,这些消费者通常被称为工作者(workers)。此特性还支持独立故障,这对于许多工作负载来说是一个很好的功能。例如,无法处理来自一个用户的付款(可能是由于缺少个人资料信息或其他微不足道的问题)不会停止所有用户的整个付款处理流水线。
此类别中最知名的工具是 RabbitMQ,其次是大量其他工具和云服务,它们大多支持 AMQP(Rabbit 的原生协议)或 MQTT(类似的开放标准)。通过框架使用 RabbitMQ 是常见的做法,这些框架提供了一种简单的方式来实现各种重试策略(例如指数退避和死信队列),并提供一个更简洁的接口,使在特定客户端生态系统中处理消息更加符合习惯用法。其中一些框架是“朴素”的任务队列,如 Sidekiq (Ruby)、Celery (Python)、Dramatiq (Python) 等。其他则是“更严肃”的企业服务总线(ESB),如 NServiceBus (C#)、MassTransit (C#)、Apache Synapse (Java) 或 Mulesoft (Java)。
这种模式(任务队列)的更简单版本也可以直接使用 Redis Lists 实现。Redis 具有阻塞和原子操作,这使得构建定制解决方案非常容易。值得特别一提的是 Kue,它巧妙地使用 Redis 为 JavaScript 实现了任务队列。
首先,值得注意的是,使用流的最简单方式仅作为一种存储形式。流是不可变的、仅附加的按时间排序的条目序列,许多类型的数据很自然地符合这种格式。例如,传感器读数或日志包含的值按其性质由创建时间索引且仅附加(您不能改变过去)。它们还具有相当规则的结构(因为它们倾向于保持相同的字段集),这是流可以利用以提高空间效率的特性。这种类型的数据非常适合流,因为访问数据的最直接方式是检索给定时间范围,而流可以非常高效地做到这一点。
回到我们的通信用例,所有流实现都允许客户端跟踪流,在新条目添加时接收实时更新。这有时称为观察或订阅流。将 Streams 用作通信工具的最简单方法是,将原本会通过 Pub/Sub 发布的内容推送到流中,从而基本上创建一个可恢复的 Pub/Sub。每个订阅者只需要记住它处理的最后一个条目 ID,这样在发生崩溃或断开连接时就可以轻松恢复。
还可以——有时更可取——在流上实现服务到服务的通信,进入**流式架构**的领域。这里的主要概念是,我们之前描述为任务/消息的内容,现在将成为事件。在基于队列的设计中,任务由想要某个服务执行某事的另一个服务推送到该服务的队列中,但在流式架构中,情况正好相反:每个服务将其状态更新推送到自己的流中,然后其他服务会观察这个流。
设计上的这种改变带来了许多微妙的影响。例如,您可以稍后添加新服务,并让它们遍历整个流历史记录。在队列中,这是不可能的,因为任务完成后会被删除,而且这些系统中通常表达通信的方式也不允许这样做(想想命令式与函数式)。
流具有双重性质:数据结构和通信模式。有些数据很自然地适合这种形式(例如日志),而且服务之间的通信不一定必须基于任务队列。完全拥抱这种双重性质的做法称为事件溯源。
通过事件溯源,您可以将业务模型定义为无限的事件流,并让业务逻辑和其他服务对此做出反应。进行这种转换并不总是容易的,但在处理诸如“对象 X 在时间 Y 的状态是什么?”等难题时,其好处可能是巨大的,如果没有适当的审计日志,这些问题将很难回答,甚至根本不可能回答。
也许您的普通移动应用程序不需要事件溯源,但对于必须处理客户个人数据、发货和其他“混乱”领域的企业软件而言,事件溯源可能大有裨益。
为了实现这些模式,您可以使用多种工具。当然,我们应该从房间里的大象开始谈起:Apache Kafka,以及像 Apache Pulsar(来自 Yahoo)和用其他语言重新实现的 Kafka 的替代方案,再加上一些 SaaS 产品。最后,还有一个新来者:Redis Streams。
首先,请注意,Redis 称之为“流”的东西,Kafka 称之为“主题分区”,而在 Kafka 中,“流”是一个完全不同的概念,它围绕处理 Kafka 主题的内容展开。
话虽如此,在表达能力方面,这两个系统是等效的:您可以在其中任何一个上实现相同的应用程序,而无需对数据建模方式进行任何实质性更改。一旦您深入到实际细节,差异就开始显现,而且差异很多且实质性。
Kafka 已经存在很长时间了,人们已经成功构建了可靠的流式架构,其中 Kafka 是单一事实来源。然而,如果您愿意尝试新技术,重视开发和运维的简单性,并且需要亚毫秒级延迟,那么 Redis Streams 可以在您的架构中占据非常相似的位置。
当我说简单时,我是认真的。如果您从未尝试过 Redis Streams,即使您计划在生产环境中使用 Kafka,我还是建议您尝试使用 Redis Streams 进行应用程序原型开发,因为它确实只需几分钟就可以在您的笔记本电脑上启动并运行。
让我们考虑几个例子,看看每种模式最适合解决哪些问题。
如果您想让几个客户端设备(例如手机、Arduino)在局域网中相互通信或与运行在计算机上的程序通信,最快获得可行解决方案的途径可能是“加强版的 TCP 连接”。
IRC 风格的聊天应用程序(即无历史记录)或用于易失性日志/事件的即插即用实时处理流水线,使用有代理方法效果很好。本杰明·瑟詹 (Benjamin Sergeant) 在旧金山的 RedisConf19 上谈到了最后一个用例 (幻灯片)。
网络爬虫经常依赖这种模式,许多网页服务也有无法立即完成响应请求的操作。例如,想想 YouTube 中的视频编码。
除了 Slack 风格的聊天应用程序(即有历史记录)外,这种技术最适用于日志处理、物联网 (IoT) 设备和微服务。
在结束之前,我想给您留下最后一个考虑。Redis 真正的超能力在于它不仅仅是一个 Pub/Sub 消息系统、队列或流服务。它也不仅仅是一个通用数据库。实际上,只要有足够的毅力,您可以在关系型 DBMS 的基础上实现上述描述的每一种模式,但从实际角度来看,这样做是个坏主意。
Redis 提供了一个**真正**的 Pub/Sub 即发即弃系统,以及一个**真正**的 Stream 数据类型。此外,通过 Redis 模块,Redis 还支持许多不同数据类型的真实实现。让我将此论断映射回我们的持久化和非持久化聊天应用程序用例。
上面,我得出结论认为 Pub/Sub 是正确的选择,因为这种类型的聊天应用程序只需要将消息发送给连接的客户端。然而,即使要实现这个应用程序的简单版本,您仍然必须考虑一个全局频道列表和每个频道的用户在线状态列表。您将这些状态存储在哪里?如何保持其最新,尤其是在服务实例意外死亡时?在 Redis 中,答案很简单:有序集合、过期键和原子操作。如果您使用 RabbitMQ,您将需要一个 DBMS。
对话可以非常自然地表示为消息流。在这里,我们甚至不必引入事件溯源,因为它已经是这种数据类型的原生结构。那么为什么这个例子选择 Redis 而不是 Kafka 呢?就像之前一样,您的聊天系统不会仅仅是一个消息流。还有频道和其他状态,最好以不同的方式表示。还有“用户 X 正在输入…”功能:这些信息是易失性的,您想将其发送给所有参与者,但仅当他们连接时。如果您使用 Kafka,无论如何都需要启动一个 Pub/Sub 系统。
在分布式系统中,当您需要协调时,通常也需要共享状态,反之亦然。忽略这个事实通常会导致解决方案过于复杂。Redis 非常理解这一点,这也是其独特设计的原因之一。如果您遵循这一原则,您会发现,有了正确的原语,难题有时只需几条命令即可解决,而这正是 Redis 提供给您的。例如,您可以通过以下方式事务性地将条目附加到流、将任务推送到队列(开头)并发布到 Pub/Sub
MULTI
XADD logs:service1 * level error req-id 42 stack-trace "..."
LPUSH actions-queue "RESTART=service1"
PUBLISH live-notifs "New error event in service1!"
EXEC
要获得一个可行的解决方案,您需要击败七个邪恶的并发问题。
《Redis 朝圣者大战世界》的情节
我希望这能让您了解分布式系统中常用的主要通信模式。下次您需要连接两个服务时,这应该能帮助您选择方案。如果您喜欢讨论加强版的 TCP 连接和流式架构,请随时在 Twitter @croloris 上联系我。
Redis Streams 数据类型是 Redis 的一个重要特性,并将成为许多应用程序的构建模块,尤其是在 Redis 现在拥有一系列模块,为时间序列、图和搜索添加了全新的完整功能。要了解更多关于 Redis Streams 的信息,请查看 Antirez 的这篇介绍性博客文章以及官方文档。但请不要忘记,流并非适用于所有任务的正确工具:有时您需要 Pub/Sub,或者仅仅是 Redis Lists(或 Sorted Sets,Redis 也有)上简单的阻塞操作。