En un programa que corre en una sola máquina, la pregunta "¿qué pasó primero?" tiene siempre respuesta: basta con mirar el reloj o el orden de las instrucciones. En un sistema distribuido, esa pregunta se vuelve sorprendentemente difícil. Cada nodo tiene su propio reloj, esos relojes nunca coinciden exactamente y los mensajes tardan un tiempo variable en llegar. Cuando Ana, desde Barcelona, y Marc, desde Valencia, pulsan "comprar" sobre la última unidad de queso curado de Quesería Montblanc, decidir quién fue primero no es una cuestión de mirar dos marcas de tiempo.
Esta lección explica por qué no existe un reloj global, hasta qué punto podemos sincronizar los relojes físicos (NTP, PTP) y, sobre todo, presenta la solución que Leslie Lamport propuso en 1978 y que sigue siendo fundamental: sustituir el tiempo físico por un orden lógico basado en la causalidad. Implementaremos paso a paso los relojes de Lamport y los relojes vectoriales en Python, veremos cómo detectan eventos concurrentes y terminaremos con una visión de los relojes híbridos y de TrueTime, que combinan lo mejor de ambos mundos.
Contenido
- Por qué no hay un reloj global
- Relojes físicos: deriva y sincronización (NTP y PTP)
- El problema de ordenar eventos: Ana, Marc y el último queso
- La relación "sucedió antes" (happens-before)
- Relojes lógicos de Lamport
- Relojes vectoriales
- Relojes híbridos y TrueTime: una visión general
- Los relojes lógicos en la práctica
- Errores comunes y consejos
- Ejercicios
- Conclusión
- Por qué no hay un reloj global
Un "reloj global" sería un instante de tiempo compartido y exacto que todos los nodos pudieran consultar. No existe por tres razones que se acumulan:
- Cada nodo tiene su propio reloj de hardware, un oscilador de cuarzo cuya frecuencia depende de la temperatura, la antigüedad y la fabricación. Dos relojes idénticos, puestos en hora a la vez, divergen inevitablemente.
- Consultar el reloj de otro nodo lleva tiempo, y ese tiempo es variable y desconocido. Si preguntas "¿qué hora es?" y la respuesta tarda 30 ms en llegar, ¿la hora que te han dado es de hace 15 ms? ¿De hace 5? ¿De hace 25? No lo sabes.
- Los nodos se pausan sin saberlo. Un proceso puede detenerse durante decenas o cientos de milisegundos por una recolección de basura, por una interrupción de la máquina virtual o por sobrecarga de CPU. Cuando reanuda, cree que "ahora" es el instante en que se detuvo.
La consecuencia práctica: dos marcas de tiempo tomadas en máquinas distintas no se pueden comparar con precisión mejor que el error de sincronización entre ellas. Si ese error es de 10 ms y las marcas difieren en 2 ms, el orden real es indeterminado.
- Relojes físicos: deriva y sincronización (NTP y PTP)
Deriva
La deriva (drift) es la velocidad a la que un reloj se desvía del tiempo real. Un cuarzo típico de servidor tiene una deriva de unas 50 partes por millón (ppm): se desvía 50 microsegundos por cada segundo, es decir, unos 4,3 segundos al día. Parece poco, pero para un sistema que procesa cientos de pedidos por segundo, 4 segundos es una eternidad. Por eso los relojes se sincronizan periódicamente con una fuente de referencia.
NTP (Network Time Protocol)
NTP es el protocolo que usan casi todos los sistemas operativos para poner en hora sus relojes a través de Internet. Funciona en una jerarquía de "estratos": los servidores de estrato 0 son relojes atómicos o receptores GPS; los de estrato 1 se sincronizan directamente con ellos; los de estrato 2 con los de estrato 1, etc. Un cliente NTP envía una petición, anota cuándo la envió y cuándo recibió la respuesta, y usa las marcas de tiempo del servidor para estimar el desfase, asumiendo que el viaje de ida tarda lo mismo que el de vuelta. Esa suposición es la fuente principal de error.
| Entorno | Precisión típica de NTP |
|---|---|
| Internet pública | 5 a 100 ms |
| Red local bien configurada | 0,5 a 5 ms |
| Con servidor GPS local | 0,1 a 1 ms |
Además, NTP ajusta el reloj de dos formas: deslizando (slew), acelerando o frenando ligeramente el reloj hasta corregir el desfase, o saltando (step), si el desfase es grande. Un salto hacia atrás significa que el reloj puede marcar dos veces el mismo instante, o que una medida de duración (fin - inicio) puede salir negativa. Los sistemas operativos ofrecen por ello un reloj monotónico (time.monotonic() en Python), que nunca retrocede y sirve para medir duraciones, frente al reloj de pared (time.time()), que sirve para saber la fecha y hora pero puede saltar.
PTP (Precision Time Protocol)
PTP (IEEE 1588) alcanza precisiones de microsegundos o menos gracias a que las marcas de tiempo las ponen las propias tarjetas de red y los conmutadores por hardware, eliminando la variabilidad del software. Requiere hardware compatible en toda la ruta, por lo que se usa en centros de datos, en finanzas de alta frecuencia y en telecomunicaciones, no en Internet abierta.
Incluso con PTP, los relojes tienen un error. Y ese error, por pequeño que sea, es suficiente para que dos eventos "casi simultáneos" no puedan ordenarse con certeza. Necesitamos otra idea.
- El problema de ordenar eventos: Ana, Marc y el último queso
Supongamos que Kilómetro Cero ha replicado el servicio inventario en dos ciudades para reducir la latencia: una réplica en Barcelona (inv-bcn) y otra en Valencia (inv-vlc). Queda una unidad de queso curado de Quesería Montblanc. Ana compra desde Barcelona y Marc desde Valencia, casi a la vez.
| Evento | Nodo | Hora según el reloj local |
|---|---|---|
| Ana pulsa "comprar" | inv-bcn |
10:00:00.120 |
| Marc pulsa "comprar" | inv-vlc |
10:00:00.118 |
Si nos fiamos de los relojes, Marc fue 2 ms antes. Pero el reloj de inv-vlc está sincronizado por NTP con un error estimado de ±15 ms. Así que la "verdadera" hora de Marc está entre 10:00:00.103 y 10:00:00.133: puede haber sido antes o después que Ana. Los relojes físicos no pueden decidir.
Y ahora la pregunta clave: ¿importa realmente "quién fue primero" en tiempo físico? Lo que el sistema necesita es que ambas réplicas tomen la misma decisión (una de las dos compras gana, la otra recibe "sin stock") y que esa decisión respete las relaciones de causa y efecto que sí conocemos. Por ejemplo, si Ana consultó el stock, vio "1 unidad" y luego compró, su compra sucedió después de su consulta, y eso sí es un hecho.
Esta es la idea de Lamport: renunciar al tiempo físico y quedarnos solo con lo que podemos saber con certeza, la causalidad.
- La relación "sucedió antes" (happens-before)
Lamport definió la relación "sucedió antes" (que se escribe a → b, "a sucedió antes que b") a partir de solo tres reglas:
- Mismo proceso. Si
aybson eventos del mismo proceso yaocurre antes queben su ejecución, entoncesa → b. - Envío y recepción. Si
aes el envío de un mensaje ybes la recepción de ese mismo mensaje, entoncesa → b. - Transitividad. Si
a → byb → c, entoncesa → c.
Y una definición fundamental: si ni a → b ni b → a, entonces a y b son concurrentes (a ∥ b). Concurrente no significa "al mismo tiempo": significa que ninguno pudo haber influido en el otro, porque no hay ninguna cadena de mensajes que los conecte. Por eso, para el sistema, su orden es indiferente: cualquier orden es igual de válido, siempre que todos los nodos elijan el mismo.
sequenceDiagram
participant A as inv-bcn (Ana)
participant Q as Quesería Montblanc
participant M as inv-vlc (Marc)
Q->>A: a1: stock = 1 (mensaje)
Q->>M: m1: stock = 1 (mensaje)
Note over A: a2: Ana consulta stock
Note over M: m2: Marc consulta stock
Note over A: a3: Ana compra
Note over M: m3: Marc compra
A->>M: a4: "he vendido la unidad"
Note over M: m4: recibe aviso de Barcelona
En este diagrama: a2 → a3 (mismo proceso), a3 → a4 → m4 (envío y recepción, más transitividad). Pero a3 y m3 (las dos compras) son concurrentes: no hay ninguna cadena de mensajes entre ellas. El sistema no puede saber cuál fue "realmente" primero, y tampoco lo necesita: lo que necesita es una regla determinista para desempatar.
- Relojes lógicos de Lamport
Un reloj lógico de Lamport es un contador entero por proceso que asigna a cada evento una marca L(e) de forma que se cumple la condición de reloj: si a → b, entonces L(a) < L(b). El algoritmo tiene tres reglas, en paralelo a las tres de happens-before:
- Antes de cada evento local, el proceso incrementa su contador:
L = L + 1. - Al enviar un mensaje, el proceso incrementa su contador y adjunta el valor al mensaje.
- Al recibir un mensaje con marca
Lm, el proceso haceL = max(L, Lm) + 1.
La regla 3 es la clave: garantiza que la recepción siempre tiene una marca mayor que el envío, y que a partir de ese momento el receptor "sabe" que existe al menos ese tiempo lógico.
Implementación en Python
from dataclasses import dataclass, field
@dataclass
class Mensaje:
origen: str
contenido: str
marca: int # el reloj de Lamport del emisor en el momento del envío
@dataclass
class ProcesoLamport:
"""Un nodo con su reloj lógico de Lamport y un registro de eventos."""
nombre: str
reloj: int = 0
registro: list = field(default_factory=list)
def _anotar(self, descripcion: str) -> None:
self.registro.append((self.reloj, self.nombre, descripcion))
print(f" [{self.nombre} L={self.reloj:>2}] {descripcion}")
def evento_local(self, descripcion: str) -> None:
# Regla 1: incrementar antes del evento
self.reloj += 1
self._anotar(descripcion)
def enviar(self, contenido: str) -> Mensaje:
# Regla 2: incrementar y adjuntar la marca al mensaje
self.reloj += 1
self._anotar(f"envía '{contenido}'")
return Mensaje(self.nombre, contenido, self.reloj)
def recibir(self, msg: Mensaje) -> None:
# Regla 3: adelantar el reloj si el emisor iba por delante, y avanzar uno
self.reloj = max(self.reloj, msg.marca) + 1
self._anotar(f"recibe '{msg.contenido}' de {msg.origen} (marca {msg.marca})")
if __name__ == "__main__":
bcn = ProcesoLamport("inv-bcn")
vlc = ProcesoLamport("inv-vlc")
queseria = ProcesoLamport("queseria")
print("Quesería Montblanc publica el stock:")
m_bcn = queseria.enviar("stock queso curado = 1")
m_vlc = queseria.enviar("stock queso curado = 1")
bcn.recibir(m_bcn)
vlc.recibir(m_vlc)
print("\nAna (Barcelona) y Marc (Valencia) actúan de forma concurrente:")
bcn.evento_local("Ana consulta stock: ve 1 unidad")
bcn.evento_local("Ana compra la unidad")
vlc.evento_local("Marc consulta stock: ve 1 unidad")
vlc.evento_local("Marc compra la unidad")
print("\nBarcelona avisa a Valencia de la venta:")
aviso = bcn.enviar("vendida unidad de queso curado")
vlc.recibir(aviso)
vlc.evento_local("detecta conflicto con la compra de Marc")
print("\nOrden total de eventos (marca, nombre de proceso):")
todos = sorted(bcn.registro + vlc.registro + queseria.registro)
for marca, nombre, desc in todos:
print(f" {marca:>2} {nombre:<9} {desc}")La salida:
Quesería Montblanc publica el stock: [queseria L= 1] envía 'stock queso curado = 1' [queseria L= 2] envía 'stock queso curado = 1' [inv-bcn L= 2] recibe 'stock queso curado = 1' de queseria (marca 1) [inv-vlc L= 3] recibe 'stock queso curado = 1' de queseria (marca 2) Ana (Barcelona) y Marc (Valencia) actúan de forma concurrente: [inv-bcn L= 3] Ana consulta stock: ve 1 unidad [inv-bcn L= 4] Ana compra la unidad [inv-vlc L= 4] Marc consulta stock: ve 1 unidad [inv-vlc L= 5] Marc compra la unidad Barcelona avisa a Valencia de la venta: [inv-bcn L= 5] envía 'vendida unidad de queso curado' [inv-vlc L= 6] recibe 'vendida unidad de queso curado' de inv-bcn (marca 5) [inv-vlc L= 7] detecta conflicto con la compra de Marc Orden total de eventos (marca, nombre de proceso): 1 queseria envía 'stock queso curado = 1' 2 inv-bcn recibe 'stock queso curado = 1' de queseria (marca 1) 2 queseria envía 'stock queso curado = 1' 3 inv-bcn Ana consulta stock: ve 1 unidad 3 inv-vlc recibe 'stock queso curado = 1' de queseria (marca 2) 4 inv-bcn Ana compra la unidad 4 inv-vlc Marc consulta stock: ve 1 unidad 5 inv-bcn envía 'vendida unidad de queso curado' 5 inv-vlc Marc compra la unidad 6 inv-vlc recibe 'vendida unidad de queso curado' de inv-bcn (marca 5) 7 inv-vlc detecta conflicto con la compra de Marc
El mismo escenario, dibujado como diagrama de secuencia con la marca de Lamport de cada evento:
sequenceDiagram
participant Q as queseria
participant A as inv-bcn
participant M as inv-vlc
Note over Q: L=1 envía stock=1
Q->>A: marca 1
Note over Q: L=2 envía stock=1
Q->>M: marca 2
Note over A: L=2 recibe (max(0,1)+1)
Note over M: L=3 recibe (max(0,2)+1)
Note over A: L=3 Ana consulta
Note over A: L=4 Ana compra
Note over M: L=4 Marc consulta
Note over M: L=5 Marc compra
Note over A: L=5 envía "vendida"
A->>M: marca 5
Note over M: L=6 recibe (max(5,5)+1)
Note over M: L=7 detecta conflicto
Observaciones importantes:
- La condición de reloj se cumple. Cada recepción tiene una marca mayor que su envío (1 → 2, 2 → 3, 5 → 6), y dentro de cada proceso las marcas crecen. Toda cadena causal tiene marcas crecientes.
- Hay empates. "Ana compra" (
inv-bcn, 4) y "Marc consulta" (inv-vlc, 4) tienen la misma marca. Lamport resuelve los empates con un criterio arbitrario pero determinista: si las marcas coinciden, ordena por el nombre (o identificador) del proceso. Es lo que hacesorted(...)con las tuplas(marca, nombre, descripcion). Así, todos los nodos que apliquen la misma regla obtendrán el mismo orden total. - El orden total no es "el orden real", y no importa. En el orden final, "Ana compra" (4) precede a "Marc compra" (5). ¿Fue así en el tiempo físico? No lo sabemos ni lo podemos saber. Pero es un orden consistente con toda la causalidad conocida y es el mismo para todos, que es exactamente lo que necesitábamos para decidir quién se lleva el queso.
- La limitación de Lamport. Si
a → b, entoncesL(a) < L(b). Pero el recíproco es falso: deL(a) < L(b)no se deducea → b. "Ana compra" (4) tiene una marca menor que "Marc compra" (5), pero son eventos concurrentes: no hay ninguna relación causal entre ellos. Mirando solo las marcas de Lamport no podemos distinguir "sucedió antes" de "concurrente". Para eso hacen falta los relojes vectoriales.
- Relojes vectoriales
Un reloj vectorial sustituye el entero único por un vector con un contador por proceso. Si hay tres procesos, cada evento lleva una marca como {inv-bcn: 3, inv-vlc: 1, queseria: 2}, que se lee como "este evento conoce hasta el evento 3 de Barcelona, el 1 de Valencia y el 2 de la quesería". Las reglas:
- Antes de cada evento local, el proceso
iincrementa su propia componente:V[i] = V[i] + 1. - Al enviar, incrementa su componente y adjunta el vector completo al mensaje.
- Al recibir un mensaje con vector
Vm, haceV[k] = max(V[k], Vm[k])para cada componentek, y después incrementa su propia componente.
Y la comparación, que es lo que ganamos:
V(a) ≤ V(b)si todas las componentes deV(a)son menores o iguales que las deV(b).a → bsi y solo siV(a) ≤ V(b)yV(a) ≠ V(b).a ∥ b(concurrentes) si niV(a) ≤ V(b)niV(b) ≤ V(a): es decir, cada uno tiene alguna componente mayor que el otro.
Ahora el recíproco sí se cumple: comparando vectores podemos saber con certeza si dos eventos están causalmente relacionados o son concurrentes.
Implementación en Python
from dataclasses import dataclass, field
@dataclass(frozen=True)
class MarcaVectorial:
"""Un vector de contadores, inmutable, con las operaciones de comparación."""
valores: tuple # una tupla de enteros, uno por proceso, en orden fijo
procesos: tuple # nombres de los procesos, en el mismo orden
def __le__(self, otra: "MarcaVectorial") -> bool:
return all(a <= b for a, b in zip(self.valores, otra.valores))
def sucedio_antes(self, otra: "MarcaVectorial") -> bool:
return self <= otra and self.valores != otra.valores
def concurrente(self, otra: "MarcaVectorial") -> bool:
return not (self <= otra) and not (otra <= self)
def __str__(self) -> str:
return "{" + ", ".join(f"{p}:{v}" for p, v in zip(self.procesos, self.valores)) + "}"
@dataclass
class MensajeV:
origen: str
contenido: str
marca: MarcaVectorial
class ProcesoVectorial:
"""Un nodo con reloj vectorial. Todos los procesos deben conocer la lista completa."""
def __init__(self, nombre: str, procesos: list[str]):
self.nombre = nombre
self.procesos = tuple(procesos)
self.indice = procesos.index(nombre) # mi posición en el vector
self.vector = [0] * len(procesos)
self.eventos: dict[str, MarcaVectorial] = {} # etiqueta -> marca del evento
def _marca_actual(self) -> MarcaVectorial:
return MarcaVectorial(tuple(self.vector), self.procesos)
def _anotar(self, etiqueta: str, descripcion: str) -> MarcaVectorial:
marca = self._marca_actual()
self.eventos[etiqueta] = marca
print(f" [{self.nombre} {marca}] {etiqueta}: {descripcion}")
return marca
def evento_local(self, etiqueta: str, descripcion: str) -> None:
self.vector[self.indice] += 1 # regla 1
self._anotar(etiqueta, descripcion)
def enviar(self, etiqueta: str, contenido: str) -> MensajeV:
self.vector[self.indice] += 1 # regla 2
marca = self._anotar(etiqueta, f"envía '{contenido}'")
return MensajeV(self.nombre, contenido, marca)
def recibir(self, etiqueta: str, msg: MensajeV) -> None:
# regla 3: máximo componente a componente, y luego avanzar el propio
self.vector = [max(mio, suyo) for mio, suyo in zip(self.vector, msg.marca.valores)]
self.vector[self.indice] += 1
self._anotar(etiqueta, f"recibe '{msg.contenido}' de {msg.origen}")
if __name__ == "__main__":
nombres = ["inv-bcn", "inv-vlc", "queseria"]
bcn = ProcesoVectorial("inv-bcn", nombres)
vlc = ProcesoVectorial("inv-vlc", nombres)
queseria = ProcesoVectorial("queseria", nombres)
print("Quesería Montblanc publica el stock:")
m1 = queseria.enviar("q1", "stock queso curado = 1")
m2 = queseria.enviar("q2", "stock queso curado = 1")
bcn.recibir("a1", m1)
vlc.recibir("m1", m2)
print("\nAna y Marc actúan de forma concurrente:")
bcn.evento_local("a2", "Ana consulta stock: ve 1 unidad")
bcn.evento_local("a3", "Ana compra la unidad")
vlc.evento_local("m2", "Marc consulta stock: ve 1 unidad")
vlc.evento_local("m3", "Marc compra la unidad")
print("\nBarcelona avisa a Valencia:")
aviso = bcn.enviar("a4", "vendida unidad de queso curado")
vlc.recibir("m4", aviso)
print("\nRelaciones causales:")
eventos = {**bcn.eventos, **vlc.eventos, **queseria.eventos}
for x, y in [("a2", "a3"), ("a3", "m4"), ("a3", "m3"), ("m3", "a3"), ("q1", "m3")]:
vx, vy = eventos[x], eventos[y]
if vx.sucedio_antes(vy):
relacion = f"{x} -> {y} (sucedió antes)"
elif vy.sucedio_antes(vx):
relacion = f"{y} -> {x} (sucedió antes)"
else:
relacion = f"{x} || {y} (CONCURRENTES)"
print(f" {x}={vx} {y}={vy} => {relacion}")La salida:
Quesería Montblanc publica el stock:
[queseria {inv-bcn:0, inv-vlc:0, queseria:1}] q1: envía 'stock queso curado = 1'
[queseria {inv-bcn:0, inv-vlc:0, queseria:2}] q2: envía 'stock queso curado = 1'
[inv-bcn {inv-bcn:1, inv-vlc:0, queseria:1}] a1: recibe 'stock queso curado = 1' de queseria
[inv-vlc {inv-bcn:0, inv-vlc:1, queseria:2}] m1: recibe 'stock queso curado = 1' de queseria
Ana y Marc actúan de forma concurrente:
[inv-bcn {inv-bcn:2, inv-vlc:0, queseria:1}] a2: Ana consulta stock: ve 1 unidad
[inv-bcn {inv-bcn:3, inv-vlc:0, queseria:1}] a3: Ana compra la unidad
[inv-vlc {inv-bcn:0, inv-vlc:2, queseria:2}] m2: Marc consulta stock: ve 1 unidad
[inv-vlc {inv-bcn:0, inv-vlc:3, queseria:2}] m3: Marc compra la unidad
Barcelona avisa a Valencia:
[inv-bcn {inv-bcn:4, inv-vlc:0, queseria:1}] a4: envía 'vendida unidad de queso curado'
[inv-vlc {inv-bcn:4, inv-vlc:4, queseria:2}] m4: recibe 'vendida unidad de queso curado' de inv-bcn
Relaciones causales:
a2={inv-bcn:2, inv-vlc:0, queseria:1} a3={inv-bcn:3, inv-vlc:0, queseria:1} => a2 -> a3 (sucedió antes)
a3={inv-bcn:3, inv-vlc:0, queseria:1} m4={inv-bcn:4, inv-vlc:4, queseria:2} => a3 -> m4 (sucedió antes)
a3={inv-bcn:3, inv-vlc:0, queseria:1} m3={inv-bcn:0, inv-vlc:3, queseria:2} => a3 || m3 (CONCURRENTES)
m3={inv-bcn:0, inv-vlc:3, queseria:2} a3={inv-bcn:3, inv-vlc:0, queseria:1} => m3 || a3 (CONCURRENTES)
q1={inv-bcn:0, inv-vlc:0, queseria:1} m3={inv-bcn:0, inv-vlc:3, queseria:2} => q1 -> m3 (sucedió antes)Analicemos lo que hemos ganado:
- Detectamos la concurrencia.
a3(Ana compra) tieneinv-bcn:3mayor quem3, perom3(Marc compra) tieneinv-vlc:3mayor quea3. Cada uno "sabe algo" que el otro no sabe: son concurrentes, y el sistema puede detectarlo mecánicamente. Con Lamport, esto era imposible. - Confirmamos la causalidad.
a3 → m4: cuando Valencia recibe el aviso de Barcelona, su vector absorbe el de Barcelona (inv-bcn:4), y a partir de ahí todo lo que ocurra en Valencia "sabe" de la compra de Ana. El vector dem4domina al dea3en todas las componentes. - Un detalle sutil:
q1 → m3. La quesería envióq1a Barcelona, no a Valencia, así que ¿cómo es quem3"sabe" deq1? Porqueq2sucedió después deq1en la quesería (misma componente: 2 > 1), ym1recibióq2. La transitividad se propaga sola a través del vector. - El precio. Cada marca ocupa tantos enteros como procesos hay. Con 3 procesos es trivial; con 1.000 réplicas o con millones de clientes, no. En la práctica se usan vectores con solo los nodos que escriben (no los clientes), o variantes compactadas (dotted version vectors), o se acepta la pérdida de información de Lamport cuando no hace falta detectar concurrencia.
- Relojes híbridos y TrueTime: una visión general
Los relojes lógicos resuelven la ordenación causal, pero pierden algo valioso: la relación con el tiempo real. Una marca de Lamport 4732 no dice nada de si el evento ocurrió esta mañana o el año pasado, y hay muchos usos (auditoría, caducidades, "dame los pedidos de la última hora") que necesitan tiempo físico. Dos familias de soluciones combinan ambos mundos:
- Relojes lógicos híbridos (HLC, Hybrid Logical Clocks, 2014). Una marca HLC tiene dos partes: el tiempo físico (según NTP) y un contador lógico. Se comporta como un reloj de Lamport (respeta la causalidad: si
a → b,HLC(a) < HLC(b)), pero su parte física se mantiene siempre cerca del tiempo real (con un error acotado por el de NTP). El contador lógico solo interviene para desempatar eventos que el tiempo físico no puede ordenar. Los usan bases de datos distribuidas como CockroachDB o MongoDB. - TrueTime (Google Spanner, 2012). En lugar de dar un instante, la API de TrueTime devuelve un intervalo
[más_temprano, más_tarde]que con certeza contiene el instante real, gracias a relojes atómicos y GPS en cada centro de datos, que mantienen el intervalo en unos pocos milisegundos. Spanner asigna a cada transacción una marca de tiempo y, antes de confirmarla, espera a que el intervalo de incertidumbre haya pasado por completo (commit wait). Así garantiza que si la transacción A terminó antes de que empezara la B en tiempo real, entonces la marca de A es menor que la de B, en cualquier parte del mundo. Es una solución cara (hardware especializado) pero conceptualmente elegante: convierte la incertidumbre del reloj en una espera explícita y acotada.
| Mecanismo | Ordena por causalidad | Detecta concurrencia | Relación con tiempo real | Tamaño de la marca | Requisitos |
|---|---|---|---|---|---|
| Reloj físico (NTP) | No (con errores) | No | Sí (±ms) | 1 entero | Ninguno |
| Lamport | Sí | No | Ninguna | 1 entero | Ninguno |
| Vectorial | Sí | Sí | Ninguna | N enteros | Conocer los N procesos |
| HLC | Sí | No | Sí (±error NTP) | 2 enteros | NTP |
| TrueTime | Sí (con espera) | Implícita | Sí (intervalo garantizado) | Intervalo | Relojes atómicos/GPS |
- Los relojes lógicos en la práctica
¿Dónde aparecen estos mecanismos en sistemas reales, y en Kilómetro Cero?
- Ordenación de mensajes y eventos. Cuando el servicio
repartorecibe posiciones de los repartidores por 4G, pueden llegar desordenadas. Si cada posición lleva un contador por repartidor (un reloj de Lamport de un solo proceso),repartopuede descartar posiciones antiguas que llegan tarde. Los sistemas de mensajería (lección 02-04) ofrecen garantías de orden basadas en la misma idea de números de secuencia. - Detección de conflictos en replicación. Cuando dos réplicas de
inventario(o dos copias del carrito de Ana, una en su móvil y otra en el servidor) se modifican de forma concurrente, los vectores de versión permiten detectar que hay un conflicto real (dos escrituras concurrentes) frente a una simple actualización (una escritura que sucedió después de la otra). El sistema puede entonces resolver el conflicto (con una regla de negocio, pidiéndoselo al usuario, o quedándose con ambas versiones). Es el mecanismo que popularizó Amazon Dynamo y que estudiaremos en la lección 03-04. - Instantáneas consistentes. Para que
analiticacalcule "el stock total a las 12:00" sobre datos repartidos en muchas réplicas sin parar el sistema, hace falta saber qué eventos incluir. Los relojes lógicos permiten definir cortes consistentes (el algoritmo de Chandy-Lamport) que no incluyan un efecto sin su causa. - Marcas de tiempo de transacciones. Las bases de datos distribuidas asignan marcas de tiempo (HLC, TrueTime) a las transacciones para decidir qué versión de un dato ve cada lectura. Aparecerá en el Módulo 3 y en la lección 04-04.
La regla práctica que se deriva de todo esto: nunca uses marcas de tiempo físicas tomadas en máquinas distintas para decidir el orden de operaciones que afectan a la coherencia de los datos. Úsalas para lo que sirven (fechas para humanos, caducidades, métricas) y usa contadores o vectores para la ordenación.
Errores Comunes y Consejos
- Usar
time.time()para medir duraciones. El reloj de pared puede saltar hacia atrás por un ajuste de NTP y dar duraciones negativas o absurdas. Para medir cuánto tarda algo, usa siempretime.monotonic(). - Ordenar eventos de distintos nodos por su marca de tiempo física. Es el error de "último en escribir gana" (last-writer-wins) basado en relojes: con relojes desincronizados, una escritura más antigua puede "ganar" a una más reciente y perder datos silenciosamente. Si se usa esa estrategia, hay que ser consciente de que puede perder escrituras.
- Creer que Lamport detecta concurrencia. De
L(a) < L(b)no se deduce nada sobre la relación causal entreayb. Si necesitas saber si dos eventos son concurrentes, necesitas vectores. - Olvidar el desempate determinista. Un orden total de Lamport exige una regla para los empates (habitualmente el identificador de proceso). Sin ella, dos nodos pueden ordenar de forma distinta dos eventos con la misma marca.
- Vectores que crecen sin control. Si cada cliente que escribe añade una componente al vector, el vector crece sin límite. Hay que decidir quién "cuenta" como proceso (normalmente las réplicas, no los clientes) y podar componentes antiguas.
- Consejo: en cada mensaje o registro que atraviese la red, incluye siempre dos cosas: una marca de tiempo física (para los humanos y para las métricas) y un número de secuencia o vector (para la ordenación). Cuestan pocos bytes y ahorran horas de depuración.
Ejercicios
Ejercicio 1: Trazar relojes de Lamport a mano
Tres procesos, pedidos, pagos y analitica, empiezan con reloj 0. Ocurren, en este orden de ejecución, los eventos siguientes:
pedidosevento local "crea pedido de Lucía".pedidosenvía "cobra 14,50 €" apagos.analiticaevento local "inicia informe".pagosrecibe el mensaje depedidos.pagosevento local "cobro aceptado".pagosenvía "cobro OK" apedidosy también aanalitica(dos envíos consecutivos).analiticarecibe "cobro OK".pedidosrecibe "cobro OK".
Calcula la marca de Lamport de cada evento e indica dos eventos que sean concurrentes aunque sus marcas sean distintas.
Ejercicio 2: Detectar conflictos en el carrito
Ana modifica su carrito desde el móvil (proceso movil) y desde el navegador (proceso web), y ambos se sincronizan con el servidor (proceso servidor). Usando la clase ProcesoVectorial, simula:
servidorenvía el carrito inicial amovily aweb(dos envíos).movilrecibe, y añade "queso fresco" (evento local).webrecibe, y añade "vino tinto" (evento local).movilenvía su carrito alservidor; elservidorlo recibe.webenvía su carrito alservidor; elservidorlo recibe.
Comprueba con concurrente() si las dos modificaciones son concurrentes y explica qué debería hacer el servidor. Después, cambia el orden para que web reciba el carrito del servidor después de que este haya integrado el de movil, y comprueba que ahora la modificación de web sucede después de la de movil.
Ejercicio 3: Reloj de Lamport para posiciones de repartidores
El servicio reparto recibe posiciones de un repartidor por una red que las desordena. Escribe una clase SeguimientoRepartidor con un método recibir_posicion(secuencia, lat, lon) que solo actualice la posición actual si secuencia es mayor que la última aplicada, y que cuente cuántas posiciones ha descartado por antiguas. Simula la llegada de las secuencias [1, 2, 4, 3, 5, 7, 6, 8] y muestra la posición final y el número de descartes. ¿Qué información se pierde con esta estrategia y cuándo sería aceptable?
Soluciones
Solución 1:
| Paso | Proceso | Evento | Cálculo | Marca |
|---|---|---|---|---|
| 1 | pedidos | crea pedido | 0 + 1 | 1 |
| 2 | pedidos | envía "cobra" | 1 + 1 | 2 |
| 3 | analitica | inicia informe | 0 + 1 | 1 |
| 4 | pagos | recibe "cobra" (marca 2) | max(0, 2) + 1 | 3 |
| 5 | pagos | cobro aceptado | 3 + 1 | 4 |
| 6a | pagos | envía "cobro OK" a pedidos | 4 + 1 | 5 |
| 6b | pagos | envía "cobro OK" a analitica | 5 + 1 | 6 |
| 7 | analitica | recibe "cobro OK" (marca 6) | max(1, 6) + 1 | 7 |
| 8 | pedidos | recibe "cobro OK" (marca 5) | max(2, 5) + 1 | 6 |
Eventos concurrentes con marcas distintas: "inicia informe" (analitica, 1) y "cobro aceptado" (pagos, 4). No hay ninguna cadena de mensajes entre ellos (analitica no había recibido nada todavía), así que son concurrentes, aunque 1 < 4. Otro par: "crea pedido" (1) e "inicia informe" (1), con marcas iguales y también concurrentes. Fíjate en que "inicia informe" (1) y "recibe cobro OK" en pedidos (6) también son concurrentes: el 6 de pedidos desciende de pagos, no de analitica.
Solución 2:
nombres = ["servidor", "movil", "web"]
servidor = ProcesoVectorial("servidor", nombres)
movil = ProcesoVectorial("movil", nombres)
web = ProcesoVectorial("web", nombres)
c1 = servidor.enviar("s1", "carrito inicial")
c2 = servidor.enviar("s2", "carrito inicial")
movil.recibir("mv1", c1)
movil.evento_local("mv2", "añade queso fresco")
web.recibir("w1", c2)
web.evento_local("w2", "añade vino tinto")
servidor.recibir("s3", movil.enviar("mv3", "carrito con queso"))
servidor.recibir("s4", web.enviar("w3", "carrito con vino"))
mv2, w2 = movil.eventos["mv2"], web.eventos["w2"]
print(mv2, w2, "concurrentes:", mv2.concurrente(w2))El resultado es {servidor:1, movil:2, web:0} frente a {servidor:2, movil:0, web:2}: concurrentes. Ninguna modificación sabía de la otra. El servidor no debe quedarse con la última que llegó (perdería el queso o el vino): debe fusionar ambas (el carrito con queso y vino) o, si la fusión no es obvia (por ejemplo, ambas cambiaron la cantidad del mismo producto), preguntar a Ana.
En la variante secuencial (el servidor integra primero el carrito de movil y después envía el carrito actualizado a web, que entonces añade el vino), la marca de w2 será algo como {servidor:3, movil:3, web:2}, que domina a mv2: mv2.sucedio_antes(w2) es True. No hay conflicto: la modificación de web ya conocía la de movil, y el servidor puede aplicarla sin más.
Solución 3:
class SeguimientoRepartidor:
def __init__(self, nombre: str):
self.nombre = nombre
self.ultima_secuencia = 0
self.posicion = None
self.descartadas = 0
def recibir_posicion(self, secuencia: int, lat: float, lon: float) -> None:
if secuencia <= self.ultima_secuencia:
self.descartadas += 1
print(f" descartada seq {secuencia} (ya aplicada la {self.ultima_secuencia})")
return
self.ultima_secuencia = secuencia
self.posicion = (lat, lon)
print(f" aplicada seq {secuencia}: {self.posicion}")
seguimiento = SeguimientoRepartidor("furgoneta-3")
llegadas = [1, 2, 4, 3, 5, 7, 6, 8]
for seq in llegadas:
seguimiento.recibir_posicion(seq, round(41.38 + seq * 0.001, 3), round(2.17 + seq * 0.001, 3))
print(f"Posición final: {seguimiento.posicion}, descartadas: {seguimiento.descartadas}")La posición final es la de la secuencia 8 y se descartan 2 posiciones (la 3 y la 6). Se pierde el recorrido completo: si analitica quisiera reconstruir la ruta exacta, le faltarían dos puntos. La estrategia es aceptable cuando solo importa la posición actual (mostrar el repartidor en el mapa), que es el caso del seguimiento en tiempo real; si hace falta el histórico, habría que guardar todas las posiciones y ordenarlas por secuencia después, en lugar de descartar.
Conclusión
No existe un reloj global: cada nodo tiene el suyo, con deriva propia, y sincronizarlos (NTP con precisión de milisegundos, PTP de microsegundos) reduce el error pero nunca lo elimina. Por eso el orden de dos eventos ocurridos en máquinas distintas no puede decidirse comparando sus marcas de tiempo físicas, como muestra el caso de Ana y Marc comprando el último queso.
La salida de Lamport fue cambiar la pregunta: en lugar de "¿qué ocurrió antes en el tiempo?", preguntar "¿qué pudo haber causado qué?". La relación sucedió antes captura exactamente esa causalidad, los relojes de Lamport la convierten en un orden total que todos los nodos comparten (a costa de no distinguir la concurrencia), y los relojes vectoriales añaden la capacidad de detectar cuándo dos eventos son concurrentes, que es la base de la detección de conflictos en replicación. Los relojes híbridos y TrueTime reconcilian la causalidad con el tiempo real para los sistemas que necesitan ambos.
Con esto cerramos los fundamentos conceptuales del módulo. La siguiente lección, Del Monolito a la Plataforma Distribuida: el Caso Kilómetro Cero, recoge todo lo visto —fallos parciales, modelos, ventajas y costes, falacias, tiempo— y lo aplica a un diseño concreto: la arquitectura objetivo que construiremos durante el resto del curso.
Curso de Arquitecturas Distribuidas
Módulo 1: Introducción a los Sistemas Distribuidos
- Conceptos Básicos de Sistemas Distribuidos
- Modelos de Sistemas Distribuidos
- Ventajas y Desafíos de los Sistemas Distribuidos
- Las Falacias de la Computación Distribuida
- Tiempo, Relojes y Ordenación de Eventos
- Del Monolito a la Plataforma Distribuida: el Caso Kilómetro Cero
Módulo 2: Comunicación en Sistemas Distribuidos
- Protocolos de Comunicación
- RPC y RMI
- gRPC y Serialización de Datos
- Mensajería y Colas de Mensajes
- Patrones de Comunicación Asíncrona
Módulo 3: Consistencia y Replicación
- Modelos de Consistencia
- El Teorema CAP y PACELC
- Algoritmos de Consenso
- Replicación de Datos
- Transacciones Distribuidas y Sagas
Módulo 4: Almacenamiento Distribuido
- Particionado de Datos y Hashing Consistente
- Sistemas de Archivos Distribuidos
- Almacenamiento de Objetos
- Bases de Datos Distribuidas
- Cachés Distribuidos
Módulo 5: Computación Distribuida
- Modelos de Computación Distribuida
- MapReduce y Hadoop
- Spark y Computación en Memoria
- Procesamiento de Flujos de Datos
- Planificación de Trabajos y Pipelines de Datos
Módulo 6: Seguridad en Sistemas Distribuidos
- Autenticación y Autorización
- Cifrado y Protección de Datos
- Gestión de Identidades
- Seguridad entre Servicios: mTLS y Gestión de Secretos
- Puertas de Enlace, Limitación de Tasa y Auditoría
Módulo 7: Monitoreo y Mantenimiento
- Monitoreo de Sistemas Distribuidos
- Logs Centralizados y Trazabilidad Distribuida
- Gestión de Fallos y Recuperación
- Patrones de Resiliencia: Timeouts, Reintentos y Circuit Breaker
- Automatización y Orquestación
- Pruebas en Sistemas Distribuidos e Ingeniería del Caos
