Replicación y modelos de consistencia
"Consistency, availability, partition tolerance — pick two. But actually, you always need partition tolerance, so pick one." — perspectiva más honesta del teorema CAP.
Qué vas a aprender en este capítulo
En el capítulo anterior de BDD415 vimos replicación de bases de datos (primary-replica). Aquí vamos más profundo: ¿qué significa exactamente que los datos sean "consistentes"? Hay múltiples definiciones, algunas más fuertes (y más costosas) que otras.
2.1 El espectro de la consistencia
💡 Intuición
"Consistente" en sistemas distribuidos tiene un significado preciso — no es simplemente "los datos son correctos". Hay un espectro desde la consistencia más fuerte (todos los lectores ven los mismos datos siempre, como si hubiera un solo servidor) hasta la más débil (eventualmente los datos convergen, pero durante un tiempo pueden ser diferentes en distintos nodos).
Más fuerte = más lento (requiere coordinación entre nodos). Más débil = más rápido (no requiere coordinación).
📐 Fundamento
Modelos de consistencia (de más fuerte a más débil):
1. Linearizabilidad (Linearizability):
El sistema se comporta como si fuera un único servidor. Toda operación parece ocurrir en un momento puntual entre su inicio y su fin. Si A escribe y luego B lee, B ve el valor escrito por A.
- El modelo más intuitivo para el programador.
- El más costoso: requiere coordinación entre réplicas para cada operación.
- Usado en: etcd, ZooKeeper, Spanner.
2. Consistencia secuencial (Sequential Consistency):
Las operaciones de cada proceso parecen ejecutarse en orden, y hay algún orden total consistente con todos los procesos. Pero ese orden no tiene que respetar el tiempo real.
- Más débil que linearizabilidad: no garantiza "tiempo real".
- Usado en: algunos protocolos de memoria compartida.
3. Consistencia causal (Causal Consistency):
Si A causa B (A happenend-before B), todos los nodos ven A antes que B. Operaciones sin relación causal pueden verse en cualquier orden.
- Mucho más eficiente que linearizabilidad.
- Usado en: Dynamo, algunas implementaciones de CRDTs.
4. Consistencia eventual (Eventual Consistency):
Si no hay nuevas actualizaciones, eventualmente todas las réplicas convergen al mismo valor. Sin garantías de cuándo.
- El modelo más débil y más eficiente.
- Aceptable para: contadores de "likes", carritos de compra, DNS.
- No aceptable para: saldos bancarios, inventario crítico.
Resumen:
Linearizabilidad ← más fuerte, más lento
↓
Secuencial
↓
Causal
↓
Eventual Consistency ← más débil, más rápido
2.2 Conflictos de escritura concurrente
💡 Intuición
Si dos nodos reciben escrituras para el mismo dato al mismo tiempo (porque el sistema es AP — disponible sin coordinación), ¿qué pasa cuando se sincronizan? Los dos cambios son "correctos" desde la perspectiva de cada nodo — pero son incompatibles.
¿Cuál ganó? ¿El más reciente? ¿Cómo sabemos cuál es más reciente sin un reloj global?
📐 Fundamento
Last Write Wins (LWW):
La escritura con mayor timestamp gana. Simple, pero problemático: los relojes no están sincronizados perfectamente, y puede perder datos.
# Problema con LWW:
# Nodo A (reloj adelantado): actualiza precio a $2.00 a las 12:00:01
# Nodo B (reloj atrasado): actualiza precio a $1.50 a las 12:00:00.5
# Al sincronizar: LWW elige $2.00 (ts mayor) → ¡correcto por accidente!
# Pero si el reloj de B fuera el adelantado, elegiría $1.50 → ¡incorrecto!
Relojes vectoriales (Vector Clocks):
Un vector de contadores, uno por nodo. Capturan la causalidad perfectamente.
from typing import Dict
class VectorClock:
def __init__(self, nodos: list):
self.clock: Dict[str, int] = {n: 0 for n in nodos}
self.nodo = None
def incrementar(self, nodo: str):
self.clock[nodo] += 1
def actualizar(self, otro: 'VectorClock'):
for nodo, tiempo in otro.clock.items():
self.clock[nodo] = max(self.clock[nodo], tiempo)
def precede(self, otro: 'VectorClock') -> bool:
"""¿Este reloj precede causalmente a otro?"""
return (all(self.clock[n] <= otro.clock[n] for n in self.clock)
and any(self.clock[n] < otro.clock[n] for n in self.clock))
def concurrente(self, otro: 'VectorClock') -> bool:
"""¿Estos eventos son concurrentes (sin relación causal)?"""
return not self.precede(otro) and not otro.precede(self)
# Ejemplo: dos escrituras concurrentes
vc_a = VectorClock(['A', 'B', 'C'])
vc_b = VectorClock(['A', 'B', 'C'])
vc_a.incrementar('A') # A escribe: {A:1, B:0, C:0}
vc_b.incrementar('B') # B escribe: {A:0, B:1, C:0}
print(vc_a.concurrente(vc_b)) # True — escrituras concurrentes, hay conflicto
¿Qué hacer con el conflicto?
- Elegir automáticamente (LWW) — puede perder datos.
- Guardar ambas versiones — la aplicación resuelve el conflicto (Amazon Dynamo).
- Prevenir el conflicto — usar CRDT (siguiente sección).
Amazon Dynamo: Guarda ambas versiones con sus relojes vectoriales. La aplicación (o el usuario) decide cuál es la "correcta". El carrito de compras puede tener dos versiones — se hace la unión de los ítems.
2.3 CRDTs — estructuras sin conflictos
💡 Intuición
¿Qué pasa si diseñamos las operaciones de forma que nunca haya conflicto, sin importar el orden en que se apliquen?
Un CRDT (Conflict-free Replicated Data Type) es una estructura de datos donde las operaciones son commutativas y asociativas — el resultado es el mismo sin importar el orden. No necesitan coordinación para ser consistentes.
📐 Fundamento
G-Counter (Grow-Only Counter) — el CRDT más simple:
class GCounter:
"""Contador que solo puede incrementarse. Sin conflictos posibles."""
def __init__(self, nodos: list, nodo_id: str):
self.contadores = {n: 0 for n in nodos}
self.nodo_id = nodo_id
def incrementar(self, cantidad: int = 1):
self.contadores[self.nodo_id] += cantidad
def valor(self) -> int:
return sum(self.contadores.values())
def merge(self, otro: 'GCounter') -> 'GCounter':
"""Merge: tomar el máximo de cada nodo."""
resultado = GCounter(list(self.contadores.keys()), self.nodo_id)
for nodo in self.contadores:
resultado.contadores[nodo] = max(
self.contadores[nodo],
otro.contadores.get(nodo, 0)
)
return resultado
# Ejemplo: contador de pedidos distribuido
nodos = ['local_smi', 'local_smn', 'local_usulutan']
pedidos_smi = GCounter(nodos, 'local_smi')
pedidos_smn = GCounter(nodos, 'local_smn')
pedidos_smi.incrementar(50) # 50 pedidos en SMI
pedidos_smn.incrementar(30) # 30 pedidos en SMN
# Al sincronizar (merge):
total = pedidos_smi.merge(pedidos_smn)
print(f"Total pedidos: {total.valor()}") # 80
# Si el merge se hace en el otro orden, el resultado es el mismo
total2 = pedidos_smn.merge(pedidos_smi)
print(f"Total pedidos: {total2.valor()}") # 80 (commutativo)
Otros CRDTs comunes:
| CRDT | Operaciones | Uso |
|---|---|---|
| G-Counter | Solo incremento | Vistas de página, likes |
| PN-Counter | Incremento y decremento | Stock (cuidado con negativo) |
| G-Set | Solo agregar elementos | Tags, membresías |
| 2P-Set | Agregar y eliminar (con tombstones) | Listas con bajas |
| LWW-Register | Set con last-write-wins | Preferencias de usuario |
| OR-Set | Set con semántica "add wins" | Carrito de compras |
Sistemas que usan CRDTs: Riak, Redis (tipos de datos distribuidos), Apple Notes (sincronización offline), figma (edición colaborativa de diseños).
2.4 Ejercicios
✏️ Ejercicio 2.1 — Modelo de consistencia apropiado
Para cada dato de La Esquina Cloud, elegí el modelo de consistencia apropiado y justificá:
a. El saldo de la cuenta bancaria del dueño. b. El contador de "me gusta" en la foto del menú en redes sociales. c. El stock de pupusas (cuántas quedan) en un local específico. d. El historial de pedidos de un cliente para mostrar en su perfil.
Solución
a. Linearizabilidad — el saldo bancario nunca puede estar "aproximadamente correcto". Operaciones concurrentes (débito y crédito simultáneos) deben coordinarse perfectamente.
b. Consistencia eventual — si un "me gusta" tarda 2 segundos en propagarse a todas las réplicas, nadie sufre. La latencia baja importa más que la consistencia perfecta.
c. Linearizabilidad o consistencia fuerte — si dos mesas piden el último platillo simultáneamente, solo una puede tenerlo. Sin coordinación, ambas podrían ver "disponible" y crear una situación imposible.
d. Consistencia causal — si el cliente acaba de hacer un pedido y recarga la página, debe ver ese pedido. Pero si un pedido de hace una semana tarda 100ms más en aparecer, no importa. La consistencia causal garantiza que el cliente ve sus propias escrituras.
✏️ Ejercicio 2.2 — Diseñar un CRDT
Diseñá un CRDT para un carrito de compras compartido entre dispositivos de un mismo usuario (teléfono y tablet pueden modificar el carrito offline y sincronizarse después).
Operaciones: agregar ítem, eliminar ítem.
¿Por qué un simple Set no funciona? ¿Cómo lo resolverías?
Solución
Por qué un Set simple no funciona:
Teléfono: agrega pupusa, elimina pupusa
Tablet: agrega pupusa
Al sincronizar, ¿el estado final tiene o no tiene pupusa?
Depende del orden de los mensajes — sin CRDT, puede ser inconsistente.
Solución: OR-Set (Observed-Remove Set):
Cada adición recibe un ID único. Para eliminar un elemento, se eliminan todas las adiciones conocidas hasta ese momento. Si hay una adición posterior (que el eliminador no conocía), el elemento "sobrevive".
import uuid
class ORSet:
"""Observed-Remove Set: 'add wins' cuando hay concurrencia."""
def __init__(self):
self.elementos = {} # item → set de unique_ids activos
def agregar(self, item: str) -> str:
uid = str(uuid.uuid4())
if item not in self.elementos:
self.elementos[item] = set()
self.elementos[item].add(uid)
return uid
def eliminar(self, item: str):
if item in self.elementos:
del self.elementos[item]
def contiene(self, item: str) -> bool:
return item in self.elementos and len(self.elementos[item]) > 0
def merge(self, otro: 'ORSet') -> 'ORSet':
resultado = ORSet()
todos_items = set(self.elementos) | set(otro.elementos)
for item in todos_items:
ids_self = self.elementos.get(item, set())
ids_otro = otro.elementos.get(item, set())
ids_union = ids_self | ids_otro
if ids_union:
resultado.elementos[item] = ids_union
return resultado
# Si el teléfono agrega y elimina, y el tablet agrega
# después: al hacer merge, el ítem del tablet sobrevive.
2.5 Para profundizar
- Kleppmann, DDIA, cap. 9 — consistencia y consenso.
- Shapiro et al., "A Comprehensive Study of CRDTs" (2011).
- Vogels (Amazon CTO), "Eventually Consistent" — artículo seminal de Dynamo.
- Siguiente: Consenso distribuido.
2.X Errores comunes
⚠️ Trampa común
Confundir "eventual consistency" con "lento" o "incorrecto". Eventual consistency garantiza que si dejás de escribir, todas las réplicas convergen. No es "datos viejos que nunca se actualizan": el sistema sí converge, solo que sin garantía de cuándo. En la práctica, milisegundos en una red sana.
Tip: la pregunta correcta no es "¿es consistente?" sino "¿qué garantías necesita mi caso de uso?". Para un timeline de redes sociales, eventual está bien. Para saldo bancario, no.
⚠️ Trampa común
"Last Write Wins" usando reloj de pared. Si dos servidores tienen relojes desincronizados (incluso por 100 ms) y aplican LWW por timestamp, el "último escritor" puede ser el que llegó primero según el reloj real. Datos se pierden silenciosamente — sin error, sin log.
Tip: para LWW, usar relojes lógicos (Lamport timestamps o vector clocks) o HLC (Hybrid Logical Clock). Si tu sistema realmente requiere LWW, asegurate al menos de tener NTP estricto y un margen de tolerancia explícito.
Definiciones nuevas: linearizabilidad, consistencia secuencial, consistencia causal, consistencia eventual, Last Write Wins (LWW), relojes vectoriales, conflicto, CRDT, G-Counter, OR-Set, tombstone, commutativity, asociatividad.