CRDTs: fundir sin árbitro
Un CRDT, tipo de dato replicado sin conflictos, es una estructura de datos que puede copiarse en muchos dispositivos, editarse en cada uno de forma concurrente y sin coordinación, y aun así garantizar que todas las réplicas convergen matemáticamente al mismo estado en cuanto han visto los mismos cambios, sin que ningún servidor central arbitre. Esta lección construye la intuición desde sus cimientos algebraicos: la operación de fusión es la unión mínima en un semirretículo, y para que la convergencia sea inevitable esa fusión debe cumplir tres propiedades —conmutatividad, asociatividad e idempotencia— que hacen irrelevantes el orden y la duplicación con que llegan los cambios. Sobre esa base se distinguen las dos familias, los CRDTs basados en estado y los basados en operaciones, y se recorre un catálogo de ejemplos —contadores, conjuntos, registros y secuencias— que muestran cómo el mismo principio escala desde un contador trivial hasta el texto de un editor colaborativo. Al final el lector entiende no solo qué es un CRDT sino por qué funciona, y qué precio en metadatos paga por eliminar el conflicto en lugar de arbitrarlo.
La lección anterior dejó una promesa pendiente: para que local-first funcione, dos copias del mismo dato editadas a la vez en dos dispositivos deben poder fundirse en un resultado único sin que un servidor decida quién gana. Suena imposible, porque nuestra intuición sobre datos concurrentes está entrenada por el conflicto: dos personas editan la misma celda, alguien tiene que perder. Los CRDTs —tipos de datos replicados sin conflictos— disuelven esa intuición cambiando la pregunta. No se preguntan quién gana cuando hay conflicto, sino cómo diseñar el dato para que el conflicto no exista, para que cualquier par de réplicas que hayan visto los mismos cambios acaben, por construcción matemática, en el mismo estado. La palabra clave es converger: no reconciliar a posteriori, no votar, no consultar un reloj central, sino garantizar de antemano que la fusión es determinista y que da igual el orden en que lleguen las piezas. Esta lección abre la caja negra. Verás que detrás de la magia hay un teorema modesto y hermoso —el de los semirretículos— y tres propiedades algebraicas que, una vez las reconoces, hacen que la convergencia deje de parecer un truco y se vuelva obvia. Entender esto es entender el ladrillo con el que están hechos Yjs, Automerge y todos los motores que veremos después.
- Definir CRDT y la consistencia eventual fuerte: réplicas que convergen sin coordinación ni árbitro central.
- Reconocer las tres propiedades de la fusión —conmutatividad, asociatividad, idempotencia— y por qué garantizan la convergencia.
- Distinguir las dos familias, CRDTs basados en estado y basados en operaciones, y qué exige cada una a la red.
- Recorrer el catálogo —contador, conjunto, registro, secuencia— y situar el precio en metadatos que se paga.
Convergencia sin árbitro: la consistencia eventual fuerte
Un sistema distribuido clásico persigue la consistencia fuerte: todas las réplicas ven el mismo valor en todo momento, lo que exige coordinación —bloqueos, consenso, un líder— antes de cada escritura. Esa coordinación es cara y, sobre todo, requiere red: sin poder hablar con las demás réplicas, una réplica no puede escribir. Local-first no puede pagar ese precio, porque su ideal tres es que la red sea opcional. Los CRDTs renuncian a la consistencia fuerte y abrazan una variante disciplinada de la consistencia eventual: la consistencia eventual fuerte, o SEC por sus siglas en inglés. Su garantía es precisa y más potente de lo que suena: dos réplicas que han recibido el mismo conjunto de actualizaciones —sin importar el orden— tienen exactamente el mismo estado. No hay ventana de divergencia que reconciliar después, ni posibilidad de que dos réplicas con la misma información discrepen. La convergencia no es probable ni eventual en el sentido flojo: es una certeza matemática que se cumple en cuanto la información ha circulado.
Lo notable es lo que esa garantía compra: cada réplica escribe cuando quiere, sin preguntar a nadie, incluso completamente desconectada, y aun así el sistema no puede divergir permanentemente. La coordinación desaparece del camino de la escritura y se traslada al diseño del propio tipo de dato. Ahí está el truco, y no es magia: es álgebra. Y esa reubicación de la coordinación no es casual, sino forzada por un teorema famoso —el teorema CAP—, que demuestra que ante una partición de red un sistema solo puede elegir entre seguir disponible o seguir consistente. Los CRDTs eligen sin titubear la disponibilidad, y recuperan una forma fuerte de consistencia por la puerta de atrás: no impidiendo la divergencia, sino garantizando que toda divergencia es temporal y se resuelve sola.
flowchart TD I[estado inicial compartido] --> A1[replica A inserta X] I --> B1[replica B inserta Y] A1 -->|sin red, en paralelo| A2[A tiene X] B1 -->|sin red, en paralelo| B2[B tiene Y] A2 --> M[merge al reconectar] B2 --> M M --> F[ambas convergen al mismo estado con X e Y] style M fill:#cba6f7,color:#11111b style F fill:#a6e3a1,color:#11111b
Las tres propiedades de la fusión
La pieza central de todo CRDT es su operación de fusión, el merge que combina dos réplicas en una. Para que la convergencia sea inevitable, esa fusión debe cumplir tres propiedades. Son las mismas que caracterizan a un semirretículo de unión, y por eso los CRDTs también se llaman, con más precisión, tipos de datos convergentes basados en semirretículos.
- Conmutatividad. Fundir A con B da lo mismo que fundir B con A. El orden en que dos réplicas se encuentran no altera el resultado.
- Asociatividad. Al fundir tres o más réplicas, da igual cómo las agrupes. La topología de propagación —quién habla con quién y en qué orden— es irrelevante.
- Idempotencia. Fundir una réplica consigo misma, o volver a aplicar un cambio ya visto, no cambia nada. Recibir el mismo mensaje dos veces es inofensivo.
Junta las tres y obtienes un resultado poderoso: el estado final no depende ni del orden, ni de la agrupación, ni de la repetición de los mensajes. Y esas son exactamente las tres patologías de una red real —mensajes desordenados, caminos distintos, entregas duplicadas—. Un CRDT es, en el fondo, una estructura de datos inmune a las tres. El ejemplo canónico es el contador que solo crece, el G-Counter: cada réplica lleva su propia cuenta en un mapa indexado por identificador de réplica, y la fusión toma el máximo elemento a elemento.
type GCounter = Record<string, number> // idReplica -> conteo local
function incrementar(c: GCounter, id: string): GCounter {
return { ...c, [id]: (c[id] ?? 0) + 1 }
}
// merge = maximo elemento a elemento: conmutativo, asociativo, idempotente
function merge(a: GCounter, b: GCounter): GCounter {
const r: GCounter = { ...a }
for (const id in b) r[id] = Math.max(a[id] ?? 0, b[id] ?? 0)
return r
}
const valor = (c: GCounter) => Object.values(c).reduce((s, n) => s + n, 0)
El máximo es la clave. Como Math.max es conmutativo, asociativo e idempotente, el merge hereda las tres propiedades gratis, y el contador converge pase lo que pase con la red. Cada réplica solo toca su propia casilla, así que nunca sobrescribe el trabajo de otra; la fusión reconstruye el total tomando lo más alto que cada réplica haya visto de cada una. Esa es la intuición del merge en su forma más pura: no elegir un ganador, sino combinar de un modo que respeta lo que todos aportaron. Compáralo con la alternativa ingenua —guardar un único número y que la última escritura lo pise—: esa sí pierde incrementos concurrentes, porque la asignación directa no es conmutativa. El diseño del CRDT consiste, precisamente, en encontrar la representación cuyo merge sí lo sea.
Dos familias y un catálogo
Los CRDTs se presentan en dos familias, según qué viaje por la red. Son teóricamente equivalentes —cada una puede emular a la otra— pero imponen exigencias muy distintas al transporte.
Basados en estado (CvRDT)
Las réplicas se intercambian su estado completo y lo funden con el merge del semirretículo. Toleran una red pésima: mensajes perdidos, desordenados o duplicados no importan, porque el merge es idempotente y asociativo. El precio es enviar mucho: el estado entero, no solo el cambio.
Basados en operaciones (CmRDT)
Las réplicas propagan solo las operaciones —insertar, borrar, incrementar—. Envían poquísimo, pero exigen más de la red: las operaciones deben entregarse exactamente una vez y respetar el orden causal. A cambio, cada operación conmuta con las demás.
El catálogo de CRDTs conocidos cubre casi cualquier estructura, y conviene tener un mapa de sus familias porque casi todo lo que construyas se apoya en una de ellas.
Contadores
El G-Counter solo suma, con un conteo por réplica que se funde por máximo. El PN-Counter junta dos G-Counter —uno de sumas, otro de restas— para permitir decrementos sin romper la convergencia.
Conjuntos
El G-Set solo añade y funde por unión. El OR-Set, observed-remove set, permite añadir y quitar etiquetando cada elemento con un identificador único, de modo que un añadir y un borrar concurrentes no se confundan.
Registros
El LWW-Register guarda un valor atómico y resuelve escrituras concurrentes con una marca de tiempo y un desempate determinista: gana la última. Es el CRDT más simple y el que más datos silencia.
Secuencias
RGA, Logoot, YATA y Fugue representan listas y texto de modo que dos inserciones concurrentes en la misma posición se ordenen igual en toda réplica. Son la base de los editores colaborativos.
Un LWW-Register merece un vistazo al código porque muestra el otro extremo del espectro frente al G-Counter: donde el contador nunca pierde datos, el registro elige un ganador por reloj y descarta al perdedor. Ambos convergen, pero encarnan filosofías opuestas sobre qué hacer con lo concurrente.
type LWW<T> = { valor: T; ts: number; id: string } // ts = marca de tiempo
// merge: gana el ts mayor; empate resuelto por id, para que sea determinista
function merge<T>(a: LWW<T>, b: LWW<T>): LWW<T> {
if (a.ts !== b.ts) return a.ts > b.ts ? a : b
return a.id > b.id ? a : b
}
La convergencia se paga en metadatos. Un CRDT de texto no guarda solo tus caracteres: guarda, por cada carácter jamás escrito, un identificador único y a menudo su relación causal con los vecinos, y los borrados no eliminan nada sino que dejan lápidas —tombstones— para que la ausencia sea también un hecho replicable. Un documento de mil palabras puede arrastrar el rastro de decenas de miles de operaciones históricas. Ese peso es el motivo de que la recolección de basura en CRDTs sea un problema abierto: no puedes tirar una lápida con seguridad mientras exista una réplica desconectada que quizá no la ha visto. Fundir sin árbitro tiene un coste, y ese coste es memoria e historia.
El salto conceptual que cuesta dar, y que lo cambia todo, es este: la resolución de conflictos que estudiaste en el nivel anterior —last-write-wins, autoridad del servidor, fusión campo a campo— asume que el conflicto es un hecho de la naturaleza que hay que arbitrar después de que ocurra. Los CRDTs niegan esa premisa. No arbitran el conflicto: rediseñan el dato para que la pregunta quién gana no llegue a plantearse, porque la fusión de dos estados cualesquiera está definida de antemano y es la misma en todas las réplicas. La sede de esa garantía no es un servidor ni un algoritmo de reconciliación, sino tres propiedades algebraicas —conmutatividad, asociatividad, idempotencia— que convierten las tres patologías de una red real, el desorden, la topología y la duplicación, en detalles irrelevantes. Ahí reside la elegancia y la lección profunda: en sistemas distribuidos, la coordinación es cara porque exige que todos hablen antes de actuar, y el genio del CRDT es trasladar esa coordinación del tiempo de ejecución al tiempo de diseño. Se paga una vez, al construir el tipo de dato, eligiendo una estructura cuyo merge sea la unión mínima de un semirretículo, y a cambio se compra la libertad de escribir sin pedir permiso a nadie, para siempre. No es una librería que resuelve conflictos: es una rama de las matemáticas que los hace imposibles. Cuando en las próximas lecciones veas a Yjs fundir dos ediciones simultáneas sin parpadear, recuerda que no está siendo listo en tiempo real, sino cobrando una deuda algebraica que alguien saldó al diseñar YATA.
- Implementa el
G-Counterdel ejemplo y verifica empíricamente las tres propiedades: comprueba quemerge(a, b)es igual amerge(b, a), que reagrupar tres réplicas no cambia el total y que fundir dos veces no altera nada. - Simula una red hostil: funde las réplicas en orden aleatorio, repite mensajes y omite algunos temporalmente. Confirma que el valor final siempre coincide.
- Extiende tu contador a un
PN-Countercombinando dosG-Countery comprueba que sigue convergiendo con decrementos. - Implementa el
LWW-Registerdel ejemplo y demuestra que converge pero puede perder una de dos escrituras concurrentes. Contrástalo con elG-Counter, que no pierde nada. - Explica en una frase por qué
Math.maxfunde sin conflictos pero una asignación directa —el último que escribe pisa— no lo hace. - Investiga qué es una lápida en un
OR-Sety explica por qué no puedes borrarla mientras exista una réplica que quizá no la ha visto.