Relojes lógicos híbridos: hora real sin perder causalidad
El reloj híbrido pega un contador lógico a la hora física para producir marcas que se parecen a la hora del reloj de pared y nunca contradicen la causalidad, siempre que la deriva esté acotada.
Tienes dos relojes y cada uno te falla en lo que el otro resuelve. El reloj físico produce marcas que un humano entiende, que se ordenan entre sí, que caben en ocho bytes y que sirven para preguntar qué pasó el martes por la tarde; a cambio miente, porque los relojes de dos dispositivos no coinciden y una escritura posterior puede llevar una marca anterior. El reloj lógico nunca contradice la causalidad; a cambio su número no significa nada fuera del sistema y no hay forma de convertirlo en una fecha. El reloj lógico híbrido es la construcción que se niega a elegir: mantiene un valor que sigue de cerca a la hora física y le añade el mínimo contador necesario para que la condición del reloj no se rompa jamás. La pregunta interesante no es cómo funciona —es corto— sino qué garantiza exactamente y qué le está pidiendo al mundo a cambio.
- Enunciar por separado lo que falla en el reloj físico y lo que falla en el lógico.
- Implementar el algoritmo del reloj híbrido en sus tres casos: evento local, envío y recepción.
- Distinguir la garantía que sí da de la que no da, y por qué no detecta concurrencia.
- Identificar la hipótesis de deriva acotada y las defensas cuando esa hipótesis se incumple.
Las dos horas y sus dos fallos
El fallo del reloj físico se llama deriva y es inevitable: dos osciladores independientes divergen, la sincronización por red corrige a saltos y el usuario puede cambiar la hora a mano. La consecuencia para un sistema de datos es concreta: si sellas cada escritura con la hora local, un dispositivo adelantado gana todos los conflictos aunque sus escrituras sean más antiguas, y un mensaje puede llegar con una marca posterior a la del evento que lo provocó. La causalidad y el reloj de pared se contradicen, y cuando eso pasa el sistema ordena mal sin darse cuenta.
En un navegador la situación es peor que en un servidor administrado, y conviene no consolarse con la idea de que la sincronización horaria moderna lo tiene resuelto. El reloj de pared que la plataforma te expone lo controla el usuario, puede saltar hacia atrás cuando el sistema se corrige, puede quedarse congelado mientras el dispositivo duerme y en algunos entornos se reduce deliberadamente su resolución por motivos de privacidad. No es una fuente de verdad: es una lectura razonable la mayoría de las veces.
El fallo del reloj lógico es de otra naturaleza. Un contador de Lamport respeta la causalidad por construcción, pero su valor es un número sin unidades: no puedes restar dos marcas para saber cuánto tiempo pasó, no puedes filtrar por fecha, no puedes enseñárselo a nadie y no puedes correlacionarlo con los registros de un servidor ni con el reloj del sistema. Para depurar, para auditar y para casi todo lo que un producto necesita mostrar, ese número es inservible.
Merece la pena ver cómo se rompe el reloj físico en un caso mínimo, porque el fallo no exige una deriva grande ni un escenario rebuscado. Dos dispositivos, uno adelantado cuarenta segundos. El adelantado escribe y sella con su hora; envía el cambio; el otro lo recibe, lo lee y escribe encima una corrección. La segunda escritura ocurrió después y depende causalmente de la primera, pero su marca es menor, así que cualquier fusión por marca mayor conserva la primera y descarta la corrección. No hay conflicto, no hay aviso, no hay nada raro en los registros: simplemente el trabajo del segundo usuario desaparece.
El reloj híbrido, formalizado por Kulkarni, Demirbas y sus colaboradores en 2014, es un par: un valor l que arranca de la hora física y nunca retrocede, y un contador c que solo entra en juego cuando la hora física no ha avanzado lo suficiente para desempatar. Las marcas se comparan lexicográficamente, primero por l y luego por c, y ese orden total es compatible con la causalidad.
El algoritmo
Hay tres casos y ninguno tiene sutilezas ocultas, aunque el tercero es donde se cometen los errores.
function crearHibrido(ahora = () => Date.now()) {
return { l: 0, c: 0, ahora };
}
// Evento local o envio de mensaje
function sellar(r) {
const pt = r.ahora();
const anterior = r.l;
r.l = Math.max(anterior, pt);
r.c = r.l === anterior ? r.c + 1 : 0; // si el fisico no avanzo, desempata el contador
return { l: r.l, c: r.c };
}
// Recepcion de un mensaje sellado
function alRecibir(r, m) {
const pt = r.ahora();
const anterior = r.l;
r.l = Math.max(anterior, m.l, pt);
if (r.l === anterior && r.l === m.l) r.c = Math.max(r.c, m.c) + 1;
else if (r.l === anterior) r.c = r.c + 1;
else if (r.l === m.l) r.c = m.c + 1;
else r.c = 0; // el reloj fisico adelanto a ambos: el contador se reinicia
return { l: r.l, c: r.c };
}
function comparar(a, b) {
return a.l - b.l || a.c - b.c; // orden lexicografico
}
Conviene notar la forma general antes de perderse en los casos: en todos ellos l es el máximo de tres cantidades —tu valor actual, el valor que llega y tu hora física— y el contador solo se ocupa de romper empates dentro de ese máximo. Cuando el máximo lo aporta la hora física, no hay empate que romper y el contador se reinicia; cuando lo aporta un valor lógico que ya existía, el contador tiene que crecer por encima de todos los que compartían ese mismo l. Toda la lógica de los cuatro casos es esa frase escrita con condicionales.
El caso que conviene mirar dos veces es el último de la recepción. Cuando la hora física local supera tanto a tu valor como al del mensaje, el contador vuelve a cero, y esa es la razón por la que c no crece indefinidamente: cada vez que el tiempo real avanza de verdad, el desempate se resuelve solo. Bajo deriva acotada y retardos razonables, c permanece pequeño, y por eso el par entero cabe cómodamente en sesenta y cuatro bits repartidos entre milisegundos y contador.
Cuarenta y ocho bits de milisegundos alcanzan hasta bien entrado el siglo que viene y dejan dieciséis para el contador. Con eso el sello es un entero de sesenta y cuatro bits que se ordena con una comparación numérica y se indexa como cualquier clave. Y conviene añadir un tercer criterio de desempate, la identidad de la réplica, porque dos réplicas pueden producir el mismo par y el orden total tiene que ser el mismo en todos los nodos.
Qué garantiza y qué no
La garantía es la condición del reloj, en una sola dirección: si el evento a precede causalmente a b, entonces la marca híbrida de a es lexicográficamente menor que la de b. Eso significa que el orden que produce el reloj nunca contradice a la causalidad, y que puedes ordenar un registro de operaciones por marca sabiendo que ninguna causa aparecerá después de su efecto.
Lo que no da es la dirección contraria, y este es el punto donde más gente se equivoca. De que la marca de a sea menor que la de b no se sigue nada. Pueden ser causalmente dependientes o pueden ser completamente independientes, y el reloj híbrido no distingue los dos casos, exactamente igual que el de Lamport. El híbrido es un reloj escalar con un ancla física; no es un vector y no puede serlo, porque su tamaño es constante y detectar concurrencia entre n réplicas exige n dimensiones. Si tu sistema necesita saber que hubo un conflicto, necesita un vector, y ninguna cantidad de precisión horaria lo sustituye.
Vale la pena traducirlo a una regla operativa que se pueda aplicar sin pensar. Si tu código va a preguntar cuál de estas dos escrituras se aplica después, el híbrido responde. Si va a preguntar si estas dos escrituras se pisaron, el híbrido no responde y hay que ir a buscar la respuesta a otra estructura. Poner esa frontera por escrito en el módulo de sincronización evita que alguien, dentro de un año, use la comparación de sellos para tomar una decisión que la comparación de sellos no puede tomar.
La segunda propiedad interesante es de proximidad: bajo la hipótesis de que la deriva entre relojes físicos está acotada por una cantidad e, el valor l de cualquier réplica se mantiene dentro de esa misma cota respecto de la hora física real. La marca no es la hora, pero está cerca de ella y la diferencia está acotada, y eso es justo lo que hace falta para que consultas por rango de fechas y correlaciones con registros externos sean útiles.
flowchart TB P[hora fisica del dispositivo] --> L[valor l que nunca retrocede] M[marca recibida en un mensaje] --> L L --> S[sello con l y contador c] S --> O[orden total compatible con la causalidad] S --> F[cercania acotada a la hora real] O --> N[no detecta concurrencia] style S fill:#89b4fa,color:#11111b style N fill:#f38ba8,color:#11111b
Lo que asume sobre la deriva
Un apunte sobre el valor inicial que ahorra un rato de depuración: una réplica que arranca con el par en cero adopta la hora física en su primer sello, porque el máximo la incorpora de inmediato. No hace falta ninguna inicialización especial ni ningún acuerdo previo, y esa ausencia de arranque negociado es una de las razones por las que el híbrido encaja tan bien en un entorno donde las réplicas aparecen y desaparecen sin avisar.
Toda la utilidad del híbrido descansa sobre una hipótesis que conviene enunciar en voz alta: la deriva entre los relojes físicos de las réplicas está acotada. Si lo está, el contador se mantiene pequeño, la marca se mantiene cerca de la hora real y todo funciona. Si no lo está, la construcción no pierde su garantía causal —eso nunca lo pierde— pero sí pierde la propiedad de proximidad, que era la mitad de la razón para usarla.
Hay una segunda hipótesis menos evidente y es sobre el retardo de los mensajes. El argumento que acota el contador supone que un mensaje no tarda arbitrariamente en llegar; si tarda meses, como pasa cuando un dispositivo sincroniza después de un largo silencio, el sello que trae puede estar muy por detrás del valor local y el caso que se dispara es el trivial, así que no hay daño. El problema es el simétrico y lo cubre el párrafo siguiente.
El escenario que hay que temer es el dispositivo con la hora mal puesta muy hacia delante. Como l es un máximo y nunca retrocede, ese dispositivo produce una marca en el futuro lejano, la marca viaja en el primer mensaje que envía y todas las réplicas que la reciben adoptan ese valor para siempre. Un solo reloj estropeado arrastra al sistema entero a una fecha imposible, y a partir de ahí el contador c pasa a hacer todo el trabajo porque la hora física local ya nunca supera a l. El sistema sigue siendo correcto y deja de ser útil.
La defensa canónica es un umbral de desconfianza y se escribe en pocas líneas, pero exige una decisión de producto que no conviene tomar a la ligera: qué hacer con el mensaje sospechoso. Descartarlo protege el reloj y arriesga perder una escritura legítima de alguien cuyo dispositivo está mal configurado; aceptarlo con la marca recortada conserva el dato y sacrifica un poco de la propiedad de orden. La respuesta razonable en local-first suele ser la segunda, acompañada de un aviso, porque perder el dato de un usuario para proteger una propiedad de ordenación es un mal negocio.
const COTA_MS = 5 * 60 * 1000; // tolerancia de deriva declarada explicitamente
function alRecibirConDefensa(r, m, avisar) {
const pt = r.ahora();
if (m.l - pt > COTA_MS) {
avisar({ tipo: "sello_adelantado", exceso: m.l - pt });
return alRecibir(r, { l: pt, c: m.c }); // se acepta el dato, se recorta la marca
}
return alRecibir(r, m);
}
Rechaza el futuro lejano
Descarta o marca como sospechoso todo sello cuyo valor supere tu hora local por más de la cota que hayas fijado. Es la defensa estándar y la que aplican las bases distribuidas serias.
Vigila el contador
Un contador que crece de forma sostenida es el síntoma temprano de que la hipótesis de deriva se está incumpliendo. Publícalo como métrica antes de necesitarlo.
Reloj monótono para lo local
El reloj de pared del navegador puede retroceder si el usuario cambia la hora. Usa una fuente monótona para medir avance local y el de pared solo como ancla del sello.
Ancla al servidor cuando lo haya
Si tu sistema tiene un relé, la diferencia observada entre su hora y la local es una estimación barata de tu propia deriva y sirve para decidir si te fías de tu reloj.
La forma correcta de colocar el reloj híbrido en tu cabeza es entender que no resuelve el problema de la lección uno, sino uno distinto que se le parece. El vector clock existe para responder a una pregunta sobre información: quién sabía qué. El híbrido existe para responder a una pregunta sobre presentación y almacenamiento: en qué orden pongo esto y cómo lo relaciono con el calendario del usuario. Son necesidades ortogonales, y confundirlas produce las dos arquitecturas fallidas que se ven una y otra vez. La primera es el sistema que sella con hora de pared, resuelve conflictos quedándose con la marca mayor y jura que eso es suficiente; funciona en la demo porque los dos portátiles del equipo están sincronizados por la misma red, y pierde datos en producción en cuanto un móvil se adelanta cuatro minutos. La segunda es el sistema que descubre los relojes híbridos, lee que garantizan causalidad y concluye que puede tirar el vector; ese sistema es correcto en el orden y ciego en el conflicto, y su ceguera es silenciosa, que es la peor propiedad que puede tener un error. Lo que hay que interiorizar es que la causalidad, en el sentido fuerte que sirve para detectar concurrencia, no es información que el tiempo contenga. Dos eventos son concurrentes por lo que no se dijeron, y un reloj de tamaño constante no puede codificar lo que no se dijeron entre n participantes, por muy bien sincronizado que esté. El híbrido te da todo lo que un escalar puede dar y ni un bit más, y su valor real es que ese techo lo alcanza con marcas que además significan algo para un humano. Es una pieza excelente y su excelencia consiste en saber exactamente qué no hace.
- Implementa el híbrido completo con sus tres casos y una función de hora inyectable para poder mentirle en los tests.
- Escribe una simulación con deriva aleatoria acotada y comprueba que la condición del reloj se cumple en todos los pares causalmente relacionados.
- Adelanta el reloj de una réplica un año, propaga un mensaje y observa cómo el valor de todo el sistema queda anclado en el futuro.
- Implementa el rechazo de sellos demasiado adelantados y repite el experimento anterior midiendo el daño evitado.
- Empaqueta el sello en sesenta y cuatro bits con desempate por identidad y comprueba que el orden total resultante es idéntico en todas las réplicas.
- Demuestra con un contraejemplo de tres eventos que el híbrido no puede distinguir concurrencia de dependencia causal.