想象一条街道上有许多身手矫健的骑手:你递给他们一张纸条,他们飞快地送到对方手里;如果对方不在,纸条就随手扔掉了。这套系统快得惊人、极简到几乎没有规则——但也正因为如此,纸条一旦送出,风一吹就再也找不回来。NATS 核心,就是这群只负责“送到即走”的骑手。而 JetStream,则像是给骑手们配了一座智能驿站仓库:包裹进仓后自动编号、按货架顺序排好,分拣员扫码取件、签收回执;即使骑手半路摔跤,仓库里的货物也分毫不差。本文带你参观这座驿站:货架如何设计(Stream)、分拣员如何工作(Consumer)、回执怎么签(ACK)、怎么防止重复接单(消息去重),以及它和传统巨型物流中心(Kafka)的差异。
理解 JetStream 的流存储与消息确认模型,能让你在微服务消息选型中作出“轻性能、低成本”的可靠决策——这是后端工程师走向架构师的关键一步。
可视化演示
展示Spark RDD的惰性求值机制:转换操作构建血缘DAG图,行动操作触发执行,节点故障时通过血缘重建分区。
本节配有交互动效(进入视口后自动加载,约 3KB)。
转换模式(惰性求值) 转换操作(map/filter)仅构建血缘DAG,不立即执行 每个RDD记录父依赖关系,形成有向无环图 RDD \u2192 transform \u2192 new RDD 转换操作创建新的RDD并记录血缘关系
架构定位
回到骑手比喻。NATS 核心就是一个“即时送达、无仓储”的快递网络:生产者和消费者都在线时,消息秒达;一旦有一方不在线,消息立刻蒸发。它的默认语义是 at-most-once——至多送一次,送了就完事,没人签收也不补送。
JetStream 的诞生,本质上是在骑手网络旁边加了一座仓库。它不是一个独立安装的中间件,而是内嵌在 nats-server 程序中的持久化层:消息先落仓(磁盘),再按货架号(序列号)组织,消费者可以随时来取件,哪怕取件人迟到了十分钟,包裹也还好好地躺在架子上。
从架构上看,NATS 提供两条路径:核心路径只在内存中转发消息,追求极低延迟;JetStream 路径把消息写入流存储,并用 Raft 协议在多个节点间复制副本,保证节点崩溃后数据仍在。代价是每条消息多一次落盘与回执的开销,这就是持久化必须支付的成本。
所以选型时先问自己:这笔“快递”能不能丢?能丢,走核心路径;不能丢,就进 JetStream 仓库。架构定位的本质,就是为每一类消息选择正确的仓储方式。
- 端到端总时延消息从发布方发出到消费者确认收到所经历的总时间。
- 发布传输发布方把消息通过网络送到 JetStream 服务器的时间。
- 落盘写入JetStream 将消息追加写入磁盘存储文件的耗时。
- 回执确认消费者处理完成后把 ACK 送回服务器的一段额外往返。
流与消费者
驿站里每个货架都只放同一类包裹,按到达顺序从 1 号位摆到 N 号位——这就是 Stream(流)。分拣员不会每次从头看完整条货架,手里只捏着一张“取件进度卡”,记录自己取到了几号位——这就是 Consumer(消费者)。
在 JetStream 中,Stream 是消息的有序写入日志:每条消息获得一个单调递增的序列号,并长期保存在磁盘上。Consumer 则是独立于 Stream 的读取视角,它维护自己的游标(已确认的交付序号),决定下一次从哪个位置开始读。
Stream 用 subject 过滤器决定什么消息入流,比如只收 orders.* 主题下的订单事件;Consumer 则通过交付策略(deliver policy)选择起点:取最新、取最旧、按序列号回溯或按时间回溯。多个 Consumer 可以挂载在同一个 Stream 上,彼此互不干扰——就像多位分拣员各拿一张进度卡,共享同一排货架,但各自记录各自的进度。
游标就是分拣员手里的卡片。只要卡片没丢,货架再怎么新增包裹,他都能接着上次的位置继续取——这正是“流式消费可回溯”的来源,也是 JetStream 能做事件重放的基础。
- 积压量 B消费者尚未处理的消息条数,数值越大积压越严重。
- 头部序号生产者最新写入 Stream 的消息序列号,代表货架最新一格的编号。
- 游标位置消费者已确认推进到的消息序号,代表进度卡上划掉的最后一格。
- 比较求差头部序号减去游标序号,就是待消费的积压数量。
投递语义
暴雨天骑手送包裹,你问他送不到怎么办。第一种应答:“我再补送,送到为止,但可能多送一遍”——这是至少一次。第二种应答:“我给每件包裹贴唯一快递单号,你扫码核对,重复件直接拒收”——这是精确一次。
投递语义描述系统对“丢消息”和“重复消息”两种事故的承诺。NATS 核心是至多一次;JetStream 默认提供至少一次:消息只要被写入 Stream 就不会丢,但消费者可能重复收到同一条消息。
JetStream 的至少一次,靠 ACK 加自动重发实现:消费者处理完必须回 ACK,超时未回就重新投递。要逼近精确一次,需要三件套:生产者给消息设置唯一 Nats-Msg-Id(用于去重)、消费者用 AckExplicit 逐条确认、消费业务逻辑保持幂等。注意:跨系统的分布式事务在真实世界里并不存在,业界常说的“精确一次”本质上是“有效一次”。
所以“精确一次”不是魔法,而是“去重 + 幂等 + 确认”三件套的合体。就像驿站不保证暴雨中每件包裹都不淋湿,但保证每张单子都有唯一编号、每笔签收都留底。
- 单次成功率 p一次投递尝试成功到达消费者的概率。
- 失败概率某一次投递失败的概率为 1 减去成功率。
- 重试轮数首次投递加 n 次重试,总共尝试 n+1 次,全部失败的概率是它。
- 至少一次保证只要有一次成功就算送达,故用 1 减去“全部失败”。
ACK 机制
分拣员从货架取走包裹后,要在机器上扫码签收——这就是 ACK。签了,驿站认为这件货处理完毕;迟迟不签,驿站就把包裹重新放回待取区,再次投递。
ACK 是 JetStream 可靠性的核心操作。消费者拉取消息后有两种回应:ACK 表示成功处理;NAK 表示处理失败,要求服务器立即重投。如果消费者在 ack_wait 等待窗口内没有任何回应,服务器也会自动判定超时并重投。
JetStream 的 Consumer 有三种确认策略:AckNone 完全不需要确认,服务器只管推送;AckAll 一次确认到当前游标位置,效率高但粒度粗;AckExplicit 逐条确认,最精确也最稳妥。配合 max_deliver 参数可以限制最大投递次数,超过后消息进入死信队列或直接丢弃,避免一条坏消息无限重试拖垮整条流水线。
把 ack_wait 想象成“骑手等签收的最长耐心”。耐心太短,包裹反复重投浪费运力;耐心太长,一件坏消息就能堵住整条货架。优秀的运维者会按业务处理耗时来调这个参数。
- 往返时间客户端与服务器之间一来一回的网络耗时。
- 往返双倍考虑 ACK 在途丢失、需要保守估计的情况,取两倍往返。
- 处理耗时消费者处理单条消息所需的最长耗时预算。
- 判定超时超过该时间仍未收到 ACK,JetStream 就判定失败并触发重发。
消息去重
驿站每天收到大量长得一模一样的包裹。怎么区分这是两单,还是同一单被重复录入?办法是看唯一快递单号——相同的单号,第二次直接拒收。
JetStream 的去重同样靠“单号”:发布者在消息头里带上 Nats-Msg-Id。Stream 会记住一段时间内见过的所有 ID,新消息的 ID 若已存在,直接丢弃并返回“重复”。
这个“一段时间”叫去重窗口,默认 2 分钟,可通过 Stream 配置调整。窗口期内,同一 ID 的重复消息会被拦截;窗口过后,同样的 ID 会被当作新消息放行。去重窗口不是免费的午餐:窗口越大,服务器内存里需要保留的 ID 集合就越大,内存占用随之上升。
给每条消息贴单号,代价是一点内存;收益是网络重试、客户端重发这些“人工补送”不再造成重复。单据查重,是这座驿站里最划算的投资。
- 窗口容量去重窗口内需要保留的消息 ID 总数,直接决定内存占用。
- 消息速率生产者在高峰期每秒发布的消息条数。
- 窗口时长JetStream 保留消息 ID 的有效时间,默认 2 分钟。
- 估算乘积速率乘时长,得到窗口内同时存活的最大 ID 数量。
Kafka 对比
Kafka 是城市边缘的巨型物流中心:分拣线(分区)成千上万,承载着全网最重的吞吐,但也需要专门的电力系统(ZooKeeper/KRaft)和一支庞大的运维队伍。JetStream 则是街角的智能驿站:一个二进制文件就能起服务,分拣员按需取件,日常几乎免维护。
选型不是比谁更“高级”,而是比谁更匹配。功能上,Kafka 用分区加消费组实现并行扩展,JetStream 用 Stream 加 Consumer 实现顺序与回溯;Kafka 的生态(连接器、流处理框架)更成熟,JetStream 胜在简单直接。
性能与运维上,JetStream 在每秒百万条消息以下的场景完全够用,端到端延迟更低、部署成本更小;Kafka 在超大规模、跨团队数据平台场景下优势明显。运维上,JetStream 的单二进制开箱即用,Kafka 则需要管理 broker、分区副本和元数据集群,复杂度不在一个量级。
一句话:你的业务需要“全球分仓”就选 Kafka,需要“街角驿站”就选 JetStream。绝大多数微服务团队,其实只需要一座驿站。
- 目标吞吐业务高峰需要的总写入吞吐量,单位通常是条/秒。
- 单分区能力单个分区在所在节点上能达到的写入峰值。
- 除法估算目标吞吐除以单分区能力,得到需要的最少分区数。
- 向上取整分区数必须是整数,且要留出余量应对峰值抖动。
集群与容量
一座驿站可能会失火,所以聪明的做法是开三家分店,同一批货在每家库房各存一份——这就是 JetStream 集群的 Raft 复制:三个节点共同保管同一份流数据,任一节点宕机,其余节点照常营业。
JetStream 集群建议至少 3 个节点,Stream 的副本数(replicas)决定冗余度。写入时采用 Raft 多数派确认,过半节点落盘才算成功,因此数据不会因单点故障丢失。容量规划要同时算清存储、内存和网络三本账,避免“货架塞满才发现仓库不够大”。
存储量由消息速率、保留时长和副本数共同决定。主题设计上,推荐用 subject 层级(如 orders.created、orders.paid)代替大量独立 Stream;每条 Stream 可以用最大消息数(MaxMsgs)、最大字节(MaxBytes)或最长保留时间(MaxAge)组合控制生存期限。
容量规划的公式,就是驿站进货的账本:每天进多少货(速率)、留多久(保留)、存几份(副本)。算不清这三项的物流公司,仓库迟早爆仓。
- 写入速率生产者的平均消息写入速率,需要换算成字节每秒。
- 保留时长Stream 中消息需要保留的天数或小时数。
- 副本因子每个 Stream 在集群中保存的副本数量,通常为 3。
- 容量下限三者相乘得到所需存储空间的最小估算值。
AI 应用场景
场景 1:AI 推理任务的可靠分发队列
阶段:推理服务接入与任务调度
解决的问题:在线推理服务在高峰期有大量请求涌入,直接同步调用 GPU 服务容易排队超时;用普通内存队列又会因节点重启丢失任务。JetStream 把推理请求先持久化到 Stream,推理 worker 以 Consumer 拉取,处理成功回 ACK,失败回 NAK 触发重投;请求带 Nats-Msg-Id 去重,防止网络重试造成同一张图片被计费两次。
具体案例:一个 AI 图像审核服务,前端把图片 URL 发布到 infer.review 主题,三个推理 worker 各自创建 Consumer。某个 worker 处理第 7 张图片时进程崩溃,JetStream 等待 ack_wait 超时后把该任务重投给其他 worker,图片最终被审核且只扣费一次。
场景 2:AI 训练日志与特征事件流
阶段:数据采集与特征工程
解决的问题:训练样本采集脚本分布在多台机器上,直接写数据库压力大且难以回溯。利用 JetStream 的 subject 层级(如 feature.user.*)统一收集事件,分析作业按需从 Stream 回溯消费,按游标断点续传。
具体案例:推荐系统团队把用户点击流发布到 JetStream,离线特征作业每天按时间回溯重算特征,在线特征服务则用 AckNone 策略低延迟订阅最新事件——一条 Stream 同时服务离线和在线两个场景。
Python 演示
import time class SimStream: """模拟 JetStream Stream:有序消息日志 + 去重窗口""" def __init__(self, dedup_window=5): self.log = [] self.dedup_window = dedup_window self.seen_ids = {} def publish(self, msg_id, payload, now): if msg_id in self.seen_ids: return "duplicate" self.seen_ids[msg_id] = now seq = len(self.log) self.log.append({"seq": seq, "msg_id": msg_id, "payload": payload}) self._purge(now) return seq def _purge(self, now): for msg_id in list(self.seen_ids): if now - self.seen_ids[msg_id] > self.dedup_window: del self.seen_ids[msg_id] class SimConsumer: """模拟 JetStream Consumer:游标推进 + ACK + 至少一次重投""" def __init__(self, stream): self.stream = stream self.cursor = 0 self.unacked = {} def pull(self): for msg in self.stream.log: if msg["seq"] >= self.cursor: if msg["seq"] not in self.unacked: self.unacked[msg["seq"]] = 1 return msg self.unacked[msg["seq"]] += 1 return msg return None def ack(self, seq): if seq in self.unacked: del self.unacked[seq] if seq >= self.cursor: self.cursor = seq + 1 def run(): stream = SimStream(dedup_window=5) consumer = SimConsumer(stream) stream.publish("m1", "hello", 0) print(stream.publish("m1", "hello-again", 0)) stream.publish("m2", "jetstream", 1) msg = consumer.pull() print("投递:", msg["seq"], msg["payload"]) msg = consumer.pull() print("未ACK重投:", msg["seq"], msg["payload"]) consumer.ack(0) msg = consumer.pull() print("ACK后推进:", msg["seq"], msg["payload"]) if __name__ == "__main__": run()
if msg_id in self.seen_ids: return "duplicate"去重窗口内发现相同 ID,直接拒绝。seq = len(self.log)序列号取自当前日志长度,保证单调递增。self._purge(now)每次发布后清理过期 ID,模拟去重窗口滑动。if msg["seq"] >= self.cursor:只读取游标之后的消息。self.unacked[msg["seq"]] += 1未确认消息再次被拉取,重投次数加一。if seq >= self.cursor: self.cursor = seq + 1ACK 后推进游标,对应消费进度卡划格。
常见误区
误区“我只要在 nats-server 里开启 JetStream,发布的所有消息就都自动落盘了,不用做任何额外配置。”
正解JetStream 只是提供了仓库,不等于你用了仓库。消息要真正持久化,必须先把消息发布到配置了 Stream 的主题上;没有 Stream 接收的主题消息依旧随发随弃,就像把包裹丢在驿站门口而不是放进货架。
为何常见:“开启 JetStream”和“创建 Stream”看起来是同一个动作,官方配置又都藏在 jetstream 命名空间下,初学者容易混为一谈。
误区“既然文档说有 exactly once,那我消费逻辑就不用管重复,反正系统保证不会重复。”
正解JetStream 的“精确一次”是“有效精确一次”:靠消息 ID 去重、ACK 确认和幂等消费三者叠加而成。跨网络的分布式事务并不存在,消费者业务逻辑依然要幂等,否则去重窗口外的重复消息照样会造成重复扣款。
为何常见:文档里"Exactly Once"字样太醒目,读者容易忽略它的前置条件——生产端去重 ID 加消费端幂等。
误区“我 ACK 了一条消息,它应该马上从磁盘上消失,释放空间。”
正解ACK 只是告诉服务器“这个消费者处理完了”,相当于在消费进度卡上划掉一格。消息本身仍留在 Stream 里,直到保留策略(消息数、字节数、时间)把它淘汰。其他消费者还能从 Stream 重新消费它,这正是事件回溯能力的基础。
为何常见:把“消息队列”类比成“取走即删的收件箱”,忽略了流存储的本质是日志追加文件。
误区“为了防止重复,把去重窗口从 2 分钟调到 2 小时,不是更保险吗?”
正解去重窗口越大,服务器内存中保留的消息 ID 集合越大,内存占用随速率乘窗口线性增长。窗口应覆盖“生产者重试的最坏间隔”即可,默认 2 分钟对绝大多数场景够用;更稳的保障可以靠消费端幂等兜底。
为何常见:直觉上“范围越大越安全”,但忽略了去重是有内存成本的,典型的只见收益不见成本。
误区“Kafka 是百万级吞吐的大厂标配,JetStream 这种轻量级选手在大流量下肯定撑不住。”
正解性能取决于场景:JetStream 在中低吞吐、低延迟、小集群场景下往往更快且更省资源;Kafka 的优势在超大规模分区和成熟生态。选型应先量化自身速率,而不是假设“大就是好”。
为何常见:拿 Kafka 的峰值宣传与 JetStream 的默认配置比较,忽略了部署形态与硬件环境的差异。
面试题
Q1. NATS 核心(不含 JetStream)默认提供哪种投递语义?
- A.至少一次
- B.至多一次← 正确答案
- C.精确一次
- D.幂等一次
答案:B。NATS 核心是内存转发、送完即忘的模型,消费者离线或连接中断时消息直接丢弃,因此最多只会投递一次,即 at-most-once。JetStream 才引入持久化和至少一次语义。类比:核心 NATS 是“喊一嗓子”的骑手,没听见就算了。
Q2. JetStream 中负责保存有序消息的存储结构是什么?
- A.主题存储
- B.消费者对象
- C.流 Stream← 正确答案
- D.桶 Bucket
答案:C。JetStream 中 Stream 是有序消息日志,负责落盘与保留;Consumer 只是读取视角,维护游标。类比驿站里 Stream 是货架,Consumer 是分拣员的进度卡,两者不能混为一谈。
Q3. 消费者处理完消息后,向 JetStream 发送什么表示成功?
- A.NAK 否定确认
- B.ACK 成功确认← 正确答案
- C.SYN 同步握手
- D.FIN 结束连接
答案:B。消费者处理完消息后回复 ACK 表示成功;NAK 表示失败并要求立即重投。SYN 和 FIN 是 TCP 传输层的握手概念,与消息确认无关。类比:ACK 就是分拣员的扫码签收动作。
Q4. JetStream 消息去重依赖消息头中的哪个属性?
- A.目标主题名
- B.客户端回信
- C.消息唯一ID← 正确答案
- D.接收时间点
答案:C。JetStream 用消息头 Nats-Msg-Id 作为唯一 ID,在去重窗口内拦截重复发布。Subject 决定消息路由,Reply-To 用于请求应答,时间戳只标记时间,都不能做全局去重。类比:Nats-Msg-Id 就是快递单号。
Q5. 一个 Stream 上的消费者游标主要用于记录什么?
- A.消息内容
- B.消费位置← 正确答案
- C.消息容量
- D.确认次数
答案:B。消费者游标记录已确认推进到的消息序号,决定下次拉取从哪条开始;积压量等于头部序号减去游标。类比分拣员手里的取件进度卡,卡上记的是“取到几号位”,不是包裹内容。
Q6. 关于 JetStream 的 at-least-once 语义,下列说法正确的是?
- A.消息一定不会重复投递
- B.不丢失但可能重复投递← 正确答案
- C.可能丢失且不重复投递
- D.必定丢失并无限重试
答案:B。at-least-once 承诺消息不丢失,但可能重复投递;去重和幂等可以缓解重复的影响。选项 A 错在把至少一次与“不重复”划等号,C 是 at-most-once 的描述,D 与事实相反。
Q7. 与 Kafka 相比,JetStream 一个比较突出的优势是什么?
- A.分区数量可以无限横向扩展
- B.单二进制无外部依赖部署简单← 正确答案
- C.内置流式 SQL 查询处理引擎
- D.提供全链路分布式事务协议
答案:B。JetStream 是 nats-server 内置能力,单二进制即可部署;Kafka 历史上依赖 ZooKeeper,现在还要维护 KRaft,集群组件多。分区无限扩展、SQL 引擎、全链路事务都不是 JetStream 的主张。
Q8. 下列哪些属于 JetStream Consumer 的内置 ACK 策略?
- A.AckNone 不自动确认← 正确答案
- B.AckAll 确认到游标← 正确答案
- C.AckExplicit 逐条确认← 正确答案
- D.AckSleep 延时确认
答案:ABC。JetStream Consumer 的内置 ACK 策略是 AckNone、AckAll、AckExplicit,不存在 AckSleep。AckExplicit 最精确但开销最大,AckNone 开销最小但可靠性需要外部机制保证。
Q9. 要获得“有效精确一次”效果,JetStream 使用者通常需要哪些配合?
- A.为每条消息生成唯一消息标识← 正确答案
- B.消费者业务处理逻辑做到幂等← 正确答案
- C.配置 AckExplicit 逐条确认← 正确答案
- D.完全关闭所有消息重发机制
答案:ABC。生产端为每条消息生成唯一 Nats-Msg-Id 做去重,消费端逻辑幂等,配合 AckExplicit 逐条确认,三者叠加才能实现“有效精确一次”。完全关闭重发会让消息丢失,反而破坏了可靠性。
Q10. 关于容量规划与主题设计,JetStream 推荐哪些做法?
- A.控制 Stream 数量避免过多← 正确答案
- B.用 subject 层级做过滤← 正确答案
- C.设置消息保留策略上限← 正确答案
- D.每个消费者绑定独立节点
答案:ABC。控制 Stream 数量、用 subject 层级做过滤、设置 MaxMsgs/MaxBytes/MaxAge 保留策略都是官方推荐做法。“每个消费者绑定独立节点”既浪费资源也无必要——Consumer 是逻辑对象,与物理节点没有绑定关系。