El Módulo 2 terminó con una escena incómoda: Ana reserva el último queso-curado de la Quesería Montblanc, inventario lo descuenta, y durante el medio segundo que tarda stock.actualizado en llegar a catalogo, Marc sigue viendo en la ficha un queso que ya no existe. Nadie ha cometido un error de programación; simplemente, el dato "unidades de queso curado" vive ahora en más de un sitio, y esos sitios no se ponen de acuerdo al instante. La pregunta que abre este módulo es qué significa exactamente que un sistema así sea "consistente", porque la palabra, usada sin más, no dice nada: hay una docena de significados distintos, cada uno con un coste y con un conjunto de anomalías que permite.
Esta lección construye ese vocabulario. Veremos que un modelo de consistencia es un contrato entre el almacén de datos y el programa que lo usa, y recorreremos la jerarquía de contratos desde el más fuerte (linealizabilidad, y por qué el ideal de "consistencia estricta" es físicamente irrealizable) hasta el más débil (consistencia eventual), pasando por la serializabilidad de las bases de datos, la consistencia secuencial y la causal, que enlaza directamente con el happens-before y los relojes vectoriales de 01-05. Después cambiaremos de punto de vista y miraremos las garantías que importan a un cliente concreto, Ana, cuando edita su dirección de entrega y la ve desaparecer. Todo se hará visible con una simulación en Python de las réplicas inv-bcn e inv-vlc con propagación retardada. Qué se puede prometer cuando la red se parte (CAP), cómo se ponen de acuerdo los nodos (consenso) y cómo se copian físicamente los datos (replicación) son las lecciones siguientes; aquí solo definimos con precisión qué se está prometiendo.
Contenido
- Un modelo de consistencia es un contrato
- La jerarquía de modelos
- Linealizabilidad y el ideal irrealizable de la consistencia estricta
- Comprobar la linealizabilidad de un historial a mano (y en Python)
- Serializabilidad: parecida, pero no es lo mismo
- Consistencia secuencial
- Consistencia causal: happens-before aplicado a los datos
- Consistencia eventual y consistencia eventual fuerte (CRDTs)
- Modelos centrados en el cliente: lo que Ana espera de su sesión
- Simulación:
inv-bcneinv-vlccon propagación retardada - Tabla comparativa: modelo, garantía, coste y uso en Kilómetro Cero
- Errores comunes y consejos
- Ejercicios
- Conclusión
- Un modelo de consistencia es un contrato
Cuando el monolito de Kilómetro Cero tenía una sola base de datos PostgreSQL, el programador nunca se preguntaba "qué versión del stock estoy leyendo": había una, la última. Al distribuir el dato, aparecen varias copias que en cada instante pueden diferir, y el almacén tiene que decidir qué promete sobre esas copias a quien lee y escribe. Esa promesa es el modelo de consistencia.
Formalmente, un modelo de consistencia es el conjunto de historiales (secuencias de operaciones con sus resultados) que el almacén considera válidos. Si el programa observa un historial que el modelo no permite, el almacén ha incumplido el contrato; si observa cualquier otro, aunque le parezca extraño, el almacén ha cumplido. Dos consecuencias prácticas:
- El contrato tiene dos partes. El almacén promete no producir ciertos historiales, y el programa se compromete a funcionar correctamente con cualquier historial permitido. Si
catalogoasume que siempre lee el último stock pero el almacén solo garantiza consistencia eventual, el error no es del almacén. - Más fuerte no es mejor sin más. Cada garantía adicional cuesta latencia, disponibilidad o ambas (lo cuantificaremos en 03-02). Elegir un modelo es elegir qué anomalías se está dispuesto a tolerar a cambio de qué.
En 01-05 aprendimos a razonar sobre el orden de eventos sin reloj global; los modelos de consistencia son esa misma idea aplicada a lecturas y escrituras: cada modelo impone un tipo distinto de orden a las operaciones.
- La jerarquía de modelos
Los modelos se ordenan por fuerza: uno es más fuerte que otro si permite un subconjunto de sus historiales. El diagrama muestra la jerarquía que usaremos en el curso (simplificada; la real tiene decenas de modelos intermedios):
flowchart TB
E["Consistencia estricta<br/>(ideal físico: reloj global perfecto)"]
L["Linealizabilidad<br/>(orden total respetando el tiempo real)"]
SS["Serializabilidad estricta<br/>(transacciones + tiempo real)"]
SR["Serializabilidad<br/>(transacciones en algún orden total)"]
SQ["Consistencia secuencial<br/>(orden total respetando el orden de cada proceso)"]
C["Consistencia causal<br/>(solo se ordena lo causalmente relacionado)"]
CL["Modelos centrados en el cliente<br/>read-your-writes, monotonic reads..."]
EV["Consistencia eventual<br/>(las réplicas convergen... algún día)"]
E -.-> L
L --> SQ
SS --> L
SS --> SR
SQ --> C
C --> CL
CL --> EV
style E stroke-dasharray: 5 5
Las flechas van de más fuerte a más débil. La columna izquierda (estricta → linealizable → secuencial → causal → eventual) se refiere a operaciones individuales sobre objetos; la rama de la derecha (serializabilidad) se refiere a transacciones que agrupan varias operaciones, y ambas se encuentran en la serializabilidad estricta. Recorreremos la jerarquía de arriba abajo.
- Linealizabilidad y el ideal irrealizable de la consistencia estricta
Consistencia estricta
El modelo más fuerte imaginable dice: "toda lectura devuelve el valor de la escritura más reciente según un reloj global". Es exactamente lo que ofrece una variable en un programa de un solo hilo, y es lo que intuitivamente esperamos. Pero en 01-05 vimos que el reloj global no existe: NTP sincroniza con un error de milisegundos, y "la escritura más reciente" no está definida para dos escrituras que ocurren con 100 microsegundos de diferencia en Girona y Valencia, porque ninguna información puede viajar entre ellas en ese tiempo. La consistencia estricta es un ideal físico, útil como referencia, pero ningún sistema distribuido puede implementarla ni verificarla. Por eso aparece con línea discontinua en el diagrama.
Linealizabilidad
La linealizabilidad (Herlihy y Wing, 1990) es la versión realizable de ese ideal. Observa que toda operación en un sistema real no es instantánea: tiene un instante de inicio (el cliente envía la petición) y un instante de fin (recibe la respuesta). La linealizabilidad exige:
- Existe un orden total de todas las operaciones.
- En ese orden, cada lectura devuelve el valor de la escritura inmediatamente anterior (la especificación secuencial del objeto: para un registro, "leer devuelve lo último escrito").
- El orden respeta el tiempo real: si la operación A terminó antes de que la operación B empezara, A va antes que B en el orden.
La consecuencia intuitiva es que cada operación parece ocurrir atómicamente en algún instante entre su inicio y su fin, el llamado punto de linealización. Dos operaciones que se solapan en el tiempo pueden ordenarse de cualquier manera (ahí está la libertad que hace el modelo realizable), pero una vez que una operación ha terminado, su efecto es visible para todo el mundo. En Kilómetro Cero, la linealizabilidad es lo que hace que la reserva del último queso tenga sentido: si ReservarStock de Ana devuelve "confirmada", cualquier consulta posterior de cualquiera, desde cualquier réplica, debe ver 0 unidades. Ni inv-vlc puede seguir diciendo 1.
La linealizabilidad es componible: si cada objeto es linealizable por separado, el sistema completo lo es. Esto no ocurre con la consistencia secuencial ni con la serializabilidad, y es una de las razones de su popularidad como garantía de los sistemas de coordinación (etcd, ZooKeeper, que veremos en 03-03).
- Comprobar la linealizabilidad de un historial a mano (y en Python)
Un historial es una lista de operaciones, cada una con proceso, tipo, argumento o resultado, e intervalo de tiempo real. Comprobar si es linealizable consiste en buscar un orden total que cumpla las tres condiciones. Tomemos el stock de queso-curado, que empieza con valor 1:
| Operación | Cliente | Inicio (ms) | Fin (ms) | Resultado |
|---|---|---|---|---|
| A | Ana: escribir stock = 0 (reserva) | 0 | 10 | ok |
| B | Marc: leer stock | 12 | 15 | 1 |
| C | Lucía: leer stock | 5 | 20 | 0 |
A mano. A terminó en 10 y B empezó en 12, así que A debe ir antes que B. Pero B leyó 1, es decir, el valor de antes de A. No existe orden válido: el historial no es linealizable. Es exactamente lo que le pasaba a Marc en el cierre de 02-05. Fíjate en C: se solapa con A (5-20 frente a 0-10), así que puede ir antes o después de A; leyó 0, luego debe ir después. Eso es legal. Si B hubiera leído 0, el orden A, C, B (o A, B, C) sería válido y el historial sería linealizable.
En Python. Para historiales pequeños, la comprobación puede hacerse por fuerza bruta: generar todas las permutaciones, descartar las que violen el tiempo real y comprobar la especificación del registro contra las restantes. No sirve para historiales reales (el problema es NP-completo, y herramientas como Jepsen/Knossos usan algoritmos mucho más listos), pero hace tangible la definición:
# km0/simulaciones/linealizabilidad.py
from dataclasses import dataclass
from itertools import permutations
@dataclass(frozen=True)
class Op:
nombre: str
cliente: str
tipo: str # "escribir" o "leer"
valor: int # lo que escribe, o lo que leyó
inicio: int
fin: int
def respeta_tiempo_real(orden: list[Op]) -> bool:
"""Si A terminó antes de que B empezara, A debe ir antes que B."""
for i, a in enumerate(orden):
for b in orden[i + 1:]:
if b.fin < a.inicio: # b terminó antes de que a empezara, pero va después
return False
return True
def cumple_especificacion(orden: list[Op], valor_inicial: int) -> bool:
"""Un registro: cada lectura devuelve la última escritura anterior en el orden."""
actual = valor_inicial
for op in orden:
if op.tipo == "escribir":
actual = op.valor
elif op.valor != actual:
return False
return True
def es_linealizable(historial: list[Op], valor_inicial: int) -> list[Op] | None:
"""Devuelve un orden de linealización válido, o None si no existe."""
for orden in permutations(historial):
orden = list(orden)
if respeta_tiempo_real(orden) and cumple_especificacion(orden, valor_inicial):
return orden
return None
if __name__ == "__main__":
historial = [
Op("A", "Ana", "escribir", 0, inicio=0, fin=10),
Op("B", "Marc", "leer", 1, inicio=12, fin=15),
Op("C", "Lucía", "leer", 0, inicio=5, fin=20),
]
orden = es_linealizable(historial, valor_inicial=1)
print("Historial 1:", "linealizable" if orden else "NO linealizable",
[op.nombre for op in orden] if orden else "")
historial[1] = Op("B", "Marc", "leer", 0, inicio=12, fin=15) # Marc ve el 0
orden = es_linealizable(historial, valor_inicial=1)
print("Historial 2:", "linealizable" if orden else "NO linealizable",
[op.nombre for op in orden] if orden else "")Salida:
Explicación del código para quien empieza:
Opes una operación con su intervalo de tiempo real.frozen=Truela hace inmutable, lo que permite usarla enpermutationssin sorpresas.respeta_tiempo_realcomprueba la condición 3 de la definición: recorre cada par (a antes que b en el orden candidato) y falla si b terminó estrictamente antes de que a empezara.cumple_especificacionrecorre el orden llevando el valor "actual" del registro y comprueba que cada lectura devuelve ese valor. Cambiar esta función permitiría comprobar otros objetos (una cola, un contador).es_linealizableprueba todos los órdenes. Con 3 operaciones son 6 permutaciones; con 10 serían 3,6 millones. Sirve para entender, no para producción.
- Serializabilidad: parecida, pero no es lo mismo
La serializabilidad viene del mundo de las bases de datos y habla de transacciones, no de operaciones individuales: un historial de transacciones es serializable si su resultado es igual al de algún orden secuencial de esas mismas transacciones. Es la "I" de ACID en su forma más fuerte (retomaremos ACID en 03-05). Se confunde constantemente con la linealizabilidad porque ambas hablan de "un orden total", pero difieren en dos cosas:
| Aspecto | Linealizabilidad | Serializabilidad |
|---|---|---|
| Unidad | Una operación sobre un objeto | Una transacción sobre varios objetos |
| Restricción temporal | Sí: respeta el tiempo real | No: cualquier orden secuencial vale, aunque contradiga el tiempo real |
| Composición | Componible por objeto | No componible |
| Dónde se ve | Registros, locks, colas, sistemas de coordinación | Bases de datos relacionales (nivel SERIALIZABLE) |
La ausencia de restricción temporal tiene consecuencias reales. Imagina que pedidos ejecuta la transacción T1 (crear P-2026-000123) y, tras recibir el "commit ok", Lucía ejecuta T2 (listar pedidos de hoy). Un sistema serializable puede legalmente ordenar T2 antes que T1 y devolver a Lucía una lista sin el pedido, porque ese orden secuencial es válido. PostgreSQL con SERIALIZABLE en un solo nodo no hace esto en la práctica, pero una base de datos distribuida serializable con réplicas retrasadas sí puede.
La combinación de ambas, serializabilidad estricta (strict serializability), exige un orden secuencial de transacciones que además respete el tiempo real. Es la garantía más fuerte que se ofrece en la práctica (Spanner la implementa con TrueTime, mencionado en 01-05; CockroachDB y FoundationDB también la persiguen) y la más cara. Cuando en 03-02 hablemos de la "C" de CAP, nos referiremos a linealizabilidad; cuando en 03-05 hablemos de transacciones, a serializabilidad.
- Consistencia secuencial
Relajemos la linealizabilidad quitando la restricción de tiempo real y dejando solo esta: existe un orden total que respeta el orden de programa de cada proceso. Es decir, las operaciones de Ana aparecen en el orden en que Ana las hizo, las de Marc en el orden de Marc, pero cómo se entrelazan ambas secuencias es libre, sin importar el reloj.
Con consistencia secuencial, el historial 1 del apartado 4 sí sería válido: el orden B, A, C (Marc lee 1, Ana escribe 0, Lucía lee 0) respeta el orden de cada proceso (cada uno hizo una sola operación) y la especificación del registro. Que Marc empezara a leer después de que Ana terminara no importa. Lo que la consistencia secuencial prohíbe son historiales donde un proceso vea sus propias operaciones desordenadas, o donde dos procesos vean las escrituras de otros en órdenes distintos. Este es el modelo que ofrecen las CPU multinúcleo con algunas restricciones (el "modelo de memoria" de Java o C++ está construido sobre esta idea) y, en sistemas distribuidos, un almacén con un solo líder que ordena todas las escrituras y réplicas que las aplican en ese mismo orden, aunque con retraso.
En Kilómetro Cero, la consistencia secuencial basta para el historial de posiciones de furgoneta-3: todos los que miren el mapa verán la furgoneta recorrer las mismas calles en el mismo orden, aunque uno la vea un segundo por detrás de otro.
- Consistencia causal: happens-before aplicado a los datos
La consistencia secuencial sigue exigiendo un orden total, y eso obliga a que todos los nodos acuerden cómo entrelazar operaciones que no tienen nada que ver entre sí. La consistencia causal relaja precisamente eso: solo exige que se respete el orden de las operaciones causalmente relacionadas, y deja las concurrentes en cualquier orden, incluso distinto para distintos observadores.
"Causalmente relacionadas" es exactamente la relación happens-before de 01-05, aplicada a lecturas y escrituras:
- Si un proceso hace la operación A y después la B, A → B.
- Si un proceso lee un valor escrito por A y después escribe B, A → B (la escritura pudo depender de lo leído).
- Transitividad.
Y "concurrentes" son las operaciones que no están relacionadas en ninguna dirección, las mismas que en 01-05 detectábamos con MarcaVectorial.concurrente. No es casualidad: los sistemas con consistencia causal (COPS, Antidote, o el modo causal de MongoDB) se implementan con relojes vectoriales o estructuras equivalentes, y una réplica solo aplica una escritura cuando ya ha aplicado todas las que la precedieron causalmente.
El ejemplo canónico es una conversación. Marc publica una pregunta en la ficha de la Quesería Montblanc ("¿el curado lleva leche cruda?") y la quesería responde ("sí, de vaca frisona"). La respuesta depende causalmente de la pregunta (la quesería la leyó antes de escribir). Con consistencia eventual, Lucía en otra réplica podría ver la respuesta antes que la pregunta: un absurdo. Con consistencia causal, eso está prohibido; pero si Ana publica al mismo tiempo una pregunta independiente sobre el envío, que Lucía la vea antes o después que la de Marc es irrelevante y el sistema no gasta nada en decidirlo.
La consistencia causal es el modelo más fuerte que puede ofrecerse sin sacrificar disponibilidad durante una partición de red, un resultado que hace de ella el punto de equilibrio favorito de la investigación académica y que dará sentido a la discusión de 03-02.
- Consistencia eventual y consistencia eventual fuerte (CRDTs)
En el extremo débil está la consistencia eventual: si dejan de llegar escrituras, todas las réplicas acabarán teniendo el mismo valor. No dice cuándo ("eventual" puede ser un milisegundo o una hora), no dice qué se lee mientras tanto, y no dice cuál será el valor final si hubo escrituras concurrentes. Es más una propiedad de convergencia (o liveness) que un modelo de consistencia en el sentido del apartado 1: casi cualquier historial es válido mientras dure la actividad. Su virtud es que se puede ofrecer siempre, con cualquier red y cualquier latencia, y por eso es el modelo por defecto de DNS, de las cachés y de la mayoría de los almacenes "AP" (03-02).
El problema de la consistencia eventual pura es la reconciliación: cuando dos réplicas han aceptado escrituras concurrentes, alguien tiene que decidir el valor final, y la estrategia más común ("gana la última escritura", last-write-wins) pierde datos silenciosamente, como veremos con crudeza en 03-02. La consistencia eventual fuerte (strong eventual consistency, Shapiro et al., 2011) elimina esa decisión: exige que dos réplicas que hayan recibido el mismo conjunto de actualizaciones, en cualquier orden, estén en el mismo estado. No hace falta reconciliar porque el propio tipo de dato está diseñado para que la fusión sea determinista. Esos tipos son los CRDT (Conflict-free Replicated Data Types).
El CRDT más sencillo es el contador de solo incremento, G-Counter: cada réplica lleva su propia cuenta, el valor total es la suma, y fusionar dos estados es tomar el máximo componente a componente (nunca se pierde un incremento porque cada réplica solo sube su propia casilla):
# km0/simulaciones/gcounter.py
class GCounter:
"""Contador replicado de solo incremento (CRDT basado en estado)."""
def __init__(self, replicas: list[str]):
self.cuentas = {r: 0 for r in replicas}
def incrementar(self, replica: str, n: int = 1) -> None:
self.cuentas[replica] += n # cada réplica solo toca su casilla
def valor(self) -> int:
return sum(self.cuentas.values())
def fusionar(self, otro: "GCounter") -> None:
for r in self.cuentas:
self.cuentas[r] = max(self.cuentas[r], otro.cuentas[r])
def __repr__(self) -> str:
return f"GCounter({self.cuentas}, total={self.valor()})"
if __name__ == "__main__":
replicas = ["inv-bcn", "inv-vlc"]
# Unidades vendidas de queso-curado durante la "Semana del Queso Artesano",
# contadas localmente en cada réplica sin coordinación.
bcn = GCounter(replicas)
vlc = GCounter(replicas)
bcn.incrementar("inv-bcn", 3) # 3 ventas en Barcelona
vlc.incrementar("inv-vlc", 2) # 2 en Valencia, concurrentes
print("antes de fusionar:", bcn, vlc)
# Fusión en ambas direcciones (da igual el orden: la operación es conmutativa,
# asociativa e idempotente)
copia_bcn = GCounter(replicas); copia_bcn.cuentas = dict(bcn.cuentas)
bcn.fusionar(vlc)
vlc.fusionar(copia_bcn)
print("después de fusionar:", bcn, vlc)
bcn.fusionar(vlc) # fusionar otra vez no cambia nada
print("fusión repetida:", bcn)Salida:
antes de fusionar: GCounter({'inv-bcn': 3, 'inv-vlc': 0}, total=3) GCounter({'inv-bcn': 0, 'inv-vlc': 2}, total=2)
después de fusionar: GCounter({'inv-bcn': 3, 'inv-vlc': 2}, total=5) GCounter({'inv-bcn': 3, 'inv-vlc': 2}, total=5)
fusión repetida: GCounter({'inv-bcn': 3, 'inv-vlc': 2}, total=5)La clave está en fusionar: max es conmutativa (da igual quién fusiona a quién), asociativa (da igual en qué orden lleguen tres estados) e idempotente (fusionar dos veces no duplica). Con esas tres propiedades, la propagación puede ser tan descuidada como se quiera (duplicados, reordenaciones, retrasos) y las réplicas convergen igualmente. Existen CRDTs para conjuntos (G-Set, OR-Set), registros (LWW-Register, MV-Register), mapas y texto colaborativo, y los usaremos en 03-04 como una estrategia de resolución de conflictos en replicación multilíder.
Lo que un CRDT no puede hacer es mantener un invariante global como "el stock nunca baja de cero": un contador PN (incrementos y decrementos) replicado permitiría que inv-bcn e inv-vlc descontaran cada una la última unidad y el total fusionado fuese -1. Contar visitas a la ficha de tomate-rosa o unidades vendidas en una campaña sí es un caso de CRDT; reservar la última unidad, no. Esa diferencia entre "datos que se suman" y "datos que se disputan" es la que guiará las decisiones de 03-02.
- Modelos centrados en el cliente: lo que Ana espera de su sesión
Los modelos anteriores describen el sistema desde fuera, para todos los clientes a la vez. Pero muchas anomalías que molestan de verdad a un usuario son sobre su propia sesión, y hay un conjunto de garantías, formuladas por Terry et al. para el sistema Bayou en 1994, que se pueden ofrecer con coste bajo incluso sobre consistencia eventual. Las cuatro se ven con Ana editando su dirección de entrega desde la web, con el perfil replicado en dos nodos y propagación retardada:
| Garantía | Qué promete | Anomalía que evita | Escena con Ana |
|---|---|---|---|
| Read-your-writes (lee tus escrituras) | Tras escribir un valor, el mismo cliente nunca lee uno anterior | "He guardado y ha desaparecido" | Ana cambia su dirección de Carrer Major 12, Girona a Passeig de Gràcia 5, Barcelona, pulsa guardar, recarga la página y ve la de Girona porque la recarga cayó en la réplica retrasada |
| Monotonic reads (lecturas monotónicas) | Si un cliente ha leído una versión, no leerá después una más antigua | "Ha vuelto atrás en el tiempo" | Ana ve su nueva dirección, vuelve a la página y ve la antigua; recarga, y vuelve la nueva |
| Monotonic writes (escrituras monotónicas) | Las escrituras de un cliente se aplican en todas las réplicas en el orden en que las hizo | "Mi segundo cambio ha pisado al primero" | Ana cambia la dirección y después el teléfono; una réplica aplica primero el teléfono (con la dirección antigua incrustada en el mismo documento) y luego la dirección; otra lo hace al revés, y el estado final difiere |
| Writes-follow-reads (las escrituras siguen a las lecturas) | Una escritura hecha tras leer un valor se ordena después de la escritura que produjo ese valor | "Respuesta antes que la pregunta" | Ana lee la respuesta de la Quesería Montblanc y escribe "gracias"; su "gracias" nunca debe ser visible en una réplica que aún no muestra la respuesta |
Las cuatro juntas equivalen a la consistencia causal restringida a un cliente (por eso en el diagrama del apartado 2 están justo debajo). Y las cuatro se consiguen con dos técnicas baratas que veremos en la simulación:
- Sesiones pegajosas (sticky sessions): cada cliente habla siempre con la misma réplica. Trivialmente da read-your-writes y monotonic reads, con el inconveniente de que si la réplica cae, la sesión pierde sus garantías (o hay que esperar a que otra réplica se ponga al día).
- Versiones en el cliente: el cliente recuerda la versión (un número, un LSN de PostgreSQL, una marca vectorial) de lo último que escribió o leyó, y cada lectura exige a la réplica "no me respondas hasta tener al menos esta versión". Funciona con cualquier réplica y es lo que hacen los causal tokens de MongoDB o el
ReadYourWritesde algunas bibliotecas cliente.
- Simulación:
inv-bcn e inv-vlc con propagación retardada
inv-bcn e inv-vlc con propagación retardadaVamos a construir en Python dos réplicas del stock con un mecanismo deliberadamente simple: cada escritura recibe un número de versión creciente, se aplica en la réplica que la recibe y se propaga a la otra con un retardo. Un balanceador reparte las lecturas al azar entre las dos. Con eso bastará para ver aparecer las anomalías del apartado anterior y para corregirlas.
# km0/simulaciones/replicas_retardadas.py
import random
from dataclasses import dataclass, field
@dataclass
class Version:
valor: int
numero: int # versión creciente asignada por el sistema
@dataclass
class Replica:
nombre: str
datos: dict[str, Version] = field(default_factory=dict)
pendientes: list[tuple[int, str, Version]] = field(default_factory=list) # (instante, producto, versión)
def aplicar(self, producto: str, v: Version) -> None:
actual = self.datos.get(producto)
if actual is None or v.numero > actual.numero: # nunca retroceder de versión
self.datos[producto] = v
def entregar(self, ahora: int) -> None:
"""Aplica las propagaciones cuyo instante de llegada ya ha pasado."""
listas = [p for p in self.pendientes if p[0] <= ahora]
self.pendientes = [p for p in self.pendientes if p[0] > ahora]
for _, producto, v in sorted(listas):
self.aplicar(producto, v)
def leer(self, producto: str) -> Version:
return self.datos[producto]
class Sistema:
"""Dos réplicas, propagación asíncrona con retardo, balanceador aleatorio."""
def __init__(self, retardo_ms: int, semilla: int = 7):
self.replicas = {"inv-bcn": Replica("inv-bcn"), "inv-vlc": Replica("inv-vlc")}
self.retardo = retardo_ms
self.reloj = 0
self.ultima_version = 0
self.rng = random.Random(semilla)
def avanzar(self, ms: int) -> None:
self.reloj += ms
for r in self.replicas.values():
r.entregar(self.reloj)
def escribir(self, replica: str, producto: str, valor: int) -> Version:
self.ultima_version += 1
v = Version(valor, self.ultima_version)
self.replicas[replica].aplicar(producto, v)
for nombre, otra in self.replicas.items():
if nombre != replica:
otra.pendientes.append((self.reloj + self.retardo, producto, v))
return v
def replica_al_azar(self) -> Replica:
return self.rng.choice(list(self.replicas.values()))
def escenario_sin_garantias() -> None:
print("=== Sin garantías: balanceador aleatorio, propagación de 300 ms ===")
s = Sistema(retardo_ms=300)
s.escribir("inv-bcn", "queso-curado", 3); s.avanzar(1000) # estado inicial propagado
v = s.escribir("inv-bcn", "queso-curado", 2) # Ana reserva 1 unidad en inv-bcn
print(f"t={s.reloj:4} ms Ana reserva: stock=2 (versión {v.numero}) en inv-bcn")
for _ in range(4):
s.avanzar(100)
r = s.replica_al_azar()
lectura = r.leer("queso-curado")
print(f"t={s.reloj:4} ms Ana lee en {r.nombre}: stock={lectura.valor} (versión {lectura.numero})")
class ClientePegajoso:
"""Siempre lee de la réplica con la que empezó."""
def __init__(self, s: Sistema, replica: str):
self.s, self.replica = s, replica
def leer(self, producto: str) -> Version:
return self.s.replicas[self.replica].leer(producto)
class ClienteVersionado:
"""Recuerda la última versión vista y rechaza réplicas más antiguas."""
def __init__(self, s: Sistema):
self.s, self.minimo = s, 0
def escribir(self, replica: str, producto: str, valor: int) -> None:
self.minimo = self.s.escribir(replica, producto, valor).numero
def leer(self, producto: str) -> tuple[str, Version]:
for r in self.s.rng.sample(list(self.s.replicas.values()), k=2): # prueba en orden aleatorio
v = r.leer(producto)
if v.numero >= self.minimo:
self.minimo = v.numero # monotonic reads: nunca aceptar menos
return r.nombre, v
raise TimeoutError("ninguna réplica tiene aún la versión mínima; reintentar más tarde")
def escenario_con_garantias() -> None:
print("\n=== Sesión pegajosa a inv-bcn ===")
s = Sistema(retardo_ms=300)
s.escribir("inv-bcn", "queso-curado", 3); s.avanzar(1000)
ana = ClientePegajoso(s, "inv-bcn")
s.escribir("inv-bcn", "queso-curado", 2)
for _ in range(3):
s.avanzar(100)
print(f"t={s.reloj:4} ms Ana lee en inv-bcn: stock={ana.leer('queso-curado').valor}")
print("\n=== Cliente con versión mínima, balanceador aleatorio ===")
s = Sistema(retardo_ms=300)
s.escribir("inv-bcn", "queso-curado", 3); s.avanzar(1000)
ana = ClienteVersionado(s)
ana.escribir("inv-bcn", "queso-curado", 2)
for _ in range(4):
s.avanzar(100)
try:
nombre, v = ana.leer("queso-curado")
print(f"t={s.reloj:4} ms Ana lee en {nombre}: stock={v.valor} (versión {v.numero})")
except TimeoutError as e:
print(f"t={s.reloj:4} ms {e}")
if __name__ == "__main__":
escenario_sin_garantias()
escenario_con_garantias()Salida (la semilla fija hace la ejecución reproducible):
=== Sin garantías: balanceador aleatorio, propagación de 300 ms === t=1000 ms Ana reserva: stock=2 (versión 2) en inv-bcn t=1100 ms Ana lee en inv-vlc: stock=3 (versión 1) t=1200 ms Ana lee en inv-bcn: stock=2 (versión 2) t=1300 ms Ana lee en inv-vlc: stock=2 (versión 2) t=1400 ms Ana lee en inv-bcn: stock=2 (versión 2) === Sesión pegajosa a inv-bcn === t=1100 ms Ana lee en inv-bcn: stock=2 t=1200 ms Ana lee en inv-bcn: stock=2 t=1300 ms Ana lee en inv-bcn: stock=2 === Cliente con versión mínima, balanceador aleatorio === t=1100 ms Ana lee en inv-bcn: stock=2 (versión 2) t=1200 ms Ana lee en inv-bcn: stock=2 (versión 2) t=1300 ms Ana lee en inv-bcn: stock=2 (versión 2) t=1400 ms Ana lee en inv-vlc: stock=2 (versión 2)
Qué muestra cada bloque:
- Sin garantías: en t=1100 Ana, que acaba de reservar, lee 3 en
inv-vlc: violación de read-your-writes. Si la secuencia de lecturas hubiera sido bcn, vlc, bcn (cambia la semilla y lo verás), habría leído 2, 3, 2: violación de monotonic reads. Es literalmente la historia de Marc y el queso de 02-05, ahora con la propia Ana como víctima. - Sesión pegajosa: al leer siempre de
inv-bcn, donde escribió, Ana ve su reserva desde el primer instante. El coste es que la carga no se reparte y que siinv-bcncae, la sesión de Ana se rompe. - Cliente con versión mínima: Ana lleva consigo
minimo = 2. Cuando el balanceador la manda ainv-vlcantes de t=1300,inv-vlcsolo tiene la versión 1 y el cliente la descarta y prueba la otra réplica (por eso las tres primeras lecturas acaban eninv-bcnaunque el orden de prueba sea aleatorio). En t=1400 la propagación ya ha llegado yinv-vlcresponde con la versión 2. Si ambas réplicas estuvieran retrasadas, el cliente esperaría (TimeoutError) en lugar de mentir. Observa queaplicarnunca retrocede de versión: eso es lo que da monotonic writes a nivel de réplica.
En un sistema real, el "número de versión" sería el LSN del WAL de PostgreSQL, el offset de Kafka, o una marca vectorial como la de 01-05; lo veremos en 03-04 cuando montemos la réplica de km0_pedidos de verdad.
- Tabla comparativa: modelo, garantía, coste y uso en Kilómetro Cero
| Modelo | Garantía (en una frase) | Coste típico | Dónde lo usa Kilómetro Cero |
|---|---|---|---|
| Consistencia estricta | Lectura = última escritura por reloj global | Irrealizable | Nunca (referencia teórica) |
| Linealizabilidad | Orden total que respeta el tiempo real; cada operación es atómica en un instante | Latencia de coordinación en cada operación; no disponible durante particiones | Reserva del último queso-curado; locks y elección de líder (etcd, 03-03); saldo en pagos |
| Serializabilidad estricta | Transacciones en orden secuencial que respeta el tiempo real | La más cara; requiere relojes acotados o coordinación global | Ninguno hoy; candidata si pagos necesitase transacciones multiobjeto distribuidas |
| Serializabilidad | Transacciones equivalentes a algún orden secuencial | Aborts por conflicto, latencia | Dentro de cada servicio, PostgreSQL con SERIALIZABLE en km0_pedidos |
| Consistencia secuencial | Orden total que respeta el orden de cada proceso | Un líder que ordena; réplicas pueden retrasarse | Historial de posiciones de furgoneta-3 (todos ven el mismo recorrido) |
| Consistencia causal | Solo se ordena lo causalmente relacionado | Marcas vectoriales o equivalentes; metadatos por operación | Preguntas y respuestas en fichas de productores; comentarios de pedidos |
| Centrados en el cliente | Read-your-writes, monotonic reads/writes, writes-follow-reads para cada sesión | Sesiones pegajosas o versiones en el cliente | Perfil y dirección de Ana; carrito; "mis pedidos" |
| Consistencia eventual fuerte (CRDT) | Réplicas con las mismas actualizaciones tienen el mismo estado, sin reconciliar | Tipos de dato restringidos; no admite invariantes globales | Contadores de ventas por campaña, visitas a fichas, "me gusta" |
| Consistencia eventual | Las réplicas convergen si cesan las escrituras | Casi nulo; anomalías de todo tipo mientras tanto | Caché del catálogo en Redis; índice de búsqueda; agregados de analitica |
La lectura importante de la tabla es que Kilómetro Cero no elige un modelo, elige uno por dato. La misma plataforma necesita linealizabilidad para la última unidad y se conforma con eventual para el número de visitas. Justificar cada fila de la última columna frente a los fallos de red es exactamente el tema de la próxima lección.
Errores Comunes y Consejos
- Decir "consistente" sin apellido. En una reunión de diseño, "la base de datos es consistente" no significa nada. Pregunta siempre: ¿linealizable?, ¿serializable?, ¿causal?, ¿eventual con qué reconciliación? La respuesta cambia la arquitectura.
- Confundir la C de ACID con la C de CAP. La consistencia de ACID es "las transacciones respetan los invariantes del esquema" (claves foráneas, restricciones). La de los modelos de esta lección es sobre el orden de lecturas y escrituras entre réplicas. No tienen nada que ver, y en 03-02 la C de CAP será concretamente linealizabilidad.
- Confundir serializabilidad con linealizabilidad. Una base de datos distribuida
SERIALIZABLEpuede devolverte una lectura antigua después de que tu transacción haya confirmado. Si necesitas ambas cosas, el término que buscas es serializabilidad estricta, y el precio es alto. - Creer que "eventual" es un plazo. No lo es. Si un requisito dice "eventualmente consistente, en menos de un segundo", el segundo es un objetivo operativo (SLO) que hay que medir (07-01), no una garantía del modelo.
- Pedir linealizabilidad a todo por precaución. Cada operación linealizable cruza la red al menos una vez con coordinación. Aplicarla al contador de visitas de
tomate-rosamultiplicaría la latencia sin beneficio. Clasifica los datos como en la tabla del apartado 11. - Olvidar las garantías de sesión al añadir réplicas de lectura. El error más frecuente en la práctica: se añade una réplica para escalar lecturas y de repente los usuarios "pierden" lo que acaban de guardar. Antes de enrutar lecturas a réplicas, decide si usarás sesiones pegajosas o versiones.
- Consejo: cuando diseñes un servicio, escribe junto a cada tabla o entidad el modelo de consistencia que necesita y la anomalía concreta que ese modelo evita. Si no sabes nombrar la anomalía, probablemente el modelo débil basta.
Ejercicios
Ejercicio 1: Clasificar historiales
El stock de vino-crianza de la Bodega Roble Alto empieza en 5. Para cada historial, indica si es linealizable, si es secuencialmente consistente y si es causalmente consistente (razona, y comprueba la linealizabilidad con es_linealizable):
Historial H1
| Op | Cliente | Inicio | Fin | Operación |
|---|---|---|---|---|
| A | Ana | 0 | 10 | escribir 4 |
| B | Marc | 20 | 30 | escribir 3 |
| C | Lucía | 40 | 50 | leer → 4 |
Historial H2 (dos procesos, sin tiempos, solo el orden dentro de cada uno)
- Ana: escribir 4; leer → 4; leer → 3
- Marc: escribir 3; leer → 3; leer → 4
Ejercicio 2: Diagnosticar la anomalía de sesión
Un usuario reporta lo siguiente en la app de Kilómetro Cero: "He añadido calabacin al carrito, he ido a la pestaña de pago y el carrito estaba vacío; he vuelto atrás y estaba el calabacín; he vuelto a pago y volvía a estar vacío". El carrito está en dos réplicas con replicación asíncrona y un balanceador round-robin. Indica qué garantías de sesión se violan, cuál de las dos técnicas del apartado 9 aplicarías y qué inconveniente tendría cada una en el caso concreto de un carrito.
Ejercicio 3: Un CRDT que no debe serlo
Modifica GCounter para obtener un PNCounter (incrementos y decrementos, con dos G-Counter internos, positivos y negativos). Úsalo para modelar el stock de queso-fresco (inicial 1) con inv-bcn e inv-vlc descontando concurrentemente una unidad cada una, fusiona y muestra el valor. Explica por qué el resultado demuestra que el stock no debe modelarse como CRDT y qué dato de Kilómetro Cero sí encajaría con un PNCounter.
Soluciones
Solución 1:
H1: A termina (10) antes de que B empiece (20), y B termina (30) antes de que C empiece (40); el tiempo real fuerza el orden A, B, C. En ese orden C debería leer 3, pero leyó 4: no es linealizable. Sin la restricción temporal, el orden B, A, C es válido (cada proceso tiene una sola operación, así que el orden de programa no restringe nada) y C lee 4: sí es secuencialmente consistente, y por tanto también causalmente consistente (A y B son concurrentes en el sentido causal, Lucía puede verlas en cualquier orden). es_linealizable devuelve None para H1 con valor_inicial=5.
H2: no hay tiempos, así que la linealizabilidad no se puede evaluar más allá de lo secuencial. ¿Existe un orden total que respete el orden de programa de cada uno? Ana necesita ver 4 y luego 3, así que en el orden total su escritura de 4 precede a la de Marc (3) o al menos Ana lee entre medias; Marc necesita ver 3 y luego 4, así que la escritura de 3 precede a la de 4. Ambas exigencias son contradictorias en un orden total único: no es secuencialmente consistente. Sin embargo, las dos escrituras son causalmente concurrentes (ningún proceso leyó la del otro antes de escribir), así que cada proceso puede aplicarlas en un orden distinto y las lecturas son legales: sí es causalmente consistente. Es el ejemplo clásico de la diferencia entre ambos modelos.
Solución 2:
Se violan monotonic reads (el usuario ve el carrito con calabacín, luego vacío, luego con calabacín: retrocede en el tiempo) y, en la primera visita a pago, read-your-writes (acaba de escribir el calabacín y no lo lee). El round-robin garantiza precisamente alternar réplicas, la peor opción posible sin garantías de sesión.
- Sesión pegajosa: el balanceador enruta a cada usuario siempre a la misma réplica (por cookie o por hash del id de usuario). Resuelve ambas anomalías con cambio cero en la aplicación. Inconveniente para un carrito: si la réplica cae, el usuario pierde su sesión en mitad de la compra (o hay que esperar a que la otra réplica alcance la versión); además, un usuario con móvil y navegador podría caer en réplicas distintas si la pegajosidad es por conexión y no por usuario.
- Versión en el cliente: la app guarda la versión del carrito tras cada escritura y la envía en cada lectura; la réplica que no la alcanza redirige o espera. Tolera caídas de réplica y funciona en varios dispositivos. Inconveniente: hay que tocar cliente y servidor, y en el peor caso (todas las réplicas retrasadas) la lectura espera. Para un carrito, la segunda es preferible; una alternativa habitual es enrutar todas las operaciones del carrito, incluidas las lecturas, al nodo primario, sacrificando el escalado de lecturas para ese dato pequeño y crítico.
Solución 3:
from gcounter import GCounter
class PNCounter:
def __init__(self, replicas: list[str]):
self.positivos = GCounter(replicas)
self.negativos = GCounter(replicas)
def incrementar(self, replica: str, n: int = 1) -> None:
self.positivos.incrementar(replica, n)
def decrementar(self, replica: str, n: int = 1) -> None:
self.negativos.incrementar(replica, n)
def valor(self) -> int:
return self.positivos.valor() - self.negativos.valor()
def fusionar(self, otro: "PNCounter") -> None:
self.positivos.fusionar(otro.positivos)
self.negativos.fusionar(otro.negativos)
replicas = ["inv-bcn", "inv-vlc"]
bcn, vlc = PNCounter(replicas), PNCounter(replicas)
bcn.incrementar("inv-bcn", 1) # llega 1 queso fresco, se propaga a ambas
vlc.fusionar(bcn)
bcn.decrementar("inv-bcn") # Ana lo reserva en Barcelona
vlc.decrementar("inv-vlc") # Marc lo reserva en Valencia, concurrentemente
print(bcn.valor(), vlc.valor()) # 0 0: cada réplica cree que ha vendido el último
bcn.fusionar(vlc); vlc.fusionar(bcn)
print(bcn.valor(), vlc.valor()) # -1 -1El CRDT ha hecho exactamente lo que promete: las dos réplicas convergen al mismo estado sin coordinación. Pero el estado es -1: se ha vendido un queso que no existe. Un CRDT garantiza convergencia, no invariantes que dependen de ver el estado global antes de decidir (como "no descontar si queda cero"). Decidir sobre la última unidad requiere que alguien tenga la última palabra, es decir, linealizabilidad o al menos coordinación (03-02, 03-03). Un PNCounter sí encaja para datos donde restar no viola nada: unidades reservadas y luego liberadas en un contador estadístico de campaña, o el número de artículos en la lista de deseos de Ana (añadir y quitar sin límite inferior significativo).
Conclusión
Un modelo de consistencia es el contrato que fija qué historiales de lecturas y escrituras puede producir un almacén replicado, y esta lección ha recorrido la jerarquía de contratos con el vocabulario que usaremos el resto del curso. La consistencia estricta es un ideal físico imposible sin reloj global; la linealizabilidad es su versión realizable, donde cada operación parece atómica en algún punto entre su inicio y su fin, y hemos comprobado a mano y con es_linealizable que el historial de Marc viendo el queso de Ana no la cumple. La serializabilidad habla de transacciones y no respeta el tiempo real, y su unión con la linealizabilidad es la costosa serializabilidad estricta. Bajando por la jerarquía, la consistencia secuencial solo exige respetar el orden de cada proceso; la causal, únicamente el orden de lo relacionado por happens-before, lo que la conecta con los relojes vectoriales de 01-05 y la convierte en el modelo más fuerte compatible con la disponibilidad; y la eventual solo promete convergencia, salvo que se use un CRDT como GCounter, que convierte la fusión en una operación determinista a costa de renunciar a invariantes como "el stock no baja de cero". Las garantías centradas en el cliente (read-your-writes, monotonic reads, monotonic writes, writes-follow-reads) han resultado ser las que Ana nota de verdad, y la simulación de inv-bcn e inv-vlc ha mostrado cómo las sesiones pegajosas y las versiones en el cliente las restauran sobre una replicación retrasada. La tabla final deja claro que Kilómetro Cero no elige un modelo, sino uno por dato.
Queda la pregunta que la tabla esquiva: ¿por qué no darle linealizabilidad a todo? Si inv-bcn e inv-vlc no pueden hablar entre sí durante treinta segundos porque un router de Barcelona se ha reiniciado, la reserva del último queso puede esperar o puede aceptarse en ambos lados y arreglarse después, pero no puede ser linealizable y estar disponible en las dos ciudades a la vez. Esa imposibilidad tiene nombre y enunciado formal, se malinterpreta más de lo que se entiende y tiene una extensión que habla también de la latencia en ausencia de fallos. Es el tema de la siguiente lección: el teorema CAP y PACELC.
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
