事件驱动架构的极简落地:用 NATS 替代 Kafka 的决策边界与实践
事件驱动架构的极简落地:用 NATS 替代 Kafka 的决策边界与实践
一、Kafka 的运维噩梦:轻量场景下的架构肥胖
Kafka 是事件驱动架构的事实标准,但它有一个致命问题:太重了。生产环境的 Kafka 集群至少需要 3 个 Broker + 3 个 ZooKeeper 节点。配置、调优、监控、升级——每一项都是专门的运维技能。对于只有几个微服务的团队来说,维护 Kafka 集群本身就是一项全职工作。
NATS 是另一个选择。它是一个用 Go 语言编写的消息系统,部署只需一个不到 20MB 的二进制文件。不需要 ZooKeeper,不需要专门的集群管理工具,不需要 JVM 调优。启动一个三节点的集群只需要:
nats-server --cluster nats://0.0.0.0:6222 --routes nats://node1:6222,nats://node2:6222
但 NATS 不是 Kafka 的平替。它们在设计哲学上有根本差异,知道什么时候该用什么,比会用什么更重要。
graph TB
subgraph Kafka[Kafka 适用场景]
K1[事件溯源<br/>Event Sourcing]
K2[海量数据管道<br/>TB 级日志]
K3[消息重放<br/>Replay]
K4[多消费者组<br/>独立消费]
end
subgraph NATS[NATS 适用场景]
N1[服务间异步通信]
N2[实时推送<br/>毫秒级延迟]
N3[请求-回复模式]
N4[微服务 < 20]
end
Q[决策问题: 选哪个?] --> A{消息量级?}
A -->|MB/天| NATS
A -->|GB- TB/天| Kafka
A -->|需要消息持久化<br/>且支持重放?| Kafka
A -->|只需要传递消息<br/>不需要历史| NATS
A -->|运维资源 < 1 人| NATS
style NATS fill:#51cf66,color:#fff
style Kafka fill:#4dabf7,color:#fff
本文将分析 NATS 的核心能力、与 Kafka 的差异边界,并通过一个实际的订单事件系统展示极简事件驱动的完整实现。
二、NATS 的核心机制:Subject、JetStream 与 At-Least-Once
NATS 最基础的模式是 Publish-Subscribe。消息通过 Subject(主题)路由,支持通配符:
orders.created → 精确匹配
orders.* → 匹配 orders.created, orders.updated
orders.> → 匹配 orders.created, orders.updated.status
NATS 原生提供的是 At-Most-Once 语义(至多一次交付)。如果消费者不在线,消息直接丢失。这对实时推送场景(如 WebSocket 通知)是合适的,但对于订单系统等需要可靠性的场景,这就是问题。
JetStream 是 NATS 的持久化层,提供了 At-Least-Once 语义。它在 NATS 服务器上维护消息持久化存储,消费者可以通过 Stream(类似 Kafka Topic)订阅消息。关键特性:
- 消息保留:基于时间(24 小时)或大小(1GB)的策略
- 消费者组:多个消费者可以共享消费进度
- Ack 机制:消费者确认后才能消费下一条
- 重试策略:未确认的消息自动重发
JetStream 让 NATS 具备了接近 Kafka 的可靠性,但运维复杂度比 Kafka 低一个数量级。
三、NATS + JetStream 的订单事件系统实现
package main
import (
"encoding/json"
"fmt"
"log"
"time"
"github.com/nats-io/nats.go"
)
// OrderEvent 订单事件结构
type OrderEvent struct {
OrderID string `json:"order_id"`
UserID string `json:"user_id"`
Amount float64 `json:"amount"`
Status string `json:"status"`
Timestamp time.Time `json:"timestamp"`
}
// Publisher 发布订单事件
type Publisher struct {
conn *nats.Conn
js nats.JetStreamContext
}
func NewPublisher(natsURL string) (*Publisher, error) {
conn, err := nats.Connect(natsURL,
nats.ReconnectWait(2*time.Second),
nats.MaxReconnects(10),
nats.DisconnectErrHandler(func(nc *nats.Conn, err error) {
log.Printf("NATS disconnected: %v", err)
}),
nats.ReconnectHandler(func(nc *nats.Conn) {
log.Println("NATS reconnected")
}),
)
if err != nil {
return nil, fmt.Errorf("connect nats: %w", err)
}
js, err := conn.JetStream()
if err != nil {
conn.Close()
return nil, fmt.Errorf("create jetstream: %w", err)
}
return &Publisher{conn: conn, js: js}, nil
}
func (p *Publisher) PublishOrderCreated(event OrderEvent) error {
// 确保 Stream 存在
_, err := p.js.AddStream(&nats.StreamConfig{
Name: "ORDERS",
Subjects: []string{"orders.created", "orders.updated", "orders.cancelled"},
MaxAge: 24 * time.Hour,
Storage: nats.FileStorage,
}, nats.MaxWait(5*time.Second))
if err != nil {
log.Printf("stream may already exist: %v", err)
}
data, err := json.Marshal(event)
if err != nil {
return fmt.Errorf("marshal event: %w", err)
}
// 发布到 JetStream, 保证至少一次交付
ack, err := p.js.Publish("orders.created", data)
if err != nil {
return fmt.Errorf("publish event: %w", err)
}
log.Printf("Published event %s, seq: %d", event.OrderID, ack.Sequence)
return nil
}
func (p *Publisher) Close() {
p.conn.Drain()
}
// Consumer 消费订单事件
type Consumer struct {
conn *nats.Conn
js nats.JetStreamContext
sub *nats.Subscription
}
func NewConsumer(natsURL, durableName string) (*Consumer, error) {
conn, err := nats.Connect(natsURL,
nats.ReconnectWait(2*time.Second),
nats.MaxReconnects(-1),
)
if err != nil {
return nil, fmt.Errorf("connect nats: %w", err)
}
js, err := conn.JetStream()
if err != nil {
conn.Close()
return nil, fmt.Errorf("create jetstream: %w", err)
}
// 创建持久化消费者组
sub, err := js.PullSubscribe(
"orders.created",
durableName, // 持久化名称,重启后保留消费进度
nats.AckExplicit(),
nats.MaxDeliver(3), // 最多重试 3 次
nats.AckWait(30*time.Second), // 30 秒内必须 ACK
)
if err != nil {
conn.Close()
return nil, fmt.Errorf("subscribe: %w", err)
}
return &Consumer{conn: conn, js: js, sub: sub}, nil
}
func (c *Consumer) ProcessMessages(handler func(OrderEvent) error) error {
for {
// 批量拉取消息
msgs, err := c.sub.Fetch(10, nats.MaxWait(5*time.Second))
if err != nil {
if err == nats.ErrTimeout {
continue // 无消息, 等待
}
return fmt.Errorf("fetch messages: %w", err)
}
for _, msg := range msgs {
var event OrderEvent
if err := json.Unmarshal(msg.Data, &event); err != nil {
log.Printf("unmarshal event: %v", err)
msg.Nak() // 消息格式错误,不重试
continue
}
if err := handler(event); err != nil {
log.Printf("handle event %s: %v", event.OrderID, err)
msg.Nak() // 处理失败,标记重试
continue
}
msg.Ack() // 处理成功,确认
}
}
}
func (c *Consumer) Close() {
if c.sub != nil {
c.sub.Unsubscribe()
}
c.conn.Drain()
}
// 使用示例
func main() {
publisher, err := NewPublisher("nats://localhost:4222")
if err != nil {
log.Fatal(err)
}
defer publisher.Close()
// 发布订单事件
event := OrderEvent{
OrderID: "ORD-2024-001",
UserID: "USR-123",
Amount: 99.99,
Status: "created",
Timestamp: time.Now(),
}
if err := publisher.PublishOrderCreated(event); err != nil {
log.Fatal(err)
}
}
消费者使用示例:
consumer, err := NewConsumer("nats://localhost:4222", "order-service")
if err != nil {
log.Fatal(err)
}
defer consumer.Close()
err = consumer.ProcessMessages(func(event OrderEvent) error {
log.Printf("Processing order: %s, amount: %.2f", event.OrderID, event.Amount)
// 实际的业务逻辑: 更新库存、发送通知等
if event.Amount <= 0 {
return fmt.Errorf("invalid amount")
}
return nil
})
四、NATS 与 Kafka 的决策矩阵
| 维度 | NATS | Kafka |
|---|---|---|
| 部署复杂度 | 单二进制,秒级启动 | 需要 ZooKeeper/KRaft + Broker,分钟级 |
| 运维成本 | 几乎为零 | 需要专人 |
| 消息持久化 | JetStream 支持 | 原生支持 |
| 消息重放 | 有限(Stream 内重放) | 完整支持(按 Offset 重放) |
| 延迟 | < 1ms | ~10ms |
| 吞吐量 | 数百万 msg/s | 数百万 msg/s |
| 事件溯源 | 勉强可用 | 原生设计 |
| 资源占用 | < 50MB 内存 | > 1GB 内存(JVM) |
选择 NATS 的标准:
- 服务数量 < 20
- 不需要消息重放历史
- 开发/运维资源有限
- 对延迟敏感(如实时通知)
选择 Kafka 的标准:
- 消息数据需要长期保留(> 7 天)
- 需要独立的多消费者组按不同速度消费
- 消息量达到 TB 级别/天
- 团队已有 Kafka 运维经验
五、总结
NATS 是事件驱动架构中的"极简选项"。它用最小的运维成本实现了微服务间的可靠异步通信。但极简不是万能的——如果你的业务需要事件溯源、消息重放或海量数据管道,Kafka 才是正确的选择。
落地路径:先用 NATS 的原生 Pub/Sub 做服务间异步通信;确认需要消息持久化后,升级到 JetStream;如果日后消息量突破 GB/天级别,再评估是否迁移到 Kafka。
技术的选择不是看哪个"更好",而是看哪个"更适合当前的约束条件"。少即是多——用 NATS 而不是 Kafka,不是因为 NATS 更强大,而是它刚好够用。