wandres.dev
RELOJES II · vectores y relojes híbridos

Elegir: Lamport, vector o híbrido

Tres preguntas deciden qué reloj necesitas, y las familias de CRDT que ya usas en producción muestran que la respuesta madura casi nunca es uno solo sino una pila de dos o tres.

⏱ 19 min

Después de cuatro lecciones tienes tres piezas y la tentación natural es ordenarlas por potencia, colocar el vector arriba y sentir que usar un escalar es conformarse. Es una lectura equivocada y cara. Ninguna de las tres domina a las otras porque no responden a la misma pregunta: el escalar ordena, el vector detecta y el híbrido ancla. La decisión de ingeniería no consiste en elegir la más fuerte sino en identificar qué preguntas va a tener que responder tu sistema y qué metadato estás dispuesto a pagar por cada una. Esta lección cierra el nivel con el criterio de decisión, con el mapa de lo que usa cada familia de CRDT real, y con una cuarta opción que casi nunca aparece en los textos clásicos y que sin embargo sostiene algunas de las bibliotecas más usadas del mundo local-first.

🎯 Al terminar esta lección sabrás
  • Aplicar las tres preguntas que determinan la familia de reloj adecuada para un caso concreto.
  • Reconocer qué estructura causal usa cada familia de CRDT y por qué esa y no otra.
  • Entender el historial encadenado por hash como cuarta alternativa y su curva de coste distinta.
  • Componer la pila de relojes que un sistema serio acaba necesitando, sin duplicar responsabilidades.

Tres preguntas que deciden

Las tres preguntas son independientes entre sí y se responden en este orden porque cada una descarta opciones que la siguiente ya no tiene que considerar. Contestarlas al revés es el origen de la mayoría de las decisiones malas de esta materia: empezar por el presupuesto lleva a elegir un escalar por barato y descubrir dos años después que el producto necesitaba detectar conflictos.

La primera pregunta es la que más discriminación aporta: ¿necesitas detectar la concurrencia o solo desempatarla? Detectar significa que el sistema tiene que poder decir que dos cambios se produjeron sin conocerse, porque de ese hecho depende una consecuencia visible: conservar ambos valores, avisar al usuario, abrir una fusión. Desempatar significa que te basta con que todas las réplicas elijan al mismo ganador de forma determinista, sin que a nadie le importe si hubo conflicto. Detectar exige un vector. Desempatar se resuelve con un escalar.

Hay una prueba práctica para responderla sin engañarse: busca en tu diseño el punto donde dos valores rivales dejarían de caber en uno solo. Si en ese punto tienes una respuesta de producto —conservamos ambos, avisamos, abrimos una comparación— entonces necesitas detectar. Si la respuesta honesta es que da igual cuál gane mientras todos vean lo mismo, no necesitas detectar y estás a punto de ahorrarte una estructura que crece con cada dispositivo.

La segunda pregunta: ¿la marca tiene que significar algo fuera del sistema? Si vas a mostrarla, filtrar por rango de fechas, correlacionarla con registros de servidor o razonar sobre caducidades, necesitas el ancla física del reloj híbrido. Si la marca solo la lee tu propio algoritmo, un contador de Lamport es más simple, más pequeño y no arrastra ninguna hipótesis sobre la deriva.

Conviene además responder la primera pregunta capa por capa y no para el sistema entero, porque casi ningún sistema tiene una sola respuesta. Un editor de texto colaborativo no necesita detectar concurrencia entre dos inserciones de caracteres: su algoritmo de secuencia las entrelaza de forma determinista y el resultado es aceptable sin preguntar a nadie. Ese mismo editor sí necesita detectarla en el campo de título del documento, donde dos valores rivales no se entrelazan y alguien tiene que decidir. Misma aplicación, dos respuestas, dos relojes.

La tercera es de presupuesto: cuántas réplicas vas a tener y cuánto metadato aguanta cada objeto. Un escalar cuesta lo mismo con tres réplicas que con seiscientas; un vector cuesta proporcionalmente al número de identidades vivas, con todo lo que la lección anterior discutió sobre por qué esas identidades no se mueren nunca.

flowchart TB
Q1[Necesitas detectar concurrencia] -->|si| V[Vector o version vector]
Q1 -->|no| Q2[La marca debe parecerse a la hora real]
Q2 -->|si| H[Reloj hibrido]
Q2 -->|no| L[Contador de Lamport con desempate por identidad]
V --> Q3[Cuantas replicas y cuanto metadato aguantas]
Q3 --> C[Poda codificacion o historial por hash]
style V fill:#cba6f7,color:#11111b
style H fill:#89b4fa,color:#11111b
style L fill:#a6e3a1,color:#11111b

Qué usa cada familia de CRDT

El mapa concreto es más instructivo que cualquier tabla abstracta, porque cada familia eligió su reloj por una razón que se puede reconstruir. Y hay un principio que atraviesa todas: el reloj que necesita un tipo de dato replicado se deduce de su función de fusión. Si la fusión es una elección entre valores rivales, hace falta un orden total y basta un escalar; si la fusión conserva los rivales, hace falta poder decir que son rivales y hace falta un vector; si la fusión combina sin elegir, como en un contador, puede que no haga falta ningún reloj en absoluto.

Los registros de último escritor gana usan un escalar. Un LWW-Register guarda un valor y una marca, y al fusionar se queda con la marca mayor; el desempate por identidad de réplica cierra el caso del empate. No detecta nada: elige. Por eso la elección entre Lamport e híbrido aquí es puramente una cuestión de si quieres que esa marca signifique algo, y por eso los sistemas que la exponen al usuario acaban en el híbrido.

El registro multivalor es el caso contrario y necesita un version vector sin alternativa. Su semántica es conservar todos los valores mutuamente concurrentes, y para eso hace falta poder decidir la incomparabilidad exactamente. Es la estructura de la que hablaba la segunda lección al llegar a los dotted version vectors: sin el punto que identifica la escritura concreta, el conjunto de valores concurrentes se infla con el tráfico en lugar de reflejar la concurrencia real.

Las secuencias son el caso donde la elección se vuelve menos obvia y por eso merecen su párrafo. El problema que resuelven no es elegir entre dos valores sino colocar dos inserciones rivales en un mismo hueco, y para eso lo único que necesitan es un orden total determinista que todas las réplicas calculen igual. Un identificador de tipo Lamport lo da con dos enteros por elemento, sin detectar concurrencia y sin necesitar detectarla, porque la política ya está decidida de antemano: las dos inserciones se conservan, entrelazadas según ese orden. Detectar el conflicto no aportaría nada, porque no hay ninguna decisión que tomar con esa información.

Los contadores merecen una observación que suele pasar desapercibida. Un G-Counter es un mapa de identidad a contador que se fusiona con el máximo puntual, es decir, tiene exactamente la misma forma que un vector. Pero no es un reloj: es el dato. Su valor observable es la suma de las entradas, no su orden, y jamás se comparan dos de ellos para averiguar quién precede a quién. La coincidencia estructural es real y confunde a mucha gente; la semántica no tiene nada que ver.

🧮

Conjuntos con etiquetas

Un OR-Set marca cada adición con una etiqueta única. Las variantes optimizadas sustituyen el conjunto de etiquetas por un version vector más los puntos sueltos: eso es el contexto causal.

📝

Secuencias y texto

Las familias de secuencia identifican cada carácter con un par de contador e identidad de estilo Lamport, y usan ese identificador para un orden total determinista entre inserciones rivales.

📬

Deltas y estado

Los CRDT de estado por deltas llevan el contexto causal como parte del propio valor, de modo que la fusión decide con él qué se ha visto y qué falta, sin log aparte.

🔢

Contadores

Un mapa por réplica que parece un vector pero cuya lectura es la suma. Es un dato con forma de reloj, no un reloj, y compararlo por dominación no tiene sentido semántico.

En las bibliotecas de texto colaborativo que se usan en producción conviven dos relojes con papeles distintos, y verlo explícitamente aclara mucho. Cada elemento insertado lleva un identificador de tipo Lamport formado por la identidad del cliente y un contador, que sirve para desempatar de forma determinista qué inserción va antes cuando dos ocurren en la misma posición. Y a nivel de documento existe un vector de estado, un mapa de cliente a contador, que sirve para el protocolo: una réplica lo envía, la otra calcula la diferencia y devuelve solo lo que falta. Dos relojes, dos trabajos, ninguna redundancia.

La cuarta opción: encadenar por hash

Antes de describirla conviene decir por qué falta en los textos clásicos: aquellos sistemas tenían un conjunto de procesos conocido, fijo y pequeño, y en ese mundo contar por origen es la representación natural y el hash no aporta nada. La técnica que viene se hizo indispensable cuando el conjunto de participantes dejó de ser conocido y dejó de ser pequeño, que es exactamente la situación de local-first.

Hay una manera de representar la causalidad que no aparece en los textos clásicos de relojes lógicos y que sostiene buena parte del ecosistema moderno: en lugar de contar por origen, cada cambio referencia por hash a los cambios que veía cuando se produjo. El resultado es un grafo dirigido acíclico donde la relación de precedencia es la alcanzabilidad, y el estado de una réplica se resume no con un vector sino con el conjunto de vértices sin descendientes, sus cabezas.

// Resumen por cabezas: el estado es el conjunto de vertices sin descendientes
const cambio = {
  hash: "b7a1...",          // contenido direccionado, identidad e integridad a la vez
  padres: ["9f0c...", "3d51..."], // lo que el autor veia al producirlo
  ops: [],
};

// Que me falta se responde recorriendo hacia atras desde las cabezas ajenas
function faltantes(grafo, cabezasRemotas) {
  const pendientes = [...cabezasRemotas], vistos = new Set(), salida = [];
  while (pendientes.length) {
    const h = pendientes.pop();
    if (vistos.has(h) || grafo.has(h)) continue;
    vistos.add(h);
    salida.push(h); // no lo tengo: hay que pedirlo, y con el sus padres
  }
  return salida;
}

La propiedad interesante es que este esquema caracteriza la causalidad de forma exacta, igual que un vector, pero su coste no escala con el número de réplicas sino con el número de cabezas concurrentes, que en la práctica es pequeño porque converge en cuanto las réplicas se sincronizan. Un documento con seiscientas identidades históricas y dos ramas abiertas tiene dos cabezas, no seiscientas entradas. A cambio, cada cambio paga los bytes de sus referencias, el resumen deja de ser un objeto de tamaño fijo y comparar dos estados exige recorrer el grafo en lugar de comparar coordenada a coordenada.

ℹ️
Es el modelo que ya conoces de otro sitio

Un sistema de control de versiones distribuido resuelve exactamente este problema con exactamente esta técnica: cada confirmación apunta a sus padres, las ramas son las cabezas y la base común de dos ramas se calcula recorriendo el grafo. Que la analogía sea tan directa no es casual, y usarla como intuición ahorra mucho tiempo cuando toca razonar sobre fusiones, ancestros comunes y aplanado del historial.

La pila que de verdad se usa

Hay un matiz sobre el historial por hash que conviene no pasar por alto antes de adoptarlo con entusiasmo: el hash da integridad y da identidad global sin coordinación, dos propiedades que ningún contador ofrece, pero no da orden. Dos cabezas hermanas no se ordenan entre sí por su hash sin recurrir a un criterio arbitrario, y ese criterio hay que fijarlo igual que fijarías el desempate por identidad en un reloj de Lamport. El grafo te dice quién descendía de quién; para todo lo demás sigues necesitando una regla.

Un sistema local-first maduro no elige un reloj: monta tres capas, cada una con una responsabilidad que no invade a las otras. En la capa de operación, un identificador de tipo Lamport o un sello híbrido da a cada cambio una identidad única, un orden total determinista y, si eligió el híbrido, una cronología presentable. En la capa de documento, un version vector o un conjunto de cabezas resume lo que la réplica ya tiene y hace posible el intercambio por deltas sin sesión. Y dentro de los tipos de datos que lo necesitan, un contexto causal —vector más puntos— permite a la fusión distinguir el borrado de lo que nunca llegó.

// Tres responsabilidades, tres estructuras, ninguna solapada
const operacion = {
  id: { contador: 42, replica: "r7" }, // identidad y desempate determinista
  hlc: 1738500000123n,                 // cronologia presentable, opcional
  payload: { campo: "titulo", valor: "Borrador" },
};

const documento = {
  resumen: { r7: 42, r3: 118 }, // que tengo ya, para calcular deltas
};

// Ninguna de las dos estructuras se usa para el trabajo de la otra
const yaLoTengo = (op, doc) => op.id.contador <= (doc.resumen[op.id.replica] ?? 0);

La regla de higiene que mantiene la pila sana es que cada estructura tenga un único consumidor conceptual y que ese consumidor esté escrito en el nombre. El identificador de operación se usa para desempatar y para deduplicar, nunca para decidir si hubo conflicto. El resumen de documento se usa para calcular deltas y para saber si dos réplicas divergen, nunca para ordenar dos operaciones entre sí. El contexto causal vive dentro del tipo de dato y no sale de ahí. En cuanto una de las tres empieza a usarse para el trabajo de otra, el sistema entra en la zona donde los fallos son sutiles, tardíos y difíciles de reproducir.

Y conviene cerrar con los antipatrones, porque son reconocibles y frecuentes. Resolver conflictos con la hora de pared sin ningún componente lógico es el primero y el más caro. Guardar un vector completo por operación durante toda la vida del documento es el segundo, y se paga en disco y en batería. Intentar usar un version vector como criterio de desempate es el tercero, y es un error de tipos disfrazado: un orden parcial no desempata, precisamente porque su tercera respuesta es la incomparabilidad. Y el cuarto es adoptar el híbrido creyendo que sustituye al vector, que es la confusión que la lección anterior desmontó y la que más datos ha hecho desaparecer en silencio.

Todo reloj es una compresión con pérdidas del grafo causal

El marco que unifica las cuatro opciones y que conviene llevarse de este nivel es que ninguna de ellas es el objeto real. El objeto real es el grafo causal completo: todos los eventos y todas las aristas de quién sabía qué. Ese grafo es la verdad y nadie lo guarda entero, porque crece con la historia y no con el número de participantes. Todo lo demás son compresiones suyas, y cada compresión se define por lo que decide perder. El vector clock es la única sin pérdidas en la relación de orden, y por el teorema de la cota inferior paga con una dimensión por participante; no hay forma de negociar ese precio, solo de posponerlo con codificación o de asumir sus pérdidas con poda. El contador de Lamport comprime hasta un solo número y pierde exactamente una cosa, la dirección de vuelta de la implicación, que es la que permitía detectar concurrencia; a cambio cuesta lo mismo con tres réplicas que con un millón. El híbrido es ese mismo escalar con un ancla al calendario, y esa ancla es útil de verdad pero no recupera ni un bit de la información perdida. Y el historial por hash conserva la relación completa como el vector pero cambia el eje del coste: en lugar de crecer con los participantes crece con las ramas abiertas, y como las ramas se cierran al sincronizar, en la práctica se comporta mucho mejor de lo que su peor caso sugiere. Leer las cuatro así convierte la elección en una pregunta contestable: no cuál es mejor, sino qué pérdida puedes permitirte dado lo que tu producto tendrá que responder dentro de dos años, cuando ya haya usuarios con seis dispositivos y documentos con años de historia. Y hay un último detalle que ordena la decisión: las pérdidas de un reloj no se anuncian. Un sistema que perdió la capacidad de ver un conflicto no falla, converge; simplemente converge a un estado en el que el trabajo de alguien ya no está. Elegir el reloj es, mirado de cerca, elegir de qué errores quieres poder enterarte.

⚔️ Decide con criterio y justifícalo
  1. Escribe las tres preguntas y respóndelas por escrito para el sistema concreto que estés construyendo, con el número de réplicas que esperas a tres años vista.
  2. Elige el reloj de cada capa —operación, documento y tipo de dato— y comprueba que ninguna estructura hace el trabajo de otra.
  3. Implementa un LWW-Register con Lamport y otro con híbrido y compara qué preguntas puedes contestar con cada uno.
  4. Sustituye tu resumen por version vector por un conjunto de cabezas encadenadas por hash y mide el metadato en un escenario con cien identidades históricas y dos ramas.
  5. Busca en tu código cualquier comparación de marcas de tiempo de pared que decida un ganador y documenta qué pasa cuando un dispositivo va cuatro minutos adelantado.
  6. Redacta en una página la decisión final con las hipótesis que la sostienen, para que quien la revise en dos años sepa qué comprobar.