08 - NATS 集群架构与 Route Pool
本文是 NATS 指南系列第八篇,深入讲解 NATS 集群的工作原理、Route Pool 机制、订阅信息传播,以及生产环境的配置与调优。
目录
- 集群的作用
- 集群配置最低要求
- Route Pool 详解
- Subject Interest Propagation
- 集群消息路由完整流程
- Headless Service DNS 的重要性
- 配置示例
- 三种网络拓扑对比
- 滚动升级时集群的行为
- routez 端点详细字段解读
- 小结
1. 集群的作用
1.1 为什么需要集群
单节点 NATS 服务器虽然性能极高(单机每秒可处理数百万消息),但在生产环境中面临两个根本问题:
单节点痛点:
┌─────────────┐
│ NATS Node │◀── 所有客户端连接
│ (单点) │
└──────┬──────┘
│
重启/崩溃
│
▼
所有客户端断连!
消息处理中断!
业务停摆!NATS 集群解决两个核心问题:
问题 1:高可用(HA)
集群节点故障时自动转移:
┌─────────┐ ┌─────────┐ ┌─────────┐
│ Node-1 │◀──▶│ Node-2 │◀──▶│ Node-3 │
│(Primary)│ │(Standby)│ │(Standby)│
└─────────┘ └─────────┘ └─────────┘
│
节点崩溃
│
▼
客户端自动重连到 Node-2 或 Node-3
消息路由不受影响问题 2:水平扩展
客户端分散连接到不同节点,避免单点成为瓶颈:
Client-A ──▶ Node-1
Client-B ──▶ Node-2 ←→ 节点间消息路由(Route)
Client-C ──▶ Node-3
Client-D ──▶ Node-1
Subscriber 可以连接任意节点,Publisher 也可以连接任意节点,
NATS 集群内部负责消息路由。1.2 集群 vs 单节点性能对比
单节点:
所有消息经过 1 台服务器
内存: 受限于单机
QPS: ~4M msg/s(参考值)
延迟: ~0.1ms
3节点集群(客户端均匀分布):
每台服务器处理约 1/3 的客户端流量
需要额外的路由流量(节点间通信)
QPS: ~2-3M msg/s(单节点视角,集群总吞吐更高)
延迟: ~0.1-0.3ms(增加了节点间路由延迟)2. 集群配置最低要求
2.1 奇数节点原则
NATS 集群(不含 JetStream)本身不使用 Raft 共识算法,因此纯 Core NATS 集群对节点数量没有奇数要求。但当启用 JetStream 时,其元数据 Raft 群组需要奇数节点才能形成多数派:
JetStream Raft 多数派:
节点数 Raft 多数 容忍故障数
─────────────────────────────────
1 1 0
2 2 0 ← 任一故障即失效,不推荐
3 2 1 ← 生产最低要求
5 3 2 ← 大规模生产
7 4 3 ← 非常高可用场景
推荐:生产最少 3 节点2.2 网络要求
集群间通信端口(默认 6222):
Node-1:6222 ◀──▶ Node-2:6222
Node-1:6222 ◀──▶ Node-3:6222
Node-2:6222 ◀──▶ Node-3:6222
客户端连接端口(默认 4222):
Client ──▶ NodeX:4222
监控端口(默认 8222):
HTTP ──▶ NodeX:8222/routez2.3 集群名称一致性
关键要求:所有节点的 cluster.name 必须完全相同,否则节点拒绝加入集群:
错误示例(导致集群无法形成):
Node-1: cluster { name: "prod-cluster" }
Node-2: cluster { name: "prod-cluster" }
Node-3: cluster { name: "prod" } ← 名称不一致!3. Route Pool 详解
3.1 为什么需要多条 TCP 连接
NATS 集群节点之间通过 TCP Route 连接传递消息。早期版本节点间只有 1 条 TCP 连接,这带来了严重的**行头阻塞(Head-of-Line Blocking, HOL Blocking)**问题:
单 TCP 连接的行头阻塞:
Node-1 Node-2
│ │
│──── 大消息(5MB)────────────────▶ │ ← 占满带宽,阻塞后续
│──── 小消息(100B)等待排队 ────▶ │ ← 必须等大消息传完
│──── 心跳(ping)等待排队 ────▶ │ ← 延迟增大
│ │TCP 本质上是一个有序字节流,后面的数据必须等待前面的数据传输完成。当一个大消息占据 TCP 缓冲区时,后续的小消息(包括心跳、订阅更新等控制消息)都会被阻塞。
3.2 Route Pool 的解决方案
NATS 2.2 引入了 Route Pool:节点间建立多条 TCP 连接(一个"连接池"),消息通过不同连接并行传输:
Route Pool(默认 pool_size=4):
Node-1 Node-2
│ │
├──── TCP Route Connection #1 ──────▶│ 用于普通消息
├──── TCP Route Connection #2 ──────▶│ 用于大消息
├──── TCP Route Connection #3 ──────▶│ 用于控制消息
└──── TCP Route Connection #4 ──────▶│ 用于高优先级
│ │现在不同消息可以走不同连接,互不阻塞:
有了 Route Pool 后:
Node-1 Node-2
│ │
│──[Conn#1]── 大消息(5MB)─────────▶│
│──[Conn#2]── 小消息(100B)─────────▶│ ← 不再等待!
│──[Conn#3]── 心跳(ping)───────────▶│ ← 立即到达!
│ │3.3 pool_size 默认值 4 的由来
NATS 官方将 pool_size 的默认值设为 4,这是基于以下考量的权衡:
pool_size 权衡分析:
太小(1-2):
- 行头阻塞风险依然存在
- 高并发时消息排队延迟高
太大(8+):
- 每条 TCP 连接消耗文件描述符
- 操作系统 TCP 缓冲区内存占用
- 连接建立/维护开销增加
- 收益递减(并发消息流通常不超过 4 个方向)
4 = 甜蜜点:
- 覆盖大多数并发场景(读写 × 控制数据流)
- 内存/FD 开销可控
- 实测延迟抖动显著降低还有一条额外的专用连接(pinned account routes),专门用于系统账户消息,不计入 pool_size。
3.4 num_routes 计算公式
对于 N 节点集群,每个节点需要与其他 (N-1) 个节点各建立 pool_size 条连接,因此每个节点的总路由连接数为:
num_routes(单节点) = (N - 1) × pool_size
另加:每个 peer 节点 1 条专用 account route(可选):
num_routes(含账户路由)= (N - 1) × (pool_size + 1)3.5 三节点集群推导:8 条 Route 连接
以 3 节点集群、pool_size=4 为例,完整推导每个节点的连接数:
集群拓扑:
Node-1
/ \
Node-2 ─── Node-3
每个节点需要连接到其他 2 个节点(N-1 = 3-1 = 2)
每个 peer 建立 pool_size=4 条连接
每个节点总连接数 = 2 × 4 = 8 条 Route 连接具体到 Node-1:
Node-1 的 Route 连接详情:
与 Node-2 的连接(4 条):
Route #1 (to Node-2): 用于消息路由
Route #2 (to Node-2): 用于消息路由
Route #3 (to Node-2): 用于消息路由
Route #4 (to Node-2): 用于消息路由
与 Node-3 的连接(4 条):
Route #5 (to Node-3): 用于消息路由
Route #6 (to Node-3): 用于消息路由
Route #7 (to Node-3): 用于消息路由
Route #8 (to Node-3): 用于消息路由
总计:8 条 TCP 连接(从 Node-1 视角)ASCII 全图:
3节点集群 Route Pool 全图(pool_size=4):
┌──────────────────────────────────────────────────────────────────┐
│ │
│ Node-1 Node-2 Node-3 │
│ ┌───────┐ ┌───────┐ ┌───────┐ │
│ │ │═══[R1]════════│ │═══[R5]════════│ │ │
│ │ │═══[R2]════════│ │═══[R6]════════│ │ │
│ │ │═══[R3]════════│ │═══[R7]════════│ │ │
│ │ │═══[R4]════════│ │═══[R8]════════│ │ │
│ │ │ │ │ │ │ │
│ │ │═══[R5']═══════╪═══════╪═══[R1']═══════│ │ │
│ │ │═══[R6']═══════╪═══════╪═══[R2']═══════│ │ │
│ │ │═══[R7']═══════╪═══════╪═══[R3']═══════│ │ │
│ │ │═══[R8']═══════╪═══════╪═══[R4']═══════│ │ │
│ └───────┘ └───────┘ └───────┘ │
│ │
│ Node-1 视角:8条(4到Node2 + 4到Node3) │
│ Node-2 视角:8条(4到Node1 + 4到Node3) │
│ Node-3 视角:8条(4到Node1 + 4到Node2) │
│ │
│ 整个集群 TCP Route 连接总数: │
│ 节点对数 = C(3,2) = 3 对 │
│ 每对 4 条(双向各算,实际每条 TCP 双向复用) │
│ 总计 = 3 × 4 = 12 条 TCP 连接(集群全局) │
└──────────────────────────────────────────────────────────────────┘注意:TCP 连接是全双工的,一条 TCP 连接可以双向传输数据。上图中 Node-1 看到 8 条"连接",其中有 4 条是它主动发起的(到 Node-2、Node-3),另外 4 条是 Node-2/Node-3 主动发起的(到它),但实际是同一批 TCP 连接的两端视角。
实际全局唯一 TCP 连接数 = (N-1) × N / 2 × pool_size = 3 × 4 = 12(3 节点集群)。
3.6 连接建立与关闭行为
启动时的连接建立顺序:
时序:Node-1、Node-2 先启动,Node-3 后加入
T=0s: Node-1 启动,监听 :6222,无 route 连接
T=1s: Node-2 启动
Node-2 尝试连接 Node-1:6222
Node-2 ──[Conn#1]──▶ Node-1 (pool 第 1 条)
Node-2 ──[Conn#2]──▶ Node-1 (pool 第 2 条)
Node-2 ──[Conn#3]──▶ Node-1 (pool 第 3 条)
Node-2 ──[Conn#4]──▶ Node-1 (pool 第 4 条)
握手完成,互换集群状态(订阅信息等)
T=2s: Node-3 启动
Node-3 尝试连接 Node-1:6222(4条)
Node-3 尝试连接 Node-2:6222(4条)
集群形成,订阅信息在 3 节点间同步节点故障时的关闭行为:
Node-2 崩溃:
Node-1 检测到 Node-2 的 4 条 TCP 连接全部断开
→ 立即将 Node-2 标记为 offline
→ 清除 Node-2 上所有订阅的路由表
→ 通知连接到 Node-1 的客户端(如有需要)
Node-3 检测到 Node-2 的 4 条 TCP 连接全部断开
→ 同样清除路由信息
恢复时(Node-2 重启):
→ Node-2 重新建立 4×2=8 条 route 连接
→ 重新同步订阅信息(RS+)
→ 恢复消息路由实际日志中看到的现象(nats-server -DV):
[DBG] Route connect to: nats://node2:6222
[DBG] Route connect to: nats://node2:6222
[DBG] Route connect to: nats://node2:6222
[DBG] Route connect to: nats://node2:6222
[TRC] Sending CONNECT to route: {"verbose":false,"pedantic":false,...}
[TRC] Route received [PONG]
[INF] Route connection created: nats://node2:6222
...(重复 4 次,建立 pool)
[INF] Cluster Route Stats: {NumRoutes:8, ...}
# 节点下线时:
[WRN] Detected route disconnect from: nats://node2:6222
[WRN] Detected route disconnect from: nats://node2:6222
[WRN] Detected route disconnect from: nats://node2:6222
[WRN] Detected route disconnect from: nats://node2:6222
[INF] Removing route: nats://node2:6222 (cluster changed)3.7 pool_size 调优建议
场景 建议 pool_size
─────────────────────────────────────────────
开发/测试 1-2
生产(小规模, <1000 clients) 4(默认)
生产(中规模, 1k-10k clients) 4-6
生产(大规模, >10k clients) 6-8
消息体普遍较大(>100KB) 6-8
消息体普遍极小(<1KB) 4(默认足够)
节点间带宽受限(低延迟专线) 2-4修改方式:
# nats-server.conf
cluster {
name: "prod"
port: 6222
pool_size: 6 # 覆盖默认值 4
routes: [
"nats://node2:6222"
"nats://node3:6222"
]
}4. Subject Interest Propagation
4.1 什么是 Subject Interest Propagation
NATS 集群中,每个节点只将消息路由到有订阅者的节点,而不是广播给所有节点。这依赖于订阅兴趣传播机制:当一个客户端在某节点上订阅某个 subject 时,该节点会将这个"兴趣"通知给集群中的其他所有节点。
订阅兴趣传播示意:
客户端 C1 连接 Node-1,订阅 "orders.*"
Node-1 收到订阅后:
→ 本地记录:orders.* 有 1 个本地订阅
→ 向 Node-2 发送:RS+ $G orders.*(我有订阅者了)
→ 向 Node-3 发送:RS+ $G orders.*
Node-2 收到 RS+ 后:
→ 记录路由表:orders.* → 有兴趣的远程节点:Node-1
→ 向 Node-3 发送:RS+ $G orders.*(如果 Node-3 还不知道的话)
此后,任何发布到 orders.* 的消息,Node-2/Node-3 都知道要路由到 Node-1。4.2 RS+ / RS- 消息机制
NATS 集群内部使用 Route Subscribe 协议帧:
RS+(Route Subscribe):有新订阅
格式:RS+ <account> <subject> [queue_group [weight]]
示例:RS+ $G orders.new
RS+ $G orders.process workers 3 ← Queue Group,weight=3(3个成员)
RS-(Route Unsubscribe):订阅取消
格式:RS- <account> <subject> [queue_group]
示例:RS- $G orders.new
RS- $G orders.process workers ← Queue Group 成员减少
$G = Global account(默认账户)RS+ 的 weight 参数(Queue Group 专用)表示该节点上 Queue Group 的成员数,用于加权随机分发:
场景:Node-2 上有 3 个 Worker 订阅了 Queue Group "workers",
Node-3 上有 1 个 Worker
Node-2 → 其他节点发送:RS+ $G tasks.process workers 3
Node-3 → 其他节点发送:RS+ $G tasks.process workers 1
当 Node-1 上的客户端发布消息时,Node-1 知道:
- Node-2 上有 3 个成员(权重 3)
- Node-3 上有 1 个成员(权重 1)
→ 75% 概率路由到 Node-2(在 Node-2 内部再选一个成员)
→ 25% 概率路由到 Node-3
总体上各 Worker 被选中概率均等(25%)4.3 发布时如何找到有订阅者的节点
发布流程:
Publisher(连接 Node-1)发布 "orders.new" 消息
Node-1 查询本地路由表:
┌─────────────────────────────────────────────┐
│ Node-1 路由表(内存中) │
│ │
│ Subject │ 本地订阅 │ 远程节点 │
│ ─────────────┼──────────┼──────────────────│
│ orders.* │ 2 │ Node-2, Node-3 │
│ orders.new │ 0 │ Node-2 │
│ orders.done │ 1 │ - │
│ metrics.# │ 0 │ Node-3 │
└─────────────────────────────────────────────┘
决策:
- orders.new 匹配到 orders.* 和 orders.new
- 本地有 2 个订阅 → 直接投递(2 次)
- 远程 Node-2 有兴趣 → 通过路由连接发送给 Node-2
- Node-3 通过 orders.* 也有兴趣 → 发送给 Node-3
如果是 Queue Group,还需要 weight 加权路由:
- 本地 Queue 成员 2 个
- Node-2 Queue 成员 3 个
- 本地概率 = 2/(2+3) = 40%,Node-2 概率 = 60%4.4 Subject Interest 流程图(ASCII)
订阅传播阶段(客户端连接时)
─────────────────────────────
Client-A Node-1 Node-2 Node-3
│ │ │ │
│─SUB orders.*─▶│ │ │
│ │ │ │
│ │──RS+ $G orders.*────────────▶│
│ │──RS+ $G orders.*──────────▶│ │
│ │ │ │
│ │ │◀─RS+ $G orders.* (传播)
│ │ │──RS+ $G orders.*──▶│ (如需要)
│ │ │ │
消息路由阶段(Publisher 发布时)
─────────────────────────────
Client-B Node-2 Node-1 Client-A
│ │ │ │
│─PUB orders.new─▶│ │ │
│ │ │ │
│ │ (查路由表: │ │
│ │ orders.* → │ │
│ │ 有 Node-1) │ │
│ │──RMSG orders.new──────────▶│
│ │ │ │
│ │ │──MSG orders.new──▶│
│ │ │ │4.5 订阅信息的生命周期
订阅信息传播的 4 个阶段:
阶段 1:客户端订阅
Client → Node: SUB <subject> <sid>
阶段 2:本地节点通知集群
Node-1 → Node-2: RS+ $G <subject>
Node-1 → Node-3: RS+ $G <subject>
阶段 3:运行时保持
所有节点在内存路由表中维护完整的"兴趣图"
新节点加入时,双方交换完整的订阅状态
阶段 4:订阅取消
Client → Node: UNSUB <sid>
Node-1 → Node-2: RS- $G <subject>(如果本地无其他相同订阅)
Node-1 → Node-3: RS- $G <subject>5. 集群消息路由完整流程
5.1 跨节点消息路由序列图
以下是一个完整的跨节点消息路由流程,涵盖订阅建立、消息发布、路由到达全过程:
完整序列:
Service(Sub) Node-3 Node-2 Node-1 Client(Pub)
│ │ │ │ │
│ │ │ │ │
【阶段 1:订阅建立】
│ │ │ │ │
├─SUB────────▶│ │ │ │
│ "svc.calc" │ │ │ │
│ │ │ │ │
│ ├──RS+ $G svc.calc────────▶│ │
│ │ │ │ │
│ ├──RS+ $G svc.calc──▶│ │ │
│ │ │ (Node-2 记录: │
│ │ │ svc.calc→Node-3) │
│ │ │ │ │
│ │ │ │(Node-1 记录:
│ │ │ │ svc.calc→Node-3)
│ │ │ │ │
【阶段 2:客户端发布请求】
│ │ │ │ │
│ │ │ │◀─PUB──────┤
│ │ │ │ "svc.calc"
│ │ │ │ Reply: _INBOX.1
│ │ │ │ │
【阶段 3:Node-1 路由决策】
│ │ │ │ │
│ │ │ │(查路由表: │
│ │ │ │ svc.calc │
│ │ │ │ → Node-3) │
│ │ │ │ │
│ │ │◀─RMSG──────┤ │
│ │ │ "svc.calc" │ │
│ │ │ Reply: _INBOX.1 │
│ │ │ │ │
【阶段 4:Node-2 转发给 Node-3(如有必要)或直接有连接】
│ │◀─RMSG──────┤ │ │
│ │ "svc.calc" │ │ │
│ │ Reply: _INBOX.1 │ │
│ │ │ │ │
【阶段 5:Node-3 投递给订阅者】
│◀─MSG───────┤ │ │ │
│ "svc.calc" │ │ │ │
│ Reply: _INBOX.1 │ │ │
│ │ │ │ │
【阶段 6:Service 处理并回复】
│ │ │ │ │
├─PUB────────▶│ │ │ │
│ "_INBOX.1" │ │ │ │
│ "result:42" │ │ │ │
│ │ │ │ │
│ │(查路由表: │ │ │
│ │ _INBOX.1 │ │ │
│ │ → Node-1) │ │ │
│ │ │ │ │
│ ├──RMSG──────────────────▶│ │
│ │ "_INBOX.1" │ │ │
│ │ "result:42" │ │ │
│ │ │ │ │
│ │ │ ├─MSG───────▶│
│ │ │ │ "result:42"
│ │ │ │ │5.2 路由跳数优化
NATS 集群中,消息最多经过 1 次路由跳转(从发布者节点 → 订阅者节点)。集群内不存在多跳路由:
NATS 的 Full-Mesh 拓扑:
每个节点与其他所有节点直接相连
→ 任何两个节点间消息只需 1 跳
→ 时延可预测(不会因路径长度变化)
Node-1
/ \
Node-2 - Node-3
Node-1 → Node-3: 1 跳(直连)
Node-2 → Node-3: 1 跳(直连)
Node-1 → Node-2: 1 跳(直连)这与某些采用环形或星形拓扑的消息系统不同(它们可能需要 2-3 跳)。Full-Mesh 是 NATS 集群的拓扑要求,所有节点都必须互相连接。
6. Headless Service DNS 的重要性
6.1 Kubernetes 中的 NATS 集群发现
在 Kubernetes 上部署 NATS 集群时,节点需要发现彼此的 DNS 地址。普通 ClusterIP Service(有虚拟 IP)不适合 NATS 集群内部通信,因为:
普通 ClusterIP Service 的问题:
nats-cluster-svc: 10.96.0.100(虚拟 IP)
│
kube-proxy 负载均衡
/ | \
Pod-0 Pod-1 Pod-2
Node-1(Pod-0)配置 routes: ["nats://nats-cluster-svc:6222"]
→ 实际连接到哪个 Pod?随机!
→ 可能连接到自己(Pod-0 → Pod-0),造成路由循环!
→ 无法建立 Full-Mesh 连接(无法固定连到特定节点)Headless Service(clusterIP: None)的 DNS 解析返回的是所有 Pod 的真实 IP,配合 StatefulSet 的稳定 DNS 名称,每个节点可以精确连接到特定对端:
Headless Service DNS 解析:
nats-0.nats.default.svc.cluster.local → 10.244.0.2(Pod-0 真实 IP)
nats-1.nats.default.svc.cluster.local → 10.244.1.3(Pod-1 真实 IP)
nats-2.nats.default.svc.cluster.local → 10.244.2.4(Pod-2 真实 IP)
Node-1(Pod-0)配置:
routes: [
"nats://nats-1.nats.default.svc.cluster.local:6222" → 固定到 Pod-1
"nats://nats-2.nats.default.svc.cluster.local:6222" → 固定到 Pod-2
]
完美建立 Full-Mesh!6.2 Headless Service 配置
# headless-service.yaml
apiVersion: v1
kind: Service
metadata:
name: nats # StatefulSet Pod DNS 前缀
namespace: default
labels:
app: nats
spec:
clusterIP: None # ← 关键:Headless
selector:
app: nats
ports:
- name: client
port: 4222
targetPort: 4222
- name: cluster
port: 6222
targetPort: 6222
- name: monitor
port: 8222
targetPort: 82226.3 DNS 问题排查
# 验证 Headless Service DNS 解析
kubectl run -it --rm dns-test --image=busybox --restart=Never -- \
nslookup nats-0.nats.default.svc.cluster.local
# 预期输出(返回真实 Pod IP,不是 ClusterIP):
# Server: 10.96.0.10
# Name: nats-0.nats.default.svc.cluster.local
# Address 1: 10.244.0.2 nats-0.nats.default.svc.cluster.local
# 如果返回的是 ClusterIP(虚拟 IP),说明使用的是普通 Service,需要改为 Headless7. 配置示例
7.1 完整集群配置(nats-server.conf)
# nats-server.conf(3 节点集群,每个节点一份,修改 server_name 和 routes)
# === 基础配置 ===
server_name: "nats-0" # 每个节点唯一名称
listen: "0.0.0.0:4222" # 客户端连接端口
http: "0.0.0.0:8222" # 监控 HTTP 端口
# === 日志 ===
log_file: "/var/log/nats/nats.log"
log_time: true
logfile_size_limit: 100MB # 单个日志文件大小上限
max_traced_msg_len: 1024 # 追踪消息时的最大输出长度
# === 客户端连接配置 ===
max_connections: 65536
max_payload: 8MB # 最大消息体大小(根据业务调整)
max_pending: 32MB # 单个客户端待处理最大缓冲
write_deadline: "10s" # 写超时(客户端太慢时断开)
# === 集群配置(核心)===
cluster {
name: "prod-cluster" # ← 所有节点必须完全相同!
# 本节点监听路由连接的地址
listen: "0.0.0.0:6222"
# pool_size:每个 peer 之间建立的 TCP 连接数(默认 4)
pool_size: 4 # 总连接数 = (N-1) × pool_size
# 连接到其他节点(Node-0 的配置,连接到 Node-1 和 Node-2)
routes: [
"nats://nats-1.nats.default.svc.cluster.local:6222"
"nats://nats-2.nats.default.svc.cluster.local:6222"
]
# 连接超时和重连
connect_retries: 120 # 启动时重试连接次数
connect_error_reports: 5 # 连接错误上报频率
}
# === JetStream 配置(如需要)===
jetstream {
store_dir: "/data/nats"
max_memory_store: 4GB
max_file_store: 100GB
}
# === 账户配置(多租户场景)===
# accounts {
# MyAccount: {
# users: [{user: "client", password: "s3cr3t"}]
# }
# }7.2 Node-1 配置(nats-1 的 routes 配置)
# Node-1 的配置(连接到 Node-0 和 Node-2)
server_name: "nats-1"
cluster {
name: "prod-cluster" # ← 与 Node-0 完全相同
listen: "0.0.0.0:6222"
pool_size: 4
routes: [
"nats://nats-0.nats.default.svc.cluster.local:6222" # ← 连接到 Node-0
"nats://nats-2.nats.default.svc.cluster.local:6222" # ← 连接到 Node-2
]
}7.3 Kubernetes StatefulSet 完整部署
# nats-statefulset.yaml
apiVersion: apps/v1
kind: StatefulSet
metadata:
name: nats
namespace: default
spec:
serviceName: nats # 必须与 Headless Service 名称一致
replicas: 3
selector:
matchLabels:
app: nats
template:
metadata:
labels:
app: nats
spec:
# 优雅终止时间(确保 NATS 完成消息处理)
terminationGracePeriodSeconds: 60
containers:
- name: nats
image: nats:2.10-alpine
ports:
- containerPort: 4222 # client
- containerPort: 6222 # cluster routes
- containerPort: 8222 # monitoring
command:
- nats-server
- -c
- /etc/nats/nats.conf
- --server_name # 使用 Pod 名称作为 server_name
- $(POD_NAME)
env:
- name: POD_NAME
valueFrom:
fieldRef:
fieldPath: metadata.name
volumeMounts:
- name: config
mountPath: /etc/nats
- name: data
mountPath: /data/nats
resources:
requests:
memory: "512Mi"
cpu: "250m"
limits:
memory: "2Gi"
cpu: "2000m"
livenessProbe:
httpGet:
path: /healthz
port: 8222
initialDelaySeconds: 10
periodSeconds: 10
readinessProbe:
httpGet:
path: /healthz?js-enabled-only=true
port: 8222
initialDelaySeconds: 15
periodSeconds: 5
volumes:
- name: config
configMap:
name: nats-config
volumeClaimTemplates:
- metadata:
name: data
spec:
accessModes: ["ReadWriteOnce"]
storageClassName: "fast-ssd"
resources:
requests:
storage: 20Gi
---
# ConfigMap 使用模板配置(通过环境变量注入 server_name)
apiVersion: v1
kind: ConfigMap
metadata:
name: nats-config
data:
nats.conf: |
listen: "0.0.0.0:4222"
http: "0.0.0.0:8222"
max_payload: 8MB
cluster {
name: "prod-cluster"
listen: "0.0.0.0:6222"
pool_size: 4
routes: [
"nats://nats-0.nats.default.svc.cluster.local:6222"
"nats://nats-1.nats.default.svc.cluster.local:6222"
"nats://nats-2.nats.default.svc.cluster.local:6222"
]
}
jetstream {
store_dir: "/data/nats"
max_memory_store: 1GB
max_file_store: 10GB
}注意:routes 列表可以包含本节点自身的 DNS 名称,NATS 会自动跳过(不会连接自己)。这样所有节点可以共用同一份 ConfigMap,无需为每个节点单独配置。
8. 三种网络拓扑对比
8.1 拓扑总览
NATS 支持三种网络拓扑,适用于不同场景:
1. Cluster(集群)
─────────────
同一数据中心 / 同一地域
所有节点全互联(Full-Mesh)
路由连接走内网,延迟极低
2. Super Cluster via Gateway(超级集群/网关)
─────────────────────────────────────────
跨数据中心 / 跨地域
集群之间通过 Gateway 连接(非 Full-Mesh)
适合高延迟广域网链路
3. Leaf Nodes(叶节点)
─────────────────────
边缘计算 / IoT 场景
单个节点连接到核心集群
安全隔离,权限可控8.2 Cluster(同区集群)
部署位置:同一可用区 / 同一机房
┌──────────────────────────────────────────────────┐
│ Data Center A │
│ │
│ Client-A Client-B Client-C Client-D │
│ │ │ │ │ │
│ Node-1 ◀──▶ Node-2 ◀──▶ Node-3 │
│ (Full Mesh, 低延迟 <1ms) │
│ │
└──────────────────────────────────────────────────┘
特点:
✓ 延迟极低(节点间 <1ms)
✓ 消息路由透明(客户端无感知)
✓ Full-Mesh:消息最多 1 跳
✗ 不适合跨地域(WAN 延迟会影响 route 稳定性)
✗ 无法跨地域独立运行(一个集群只能在一个地域)
适用场景:
- 单机房高可用
- 同地域多可用区(AZ 内 RTT <5ms)
- K8s 单集群部署8.3 Super Cluster via Gateway(跨区超级集群)
部署位置:多个地域 / 数据中心
┌──────────────────────┐ ┌──────────────────────┐
│ Asia Cluster │ │ EU Cluster │
│ │ │ │
│ N1 ◀──▶ N2 ◀──▶ N3 │ │ N4 ◀──▶ N5 ◀──▶ N6 │
│ │ │ │ │ │
│ Gateway-Asia │◀───────▶│ Gateway-EU │
│ │ WAN 链路│ │
└──────────────────────┘ ~50ms └──────────────────────┘
◀──▶
┌──────────────────────┐
│ US Cluster │
│ N7 ◀──▶ N8 ◀──▶ N9 │
│ Gateway-US │
└──────────────────────┘
Gateway 特性:
- 采用"按兴趣路由"(只转发有订阅的消息)
- 一条 Gateway 连接(非 pool 模式)
- 自动发现:subject 兴趣通过 A+/A- 协议传播
配置示例(Asia 节点的 gateway 块):
gateway {
name: "asia"
listen: "0.0.0.0:7222"
gateways: [
{ name: "eu", urls: ["nats://eu-gw1:7222", "nats://eu-gw2:7222"] }
{ name: "us", urls: ["nats://us-gw1:7222"] }
]
}
特点:
✓ 跨地域消息路由(任意节点 pub,任意节点 sub)
✓ 地域故障时其他地域正常运行
✓ 网络分区容忍(Gateway 断开不影响本地集群)
✗ WAN 链路增加延迟(跨区消息需要 50-200ms)
✗ 配置相对复杂
适用场景:
- 全球多地域部署
- 容灾架构(Active-Active 多地域)
- 数据主权要求(各地域独立运行)8.4 Leaf Nodes(叶节点)
部署位置:边缘设备 / IoT 网关 / 独立环境
┌─────────────────────────────────────┐
│ Core NATS Cluster │
│ │
│ N1 ◀──▶ N2 ◀──▶ N3 │
└─────────────────────────────────────┘
▲ ▲
│ │ WAN / 专线
▼ ▼
┌──────────┐ ┌──────────┐
│ Leaf │ │ Leaf │
│ Node │ │ Node │
│ (工厂A) │ │ (工厂B) │
│ │ │ │
│ 本地设备 │ │ 本地设备 │
└──────────┘ └──────────┘
Leaf Node 特性:
- Leaf 连接到 Core Cluster,作为"延伸"
- 本地可独立运行(WAN 断开时 Leaf 内部通信正常)
- Subject 映射:Leaf 本地 subject 可映射到 Core subject
- 权限隔离:Leaf 只能访问配置允许的 subject
配置示例(Leaf Node 的配置):
leafnodes {
remotes: [
{
url: "nats://core-cluster-lb:7422"
credentials: "/etc/nats/leaf.creds"
}
]
}
特点:
✓ 网络断开时本地仍可运行
✓ 安全隔离(Leaf 无法访问 Core 未授权的 subject)
✓ 轻量:Leaf 只需单节点,无需集群配置
✓ 适合资源受限的边缘设备
✗ 仅单向连接到 Core(非对等)
✗ 跨 Leaf 通信必须经过 Core
适用场景:
- 工厂/仓库边缘节点
- IoT 设备汇聚网关
- 开发环境连接生产 NATS
- 多租户安全隔离8.5 三种拓扑对比表
┌────────────────┬──────────────┬────────────────┬──────────────────┐
│ 维度 │ Cluster │ Super Cluster │ Leaf Nodes │
├────────────────┼──────────────┼────────────────┼──────────────────┤
│ 适用范围 │ 同区/同机房 │ 跨区/跨地域 │ 边缘/IoT/扩展 │
│ 节点间协议 │ Route │ Gateway │ Leaf │
│ 延迟 │ 极低 <1ms │ WAN 延迟 │ 取决于链路 │
│ 故障隔离 │ 无 │ 地域级隔离 │ 边缘独立运行 │
│ 配置复杂度 │ 低 │ 中 │ 低 │
│ 消息路由跳数 │ 最多 1 跳 │ Cluster内1跳 │ 经过 Core 转发 │
│ │ │ + 1 Gateway跳 │ │
│ 订阅传播 │ RS+/RS- │ A+/A- │ LS+/LS- │
│ 安全隔离 │ 无 │ 账户级 │ Leaf 级强隔离 │
│ 网络分区容忍 │ 低(需 Raft) │ 高 │ 极高(本地运行) │
└────────────────┴──────────────┴────────────────┴──────────────────┘9. 滚动升级时集群的行为
9.1 滚动升级流程
Kubernetes 的 StatefulSet 滚动升级(RollingUpdate)按 Pod 序号倒序依次重启:
3 节点集群滚动升级过程:
初始状态(所有节点运行旧版本 v2.9):
Node-0(v2.9) ◀──▶ Node-1(v2.9) ◀──▶ Node-2(v2.9)
Step 1:升级 Node-2(序号最大的先升级)
Node-0(v2.9) ◀──▶ Node-1(v2.9) Node-2(重启中...)
│
← 约 10-30 秒 →
│
Node-0(v2.9) ◀──▶ Node-1(v2.9) ◀──▶ Node-2(v2.10)
Step 2:升级 Node-1
Node-0(v2.9) ◀──▶ Node-1(重启中...) ◀──▶ Node-2(v2.10)
Step 3:升级 Node-0
Node-0(重启中...) ◀──▶ Node-1(v2.10) ◀──▶ Node-2(v2.10)
完成:
Node-0(v2.10) ◀──▶ Node-1(v2.10) ◀──▶ Node-2(v2.10)9.2 节点离线期间的影响
Core NATS(无 JetStream):
Node-2 重启期间(约 15 秒):
影响:
- 连接到 Node-2 的客户端:连接断开,需要重连
→ 客户端配置了 nats.MaxReconnects(-1) 时自动重连到其他节点
→ 重连期间消息可能丢失(Core NATS 无持久化)
不影响:
- 连接到 Node-0、Node-1 的客户端正常工作
- Node-2 上没有本地订阅者的 subject 消息正常路由
- Node-0 ◀──▶ Node-1 之间的消息路由完全正常
恢复:
- Node-2 重启后重新建立 Route 连接(8条)
- 重新同步订阅信息(RS+)
- 客户端自动重连JetStream 集群(Raft):
JetStream Raft 在 Node-2 下线期间:
3 节点 Raft:多数派 = 2
Node-2 下线后剩余 2 个节点(Node-0 + Node-1)
2 ≥ 2(多数派),Raft 仍可工作!
影响:
- JetStream Stream/Consumer Leader 可能迁移
→ 如果 Node-2 是某个 Stream 的 Raft Leader,会触发重新选举
→ 选举期间(通常 <1 秒)该 Stream 的写操作暂停
不影响:
- 其他 Stream(Node-2 不是其 Leader)正常工作
- 已持久化的消息不丢失
- Consumer 继续消费
2节点场景的危险(升级中途故障):
如果 Node-2 正在重启,此时 Node-1 也崩溃:
→ 只剩 Node-0 一个节点,无法形成多数派
→ JetStream 元数据操作(创建 Stream/Consumer 等)暂停
→ 但 Publish 消息到已存在的 Stream 可能仍可工作(等待 quorum)9.3 优雅关闭的重要性
NATS 服务器支持 --signal drain 信号,触发优雅关闭流程:
优雅关闭流程(推荐配置 preStop hook):
K8s 发送 SIGTERM
│
▼
NATS 收到信号,开始 drain:
1. 停止接受新客户端连接
2. 等待现有客户端完成消息处理(最多 terminationGracePeriodSeconds)
3. 向路由节点发送下线通知
4. 路由节点更新路由表,不再向此节点发消息
5. 关闭所有连接
6. 退出
K8s StatefulSet 配置建议:
spec:
terminationGracePeriodSeconds: 60 # 给足够时间 drain
containers:
- lifecycle:
preStop:
exec:
command:
- nats-server
- --signal
- drain9.4 版本兼容性
NATS 服务端版本兼容规则:
升级过程中,新旧版本节点共存:
Node-0(v2.9) ◀──▶ Node-2(v2.10)
NATS 保证:
✓ 同一大版本(如 2.x)内的小版本可以混跑
✓ 路由协议向后兼容
✗ 跨大版本(如 1.x → 2.x)需要所有节点同时升级或有专门迁移文档
建议:
- 升级前查阅 NATS Release Notes
- 先在测试环境验证滚动升级
- 保持各节点版本差异不超过一个 minor 版本10. routez 端点详细字段解读
10.1 访问 routez
# 查询节点的路由状态
curl http://localhost:8222/routez
# 格式化输出
curl -s http://localhost:8222/routez | python3 -m json.tool
# 只看简要信息
curl -s http://localhost:8222/routez?subs=010.2 routez 返回字段全解析
{
"server_id": "NAQGJXPBDCFLWRP7GKTSLJFOO3AG3HFG4K4T2SLXNQNZFOBDZ6DGSM",
"now": "2024-03-15T10:30:00.123456789Z",
"num_routes": 8,
"routes": [
{
"rid": 1,
"remote_id": "NBRV5GQHKXGTLDNJYQUCR2NZXVXHRVR4MLBRPXHVGPV7HWRPKRZ7N",
"remote_name": "nats-1",
"did_solicit": true,
"is_configured": true,
"ip": "10.244.1.3",
"port": 6222,
"start": "2024-03-15T10:00:00.000000000Z",
"last_activity": "2024-03-15T10:30:00.120000000Z",
"rtt": "0.312ms",
"uptime": "30m0s",
"idle": "80ms",
"in_msgs": 1234567,
"out_msgs": 987654,
"in_bytes": 123456789,
"out_bytes": 98765432,
"subscriptions": 145,
"account": "",
"compression": "s2_best",
"auth_timeout": false,
"tls_version": "",
"tls_cipher_suite": "",
"pool_idx": 0
},
{
"rid": 2,
"remote_id": "NBRV5GQHKXGTLDNJYQUCR2NZXVXHRVR4MLBRPXHVGPV7HWRPKRZ7N",
"remote_name": "nats-1",
"did_solicit": true,
"is_configured": true,
"ip": "10.244.1.3",
"port": 6222,
"rtt": "0.298ms",
"pool_idx": 1
}
// ... 共 8 条(4 到 nats-1,4 到 nats-2)
]
}10.3 各字段详解
num_routes
当前节点的路由连接总数。
健康集群:(N-1) × pool_size
如果 num_routes < 期望值,说明有节点未连接或连接断开。
示例:3节点集群期望值 = (3-1) × 4 = 8
routes[].rid
路由连接的本地内部 ID(递增整数)。
每次连接建立时分配新 ID,重连后 ID 会变化。
用于区分同一 peer 的多条连接(pool)。
routes[].remote_id
对端节点的 Server ID(56字节 NKEY 格式)。
同一 peer 的 pool 中所有连接的 remote_id 相同。
可用来确认实际连接到了哪个节点。
routes[].remote_name
对端节点的 server_name 配置值(如 "nats-1")。
便于人工识别,是 remote_id 的可读形式。
routes[].did_solicit
true = 本节点主动发起的连接
false = 对端节点发起的连接(本节点被动接受)
在 pool 中,通常一半主动一半被动(双方各发起一部分)。
routes[].is_configured
true = 此 route 对应 routes[] 配置中的某一项(静态配置)
false = 动态发现的路由(通常不出现在基础集群中)
routes[].ip / .port
对端节点的实际 IP 和端口。
如果与 DNS 名称不一致,可能说明 DNS 解析有问题。
routes[].rtt
到对端节点的往返延迟(Round-Trip Time)。
正常值(同机房):< 1ms
跨可用区:1-5ms
异常值(>10ms):可能存在网络拥塞或配置问题。
routes[].uptime
此连接已建立的时长。
短暂 uptime(如 <5s)可能说明连接频繁重建(网络不稳定)。
routes[].idle
此连接上次传输数据到现在的时间。
长时间 idle(>30s)但节点正常是正常的(无消息时 PING 维持连接)。
routes[].in_msgs / .out_msgs
通过此路由连接收/发的消息总数(累计,从连接建立开始)。
可用于分析消息流方向和负载分布。
routes[].in_bytes / .out_bytes
通过此路由连接收/发的字节总数。
用于评估带宽使用情况。
routes[].subscriptions
通过此路由连接传播的订阅数量(对端节点告知本节点的兴趣总数)。
异常低值(=0)可能说明 RS+ 传播失败(客户端收不到跨节点消息)。
routes[].compression
路由连接是否启用压缩及压缩算法。
可选值:""(无压缩)、"s2_best"、"s2_fast"、"s2_auto"
启用压缩可降低带宽,但增加 CPU 开销。
routes[].pool_idx
此连接在 pool 中的索引(0 ~ pool_size-1)。
pool_idx=0 表示该连接是 pool 中的第一条。
用于确认 pool 是否正常建立(应该有 0,1,2,3 四个索引值)。10.4 routez 诊断案例
案例 1:集群未完全形成
# 期望 8 条路由(3节点,pool_size=4),实际只有 4 条
curl -s http://localhost:8222/routez | jq '.num_routes'
# 输出:4
# 诊断:只连接到了 1 个 peer,另 1 个 peer 未连接
# 排查:
# 1. 检查另一个节点是否启动
# 2. 检查 DNS 解析
kubectl exec nats-0 -- nslookup nats-2.nats.default.svc.cluster.local
# 3. 检查网络策略(NetworkPolicy)
# 4. 查看 NATS 日志中的连接错误
kubectl logs nats-0 | grep -E "route|error|connect"案例 2:subscriptions 为 0
curl -s http://localhost:8222/routez | jq '.routes[0].subscriptions'
# 输出:0
# 诊断:对端节点没有任何订阅,或 RS+ 传播失败
# 后果:发布到对端订阅者的消息无法路由
# 排查:
# 1. 确认对端节点有客户端订阅
# 2. 检查 NATS 服务端版本(RS+ 在旧版本有 bug)
# 3. 查看路由连接的 uptime(是否刚刚重建)案例 3:rtt 异常高
curl -s http://localhost:8222/routez | jq '.routes[] | {name: .remote_name, rtt: .rtt}'
# 输出:
# {"name": "nats-1", "rtt": "52.3ms"} ← 异常!同机房应 <1ms
# 诊断:网络拥塞或跨区路由
# 排查:
# 1. ping 对端节点 IP
# 2. 检查是否发生了 Pod 跨节点调度
kubectl get pods -o wide | grep nats
# 3. 检查是否有大量消息导致拥塞
# 4. 考虑增加 pool_size 减少行头阻塞10.5 自动化监控脚本
#!/bin/bash
# check_nats_routes.sh:检查 NATS 集群路由健康状态
EXPECTED_ROUTES=8 # (3-1) × 4
MONITOR_URL="http://localhost:8222"
check_node() {
local node=$1
local url="${MONITOR_URL}/routez"
result=$(curl -s "$url" 2>/dev/null)
if [ $? -ne 0 ]; then
echo "ERROR: 无法连接到 $node 的监控端点"
return 1
fi
num_routes=$(echo "$result" | python3 -c "import json,sys; d=json.load(sys.stdin); print(d['num_routes'])")
if [ "$num_routes" -lt "$EXPECTED_ROUTES" ]; then
echo "WARNING: $node 路由连接数不足!期望 $EXPECTED_ROUTES,实际 $num_routes"
else
echo "OK: $node 路由连接数 $num_routes(正常)"
fi
# 检查高 RTT
echo "$result" | python3 -c "
import json, sys
d = json.load(sys.stdin)
for r in d.get('routes', []):
rtt_str = r.get('rtt', '0ms').replace('ms', '')
try:
rtt = float(rtt_str)
if rtt > 5.0:
print(f'WARNING: 到 {r[\"remote_name\"]} 的 RTT 过高: {r[\"rtt\"]}')
except:
pass
"
}
for i in 0 1 2; do
MONITOR_URL="http://nats-$i.nats.default.svc.cluster.local:8222"
check_node "nats-$i"
done11. 小结
核心要点回顾
Route Pool:
- 解决行头阻塞(HOL Blocking)
- 默认 pool_size=4,生产可调整到 6-8
- 3节点集群每节点 8 条 Route TCP 连接
- 公式:num_routes = (N-1) × pool_size
Subject Interest Propagation:
- RS+ / RS- 协议在节点间同步订阅信息
- 路由表全内存,发布时 O(1) 查找目标节点
- Queue Group 通过 weight 实现加权路由
- 新节点加入时全量同步订阅状态
集群配置关键点:
- cluster.name 必须完全一致(所有节点)
- Headless Service DNS 是 K8s 部署必要条件
- StatefulSet 稳定 DNS 名称支持精确路由配置
- routes 列表可包含自身,NATS 自动跳过
网络拓扑选择:
- 同区:Cluster(Full-Mesh,1跳,极低延迟)
- 跨区:Super Cluster + Gateway(地域隔离)
- 边缘:Leaf Nodes(独立运行,安全隔离)
滚动升级:
- Core NATS 升级期间消息可能丢失(无持久化)
- JetStream 升级期间 Leader 迁移,短暂写暂停
- 配置优雅关闭(terminationGracePeriodSeconds)
- 同大版本内小版本可混跑
routez 监控:
- num_routes 应等于 (N-1) × pool_size
- subscriptions 为 0 表示无跨节点订阅传播
- rtt > 5ms(同机房)表示网络异常
- pool_idx 验证 pool 是否完整建立(0,1,2,3)参考配置速查
3 节点集群(pool_size=4,JetStream 启用):
- 每节点 Route 连接数:8
- 全集群 TCP Route 总数:12(3对 × 4条)
- JetStream Raft 容忍故障:1 个节点
- 监控端口:8222/routez
- 集群通信端口:6222上一篇:07 - JetStream 持久化消费者详解 下一篇:09 - NATS 认证与授权(TLS + NKeys + JWT)