Todo o nada: transacciones multiclave en Zaris
Un post complementario, Coordinación sin carreras sobre CAS optimista, argumentaba que una única primitiva — una versión propiedad del almacén más una escritura condicional guardada por versión — lleva una cantidad notable de coordinación. También trazó una línea dura: el CAS es atómico sobre una clave. Mueve dinero de la cuenta A a la cuenta B y un único compare-and-swap no puede cubrir ambas; el consejo honesto fue modelar la transferencia como una máquina de estados sobre una clave, o recurrir a una saga.
Esa línea es real, pero no es toda la historia. Zaris tiene una genuina transacción multiclave — BeginTransactionAsync, un conjunto de lectura/escritura preparado, CommitAsync — y hace commit a través de claves y a través de nodos con una garantía de todo-o-nada. Este post trata de lo que esa transacción realmente es por debajo (un commit de dos fases coordinado por el cliente sobre versiones optimistas), de lo que garantiza exactamente, y de la única sutileza que decide si tu invariante entre claves realmente se mantiene.
La forma de la API
Una transacción es un contexto que abres, en el que preparas trabajo, y confirmas. Es IAsyncDisposable, así que await using garantiza que se libere — y una transacción no confirmada que sale de alcance hace rollback.
await using var tx = await client.BeginTransactionAsync();
KvResult<int> a = await tx.GetAsync<int>("account:A");
KvResult<int> b = await tx.GetAsync<int>("account:B");
await tx.PutAsync("account:A", a.Value - 50);
await tx.PutAsync("account:B", b.Value + 50);
TransactionResult result = await tx.CommitAsync();
// result.IsSuccess, result.Status (Committed | Aborted | Conflict | Timeout | InternalError)
Dos comportamientos importan desde la primera línea. Las lecturas dentro de la transacción registran la versión de cada clave que tocan. Las escrituras están preparadas — bufferizadas en el cliente, invisibles para el almacén y para cualquier otro cliente hasta que el commit tiene éxito. Tus propias lecturas posteriores en la misma transacción ven tus escrituras preparadas (read-your-writes): pon una clave y léela de vuelta y obtienes lo que preparaste; borra una clave y léela de vuelta y obtienes NotFound — todo antes del commit.
CommitAsync es lo único que toca el almacén, y aplica o bien todas las operaciones preparadas o bien ninguna de ellas. RollbackAsync (o simplemente hacer dispose) descarta el conjunto entero.
Qué pasa realmente en el commit
La parte interesante es CommitAsync, porque es donde se gana el "todo o nada a través de nodos". Las claves en tu conjunto de escritura pueden ser propiedad de particiones diferentes en nodos diferentes, así que Zaris ejecuta un commit de dos fases con el cliente como coordinador:
- Agrupar las escrituras preparadas por el nodo que posee cada clave.
- Preparar — enviar a cada nodo su porción como un lote condicional. Por cada clave que también leíste, el lote lleva la versión a la que la leíste. Cada nodo comprueba que la clave sigue en esa versión y toma un lock de corta vida sobre ella. Si la versión de alguna clave se ha movido, o si otra transacción ya tiene su lock, ese nodo vota no.
- Confirmar o hacer rollback — si cada nodo votó sí, el coordinador les dice a todos que apliquen sus escrituras y liberen sus locks. Si algún nodo votó no, el coordinador hace rollback de los nodos que ya habían preparado, y el commit devuelve
TransactionStatus.Conflict.
Si el nodo B hubiera votado no — porque alguien avanzó account:B entre tu lectura y tu prepare — el coordinador le habría enviado al nodo A un rollback, el lock de A se liberaría sin ninguna escritura aplicada, y obtendrías un Conflict. Ninguna cuenta cambia. Esa es la garantía: las dos claves nunca divergen, aunque vivan en máquinas diferentes.
Las garantías, con precisión
Quita el marketing y aquí está lo que la transacción te da, y nada que no te dé:
- Atomicidad a través de claves y nodos. Cada put y delete preparado se aplica junto o nada en absoluto. Un commit que devuelve
Conflictno ha escrito nada — no hay estado parcial que limpiar. - Concurrencia optimista, comprobada en el prepare. Ninguna clave se bloquea mientras piensas. La comprobación de versión ocurre en el momento del commit; un escritor concurrente que movió una clave de la que dependes se convierte en un conflicto detectado, nunca en una sobrescritura silenciosa.
- Aislamiento serializable durante la ventana de commit. El prepare toma locks por clave que se mantienen hasta el commit o el rollback, de modo que dos transacciones que tocan una clave solapada no pueden ambas preparar — a la segunda se le dice que la clave está bloqueada y vota no.
IsolationLevel.Serializablees el único nivel hoy; el enum es deliberadamente un conjunto de un solo elemento, no una aspiración con modos más débiles ocultos. - Escrituras preparadas con read-your-writes. Nada de lo que preparas es observable fuera de la transacción hasta que el commit tiene éxito, y todo lo que preparas es observable para ti de inmediato.
Un conflicto no es un error — es la protección haciendo su trabajo. Trata un commit fallido de la forma en que el post de CAS trata una escritura condicional fallida: reabre la transacción, vuelve a leer, recalcula contra el estado actual, y reintenta, con un conteo de intentos acotado para que una clave caliente no pueda hacer bucle para siempre.
El único detalle que la mayoría pasa por alto
Aquí está la sutileza que decide si tu invariante realmente se mantiene, y vale la pena decirlo sin rodeos porque es fácil equivocarse: la comprobación de versión viaja sobre las claves que escribes, no sobre las claves que meramente lees.
Cuando tanto lees como escribes una clave — el ejemplo de la transferencia de dinero lee account:A y escribe account:A — la versión que leíste se lleva al prepare y se valida. Bien. Pero si tu transacción lee una clave y basa una decisión en ella sin escribir esa clave, la clave no forma parte del prepare, así que su versión nunca se revalida. Puede cambiar por debajo de ti entre tu lectura y tu commit, y el commit aún tendrá éxito.
await using var tx = await client.BeginTransactionAsync();
// Decision input: we only READ this.
var limit = await tx.GetAsync<int>("policy:max-transfer");
// We WRITE these. Only these are version-checked at commit.
var from = await tx.GetAsync<int>("account:A");
if (from.Value <= limit.Value)
await tx.PutAsync("account:A", from.Value - limit.Value);
await tx.CommitAsync();
// If an admin lowered policy:max-transfer after our read, we did NOT catch it:
// that key was read but never written, so its version wasn't part of prepare.
El arreglo es mecánico: incluye en el conjunto de escritura cada clave de la que dependa la corrección de tu commit. Si una clave es una entrada de decisión pura, prepara una escritura que vuelva a poner su valor actual sin cambios — eso fija su versión en el prepare, y cualquier cambio concurrente sobre ella ahora se convierte en un conflicto. La guía de Transacciones que se envía expresa el camino feliz como "lee los valores que necesitas antes de escribir" — esto es por qué ese consejo funciona, y qué hacer cuando un valor que necesitas es uno que de otro modo no ibas a escribir.
¿Transacción, CAS o lote?
Zaris te da tres formas de tocar más de lo que podrías con un PutAsync desnudo, y no son intercambiables. Recurre a la más pequeña que sostenga tu invariante:
| CAS de clave única | Lote (ExecuteBatchAsync) | Transacción | |
|---|---|---|---|
| Alcance | Una clave | Muchas claves | Muchas claves |
| ¿Atómico? | Sí, una clave | No — resultados por ítem, éxito parcial | Sí, todo-o-nada |
| Detección de conflictos | IfMatchVersion | Ninguna | Comprobación de versión en el prepare |
| Locks mantenidos | Ninguno | Ninguno | Por clave, prepare → commit |
| Costo | Un viaje de ida y vuelta, sin locks | Un viaje de ida y vuelta por nodo propietario | Viajes de prepare + commit, 2PC |
| Úsalo cuando | La actualización de una sola clave es autocontenida | Quieres menos viajes de ida y vuelta y el éxito parcial está bien | Un invariante abarca genuinamente varias claves |
El lote es el fácil de malinterpretar. ExecuteBatchAsync agrupa operaciones mixtas GET/PUT/DELETE en un viaje de ida y vuelta por nodo propietario y devuelve un resultado por ítem, en orden. Es una herramienta de rendimiento, no de consistencia: los ítems tienen éxito o fallan independientemente, y un lote puede quedar aplicado a medias. Si necesitas que las operaciones se sostengan o caigan juntas, eso es una transacción, no un lote.
Y si una sola clave cubre tu invariante, prefiere el CAS simple — no mantiene locks, no bloquea a nadie, y cuesta un viaje de ida y vuelta. Una transacción es la herramienta correcta precisamente cuando, y solo cuando, el invariante que proteges no puede comprimirse en una sola clave.
Dónde esto se detiene — y qué hacer al respecto
La transacción es real, pero es un commit de dos fases coordinado por el cliente, y el 2PC tiene costos bien conocidos que vale la pena enunciar honestamente:
El coordinador es tu proceso. El prepare toma locks en cada nodo participante y los mantiene hasta que el coordinador dice commit o rollback. Esa ventana debería ser corta: haz tu razonamiento y tus lecturas, luego confirma prontamente. Mantén el trabajo de larga duración o no relacionado fuera de la transacción, y apóyate en await using para que una transacción abandonada haga rollback en lugar de dejar trabajo preparado colgando. TransactionOptions.Timeout te deja acotar la vida de una transacción.
Es un protocolo bloqueante. Dos transacciones que contienden por la misma clave se serializan a través de su lock de prepare — la segunda vota no y reintenta. Como con el CAS, la cura para la contención es no canalizar todo a través de una sola clave: fragmenta, y mantén el conjunto de claves en cualquier transacción pequeño y disjunto de lo demás que esté caliente.
Compone lecturas y escrituras declaradas, no lógica arbitraria del lado del servidor. La ramificación — si el saldo es suficiente, debita — corre en tu cliente, exactamente como con el CAS; el servidor ejecuta un conjunto declarado de lectura/escritura guardado por versiones. No hay un procedimiento almacenado que ejecute tu lógica atómicamente en el nodo. Para la mayoría de los invariantes entre claves eso es todo lo que necesitas. Para un flujo de trabajo que abarca servicios, llamadas externas, o minutos de tiempo de reloj, un commit de dos fases es la forma equivocada — eso sigue siendo territorio de saga-y-compensación, y hacer los pasos idempotentes (el truco de deduplicación con IfAbsent del post de CAS funciona sin cambios) importa más que cualquier commit atómico individual.
Elige la herramienta más pequeña que sostenga el invariante
El post de CAS argumentaba que una primitiva afilada llega lejos, y así es. La transacción es lo que añades cuando un invariante genuinamente se niega a caber en una sola clave — y es un commit multiclave, multinodo, de todo-o-nada real, no una fachada. La disciplina que pide es pequeña y familiar: mantenla corta, reintenta ante conflictos, fragmenta antes de que una clave se caliente, y — la parte específica de las transacciones — asegúrate de que cada clave en la que se apoya tu corrección esté en el conjunto de escritura, no solo en el de lectura. Acierta con eso y "mover dinero de A a B" deja de ser el ejemplo de advertencia y se convierte en cuatro líneas honestas de código.