Los tres caminos
Coordinar antes de actuar, arbitrar después del hecho o diseñar operaciones cuyo orden no importe son las tres únicas respuestas a la divergencia, y cada una cobra su precio en un momento distinto.
La lección anterior dejó el problema reducido a su forma mínima: dos réplicas que aplican lo mismo en distinto orden divergen, salvo que las operaciones conmuten. Frente a esa constatación solo existen tres respuestas, y no es una lista basada en lo que la industria ha probado sino una enumeración exhaustiva que se deduce de la estructura del problema. O bien acuerdas el orden antes de actuar, y entonces pagas coordinación por adelantado. O bien actúas y arreglas después, y entonces pagas detección y arbitraje. O bien haces que la pregunta desaparezca, diseñando operaciones para las que el orden no tenga consecuencias, y entonces pagas en modelado y en metadatos. No hay una cuarta puerta, ninguna de las tres es gratis, y el arte de este oficio consiste en saber cuál corresponde a cada dato en lugar de elegir una sola para todo el producto.
- Enumerar los tres caminos como respuestas exhaustivas y no como estilos arquitectónicos de moda.
- Cuantificar el coste del consenso en latencia, en disponibilidad y en el resultado de imposibilidad que lo limita.
- Distinguir las variantes del arbitraje posterior y ver dónde acaba recayendo su carga de corrección.
- Enunciar el precio real de la convergencia por diseño, incluidos los invariantes que hace imposibles.
- Aplicar el teorema CALM como criterio preciso para saber cuándo es posible prescindir de coordinación.
Coordinar antes de actuar
Empecemos por el más antiguo y mejor estudiado de los tres, que es también el que la mayoría de los sistemas empresariales da por supuesto sin nombrarlo.
El primer camino consiste en no permitir que la divergencia llegue a existir. Antes de que una operación sea un hecho, los participantes acuerdan su posición en un orden común mediante un algoritmo de consenso —Paxos, Raft y sus parientes— o, en su versión más habitual, mediante un líder que ordena y un quórum que confirma. Lo que se obtiene a cambio es exactamente lo que ningún otro camino puede dar: linealizabilidad e invariantes globales, es decir, afirmaciones que solo son comprobables viendo todas las escrituras a la vez. Unicidad de un identificador, un saldo que nunca baja de cero, un aforo que no se sobrepasa, un permiso que se revoca de verdad.
El precio tiene tres partidas y conviene no confundirlas. La primera es latencia constante: la operación no es un hecho hasta que un quórum ha respondido, de modo que existe un suelo igual al tiempo de ida y vuelta hasta el nodo suficientemente lejano, y ese suelo se paga en todas las escrituras, también cuando la red va perfectamente. La segunda es disponibilidad: la minoría aislada por una partición no puede progresar, por definición y no por falta de ingenio. La tercera es más sutil y viene de la teoría: en un sistema puramente asíncrono ningún algoritmo determinista de consenso puede garantizar a la vez seguridad y terminación si puede caerse aunque sea un solo proceso. Los sistemas reales escapan de ese resultado suponiendo sincronía parcial, añadiendo detectores de fallo falibles o introduciendo aleatoriedad, y lo que consiguen es preservar siempre la seguridad y garantizar el progreso solo durante los periodos en que la red se porta bien.
Hay además un coste escondido que suele olvidarse al presupuestar este camino: las lecturas. Si quieres que una lectura sea linealizable no basta con preguntar al líder, porque un líder puede haber sido depuesto sin enterarse todavía y responder con datos de un mundo que ya no existe. Las soluciones habituales —confirmar el liderazgo con un quórum antes de responder, o sostenerlo mediante concesiones temporales que caducan— añaden latencia a las lecturas o introducen suposiciones sobre el reloj. Es un recordatorio útil de que el consenso no se paga solo en el camino de escritura.
Traducido a local-first, la conclusión es directa. Este camino es correcto y necesario para un conjunto pequeño y bien delimitado de operaciones, y es sencillamente incompatible con el bucle principal de edición, porque el suelo de latencia que impone es precisamente la sensación que la arquitectura local-first existe para eliminar. Usarlo para reservar un nombre de usuario es sensato; usarlo para confirmar cada pulsación de tecla es una contradicción con el producto.
Resolver después del hecho
Si el primer camino consiste en no permitir que la divergencia exista, el segundo consiste en dejarla existir y limpiarla luego. Es, con diferencia, el más extendido, en buena medida porque es al que se llega sin haber decidido nada.
El segundo camino acepta la escritura local de inmediato y se ocupa del desorden más tarde. Requiere dos piezas que suelen confundirse en una sola: primero detectar que dos cambios fueron concurrentes, para lo cual hacen falta relojes lógicos o vectores de versión capaces de distinguir la concurrencia real de la sucesión causal; y después arbitrar, es decir, decidir qué se hace con lo detectado.
Gana la última
Barato, converge de verdad y descarta trabajo en silencio. Si el desempate usa la hora del dispositivo, además descarta según un criterio que ni siquiera respeta la causalidad.
Guardar las dos versiones
Correcto y honesto: el sistema no inventa una decisión que no le corresponde. El coste es que traslada el problema a la aplicación y, con demasiada frecuencia, al usuario.
Fusión manual
El modelo de los sistemas de control de versiones. Funciona porque hay una persona competente revisando, y por eso mismo no sirve para un bucle de edición interactivo.
Transformación operacional
Reescribe cada operación concurrente contra las otras para que puedan aplicarse en cualquier orden. Es elegante, es correcta si las funciones cumplen sus propiedades, y esas propiedades son notoriamente difíciles.
// Detectar y arbitrar son dos pasos distintos, y solo el segundo decide.
function fusionar(mio, ajeno) {
const rel = comparar(mio.version, ajeno.version);
if (rel === "precede") return ajeno; // sucesion causal: no hay conflicto
if (rel === "sucede") return mio; // idem en el otro sentido
// rel === "concurrente": aqui, y solo aqui, hay que decidir algo.
return arbitrar(mio, ajeno); // y toda decision puede ser la mala
}
La transformación operacional merece un apunte que anticipa el enfrentamiento de los niveles siguientes. Su idea es preciosa: en lugar de exigir que las operaciones conmuten tal cual, se transforman para que conmuten, ajustando por ejemplo la posición de una inserción cuando otra inserción anterior ya ha desplazado el texto. Para que el esquema sea correcto las funciones de transformación deben satisfacer dos propiedades, y la segunda —la que hace falta cuando tres o más operaciones concurrentes se combinan en órdenes distintos— ha resultado tan difícil de conseguir que la mayoría de los sistemas desplegados la evita imponiendo un servidor central que fija un orden total. Es decir: en la práctica, el segundo camino se apoya con frecuencia en un residuo del primero.
Merece la pena separar con claridad las dos piezas, porque se pueden combinar con las de los otros caminos. La detección de concurrencia es una capacidad neutra y casi siempre deseable: saber que dos cambios fueron concurrentes es información valiosa la elijas como la elijas, y de hecho las estructuras convergentes del tercer camino la usan por dentro. Lo que caracteriza a este segundo camino no es detectar, sino arbitrar en tiempo de fusión, es decir, tomar en ese momento una decisión que no estaba predeterminada por el diseño de la operación. Cuando esa decisión existe, existe también la posibilidad de tomarla mal.
El coste general de este camino no es de latencia sino de carga de corrección. La responsabilidad de que el resultado tenga sentido se traslada a una función de arbitraje que hay que escribir, probar y mantener, y que a menudo no tiene información suficiente para decidir bien. Y cuando esa función no puede decidir, la decisión sube: primero a la interfaz, después al usuario, que se encuentra eligiendo entre dos versiones de algo que creía haber escrito una sola vez.
Que el orden deje de importar
Queda el tercero, que es el que da nombre a la mitad restante de este track y el que más cuesta aceptar, porque no mejora la resolución de conflictos sino que renuncia a tener conflictos que resolver.
El tercer camino no resuelve el conflicto: lo hace inexistente. Se restringen las operaciones a aquellas cuya fusión es conmutativa, asociativa e idempotente, de modo que los estados formen un semirretículo y la convergencia sea un teorema. El resultado es la consistencia fuerte eventual de la tercera lección, obtenida sin coordinación alguna, sin árbitro y sin deshacer nada: dos réplicas que han recibido lo mismo ya están de acuerdo, no llegarán a estarlo.
flowchart TD P[dos escrituras concurrentes sobre el mismo dato] --> A[coordinar antes de actuar] P --> B[arbitrar despues del hecho] P --> C[disenar para que el orden no importe] A --> A1[paga latencia siempre y disponibilidad bajo particion] B --> B1[paga metadatos y una funcion de arbitraje que puede perder trabajo] C --> C1[paga modelado restringido y metadatos que crecen con la historia] A1 --> Z[ninguno de los tres paga cero] B1 --> Z C1 --> Z style A fill:#f38ba8,color:#11111b style B fill:#fab387,color:#11111b style C fill:#a6e3a1,color:#11111b style Z fill:#cba6f7,color:#11111b
Conviene apreciar lo que se gana con eso, porque es más de lo que parece: desaparece la interfaz de resolución de conflictos, desaparece la posibilidad de que al usuario se le deshaga algo que ya vio aplicado, y desaparece la clase entera de fallos en los que dos réplicas se quedan discrepando para siempre por una función de arbitraje mal escrita. No es que esos problemas se resuelvan bien: es que dejan de existir como categoría.
Sus tres costes son reales y este track los va a mirar de frente. El primero es de modelado: no toda operación entra en el molde, y meterla exige rediseñar el dato hasta que la operación deje de ser una asignación y pase a ser una acumulación. El segundo es de metadatos: identificadores por elemento, contexto causal y lápidas que sustituyen al borrado, con la desagradable propiedad de que crecen con la historia y no con el tamaño del dato actual; un documento pequeño con años de ediciones puede pesar mucho más que su contenido, y compactar esa historia sin romper la convergencia es un problema serio al que se dedican niveles enteros más adelante.
El tercero es el que hay que enunciar sin suavizarlo, porque es una imposibilidad y no una carencia: este camino no puede garantizar invariantes globales no monótonos. Que un identificador sea único, que un saldo no baje de cero, que un aforo no se sobrepase: nada de eso es exigible sin coordinación, y no porque falte una estructura de datos por inventar, sino porque garantizarlo requiere ver todas las escrituras a la vez y eso es justamente lo que la partición impide. Es el mismo argumento de CAP reaparecido en otro disfraz.
Cómo se elige, y por qué siempre se mezclan
Tampoco conviene minimizar el coste de los metadatos, porque tiene una forma incómoda: no crece con lo que el usuario ve sino con lo que el sistema recuerda. Un documento de un párrafo editado a diario durante dos años puede arrastrar un historial de identificadores y lápidas que supere en órdenes de magnitud a su contenido, con consecuencias en memoria, en tiempo de carga y en el tamaño de lo que hay que transferir a un dispositivo nuevo. Podar ese historial sin romper la convergencia es difícil precisamente por la monotonía: olvidar es una operación que baja en el orden, y solo puede hacerse con seguridad cuando se sabe que todas las réplicas relevantes ya lo vieron, lo cual vuelve a exigir cierta forma de acuerdo. La compactación es, en el fondo, el tercer camino teniendo que pedirle un favor al primero.
Vistos los tres, queda la pregunta que importa en el trabajo real: cómo se decide. Y afortunadamente no hay que decidirlo por intuición, porque existe un criterio exacto.
Existe un criterio preciso para saber cuándo el tercer camino está disponible, y es uno de los resultados más útiles de la última década. El teorema CALM afirma que un programa admite una implementación distribuida consistente y libre de coordinación si y solo si es monótono, entendiendo por monótono que la llegada de más información nunca invalida una conclusión ya alcanzada. Añadir a un conjunto es monótono; contar cuántos elementos hay y actuar según ese número no lo es, porque un elemento que aún no ha llegado puede cambiar la respuesta. Ese es exactamente el corte, y no depende del talento de quien implementa.
Conviene apreciar la fuerza lógica de ese enunciado, porque el si y solo si hace un trabajo enorme. La dirección fácil dice que si algo es monótono se puede hacer sin coordinar, y eso ya es útil. La dirección difícil dice lo contrario: si algo no es monótono, entonces no existe ninguna implementación libre de coordinación que preserve la consistencia, por mucho ingenio que se le eche. Eso convierte al teorema en una herramienta para cerrar discusiones en lugar de alargarlas, porque permite demostrar que una petición es imposible en vez de limitarse a decir que resulta difícil.
Un criterio hermano, más operativo, es el de la confluencia respecto a un invariante: un invariante puede mantenerse sin coordinar si al fusionar dos estados que lo cumplen por separado el resultado sigue cumpliéndolo. Aplícalo mentalmente a un saldo que no puede ser negativo y verás el fallo al instante: dos réplicas con cien unidades cada una retiran ochenta cumpliendo el invariante por separado, y la fusión lo viola. Aplícalo a este documento tiene al menos un colaborador y verás que se conserva. Ese examen de una línea decide más arquitecturas que cualquier comparativa de librerías.
Ese examen tiene además una salida elegante que conviene conocer, porque desmonta la falsa dicotomía entre coordinar siempre y no coordinar nunca. Si el problema es que dos réplicas gastan de un mismo fondo sin saberlo, reparte el fondo por adelantado: coordina una sola vez para asignar a cada réplica una porción del presupuesto, y a partir de ahí cada una gasta dentro de lo suyo sin preguntar a nadie, porque el invariante se cumple localmente por construcción. Solo hace falta volver a coordinar cuando alguien agota su parte y quiere pedir prestado. La coordinación pasa de ser por operación a ser por reabastecimiento, que puede ser miles de veces menos frecuente.
// El invariante global pasa a ser una suma de invariantes locales.
function reservar(unidades) {
if (miCuota >= unidades) {
miCuota -= unidades; // sin coordinar: mi parte es solo mia
registrarConsumoLocal(unidades);
return { ok: true, coordinado: false };
}
// Solo aqui aparece el primer camino, y solo cuando se agota la porcion.
return pedirMasCuotaAlGrupo(unidades);
}
Este patrón no viola nada de lo dicho: la coordinación sigue siendo necesaria, simplemente se ha desplazado a un momento en el que no está el usuario esperando. Y tiene un límite honesto que hay que enunciar: reparte bien cuando el recurso es abundante respecto al ritmo de consumo, y degrada hasta convertirse en consenso puro cuando el fondo se agota. Diseñar la porción y la política de préstamo es el trabajo real.
Al llegar a los niveles de las bibliotecas reales aparecerá la tentación de creer que adoptar una de ellas resuelve la cuestión. No la resuelve, y conviene tenerlo claro desde ahora: esas bibliotecas implementan estructuras que convergen, es decir, resuelven el tercer camino con enorme competencia, y por construcción no pueden ofrecerte unicidad, límites duros ni revocaciones inmediatas, porque eso exige coordinación y su razón de ser es no coordinar. Si tu producto necesita ambas cosas —y casi todos las necesitan— el diseño híbrido es responsabilidad tuya: qué vive en la estructura convergente, qué pasa por un punto de acuerdo, y qué contrato hay entre ambas partes. Elegir la librería es la última decisión del proceso, no la primera.
La consecuencia práctica de todo lo anterior es que la pregunta correcta nunca es qué camino sigue tu aplicación, sino qué camino sigue cada campo. En un editor colaborativo realista conviven los tres sin conflicto: el cuerpo del texto converge por diseño porque necesita edición concurrente fluida; el título puede resolverse con la última escritura porque el coste de equivocarse es que alguien lo vuelva a cambiar; y la lista de miembros con permisos de escritura pasa por consenso, porque revocar un acceso es un invariante que no admite versiones divergentes. Tres caminos, tres costes y un solo producto. Los sistemas maduros no eligen una escuela: reparten.
Si de este nivel entero tuvieras que retener una sola idea, que sea esta: en un sistema distribuido la coordinación es un recurso que se gasta, y los tres caminos no son tres cantidades distintas de gasto sino tres momentos distintos para gastarlo. El consenso paga antes, con latencia en cada escritura y con indisponibilidad cuando la red se rompe, y a cambio compra certeza inmediata sobre invariantes globales. El arbitraje paga después, con metadatos para detectar la concurrencia y con una función que decide qué sobrevive, y a cambio compra escritura local instantánea al precio de que alguien —el código, la interfaz o el usuario— cargue con la decisión. La convergencia paga antes de escribir una sola línea, en tiempo de diseño, restringiendo qué operaciones puede ofrecer el producto y aceptando metadatos que crecen con la historia, y a cambio compra la propiedad más valiosa de las tres, que es no tener que decidir nada en tiempo de ejecución. Nadie paga cero, y el teorema CALM dice exactamente cuándo la factura podría ser cero: solo si el programa es monótono. Ese solo si es el que convierte la conversación en técnica y no en preferencias, porque significa que cuando alguien te pida a la vez unicidad garantizada y escritura instantánea sin coordinación, no te está pidiendo un esfuerzo mayor sino algo demostradamente imposible, y tu trabajo consiste en decirlo con esa palabra. Con esto queda cerrado el planteamiento del problema y abierto todo lo demás. Sabemos que la partición es el estado normal, que bajo partición hay que elegir, que la elección local-first es seguir aceptando escrituras, que eso produce divergencia, que la divergencia solo se resuelve si el orden deja de importar, y que hacer que el orden deje de importar exige monotonía. Lo que viene a continuación es la maquinaria concreta de esa apuesta: primero cómo se representa el orden parcial de la causalidad de forma barata, y después las dos escuelas que llevan décadas discutiendo cómo aprovecharlo. Todo lo que leas a partir de aquí es una respuesta detallada a una pregunta que ya sabes formular.
- Haz el inventario de tus entidades y campos, y escribe al lado de cada uno el invariante que promete, si es que promete alguno.
- Aplica a cada invariante el examen de la fusión: si dos estados válidos por separado se fusionan, ¿sigue siendo válido el resultado?
- Marca como candidatos al tercer camino todos los que pasen el examen, y como candidatos al primero los que no.
- Para lo que quede en el segundo camino, escribe explícitamente la función de arbitraje y qué trabajo puede llegar a perder.
- Estima el suelo de latencia que impondría el consenso en tu topología real y contrástalo con lo que tu interfaz puede tolerar.
- Escribe en una sola frase, por cada camino elegido, cuándo pagas su coste y quién nota ese pago.