Saltar al contenido principal

Una primitiva, muchas garantías: coordinación libre de condiciones de carrera con CAS optimista

· 13 min de lectura
Clustron Team
Ingeniería de Sistemas Distribuidos

Coordinación libre de condiciones de carrera con CAS optimista en Zaris

Es tentador juzgar un almacén de datos por la longitud de su lista de comandos. Redis tiene INCR, SETNX, MULTI/EXEC, WATCH, Lua, SCAN, pub/sub: un verbo para cada ocasión. El cliente .NET de Zaris, en cambio, parece casi austero. No hay incremento atómico del lado del servidor. No hay una transacción general multiclave que ejecute lógica arbitraria. No hay escaneo por prefijo. El pub/sub nativo existe únicamente a través del front-end RESP, no del cliente .NET tipado.

Lo que el cliente .NET te da en su lugar es una primitiva de coordinación pequeña y afilada: compare-and-swap optimista versionado. Esta publicación trata de hasta dónde llega realmente esa única primitiva, porque la respuesta honesta es: sorprendentemente lejos. Un limitador de tasa, una cartera que nunca queda en negativo bajo mil débitos concurrentes, una cola de trabajos donde exactamente un worker reclama cada trabajo, y un conjunto de reglas de feature flags que nunca pierde una actualización bajo ediciones concurrentes de administradores. Todos ellos, a partir de la misma primitiva, sin maquinaria adicional del lado del servidor.

La primitiva​

Cada valor en Zaris lleva una versión monótona. Tú no la estableces; el almacén es su dueño y la incrementa en cada escritura exitosa. Leer te da tanto el valor como la versión en la que estaba:

KvResult<int> read = await client.GetAsync<int>("counter:api");
int value = read.Value; // the current value
long version = read.Version; // the version that value was written at

Escribir de vuelta te permite adjuntar una precondición sobre esa versión:

var opts = new PutOptions { IfMatchVersion = version };
KvStatus status = await client.PutAsync("counter:api", value + 1, opts);
// status == KvStatus.Ok -> your write committed; version is now version+1
// status == KvStatus.Conflict -> someone else wrote first; your value was stale

IfMatchVersion significa confirma solo si la clave sigue en esta versión exacta. Si cualquier otro escritor confirmó en el intervalo entre tu lectura y tu escritura, la versión se ha movido, tu precondición falla y obtienes KvStatus.Conflict en lugar de pisar silenciosamente su cambio. Hay un sabor más, IfAbsent = true, que significa confirma solo si la clave aún no existe, una operación de creación única que usaremos para la cola de trabajos.

Esa es toda la primitiva. Una lectura que devuelve una versión, y una escritura condicional que confirma solo contra una versión sin cambios.

Por qué esto basta para serializar a los escritores​

La razón por la que esto funciona es que la versión es monótona y propiedad del almacén. Dos clientes pueden leer la versión 7 en el mismo instante. Ambos calculan un nuevo valor. Ambos llaman a PutAsync con IfMatchVersion = 7. El almacén procesa las escrituras sobre una clave dada de una en una, así que una de ellas llega primero: coincide, confirma, y la versión pasa a ser 8. Cuando se evalúa la segunda escritura, la clave está en 8, no en 7, así que su precondición falla y obtiene Conflict. Exactamente un escritor gana; al otro se le dice, con precisión, que perdió. No se tomó ningún bloqueo, ningún escritor quedó bloqueado esperando, y no hay ninguna ventana en la que ambas escrituras "tengan éxito" y una se pierda silenciosamente.

El perdedor no hace fracasar la operación: simplemente no ganó esta ronda. Vuelve a leer el valor y la versión ahora vigentes, recalcula contra el estado fresco e intenta de nuevo. Ese bucle es el corazón de todos los patrones siguientes:

async Task<T> MutateAsync<T>(string key, Func<T, T> update, int maxAttempts = 50)
{
for (int attempt = 0; attempt < maxAttempts; attempt++)
{
KvResult<T> read = await client.GetAsync<T>(key);
T next = update(read.Value);

var status = await client.PutAsync(
key, next, new PutOptions { IfMatchVersion = read.Version });

if (status == KvStatus.Ok)
return next; // we won this round
// Conflict: someone wrote between our read and write. Loop and retry
// against the value they committed — never against our stale copy.
}
throw new ConcurrencyException($"{key} too contended after {maxAttempts} attempts");
}

Dos cosas hacen que esto sea correcto en lugar de ilusorio. Primero, el reintento siempre vuelve a leer: nunca reintentas con un valor obsoleto, así que no puedes resucitar un estado sobrescrito. Segundo, update debe ser una función pura del valor que leíste. Si calcula read.Value + 1, eso es seguro; si lee el reloj de pared o un campo mutable capturado desde fuera, el reintento no será una repetición fiel. Mantén la transformación pura y el bucle es un genuino read-modify-write que se comporta como si los escritores ganadores se hubieran ejecutado uno tras otro.

Patrón 1 — un limitador de tasa distribuido​

Un limitador de tasa es un contador atómico con un techo. No hay INCR en el cable, pero el bucle CAS es un incremento atómico: lee el conteo, suma uno si hay margen, confirma contra la versión.

async Task<bool> TryAcquireAsync(string bucket, int limit)
{
for (int attempt = 0; attempt < 50; attempt++)
{
KvResult<int> read = await client.GetAsync<int>(bucket);
int count = read.Found ? read.Value : 0;
if (count >= limit)
return false; // over the limit, no write at all

var status = read.Found
? await client.PutAsync(bucket, count + 1,
new PutOptions { IfMatchVersion = read.Version })
: await client.PutAsync(bucket, 1,
new PutOptions { IfAbsent = true }); // first caller creates it

if (status == KvStatus.Ok)
return true; // we took a slot
// Conflict -> another caller moved the count; re-read and re-check.
}
return false;
}

Apunta mil llamadores concurrentes al mismo bucket con limit = 100 y obtienes exactamente 100 resultados true y 900 false: nunca 101, nunca 99. Los perdedores de cada ronda CAS vuelven a leer y descubren que el techo se ha movido; en el momento en que el conteo alcanza el límite, cada llamador posterior hace un cortocircuito sin escritura. La rama IfAbsent maneja la condición de carrera de arranque en frío donde varios llamadores encuentran el bucket ausente a la vez: solo una creación única gana, el resto cae en el camino normal de incremento en su siguiente intento.

Patrón 2 — una cartera que nunca queda en negativo​

El dinero es el caso exigente, porque la invariante es una guarda, no solo un recuento: un débito nunca debe llevar el saldo por debajo de cero, y dos débitos nunca deben tener éxito ambos a partir del mismo saldo inicial. Esa es exactamente la sobrescritura que CAS prohíbe.

async Task<bool> TryDebitAsync(string account, decimal amount)
{
for (int attempt = 0; attempt < 50; attempt++)
{
KvResult<Wallet> read = await client.GetAsync<Wallet>(account);
Wallet w = read.Value;
if (w.Balance < amount)
return false; // insufficient funds: reject, don't write

var next = w with { Balance = w.Balance - amount };
var status = await client.PutAsync(
account, next, new PutOptions { IfMatchVersion = read.Version });

if (status == KvStatus.Ok)
return true;
// Conflict -> a concurrent debit landed first. Re-read the real balance
// and re-check the guard against it, never against our stale copy.
}
throw new ConcurrencyException($"{account} too contended");
}

Dispara mil débitos concurrentes de una unidad cada uno contra una cuenta con 400 y exactamente 400 tienen éxito; el saldo aterriza en exactamente cero y nunca baja de ahí. La guarda se sostiene porque siempre se reevalúa contra el saldo confirmado después de un conflicto. Un débito que era válido contra un saldo obsoleto de 5 no llega a confirmarse contra un saldo real de 0: su versión ya no coincide, así que repite el bucle, vuelve a leer 0 y rechaza correctamente. Este es el filo más agudo del patrón: la precondición no solo protege la escritura, sino que obliga a que la regla de negocio se verifique contra la verdad.

Patrón 3 — la reclamación de una cola de trabajos​

Cuando N workers compiten por procesar el mismo trabajo, necesitas que exactamente uno gane la reclamación. Esto es un único cambio CAS sobre un campo de estado, o, en su forma más simple, una creación única:

async Task<bool> TryClaimAsync(string jobId, string workerId)
{
var claim = new Claim { Owner = workerId, ClaimedAt = DateTimeOffset.UtcNow };
// IfAbsent: the claim key is created by exactly one worker; the rest see Conflict.
var status = await client.PutAsync(
$"job:{jobId}:claim", claim, new PutOptions { IfAbsent = true });
return status == KvStatus.Ok;
}

Cada worker que pierde obtiene Conflict y pasa al siguiente trabajo: sin sondeo, sin arrendamiento de bloqueo que renovar en el camino feliz, sin empate que resolver. Si en cambio modelas el trabajo como un registro con un campo Status, la reclamación es un cambio protegido por versión de Pending a Claimed usando el bucle MutateAsync, que además te permite volver a reclamar un trabajo cuyo dueño anterior falló (léelo, ve que está Claimed pero obsoleto, cámbialo de vuelta): la misma primitiva, un campo más profundo.

Patrón 4 — un conjunto de reglas de feature flags sin pérdida de actualizaciones​

Dos administradores abren la consola de feature flags y editan el mismo conjunto de reglas al mismo tiempo. El administrador A activa una flag; el administrador B añade una nueva regla de segmentación. Con un flujo ingenuo de "leer JSON, editar, volver a escribir JSON", quien guarda segundo gana y borra silenciosamente el cambio del otro: la clásica pérdida de actualización. CAS convierte eso en un conflicto detectado:

async Task SaveRulesetAsync(string flagKey, Func<Ruleset, Ruleset> edit)
{
for (int attempt = 0; attempt < 10; attempt++)
{
KvResult<Ruleset> read = await client.GetAsync<Ruleset>(flagKey);
Ruleset updated = edit(read.Value); // apply this admin's change

var status = await client.PutAsync(
flagKey, updated, new PutOptions { IfMatchVersion = read.Version });

if (status == KvStatus.Ok)
return;
// Conflict -> the other admin saved first. Re-read THEIR version and
// re-apply this admin's edit on top, so neither change is lost.
}
throw new ConcurrencyException($"{flagKey} edited concurrently; please retry");
}

El guardado del segundo administrador entra en conflicto, vuelve a leer el conjunto de reglas incluido el cambio del primer administrador, reaplica su propia edición encima y confirma. Ambos cambios sobreviven. El matiz importante: esto funciona limpiamente cuando las dos ediciones tocan partes distintas del conjunto de reglas, porque reaplicar es componible. Si ambos administradores editan la misma regla, el segundo guardado es donde haces aflorar un aviso de "alguien más cambió esto" ante un humano en lugar de fusionar a ciegas: el conflicto es una característica, que te da el gancho para preguntar.

El corolario de idempotencia​

Como un conflicto significa "reintentar", y un reintento significa "hazlo otra vez", tienes que asegurarte de que otra vez sea seguro. El débito de cartera de arriba es el caso de advertencia: si tu llamada de red falla de forma ambigua después de que el almacén confirmó pero antes de que recibieras el Ok, un reintento a ciegas podría debitar dos veces. La solución nativa de CAS es una clave de índice propio / de deduplicación: registra la operación bajo un id único con IfAbsent, y verifícalo antes de actuar.

// Create-once the operation record; if it already exists, this is a retry of
// a debit that already happened — return its recorded outcome, don't repeat it.
var seen = await client.PutAsync(
$"op:{operationId}", new OpMarker(), new PutOptions { IfAbsent = true });
if (seen == KvStatus.Conflict)
return await ReadRecordedOutcomeAsync(operationId); // idempotent replay

La misma primitiva que serializa a tus escritores también te da el gancho de exactamente-una-vez. No estás echando mano de un segundo mecanismo: IfAbsent es la deduplicación.

Dónde se detiene esto, y qué hacer al respecto​

CAS no es gratis, y pretender lo contrario sería el relleno de marketing que esta publicación intenta evitar. Tres límites honestos:

Contención de clave caliente. Cada patrón de arriba canaliza escritores concurrentes a través de una clave. Cuantos más escritores, más conflictos, más reintentos, y el rendimiento sobre esa única clave se degrada. CAS gana exactamente un escritor por ronda por diseño, así que una clave bajo fuerte contención de escritura se convierte en un punto de serialización. La solución es fragmentar la clave: un bucket de limitador de tasa por (cliente, segundo) en lugar de un único bucket global; una cartera dividida en subsaldos reconciliados periódicamente; una cola de trabajos particionada para que los workers compitan sobre rangos de claves disjuntos. Reparte la contención y los reintentos desaparecen.

Reescrituras de valor completo O(n). Una escritura CAS reemplaza el valor entero, no un campo. Un conjunto de reglas de feature flags con diez mil reglas se reserializa y reescribe en cada edición: pagas por que el objeto entero cambie para cambiar una flag. Para valores pequeños esto no es problema; para agregados grandes y mutados con frecuencia es un costo real, y la respuesta es de nuevo descomponer: almacena partes mutables de forma independiente bajo claves separadas para que cada edición reescriba solo lo que cambió, y reserva el agregado de clave única para cosas que son genuinamente pequeñas o rara vez escritas.

Sin atomicidad entre claves. CAS es atómico sobre una clave. Si una invariante abarca dos claves —mover dinero de la cuenta A a la cuenta B— un único CAS no puede cubrir ambas. O bien modelas la transferencia como una máquina de estados sobre una clave (un registro de "transferencia" que ambos lados observan), o aceptas que necesitas una saga con compensación. No eches mano de CAS para fingir una transacción de dos claves; ahí es donde genuinamente no llega.

Una primitiva que se ganó su sustento​

La lista corta de comandos resulta no ser una pobreza. Una versión monótona propiedad del almacén más una escritura condicional protegida por versión basta para expresar un contador atómico, un decremento protegido, una reclamación de un solo ganador y una edición sin pérdida de actualizaciones: cuatro problemas con nombre de sistemas distribuidos, una primitiva, sin bloqueos retenidos y sin escritores bloqueados. Lo que entregas es la comodidad de un verbo a medida para cada caso; lo que obtienes es un único mecanismo cuya corrección puedes razonar una vez y luego confiar en todas partes, más el gancho de deduplicación para la idempotencia viajando gratis.

La disciplina que te pide es pequeña y vale la pena interiorizarla: mantén la transformación pura, vuelve a leer siempre en caso de conflicto, verifica tu invariante contra el valor confirmado, y fragmenta antes de que una clave se caliente. Haz eso, y una primitiva carga con una cantidad notable de coordinación.