JetStream

NATS 内置了一个名为 JetStream 的持久化引擎,它允许消息被存储并在未来回放。与 NATS Core 不同,后者要求您必须有一个活跃的订阅才能在消息发生时处理它们,而 JetStream 则允许 NATS 服务器捕获消息,并根据需要将这些消息重放给消费者。这种功能为你的 NATS 消息带来了不同的服务质量,并带来了容错和高可用性配置。

JetStream 是 nats-server 内置功能。如果您拥有一组启用 JetStream 的服务器集群,您可以启用数据复制,从而防范故障和服务中断。

创建 JetStream 是为了解决当今流技术所面临的问题——复杂、脆弱、不好扩展。虽然某些技术比其他技术更好,但现在没有一种流技术是真正实现了多租户、水平扩展、支持多种部署模型的。据我们所知,也没有任何其他技术能够在使用相同安全上下文的情况下从边缘扩展到云,并且具备完整的运营可观测性。

JetStream 带来的附加功能

JetStream 持久化层带来了通常在不存在于消息系统中的额外用例。由于构建在 JetStream 之上,它们继承了 JetStream 的核心功能,包括复制、安全性、路由限制和镜像。

  • 键值存储 一个具备原子操作的映射(map)(关联数组)
  • 对象存储 文件传输、复制和存储 API。还有为可扩展性准备的分块传输!

键值存储、文件传输 是 内存数据库或部署工具中常见的功能。尽管 NATS 并不打算与这些工具的功能集竞争,但我们希望为开发者提供一套合理的 一整套 数据存储和复制 功能集,用于微服务、边缘部署和服务器管理等用例。

配置

要使用 JetStream 配置 nats-server,请参阅:

示例代码

参阅 NATS by Example,获取 JetStream 相关可运行代码示例。

目标

开发 JetStream 系统时考虑了以下目标:

  • 必须易于配置、操作并可观察。
  • 必须安全,并与 NATS 2.0 安全模型良好集成。
  • 必须水平扩展,并适用于高吞吐量场景。
  • 必须支持多种用例。
  • 必须能自我修复。
  • 必须允许 NATS 消息根据需要成为流的一部分。
  • 表现出的行为必须和有效载荷无关。
  • 不得依赖第三方。

JetStream 能力

流处理:发布者与订阅者之间的时间解耦

基本发布/订阅消息传递的一个原则是,发布者和订阅者之间需要存在时间耦合:订阅者只能接收在其主动连接到消息系统时发布的消息(即,他们不会接收在未订阅、未运行或断开连接时发布的消息)(译注:想象一下你得 24*7 不断开着 QQ 才能不断接收好友消息)。消息系统为发布者和订阅者提供时间解耦的传统方法是通过 'durable subscriber' (持久订阅者) 功能,有时也通过“队列”,但这两种方法都不完美:

  • 持久订阅者需要在消息发布之前创建
  • 队列旨在用于工作负载分发和消费,而非用作消息重放的机制。

然而,在许多用例中,你并不需要“精确一次消费”功能,而是需要能 按需、随心所欲、任意次数 重放消息。这个需求导致了一些所谓的 “流式” 消息平台的流行。

我们的 JetStream 同时 提供了在消息发布时消费(即“队列”)和按需重放消息(即“流式处理”)的能力。请参阅下面的保留策略

重放策略

JetStream 消费者支持多种重放策略,具体取决于消费应用程序希望接收:

  • 流中当前存储的所有消息,即一次完整的“重放”,并且你可以选择“重放策略”(即重放速度)为:
    • instant - 即时(意味着消息以消费者能处理的最快速度传递给消费者)。
    • original - 原始(意味着消息以其发布到流中的速率传递给消费者,这对于模拟生产流量等场景非常有用)。
  • 流中存储的最后一条消息,或每个主题的最后一条消息(因为流可以捕获多个主题)。
  • 从特定的序列号开始。
  • 从特定的时间开始。

保留策略与限制

JetStream 在基础的 “Core NATS” 功能之上实现了新功能和更高的服务质量。然而,实际上,流不能总是“永远”保持增长,因此 JetStream 支持多种保留策略,并且能够对流施加大小限制。

限制

你可以对流施加以下限制

  • 最大消息存活时间。
  • 流的最大总大小(单位:字节)。
  • 流中的最大消息数。
  • 最大单个消息大小。
  • 流上面的最大消费者数量。

你还必须选择一个丢弃策略,该策略规定一旦流达到其某个限制并且有新消息发布时应发生什么:

  • 丢弃旧的意味着流将自动删除流中最旧的消息,为新消息腾出空间。
  • 丢弃新的意味着新消息将被丢弃(并且客户端调用 JetStream 发布函数 时会返回一个错误,指示已达到限制)。

保留策略

你可以为每个流选择所需的保留类型:

  • 限制(默认)旨在提供流中消息的重放。
  • 工作队列(流被用作共享队列,消息在被消费后即从其中移除)旨在提供 精确一次消费流中的消息 特性。
  • 兴趣(只要存在尚未接收消息的消费者,消息就会保存在流中)是工作队列的一种变体,仅当存在对该消息主题的兴趣(兴趣来自当前在流上定义的消费者)时才保留消息。

请注意,无论选择何种保留策略,限制(及其丢弃策略)仍然始终适用。

Subject mapping transformations

JetStream 还支持在消息被摄取到流时对其应用主题映射转换。

持久且一致的分布式存储

你可以按需选择消息存储的持久性和弹性。

  • 存储到内存。
  • 存储到文件系统。
  • 在(1 台(不进行消息复制)、2 台、3 台)NATS 服务器之间进行消息复制以实现容错。

JetStream 使用一种为 NATS 优化的 RAFT 分布式仲裁算法,在集群中的 NATS 服务器之间分发持久化服务,同时即使在发生故障时也能保持实时一致性(与最终一致性相对)。

对于写入(发消息到流),NATS JetStream 的形式一致性模型是可线性化的。对于读取(监听或从流重放消息),形式模型并不真正适用,因为 JetStream 不支持将多个操作原子性地在一起批处理(因此唯一的“事务”种类是 持久化、复制、票选 流上的单个操作),但本质上,JetStream 是可串行化的,因为消息以一个全局顺序添加到流中(你可以使用比较并发布来控制该顺序)。

请注意,虽然我们保证在单调写单调读方面的实时一致性,但我们目前不保证写后再读一致性,因为通过 direct get 请求的读取可能会被 追随者(followers) 或镜像(mirrors) 响应。通过向流领导者发送 get 请求可以获得更一致的结果。

JetStream 还可为存储的消息提供静态加密功能。

在 JetStream 中,消息存储的配置,与它们如何被消费的配置是分开的。消息存储的配置存放在中,而它们如何被消费的配置存放在多个消费者中。

流复制因子

一个流的复制因子(R,通常称为“副本”(Replicas) 数)决定了它存储在多少个地方,允许你通过调整来平衡 风险 / 资源使用和性能。一个易于重建或临时的流可以是基于内存的 R=1,而一个可以容忍一些停机时间的流可以是基于文件的 R=1。

在典型中断情况下运行并平衡性能的典型用法是使用基于文件的流且 R=3。一个高弹性但性能较低且成本更高的配置是 R=5,这是 R 的上限。

我们建议不要默认选择最大值,而是根据流背后的用例选择最佳选项。这样可以优化资源使用,以创建更具弹性的大规模系统。

  • 副本数=1 - 在托管该流的服务器下线期间,该流无法运作。但性能高。
  • 副本数=2 - 目前没有显著好处。我们建议改用副本数=3。
  • 副本数=3 - 可以容忍一个托管该流的服务器丢失(loss)。是风险与性能之间的理想平衡。
  • 副本数=4 - 与副本数=3 相比没有显著优势,除非在 5 节点集群中略有优势。
  • 副本数=5 - 可以同时容忍两个托管该流的服务器丢失。以性能为代价来降低风险。

流之间的镜像与源

JetStream 还允许服务器管理员轻松地镜像流,例如在不同的 JetStream 域之间,以提供灾难恢复。你也可以定义一个 以一个或多个其他流为源头(sources) 的流。

将数据同步到磁盘

JetStream 基于文件的流会将消息持久化到磁盘。然而,虽然 JetStream 会同步地将文件写入刷新到操作系统,但在默认配置下,它不会立即将数据 fsync 到磁盘。服务器使用可配置的 sync_interval 参数,默认为 2 分钟,用于控制服务器多久 fsync 一次数据。数据将在不晚于此间隔的时间内被 fsync。这对于在操作系统故障(指操作系统的非正常退出,如断电,而不仅仅是 nats-server 进程本身的非正常退出或被杀死)下的数据耐久性有重要影响:

在 non-replicated 部署中,操作系统故障可能导致数据丢失。客户端发布消息并可能收到确认,但数据可能尚未安全写入到磁盘。因此,在操作系统从故障恢复后,服务器就有可能丢失最近确认的消息。

在 replicated 部署中,一个已发布的消息至少在成功复制到法定数量的服务器后才会被确认。然而,仅靠复制机制,并不足以保证针对多重系统故障的最强耐用性级别。

  • 如果多个服务器同时在其数据被 fsync 之前,出现操作系统故障,集群就可能无法恢复最近确认的消息。
  • 如果某个故障服务器由于操作系统故障而在本地丢失了数据,虽然极其罕见,但在某些事件组合下,它可能会重新加入集群并与从未接收或持久化给定消息的节点形成新的多数派。然后集群可能会继续处理不完整的数据,导致已确认的消息丢失。

设置较低的 sync_interval 会增加磁盘写入的频率,并减少潜在数据丢失的窗口,但会以性能为代价。此外,设置 sync_interval: always 将确保服务器在每条消息被确认之前都做一下 fsync。这个设置与跨不同数据中心或可用区的复制结合使用,可提供最强的数据耐久性保证,但性能最慢。

默认设置旨在在我们认为的典型生产部署场景(跨多个可用区)中平衡性能和数据丢失风险。

例如,考虑一个跨三个独立可用区部署的具有 3 个副本的流。要使流状态在节点间出现分歧,需要满足以下条件:

  • 首先,3 台服务器中的一台已经下线、孤立或被网络分区。
  • 此时,在集群视角下,消息写入记录仅驻留于三节点集群中的两个节点的内存上,尚未通过 fsync 落盘。接着,第二台服务器,遭遇操作系统故障丢失了内存中的数据。
  • 而属于上述那"2/3节点"之一的流领导者,此时也必须发生宕机或进入孤立/分区状态。
  • 随后,在最初网络分区中未能收到那些写入的第一个服务器从分区状态中恢复。
  • 此时,第二台曾发生操作系统故障的服务器也已恢复,并与第一个服务器取得联系,但两者都无法联系到先前的流领导者。

最终,3 节点集群中的 2 个恢复了,但是先前 有完整写入记录的流领导者仍然不可用:这两个恢复的节点,一个根本没收到过这些写入,另一个由于操作系统故障丢失部分写入。此时,这两个服务器可以形成多数派,并开始接受新的写入,这实质上丢失了一些之前给客户端确认过的消息。

重要的是,这是一种流状态可能(与客户端们)发生分歧的故障情形。但在一个跨多个可用区部署的系统中,这需要多种故障以特定方式精准地相继发生。

对于此类故障,一种潜在的缓解措施是:不要自动重启那台因操作系统故障而崩溃的服务器进程,除非能确认剩余服务器中的多数已接收了新的写入;或者,也可以将崩溃的服务器从对等节点列表中移除,然后将其作为一个全新的、数据清空的对等节点重新加入,并允许其通过网络从现有健康节点恢复数据(尽管这可能会因涉及的数据量大小而产生高昂开销)。

对于将最小化数据丢失视为绝对优先的用例,当然仍可配置 sync_interval: always。但需注意,这将影响到整个服务器的性能:可能会降低吞吐量或增加延迟。在生产环境中,运维人员应评估默认配置是否契合其具体的用例、目标环境、成本考量及性能要求。

也可以用一种混合方法:让现有集群在默认的 sync_interval 设置下继续运行,但同时新增一个配置了 sync_interval: always 的新集群,并利用服务器标签。随后,可以通过使用放置标签来指定某个流的存储位置,使其数据存储在这个持久性更高的集群上。

# 配置一个专门用于同步写入数据的集群。
server_tags: ["sync:always"]

jetstream {
    sync_interval: always
}

使用以下命令创建一个指定副本数的流,并利用标签将其专门放置在使用 sync_interval: always 的集群中,从而仅为需要此级别持久性的流写入确保最高的耐用性。

nats stream add --replicas 3 --tag sync:always

解耦的流控制

JetStream 提供了解耦的流控制,它并非 “端到端” 流控(例如发布者发布速度不能超过所有消费者中最慢的接收速度),而是分别作用在每个客户端应用(发布者或消费者)与 NATS 服务器之间。

在发布端,当使用 JetStream publish 调用向流发布消息时,发布者和 NATS 服务器之间有 ACK 机制,你可以选择进行同步或异步(例如 'batched')的 JetStream publish 调用。

在订阅端,从 NATS 服务器向 接收/消费 流中消息的客户端应用 递交消息,同样受到流控。

精确一次语义

因为使用 JetStream publish 调用发布到流会有服务器的 ACK,所以流提供的基本服务质量是“至少一次送达”,这意味着虽然可靠且通常没有重复,但在某些特定的故障场景下,可能导致发布应用程序(错误地)认为消息未成功发布而因此再次发布,并且可能存在导致客户端应用往服务器发的 消息已成功消费的ACK消息 丢失的故障场景,从而导致服务器将消息重新发送给消费者。这些故障场景虽然罕见甚至难以重现,但确实存在,而且可能导致应用程序层面感知到的“消息重复”。

因此,JetStream 还提供了“精确一次送达”的服务质量。对于发布端,它要求发布应用程序在消息头中附加唯一的消息或发布 ID,并且服务器在可配置的滚动时间段内跟踪这些 ID,以检测发布者是否两次发布相同的消息。对于订阅者,使用双重 ACK 机制来避免在某些类型的故障后服务器错误地将消息重新发送给订阅者。

消费者

JetStream 消费者 是流上的“视图”,客户端应用通过订阅(或拉取)它们来接收存储在流中的消息的副本(或者,如果流被设置为工作队列,则消费消息)。

快速推送式消费者

客户端应用可以选择使用快速的、没有 ACK 的 push(推送式)消费者,以在指定的交付主题或收件箱上(按照选定的重放策略)尽可能快地接收消息。这些消费者旨在用于“重放”流中的消息,而不是“消费”。

支持批处理的水平可扩展拉取式消费者

客户端应用也可以使用和共享 pull(拉取式)消费者,这些消费者是根据自身需求拉取消息的,支持批处理,并且必须显式确认消息的接收和处理,这意味着它们既可以用于消费(即,将流用作分布式队列),也可以用于处理流中的消息。

拉取式消费者可以并且旨在应用程序之间一起共享(就像队列组一样),以便为流中消息的处理或消费提供简单且透明的水平可扩展性,而无需(例如)担心必须定义分区或担心容错性。

注意:使用拉取式消费者并不意味着你不能实时地将更新(发布到流中的新消息)“推送”到你的应用程序,因为你可以向消费者的 Fetch 调用传递一个(合理的)超时时间 并不断重复地调用它。

消费者确认

虽然你可以决定使用没有 ACK 的消费者,以服务质量换取尽可能快的消息传递,但大多数消息处理并非幂等的,并且需要更高的服务质量(例如,能够从各种可能导致某些消息未被处理或处理多次的故障场景中自动恢复),因此你将希望使用需要 ACK 的消费者。JetStream 支持多种确认类型:

  • 一些消费者支持确认所有直到被确认消息的序列号的消息,一些消费者提供最高的服务质量,但需要显式确认每个消息的接收和处理,并且服务器为特定消息等待确认的最长时间过后会重新传递它(给连接到该消费者的另一个进程)。
  • 你也可以发送 否定 确认。
  • 你甚至可以发送 处理中 的确认(以表明你仍在处理相关消息,需要在确认或否定确认之前需要更多时间)。

键值存储

JetStream 持久层实现了键值存储:能够将与 关联的 消息存储、检索和删除到 中。

监视与历史

你可以通过 watch 订阅键值存储中桶级别或单个键级别的更改,并可选择性地检索特定键上发生过的值(和删除)的历史记录。

原子更新与锁定

键值存储支持原子的 createupdate 操作。这支持悲观锁(通过创建一个键并持有它)和乐观锁(使用 CAS - 比较并设置)。

对象存储

对象存储与键值存储类似;键被文件名取代,而值被设计用于存储任意大的对象(例如文件,即使它们非常大),而不是“消息大小”的“值”(即默认限制为 1Mb)。这是通过消息分块实现的。

遗留说明

请注意,JetStream 完全取代了 STAN 遗留的 NATS 流层。