Event-Driven Architecture with NATS · Parte 3

JetStream KV es un stream de cambios, no una tabla

JetStream KV parece una base de datos clave-valor, pero es una abstracción de stream con una vista del valor más reciente: observa los cambios, diseña las claves como jerarquías y representa los tipos de entidad como prefijos de clave.

9 min de lectura

JetStream KV parece un HashMap distribuido. No lo es.

Un bucket de KV es un stream de JetStream con una configuración especial (KV_<bucket>). Cada clave es un subject de NATS ($KV.<bucket>.<key>). El valor almacenado es el mensaje más reciente de ese subject. Para los sistemas orientados a eventos, el modelo mental más útil es: observar los cambios, diseñar las claves como jerarquías y representar los tipos de entidad como prefijos de clave, no como buckets separados.

No hay una cláusula WHERE. Las consultas se hacen por clave, mediante comodines en los prefijos de clave o con claves de índice mantenidas por la aplicación.

Requisitos previos: Core NATS (Parte 1), JetStream (Parte 2). Investigación basada en docs.nats.io y nats.go kv.go el July 6, 2026.

TL;DR

  • Bucket X → stream KV_X → subjects $KV.X.>
  • Clave order.42 → subject $KV.orders.order.42 → mensaje más reciente = valor actual
  • watch es la vía nativa de lectura reactiva: entrega eventos PUT, DELETE y PURGE en tiempo real
  • Los tipos de entidad van en prefijos de clave (orders-v1.<id>), no en buckets separados
  • KV garantiza lecturas y escrituras monotónicas por clave; no garantiza leer las propias escrituras en las solicitudes get atendidas por seguidores
  • Los índices secundarios son un patrón de aplicación: mantén las claves de índice al escribir y límpialas al eliminar

Qué aprenderás aquí

  • Cómo se asignan los buckets a streams y subjects
  • Por qué KV es un stream de cambios, no una tabla consultable
  • Cómo diseñar prefijos de clave para buckets con múltiples entidades
  • Consultas sin SQL: comodines, ListKeysFiltered y el patrón de índice secundario
  • Ciclo de vida de watch: snapshot, centinela y actualizaciones en vivo
  • Criterios para elegir KV: cuándo usarlo y cuándo elegir otra opción
  • Una implementación concreta con watch y claves de índice

Los buckets son streams

Cuando creas un bucket de KV, JetStream materializa un stream:

nats kv add app-objects --history 5 --ttl 1h
nats kv status app-objects
# JetStream Stream: KV_app-objects

nats stream info KV_app-objects
# Subjects: $KV.app-objects.>
ConceptoImplementación
Nombre del bucketapp-objects
Stream subyacenteKV_app-objects
Patrón de subject$KV.app-objects.>
Clave individual$KV.app-objects.<key>
Valor más recienteMensaje más reciente de ese subject (MaxMsgsPerSubject, valor predeterminado 1)
  kv.Put("orders-v1.abc-123", json)
         │
         v
  PUBLISH → $KV.app-objects.orders-v1.abc-123
         │
         v
  Stream KV_app-objects (retention, replicas, limits apply)
         │
         v
  kv.Get("orders-v1.abc-123") = last message on that subject

Regla de diseño de claves: Cada bucket es un dominio de consistencia. Coloca los tipos de entidad en prefijos de clave, no en buckets separados, a menos que tengas una razón de peso para aislarlos.

Bucket: app-objects  (one stream, shared replication and limits)

  orders-v1.{orderId}       ← entity type as prefix
  customers-v1.{customerId}
  idx.status.active.{orderId}        ← secondary index (app-maintained)

Observa todos los pedidos:

Subscribe / Watch: $KV.app-objects.orders-v1.>

Esto usa las reglas de comodines de Core NATS de la Parte 1: > coincide con todos los tokens restantes.

KV como stream de cambios

La API más importante para los sistemas reactivos es watch, no get.

watch se suscribe a los cambios en las claves que coinciden. Usa un consumidor ordenado y entrega:

  1. Snapshot: el último valor de cada clave que coincide (a menos que se use UpdatesOnly)
  2. Centinela nil: indica que el snapshot inicial está completo
  3. Actualizaciones en vivo: PUT, DELETE y PURGE a medida que ocurren
watcher, _ := kv.Watch(ctx, "orders-v1.>", jetstream.UpdatesOnly())
defer watcher.Stop()

for entry := range watcher.Updates() {
    if entry == nil {
        continue // initial snapshot complete
    }
    switch entry.Operation() {
    case jetstream.KeyValuePut:
        log.Printf("PUT %s rev=%d", entry.Key(), entry.Revision())
        projectOrder(entry.Key(), entry.Value())
    case jetstream.KeyValueDelete, jetstream.KeyValuePurge:
        log.Printf("DEL %s rev=%d", entry.Key(), entry.Revision())
        removeOrder(entry.Key())
    }
}
                    JetStream KV Mental Model
                    ========================

  kv.Put(key, val)  ──►  subject $KV.bucket.key  ──►  stream KV_bucket
                                                              │
                    ┌─────────────────────────────────────────┤
                    │                                         │
                    v                                         v
              kv.Get(key)                              kv.Watch("prefix.>")
           "latest value view"                        "change stream view"
           (point read)                               (reactive projection)

Opinión editorial: Si tu servicio consulta KV repetidamente en un bucle, estás yendo contra la abstracción. Watch es el punto de integración nativo para los proyectores y los procesos que precargan cachés.

Modelo de consistencia

Según la documentación oficial de KV:

  • KV garantiza consistencia inmediata para escrituras y lecturas monotónicas por clave
  • KV no garantiza leer las propias escrituras en solicitudes directas de get atendidas por seguidores o mirrors
  • Para obtener lecturas con garantías más estrictas, dirige las solicitudes get al líder del stream

La consistencia entre KV y una base de datos primaria o cualquier fuente de verdad externa sigue siendo eventual: KV es una proyección, no la fuente de verdad.

Consultas sin WHERE

KV no tiene una API de consultas al estilo SQL. Tienes tres opciones prácticas:

1. Comodines en prefijos de clave

Diseña las claves como jerarquías separadas por puntos y observa o lista por prefijo:

// All active orders — if keys are designed as orders-v1.{status}.{id}
watcher, _ := kv.Watch(ctx, "orders-v1.*")
// nats.js
const keys = await kv.keys("orders-v1.*");

2. ListKeysFiltered (paquete jetstream de Go)

El paquete jetstream de Go (December 2024+) permite listar claves con filtros:

lister, _ := kv.ListKeysFiltered(ctx, "orders-v1.*")
for key := range lister.Keys() {
    entry, _ := kv.Get(ctx, key)
    process(entry.Value())
}
lister.Stop()

Nota: Esta API está centrada en Go a July 2026. Como alternativas portables, puedes usar Watch con MetaOnly o keys() con comodines en nats.js.

3. Patrón de índice secundario (mantenido por la aplicación)

Mantén claves adicionales que actúen como índices al escribir:

func upsertOrder(ctx context.Context, kv jetstream.KeyValue, o Order) error {
    id := o.ID
    payload, _ := json.Marshal(o)

    // Primary record
    if _, err := kv.Put(ctx, "orders-v1."+id, payload); err != nil {
        return err
    }

    // Index keys — your "WHERE clause"
    idxKey := fmt.Sprintf("idx.status.%s.%s", o.Status, id)
    if _, err := kv.Put(ctx, idxKey, []byte(id)); err != nil {
        return err
    }
    return nil
}

// Query active orders
lister, _ := kv.ListKeysFiltered(ctx, "idx.status.active.*")

Este es un patrón de aplicación, no una funcionalidad de NATS. Debes:

  • Actualizar las claves de índice cuando cambie el estado (eliminar la clave de índice anterior y escribir la nueva)
  • Limpiar las claves de índice al eliminar
  • Hacer que las operaciones de escritura sean idempotentes: los puts duplicados no deben dejar claves de índice obsoletas

Implementación concreta: precarga de caché mediante watch

Un servicio que mantiene precargada una caché en memoria a partir de los cambios en KV:

import { connect, StringCodec } from "nats";

const nc = await connect({ servers: process.env.NATS_URL });
const js = nc.jetstream();
const kv = await js.views.kv("app-objects");
const cache = new Map<string, object>();
const sc = StringCodec();

const watcher = await kv.watch({ key: "orders-v1.>" });

(async () => {
  for await (const entry of watcher) {
    if (entry === null) {
      console.log("snapshot complete, cache size:", cache.size);
      continue;
    }

    const key = entry.key;
    if (entry.operation === "DEL" || entry.operation === "PURGE") {
      cache.delete(key);
      continue;
    }

    cache.set(key, JSON.parse(sc.decode(entry.value)));
  }
})();

// Fast read path — from memory, not KV
function getOrder(id: string): object | undefined {
  return cache.get(`orders-v1.${id}`);
}

Combina esto con bloqueo optimista para la coordinación:

// Leader election / lock — only one worker runs the export
rev, err := kv.Create(ctx, "lock.export."+jobID, []byte(workerID))
if err != nil {
    return ErrAlreadyRunning
}
defer kv.Delete(ctx, "lock.export."+jobID)

Criterios para elegir KV

Elige KV cuando necesites estado compartido, persistente y observable, con acceso por clave y una vía de lectura reactiva (watch). Elige una base de datos, Object Store o un stream sin procesar cuando la carga de trabajo requiera consultas relacionales, blobs grandes, historial ilimitado o almacenamiento de entidades como fuente de verdad.

Usa KV paraUsa otra opción para
Configuración compartida y feature flagsConsultas relacionales sobre campos arbitrarios
Bloqueos distribuidos y coordinación con CASBúsqueda de texto completo (→ OpenSearch)
Lecturas por clave de blobs pequeños con baja latenciaAlmacenamiento de archivos grandes (→ Object Store)
Proyecciones y precarga de caché mediante watchFuente de verdad para entidades de negocio
Estado compartido entre servicios con historial ≤ 64 revisionesRegistro de auditoría con historial ilimitado (→ stream sin procesar)

KV almacena proyecciones y estado de coordinación, no superficies de consulta ad hoc ni la fuente de verdad del dominio. Tu base de datos primaria o servicio de dominio mantiene la fuente de verdad; KV es la copia observable a la que se suscriben otros servicios.

Errores comunes

  1. Un bucket por tipo de entidad. Genera sobrecarga operativa. Usa prefijos de clave en un solo bucket, a menos que el aislamiento requiera separarlos.
  2. Tratar get como la integración principal. Watch es la vía reactiva; get sirve para lecturas puntuales.
  3. Suponer que tiene semántica SQL. No hay joins, WHERE ni transacciones entre claves.
  4. Ignorar el mantenimiento de índices. Las claves de índices secundarios quedan obsoletas si no se limpian al actualizar o eliminar.
  5. Confundir KV con la fuente de verdad. KV es una capa de proyección o caché. Tu base de datos primaria o servicio de dominio mantiene la fuente de verdad; consulta la Parte 4.

Siguiente en la serie: EDA con NATS: una fuente de verdad, múltiples modelos de lectura — proyecciones CQRS

Fuentes