5. JetStream. Persistence и stream processing

Персистентность, распределенный консенсус и stream processing

«Самый быстрый ввод-вывод — тот, которого вовсе не происходит. Когда же сохранение на диск неизбежно, пишите последовательно, агрессивно пакетируйте данные и держите протокол координации столь же простым, как сама физика диска».
— Брайан Керниган


Что такое JetStream и зачем он нужен?

В классическом Core NATS сообщения существуют ровно столько, сколько требуется сетевой карте для отправки TCP-пакета: если в момент отправки нет активных подписчиков или процесс упал, сообщение исчезает безвозвратно. Это превосходно для телеметрии, но категорически неприемлемо для финансовых транзакций, заказов или аудита.

JetStream — это встроенная непосредственно в сервер NATS персистентная потоковая платформа (persistent streaming platform). Она превращает легковесный NATS в полноценную распределенную систему класса Apache Kafka, RabbitMQ Streams и AWS Kinesis, наделяя брокер следующими свойствами:

  • Durability (Долговечность): сохранение сообщений на диск с гарантией выживания при перезапуске кластера.
  • Ordering (Строгий порядок): монотонно возрастающие глобальные и потоковые номера последовательности (Sequence Numbers).
  • Replay (Воспроизведение истории): возможность перечитать события с начала времен, с конкретной временной метки или конкретного смещения.
  • Consumer Groups & Ack Policies: явные подтверждения обработки (Ack, Nak, Term), дедупликация и надежные рабочие очереди.
  • Stream Processing: потоковая фильтрация по субъектам без развертывания тяжелых внешних фреймворков.

При этом JetStream не требует отдельных демонов, внешних координаторов (ZooKeeper) или прожорливых виртуальных машин (JVM). Весь функционал включен в единый бинарный файл nats-server размером около 20 МБ, написанный на чистом Go.


Архитектура JetStream

JetStream строится поверх Core NATS, используя его высокоскоростной транспортный конвейер, но добавляя уровень персистентного хранения данных на базе консенсуса Raft:

graph TB
    subgraph "Application Layer (Клиентские сервисы)"
        P["Publishers (Издатели)"]
        C["Consumers (Потребители)"]
    end

    subgraph "NATS Transport Layer (Транспортный уровень)"
        NC["NATS Client"]
        NS["NATS Server (Core Hub)"]
    end

    subgraph "JetStream Storage Layer (Уровень хранения и консенсуса)"
        JS["JetStream API (Raft Meta-Group)"]
        ST["Streams (Append-Only Logs)"]
        CT["Consumers (Offset & State Trackers)"]
        DS["Disk / Memory Storage"]
    end

    P --> NC
    C --> NC
    NC <--> NS
    NS <--> JS
    JS --> ST
    JS --> CT
    ST <--> DS
    CT --> ST

    style JS fill:#1e293b,stroke:#6366f1,stroke-width:2px,color:#f8fafc
    style ST fill:#131d33,stroke:#38bdf8,stroke-width:2px,color:#f8fafc
    style CT fill:#131d33,stroke:#10b981,stroke-width:2px,color:#f8fafc
    style DS fill:#0f172a,stroke:#f59e0b,stroke-width:2px,color:#f8fafc
  

Механическое сочувствие (Mechanical Sympathy): append-only log


Основные концепции JetStream

1. Stream (Поток)

Stream — это persistent log сообщений, логический аналог Kafka topic, но обладающий ключевым отличием: один Stream в NATS может захватывать сообщения из множества иерархических субъектов (Subjects).

Go
js, err := nc.JetStream()
if err != nil {
    log.Fatal(err)
}

// Создаем stream с персистентной конфигурацией
stream, err := js.AddStream(&nats.StreamConfig{
    Name:      "ORDERS",
    Subjects:  []string{"orders.*"},        // Subject'ы, которые будут сохраняться в лог
    Retention: nats.WorkQueuePolicy,       // Удалять сообщение сразу после подтверждения обработки
    MaxAge:    7 * 24 * time.Hour,         // Хранить сообщения максимум 7 дней
    MaxBytes:  100 * 1024 * 1024,         // Ограничение размера лога: максимум 100MB
    Storage:   nats.FileStorage,           // File-based storage (дисковое хранилище)
    Replicas:  3,                          // 3 реплики для отказоустойчивости (Raft кворум)
})
if err != nil {
    log.Fatal(err)
}

2. Message Structure в JetStream

Сообщение в JetStream обогащено служебными метаданными рантайма:

Go
type Msg struct {
    Subject      string    // Subject сообщения (например, orders.new)
    Reply        string    // Reply subject для подтверждений и ответов
    Data         []byte    // Полезная нагрузка (Payload)
    Header       Header    // Заголовки (key-value, включая Nats-Msg-Id)
    Sequence     uint64    // Монотонный Sequence number в рамках конкретного stream
    Time         time.Time // Время поступления на брокер (timestamp)
    Stream       string    // Имя stream'a (например, ORDERS)
    Duplicates   bool      // Флаг дедупликации (признак дубликата)
}

3. Consumer (Потребитель)

Consumer — это сущность внутри JetStream, которая читает сообщения из потока. В отличие от Kafka, где смещение (offset) контролируется исключительно клиентом через периодический commit, в JetStream Consumer является серверным объектом с состоянием.

JetStream поддерживает:

  • Push consumers: брокер активно проталкивает сообщения клиенту при их появлении;
  • Pull consumers: клиент явно запрашивает пачки сообщений по мере своей готовности (идеально для backpressure);
  • Durable consumers: сервер запоминает имя потребителя и его позицию чтения (offset) на диске;
  • Ephemeral consumers: временные потребители, исчезающие при разрыве сетевой сессии.
Go
// Push consumer (сервер отправляет сообщения)
pushCons, err := js.Subscribe("orders.*", func(msg *nats.Msg) {
    fmt.Printf("Processing order: %s\n", string(msg.Data))
    msg.Ack() // Подтверждение получения
}, nats.Durable("order_processor")) // Durable consumer
if err != nil {
    log.Fatal(err)
}

// Pull consumer (клиент сам забирает сообщения)
pullCons, err := js.PullSubscribe("orders.*", "batch_processor")
if err != nil {
    log.Fatal(err)
}

// Батчевая обработка с контролем времени ожидания
msgs, err := pullCons.Fetch(10, nats.MaxWait(5*time.Second))
if err != nil {
    log.Printf("Fetch error: %v", err)
    return
}

for _, msg := range msgs {
    processOrder(msg)
    msg.Ack()
}

Типы Consumers: Push vs Pull

flowchart TD
    classDef process  fill:#1e293b,stroke:#38bdf8,stroke-width:1px,color:#f8fafc
    classDef success  fill:#064e3b,stroke:#34d399,stroke-width:1px,color:#f8fafc
    classDef error    fill:#7f1d1d,stroke:#f87171,stroke-width:1px,color:#f8fafc
    classDef warning  fill:#78350f,stroke:#fbbf24,stroke-width:1px,color:#f8fafc
    classDef entry    fill:#1e3a5f,stroke:#38bdf8,stroke-width:2px,color:#f8fafc
    classDef aux      fill:#334155,stroke:#64748b,stroke-width:1px,color:#f8fafc
    classDef system   fill:#312e81,stroke:#a78bfa,stroke-width:1px,color:#f8fafc
    classDef data     fill:#132d29,stroke:#2dd4bf,stroke-width:1px,color:#f8fafc
    classDef network  fill:#881337,stroke:#fb7185,stroke-width:1px,color:#f8fafc
    classDef accent   fill:#1e1b4b,stroke:#818cf8,stroke-width:1px,color:#f8fafc
    subgraph PushModel["Push Consumer (Реактивный поток)"]
        S1["JetStream Log"]:::network -->|"Проталкивание по TCP<br>(Flow Control & Heartbeats)"| C1["Worker: OnMessage()"]
    end

    subgraph PullModel["Pull Consumer (Контроль нагрузки / Backpressure)"]
        C2["Worker: Fetch(Batch=10)"]:::entry -->|"Явный запрос"| S2["JetStream Log"]
        S2 -->|"Возврат пачки сообщений"| C2
    end
  

1. Push Consumers

Push consumers получают сообщения автоматически от сервера сразу после их записи в поток. Они обеспечивают минимальную задержку и подходят для real-time processing:

Go
// Async push consumer
_, err = js.Subscribe("orders.new", func(msg *nats.Msg) {
    // Обработка в фоне
    go processOrderAsync(msg)
    msg.Ack()
}, nats.Durable("async_order_processor"))

2. Pull Consumers

Pull consumers сами запрашивают сообщения. Это золотой стандарт для микросервисов с высокими нагрузками: воркер никогда не захлебнется, если база данных начнет тормозить, так как он запрашивает ровно столько данных, сколько способен обработать:

Go
// Pull consumer с контролем скорости (Rate Limiting & Backpressure)
cons, err := js.PullSubscribe("orders.*", "batch_processor")
if err != nil {
    log.Fatal(err)
}

for {
    // Забираем до 10 сообщений, ожидая не более 10 секунд
    msgs, err := cons.Fetch(10, nats.MaxWait(10*time.Second))
    if err != nil {
        if err == nats.ErrTimeout {
            continue // Нет новых сообщений, повторяем цикл
        }
        log.Printf("Fetch error: %v", err)
        break
    }

    // Обрабатываем batch
    for _, msg := range msgs {
        processOrder(msg)
        msg.Ack()
    }
}

Stream Policies: политики хранения и жизненного цикла

JetStream позволяет гибко настраивать правила удаления сообщений из потока в зависимости от бизнес-требований:

1. Limits Policy (По умолчанию)

Поток хранит сообщения до тех пор, пока не будет превышен один из настроенных лимитов (размер в байтах, количество сообщений или возраст):

Go
stream, err := js.AddStream(&nats.StreamConfig{
    Name:      "LOGS",
    Subjects:  []string{"logs.*"},
    Retention: nats.LimitsPolicy,
    MaxMsgs:   100000,          // Максимум 100K сообщений в логе
    MaxBytes:  10 * 1024 * 1024, // 10MB
    MaxAge:    24 * time.Hour,  // 24 часа
})

2. Interest Policy

Хранит сообщения в логе ровно до тех пор, пока они нужны зарегистрированным потребителям:

Go
stream, err := js.AddStream(&nats.StreamConfig{
    Name:      "TEMP_EVENTS",
    Subjects:  []string{"temp.*"},
    Retention: nats.InterestPolicy, // Удалять сообщение после того, как ВСЕ консьюмеры прислали Ack
})

3. Work Queue Policy

Превращает поток в классическую распределенную очередь задач (аналог RabbitMQ Queue):

Go
stream, err := js.AddStream(&nats.StreamConfig{
    Name:      "TASK_QUEUE",
    Subjects:  []string{"tasks.*"},
    Retention: nats.WorkQueuePolicy, // Удалять сообщение из лога сразу после ПЕРВОГО успешного Ack
})

Message Deduplication: защита от дублей на уровне ядра

В распределенных системах сеть ненадежна: клиент может успешно отправить сообщение, но упасть до получения подтверждения от брокера. Повторная отправка (retry) приведет к дубликату.

JetStream предоставляет встроенное окно дедупликации (deduplication window) на основе уникального клиентского идентификатора Nats-Msg-Id:

Go
// Stream с включенной дедупликацией
stream, err := js.AddStream(&nats.StreamConfig{
    Name:            "ORDERS",
    Subjects:        []string{"orders.*"},
    DuplicateWindow: 2 * time.Minute, // Окно дедупликации 2 минуты
})
if err != nil {
    log.Fatal(err)
}

// Публикация с deduplication key
msg := nats.NewMsg("orders.new")
msg.Data = []byte("order data #42")
msg.Header.Set(nats.MsgIdHdr, "ORDER-12345") // Deduplication key (Nats-Msg-Id)

ack, err := js.PublishMsg(msg)
if err != nil {
    log.Printf("Publish error: %v", err)
} else {
    fmt.Printf("Message published with sequence: %d (duplicate: %v)\n", ack.Sequence, ack.Duplicate)
}

Consumer Configuration

1. Durable Consumers

Сохраняют текущий offset (Ack Floor) на сервере JetStream. При падении или перезапуске воркер продолжит чтение строго с того места, где остановился:

Go
// Durable consumer (offset сохраняется)
_, err = js.Subscribe("orders.*", func(msg *nats.Msg) {
    processOrder(msg)
    msg.Ack()
}, nats.Durable("order_processor"), // Имя durable consumer'а
   nats.ManualAck())                // Ручное подтверждение (отключаем авто-ack)

2. Ephemeral Consumers

Не сохраняют offset при завершении соединения:

Go
// Ephemeral consumer (offset теряется при перезапуске)
_, err = js.Subscribe("orders.*", func(msg *nats.Msg) {
    processOrder(msg)
    msg.Ack()
}, nats.ManualAck())

Advanced Stream Processing

1. Stream Filtering

Один Stream может собирать широкий спектр данных, а разные консьюмеры могут подписываться только на узкие подмножества благодаря маскам:

Go
// Consumer только для определенных subject'ов
cons, err := js.Subscribe("orders.new", func(msg *nats.Msg) {
    // Обрабатываем только создание новых заказов
    processOrder(msg)
    msg.Ack()
}, nats.Durable("new_orders_only"))

2. Multiple Consumers per Stream

Один и тот же поток может параллельно читаться десятками независимых сервисов с индивидуальной скоростью и индивидуальными офсетами:

Go
// Consumer для финансового аудита
js.Subscribe("orders.*", auditHandler, nats.Durable("audit_consumer"))

// Consumer для мгновенных push-уведомлений
js.Subscribe("orders.new", notificationHandler, nats.Durable("notification_consumer"))

// Consumer для бизнес-обработки платежей
js.Subscribe("orders.*", processingHandler, nats.Durable("processing_consumer"))

3. Consumer Groups (Queue Groups в JetStream)

Для горизонтального масштабирования обработки одного consumer'а несколькими подами используется объединенный Pull или Push Consumer с общим именем группы:

Go
// Несколько экземпляров одного consumer'а разделяют поток между собой
for i := 0; i < 3; i++ {
    go func(instanceID int) {
        _, err := js.Subscribe("orders.*", func(msg *nats.Msg) {
            fmt.Printf("Instance %d processing: %s\n", instanceID, msg.Subject)
            processOrder(msg)
            msg.Ack()
        }, nats.Durable("order_workers")) // Одинаковое имя = queue group
        if err != nil {
            log.Printf("Worker %d error: %v", instanceID, err)
        }
    }(i)
}

Performance и Tuning

1. File vs Memory Storage

Выбор типа хранилища определяется критичностью потерь:

Go
// Memory storage (выше производительность, задержки единицы микросекунд, но данные сбрасываются при рестарте кластера)
stream, err := js.AddStream(&nats.StreamConfig{
    Name:    "HIGH_PERF_TEMP",
    Storage: nats.MemoryStorage, // Только в памяти
})

// File storage (полная durability, сохранение на диск через WAL)
stream, err := js.AddStream(&nats.StreamConfig{
    Name:    "PERSISTENT_LOGS", 
    Storage: nats.FileStorage, // На диск
})

2. Compression: экономия места и дискового I/O

JetStream поддерживает потоковую компрессию логов с использованием алгоритма S2 (высокоскоростной потоковый форк Snappy, оптимизированный под современные SIMD-инструкции процессора):

Go
stream, err := js.AddStream(&nats.StreamConfig{
    Name:        "COMPRESSED_STREAM",
    Subjects:    []string{"logs.*"},
    Compression: nats.S2Compression, // Использует S2 compression для сжатия блоков на диске
})

Monitoring и Observability

JetStream предоставляет исчерпывающую информацию о состоянии потоков и смещениях потребителей через программное API:

Go
// Получение подробной информации о stream
info, err := js.StreamInfo("ORDERS")
if err != nil {
    log.Printf("Stream info error: %v", err)
    return
}

fmt.Printf("Messages: %d, Bytes: %d, First: %v, Last: %v\n",
    info.State.Messages,
    info.State.Bytes,
    info.State.FirstTime,
    info.State.LastTime,
)

// Получение информации о consumer
consInfo, err := js.ConsumerInfo("ORDERS", "order_processor")
if err != nil {
    log.Printf("Consumer info error: %v", err)
    return
}

fmt.Printf("Pending: %d, Ack Floor: %d\n",
    consInfo.NumPending,
    consInfo.AckFloor.Consumer,
)

Использование в Production

1. Service Mesh Integration

JetStream может выступать единой надежной шиной данных (event backbone) для координации сервисов:

Go
// Service registration через JetStream
js.Publish("service.register", []byte(serviceInfoJSON))

// Регулярные Health checks
js.Publish("service.heartbeat", []byte(healthStatusJSON))

2. Event Sourcing

Архитектурный шаблон, где состояние системы восстанавливается из неизменяемой последовательности событий:

Go
type Event struct {
    Type    string `json:"type"`
    Data    string `json:"data"`
    Version int    `json:"version"`
}

event := Event{
    Type:    "UserCreated",
    Data:    userData,
    Version: 1,
}

js.Publish("user.events", mustEncode(event))

3. CQRS Implementation

Разделение команд на изменение и событий о произошедших фактах (Command/Query Responsibility Segregation):

Go
// Command stream (поток команд)
js.Publish("commands.create.user", commandData)

// Event stream (поток фактов)  
js.Publish("events.user.created", eventData)


Итог

  • JetStream превращает NATS из легковесного pub/sub брокера в мощную распределенную потоковую платформу.
  • Append-only log и поддержка File/Memory storage обеспечивают миллионы операций записи в секунду с микросекундными задержками.
  • Pull Consumers дают встроенный контроль перегрузки (Backpressure) и пакетную выборку без риска исчерпания памяти.
  • Raft Consensus обеспечивает прозрачную репликацию и автоматический failover без внешних координаторов типа ZooKeeper.

В следующей лекции — 6. NATS vs Kafka vs RabbitMQ — мы проведем детальное лобовое сравнение NATS с лидерами индустрии и сформулируем четкие правила выбора инструмента под конкретные инженерные задачи.