Qué primitiva, para qué problema: una guía de campo del modelo de datos de Zaris
La mayor parte de este blog ha profundizado en una cosa a la vez: compare-and-swap, transacciones multiclave, estructuras de datos nativas, streams, watch, el op-log que hay debajo. Cada artículo responde a "cómo funciona esto". Este responde a una pregunta distinta y más práctica: cuando tienes un problema delante, ¿cuál eliges?
Esa pregunta importa porque la elección equivocada rara vez es un fallo estrepitoso. Es un sistema que funciona en la demo y luego pierde una actualización bajo carga, o canaliza a mil escritores a través de una sola clave, o usa una notificación de tipo "dispara y olvida" donde hacía falta un log durable. Las primitivas no te impiden usarlas mal. Así que esto es una guía de campo: el modelo mental que las une, una tabla de decisión, un recorrido por cada rama con su encaje honesto y su límite honesto, y un diagrama de flujo al final.
Un núcleo, muchas vistas
Empecemos por aquello sobre lo que se construye todo lo demás. Debajo de cada funcionalidad de este artículo, Zaris es un único almacén clave-valor particionado y replicado, y cada valor lleva una versión monótona propiedad del almacén. Eso es todo. Ese es el núcleo.
Todo lo demás es una de dos cosas:
- Una vista tipada, del lado del servidor, sobre las claves: un hash, una lista, un conjunto, un conjunto ordenado, un stream. No son blobs que recuperas y mutas en tu proceso; son colecciones reales del lado del servidor que viven en la partición dueña de su clave, y se replican exactamente igual que un valor plano.
- Una capa de coordinación sobre la versión: compare-and-swap, transacciones multiclave, locks, watch. Estas usan la versión (y la maquinaria de propiedad que la rodea) para que muchos clientes se pongan de acuerdo sobre lo que ocurrió sin pisarse unos a otros.
Ten presente esa imagen y toda la superficie deja de parecer un cajón de sastre de funcionalidades y empieza a parecer lo que es: un núcleo pequeño y un conjunto de herramientas hechas a medida, cada una de las cuales resuelve una clase concreta de problema por encima de él. Elige según la forma de tu problema, no por el nombre que más se le parezca.
La tabla de decisión
| Tu problema | Usa | Por qué | Ten cuidado con |
|---|---|---|---|
| Guardar bytes que otros nodos puedan volver a leer | GetAsync/PutAsync planos, o una integración de caché | El núcleo, nada más | Sin expiración en el momento de escritura: el TTL es una segunda llamada (abajo) |
| Un único valor debe cambiar de forma segura bajo concurrencia | Compare-and-swap | Un único ganador por ronda, sin actualizaciones perdidas, sin mantener un lock | Una clave única caliente se convierte en un punto de serialización: particiónala |
| Un invariante abarca dos o más claves | Transacción multiclave | Todo o nada entre claves y entre nodos | Más pesada que CAS; es un 2PC, no es gratis |
| El dato es una colección: mapa, lista, pertenencia, ranking | Estructuras de datos nativas | Operaciones por elemento en el servidor, sin reescribir el valor completo | Elige la colección que encaje con el patrón de acceso |
| Un log de eventos ordenado y reproducible con consumidores at-least-once | Streams | Log durable, grupos de consumidores, pending por consumidor + ack | Es un log, no una cola con prioridades |
| Reaccionar a los cambios según ocurren | Watch (nativo) / notificaciones de keyspace (clientes Redis) | Replicado, a nivel de clúster, sobrevive al failover | El pub/sub de tipo "dispara y olvida" no es un feed durable |
| Exclusión mutua gruesa en torno a un recurso | Lock (basado en lease) | Una compuerta con nombre y acotada por TTL que se libera sola al caer | Un lock basado en lease no es una barrera de correctitud: ver abajo |
El resto de este artículo es esa tabla, a cámara lenta.
KV y caché: cuando un valor es solo un valor
Si lo único que necesitas es poner bytes en algún sitio donde todos los nodos puedan leerlos —un fragmento renderizado, un DTO serializado, una instantánea de configuración— no necesitas una capa de coordinación. PutAsync(key, value) y GetAsync<T>(key) son toda la historia, y en el lado del servidor esos bytes se particionan y replican por ti.
Dos notas honestas. Primera, la expiración no se fija en el momento de escritura. No hay un campo de TTL en las opciones de escritura; fijas un tiempo de vida con una llamada aparte —ExpireAsync(key, ttl)— y lo borras con PersistAsync(key), o lees lo que queda con GetTimeToLiveAsync(key). Segunda, si tu caso de uso es literalmente "una caché distribuida para mi aplicación ASP.NET Core", no te lo montes a mano: registra uno de los paquetes de caché listos para usar y el IDistributedCache del framework (o HybridCache, o el almacén de sesiones) queda respaldado por tu clúster con un cambio de una línea en el arranque y sin código que toque la abstracción de caché.
Usa KV plano cuando el valor no tiene ningún invariante de concurrencia: que gane el último escritor está bien, o solo hay un escritor. No lo uses en cuanto dos escritores puedan competir por la misma clave y te importe que uno sobrescriba al otro en silencio. Ese es el trabajo de la siguiente herramienta.
Compare-and-swap: cuando un valor debe seguir siendo correcto bajo concurrencia
Un contador con un techo, un saldo que no puede quedar negativo, un objeto de configuración que dos administradores editan a la vez. El fallo que intentas evitar es la actualización perdida: leer, leer, escribir, escribir, y la segunda escritura borra la primera. CAS convierte esa pérdida silenciosa en un conflicto detectado y reintentable: lee un valor y su versión, vuelve a escribir solo si la versión no se ha movido, y obtén un Conflict en vez de una sobrescritura si alguien se te adelantó.
KvResult<int> read = await client.GetAsync<int>("counter:api");
var put = await client.PutAsync(
"counter:api", read.Value + 1,
new PutOptions { IfMatchVersion = read.Version });
// put.Status == KvStatus.Success -> you won this round
// put.Status == KvStatus.Conflict -> someone wrote first; re-read and retry
Ese es todo el mecanismo, y la guía de campo de CAS muestra hasta dónde llega: un limitador de ritmo, una cartera que nunca queda en descubierto bajo mil débitos concurrentes, una reclamación de trabajo con un único ganador (mediante "crear solo si está ausente"), configuración sin actualizaciones perdidas, y el gancho de idempotencia que viene de regalo.
Usa CAS cuando el invariante vive en una clave y quieres correctitud sin mantener un lock. No apuntes a una multitud de escritores a una única clave caliente y esperes que escale: CAS deja ganar deliberadamente a un único escritor por ronda, así que una clave caliente se convierte en un punto de serialización. Particiónala (un bucket por cliente-segundo, un saldo dividido en subsaldos reconciliados) y la contención se disuelve.
Transacciones multiclave: cuando el invariante abarca varias claves
La línea dura que traza CAS es que es atómico sobre una clave. Mover dinero de la cuenta A a la cuenta B, o mantener un índice secundario en sincronía con el registro al que apunta, y ningún compare-and-swap único cubre ambos. Eso no es motivo para fingirlo con dos escrituras CAS y cruzar los dedos: es el caso exacto para el que existe la transacción multiclave.
Abres una transacción, preparas lecturas y escrituras dentro de ella y haces commit. Por debajo es un commit en dos fases coordinado por el cliente sobre versiones optimistas: las claves pueden vivir en nodos distintos, así que el cliente agrupa el conjunto de escrituras por dueño, cada nodo comprueba que cada clave que leíste sigue en la versión en la que la leíste y toma un lock de corta duración, y solo si todos votan que sí se aplica el conjunto completo. Cualquier versión que se haya movido, y el commit devuelve Conflict sin que nada cambie.
Usa una transacción cuando dos o más claves deben moverse juntas o no moverse en absoluto. No la uses como tu camino de escritura por defecto: es un 2PC de verdad, con viajes de ida y vuelta de prepare/commit y locks de corta duración, más pesado que un único CAS. Si el invariante encaja honestamente en una clave, mantenlo en una clave.
Estructuras de datos nativas: cuando el dato es una colección
A veces el valor no es un escalar que custodias, sino una forma. Un perfil de usuario es un mapa de campos. Un feed de actividad reciente es una lista. La pertenencia a una etiqueta es un conjunto. Una tabla de clasificación es un ranking. Podrías serializar cada uno como un solo blob y hacerle CAS, pero entonces cada edición de un solo campo reescribe el objeto entero, y has convertido una actualización O(1) en una O(n).
Zaris les da sus propios tipos de colección nativos del lado del servidor —hashes, listas, conjuntos, conjuntos ordenados— donde una operación de un solo campo o de un solo elemento toca únicamente ese elemento en el servidor, no el valor entero en tu proceso. Se particionan y replican como cualquier clave, y como el front-end RESP mapea los mismos comandos de Redis sobre las mismas colecciones del lado del servidor, un servicio .NET y un servicio Python que usan HSET tocan el mismo hash idéntico.
Usa una colección cuando tu patrón de acceso es por elemento: fijar un campo, empujar una entrada, comprobar una pertenencia, leer el top N por puntuación. No recurras a un conjunto ordenado cuando lo que realmente necesitas es un log de eventos ordenado, confirmado y reproducible. Eso es un stream.
Streams: cuando necesitas un log ordenado y reproducible
Una cola y un log se parecen hasta que un consumidor se cae. Los streams de Zaris son un log de solo anexado de verdad: las entradas reciben IDs monótonos, puedes leer rangos, y un grupo de consumidores reparte el log entre un conjunto de trabajadores, lleva la cuenta del pending de cada trabajador (entregado pero sin confirmar) y permite que otro trabajador reclame lo que uno caído nunca llegó a confirmar. Esa maquinaria de pending y ack es lo que te compra la entrega at-least-once, lo que una notificación de tipo "dispara y olvida" no puede darte.
Usa un stream cuando necesitas eventos durables, ordenados y reproducibles con confirmación explícita y reparto de trabajo: ingesta de pedidos, un outbox, una traza de auditoría a partir de la cual una proyección se reconstruye. No lo uses como cola con prioridades ni como canal de petición/respuesta; es un log, y sus fortalezas son el orden y la reproducción, no el enrutamiento.
Watch, notificaciones y la familia del fan-out: cuando reaccionas al cambio
Hay varias formas de enterarse de que algo cambió, y no son intercambiables: la diferencia está en si la señal es durable.
La que debes usar desde .NET es Watch: un feed de cambios nativo y a nivel de todo el clúster. Te suscribes a una clave o a un prefijo, opcionalmente incorporas una instantánea inicial para que no haya hueco entre "leer el estado actual" y "suscribirse a los cambios", y recibes eventos a medida que llegan las escrituras:
var (subscription, initial) = await client.Watch.WatchKeyAsync(
"job:42",
new WatchOptions { IncludeInitialSnapshot = true },
evt => Handle(evt));
La propiedad importante es que el watch está registrado en todo el clúster y sobrevive al failover: no está atado al único nodo que casualmente procesó una escritura. Esa es también la razón por la que las notificaciones de keyspace de Redis que Zaris habla (__keyevent@0__ / __keyspace@0__, un simple PSUBSCRIBE desde cualquier cliente Redis) valen más aquí que en una sola máquina Redis: son un puente delgado sobre ese mismo watch replicado, así que un failover no deja caer en silencio el flujo de eventos.
Usa watch/notificaciones de keyspace cuando quieras reaccionar a cambios de estado: invalidar una caché, despertar a un trabajador, refrescar una proyección. No confundas el fan-out de pub/sub de mejor esfuerzo con un feed durable: si necesitas todos los eventos incluso a través de caídas y reinicios, eso es un stream, no una notificación.
Locks: exclusión mutua gruesa, con una advertencia afilada
Zaris sí tiene un lock distribuido de primera clase: una compuerta con nombre, basada en lease, con un TTL, que se adquiere desde el cliente y se libera al hacer dispose o al expirar por caída:
await using var gate = await client.Locks.AcquireAsync("order:123", TimeSpan.FromSeconds(10));
if (gate is null)
{
// someone else holds it — back off, do other work, or retry later
return;
}
// critical section; the lease auto-expires if we crash, and can be renewed
Es genuinamente útil para la exclusión mutua gruesa —evitar que dos planificadores ejecuten el mismo barrido, serializar una tarea de mantenimiento— y el TTL significa que un poseedor caído no bloquea el lock para siempre.
Pero aquí está la advertencia que la versión de brochazo deja fuera, y es la frase más importante de este artículo sobre locks: un lock basado en lease no es una barrera de correctitud. Si tu poseedor se pausa —un parón de GC, una llamada al sistema lenta— el tiempo suficiente para que el lease expire, un segundo poseedor puede adquirir el "mismo" lock mientras el primero todavía cree que lo tiene, y ahora dos trabajadores están en la sección crítica a la vez. Para vivacidad y eficiencia (normalmente solo un trabajador ejecuta el trabajo, la contención es baja) un lock de lease es exactamente lo correcto. Para un invariante de correctitud que jamás debe violarse, no confíes en el lock para imponerlo: imponlo sobre los datos con compare-and-swap o una transacción, de modo que incluso un segundo actor espurio sea rechazado por una comprobación de versión en la escritura.
Usa un lock cuando quieras exclusión mutua gruesa y de mejor esfuerzo y un solapamiento ocasional sea meramente un desperdicio, no un error. Usa CAS en su lugar cuando el solapamiento corrompería datos: deja que sea la versión, no el lock, lo que diga que no.
La parte transversal: propiedad, durabilidad, consistencia
Tres propiedades se cumplen en cada una de las primitivas de arriba, porque son propiedades del núcleo, no de ninguna herramienta en concreto:
- El dueño cambia, y no tienes que darte cuenta. Cada clave es propiedad de una partición que puede hacer failover, migrar o volver a su sitio. El cliente .NET convierte ese vaivén en una señal tipada y reintentable y reenruta al nuevo dueño, acotado por un presupuesto de intentos y un plazo de reloj de pared, de modo que un
PutAsyncdurante un failover normalmente solo tarda unos milisegundos más y devuelve éxito. - La durabilidad es por replicación, no por disco. Zaris mantiene los datos en memoria y los hace durables guardando copias en otros nodos. Por eso el factor de replicación no es lo mismo que la tolerancia a fallos: el número solo te protege si la topología coloca de verdad una segunda copia en un nodo que pueda sobrevivir a la pérdida del primero. Toda la columna vertebral de propiedad y recuperación existe para asegurar que ninguna escritura confirmada se pierda mientras la propiedad está en movimiento.
- Las escrituras y lecturas se enrutan al dueño actual. La maquinaria de reintento y reenrutado hace que tus operaciones encuentren el nodo que actualmente posee la clave, de modo que una lectura sigue a una escritura confirmada en vez de adelantarse al cambio de propiedad que acaba de ocurrir.
Ninguna de estas es una primitiva que elijas. Son el suelo sobre el que se apoya cada elección.
El diagrama de flujo
La versión corta
La longitud de la lista de funcionalidades nunca fue el punto. Zaris es un núcleo clave-valor versionado, particionado y replicado, más vistas tipadas del lado del servidor (hash, lista, conjunto, conjunto ordenado, stream) y capas de coordinación sobre la versión (CAS, transacciones, locks, watch). Haz coincidir la herramienta con la forma del problema: un escalar bajo contención quiere CAS; un invariante entre claves quiere una transacción; un dato que es una colección quiere la colección correspondiente; los eventos ordenados y durables quieren un stream; reaccionar al cambio quiere watch; la exclusión mutua gruesa quiere un lock; y cualquier cosa que jamás deba violarse quiere su garantía impuesta sobre los datos, con una comprobación de versión, no sobre un lock que puede caducar.
Elige por forma, apóyate en la versión para la correctitud, y el pequeño núcleo soporta una cantidad notable de sistema.