Cuando hablamos de "un sistema distribuido" podemos estar refiriéndonos a cosas muy distintas: un navegador que habla con un servidor web, una red de intercambio de archivos sin ningún servidor central, o una plataforma con decenas de servicios que se envían eventos entre sí. Para razonar con precisión necesitamos modelos: descripciones simplificadas que fijan cómo se organizan los nodos, cómo interactúan y qué podemos suponer sobre su comportamiento cuando algo va mal.
En esta lección presentamos tres familias de modelos que aparecerán constantemente en el resto del curso: los modelos de arquitectura (cómo se reparten los papeles entre los nodos), los modelos de interacción y de fallo (qué suposiciones hacemos sobre la comunicación y los errores) y, solo como avance, los modelos de consistencia (qué garantías da el sistema sobre los datos). Los aplicaremos a Kilómetro Cero para ver cómo cada modelo encaja —o no— con sus necesidades.
Contenido
- Para qué sirven los modelos
- Modelos de arquitectura
- Modelos de interacción: síncrono y asíncrono
- Modelos de fallo
- Modelos de consistencia: una primera visión
- Ejemplo de código: un servicio
catalogocliente-servidor - Ejemplo de código: catálogos compartidos en una red P2P simulada
- Errores comunes y consejos
- Ejercicios
- Conclusión
- Para qué sirven los modelos
Un modelo es un conjunto de supuestos explícitos. Cuando decimos "este algoritmo funciona en un sistema asíncrono con fallos de tipo crash-stop", estamos declarando exactamente en qué condiciones el algoritmo es correcto y, sobre todo, en cuáles no lo es. Sin modelos, las discusiones de arquitectura se convierten en intercambios de opiniones; con modelos, se convierten en razonamientos verificables.
Las tres familias responden a tres preguntas distintas:
| Familia | Pregunta que responde | Ejemplo de respuesta |
|---|---|---|
| Arquitectura | ¿Qué papel juega cada nodo y cómo se relacionan? | "Cliente-servidor en tres capas" |
| Interacción y fallo | ¿Qué podemos suponer sobre los tiempos y sobre los errores? | "Asíncrono, con fallos crash-stop" |
| Consistencia | ¿Qué garantías ofrece el sistema sobre los datos que devuelve? | "Consistencia eventual" |
- Modelos de arquitectura
2.1 Cliente-servidor
Es el modelo más antiguo y el más extendido. Los nodos se dividen en dos papeles: los servidores ofrecen un servicio (esperan peticiones y las responden) y los clientes lo consumen (envían peticiones y esperan respuestas). Los papeles son asimétricos: el servidor no inicia conversaciones.
El monolito inicial de Kilómetro Cero es un ejemplo puro:
flowchart LR
A[Ana - navegador] -->|"GET /catalogo?q=queso"| S
M[Marc - app móvil] -->|"POST /pedidos"| S
L[Lucía - navegador] -->|"GET /pedidos/1234"| S
S["Servidor Kilómetro Cero<br/>(app Python + PostgreSQL)"]
S -->|respuesta HTML/JSON| A
S -->|respuesta JSON| M
S -->|respuesta HTML| L
Características:
- Ventajas: sencillo de entender, el estado está centralizado (fácil de mantener coherente), la seguridad se concentra en un punto.
- Inconvenientes: el servidor es un cuello de botella y un punto único de fallo; la escalabilidad depende de cuánto pueda crecer ese servidor (o de replicarlo, lo que introduce nuevos problemas).
Conviene aclarar que "cliente" y "servidor" son papeles, no máquinas: en Kilómetro Cero, el módulo pedidos es servidor para la app móvil y, a la vez, cliente del módulo inventario cuando le pide comprobar el stock.
2.2 Multicapa (n-tier)
Una evolución natural del modelo cliente-servidor consiste en separar el servidor en capas con responsabilidades distintas, cada una de ellas potencialmente en máquinas diferentes. La versión más habitual es la de tres capas:
- Presentación (web): recibe las peticiones HTTP, sirve contenido estático, gestiona sesiones y TLS.
- Lógica de negocio (aplicación): ejecuta las reglas del dominio (calcular precios, validar pedidos, aplicar descuentos de campaña).
- Datos: almacena y recupera la información de forma persistente.
Aplicado a Kilómetro Cero tras el incidente de la Semana de la Vendimia, un primer paso razonable sería este:
flowchart TB
subgraph Capa1["Capa de presentación"]
W1[Servidor web 1]
W2[Servidor web 2]
end
subgraph Capa2["Capa de aplicación"]
A1[App Python 1]
A2[App Python 2]
A3[App Python 3]
end
subgraph Capa3["Capa de datos"]
DB[(PostgreSQL)]
end
Clientes[Ana, Marc, Lucía] --> LB[Balanceador]
LB --> W1
LB --> W2
W1 --> A1
W1 --> A2
W2 --> A2
W2 --> A3
A1 --> DB
A2 --> DB
A3 --> DB
Lo importante de este modelo es que cada capa se escala de forma independiente: si el problema es de CPU en la lógica de negocio, se añaden instancias de la aplicación; si es de tráfico web, se añaden servidores web. Además, cada capa solo habla con las adyacentes, lo que simplifica el razonamiento y la seguridad.
Fíjate, no obstante, en que la capa de datos sigue siendo un único nodo: la multicapa alivia el problema de la Semana de la Vendimia (la capa de aplicación ya no se ahoga), pero no lo resuelve, porque PostgreSQL seguirá agotando sus conexiones. Volveremos sobre ello en la lección 01-06 y en los módulos 3 y 4.
2.3 Peer-to-peer (P2P)
En el extremo opuesto al cliente-servidor está el modelo entre iguales: todos los nodos (peers) tienen el mismo papel, actúan simultáneamente como clientes y como servidores, y no existe ninguna autoridad central. Ejemplos clásicos: BitTorrent, las redes de blockchain, o el protocolo de descubrimiento de muchos sistemas de almacenamiento (Cassandra usa gossip entre iguales, como veremos en 04-04).
En Kilómetro Cero podríamos imaginar un escenario P2P para los mercados físicos: cada mercado local tiene un pequeño ordenador con el catálogo de sus productores, y los mercados se intercambian sus catálogos directamente entre ellos, sin depender del servidor central (que puede estar caído, o no tener cobertura en una zona rural).
flowchart LR
M1["Mercado Girona<br/>(Quesería Montblanc)"]
M2["Mercado Lleida<br/>(Huerta La Vega)"]
M3["Mercado Tarragona<br/>(Bodega Roble Alto)"]
M4["Mercado Valencia"]
M1 <-->|intercambio de catálogos| M2
M1 <--> M3
M2 <--> M3
M3 <--> M4
M2 <--> M4
| Aspecto | Cliente-servidor | Peer-to-peer |
|---|---|---|
| Papeles | Asimétricos | Simétricos |
| Punto único de fallo | Sí (el servidor) | No |
| Escalabilidad | Limitada por el servidor | Crece con el número de peers |
| Coherencia de datos | Fácil (estado centralizado) | Difícil (estado repartido, sin autoridad) |
| Seguridad y control | Centralizados | Difíciles (¿de quién te fías?) |
| Descubrimiento | Trivial (dirección conocida) | Complejo (¿cómo encuentro a los demás?) |
2.4 Arquitecturas orientadas a servicios y microservicios
La cuarta familia es la que dominará la segunda mitad del curso, así que aquí solo la presentamos como modelo. La idea es descomponer la aplicación en servicios independientes, cada uno responsable de un área funcional, con su propio despliegue y (habitualmente) sus propios datos. Los servicios se comunican entre sí mediante peticiones (RPC, HTTP) o mediante eventos (colas de mensajes).
Para Kilómetro Cero, los servicios corresponden a sus dominios funcionales: catalogo, pedidos, inventario, pagos, reparto y analitica.
flowchart LR
GW[Puerta de enlace] --> C[catalogo]
GW --> P[pedidos]
GW --> R[reparto]
P --> I[inventario]
P --> PA[pagos]
P -.->|evento PedidoCreado| AN[analitica]
R -.->|evento PosicionActualizada| AN
Este modelo combina rasgos de los anteriores: cada servicio es cliente-servidor respecto a los demás, y suele desplegarse en capas internamente. Su gran ventaja es la independencia (de despliegue, de escalado, de fallo y de equipo); su gran coste, la complejidad de tener decenas de piezas comunicándose por red. Lo desarrollaremos en la lección 08-01, y toda la evolución de Kilómetro Cero (lección 01-06) va encaminada hacia este modelo.
- Modelos de interacción: síncrono y asíncrono
La palabra "síncrono" se usa con dos significados distintos en sistemas distribuidos, y confundirlos es fuente de muchos malentendidos. Vamos a separarlos con cuidado.
3.1 Comunicación síncrona frente a asíncrona (estilo de interacción)
Se refiere a cómo se comporta el emisor tras enviar un mensaje:
- Comunicación síncrona: el emisor envía la petición y se bloquea esperando la respuesta. Es el estilo "petición-respuesta": llamar a una función remota, hacer una petición HTTP. Ejemplo en Kilómetro Cero:
pedidospregunta ainventariosi hay stock y no continúa hasta obtener la respuesta. - Comunicación asíncrona: el emisor envía el mensaje y continúa con su trabajo. La respuesta, si la hay, llegará más tarde por otro canal (una cola, una llamada de vuelta). Ejemplo: cuando se confirma un pedido,
pedidospublica el evento "PedidoConfirmado" y no espera a queanaliticalo procese.
| Síncrona | Asíncrona | |
|---|---|---|
| Acoplamiento temporal | Alto: ambos deben estar disponibles a la vez | Bajo: el receptor puede procesar más tarde |
| Simplicidad del código | Alta (se lee como una llamada normal) | Menor (hay que gestionar respuestas diferidas) |
| Comportamiento ante fallo del receptor | El emisor falla o espera | El mensaje espera en la cola |
| Ejemplo típico | HTTP, RPC, gRPC (Módulo 2, lecciones 02-02 y 02-03) | Colas de mensajes, eventos (lecciones 02-04 y 02-05) |
3.2 Sistemas síncronos frente a asíncronos (supuestos sobre el tiempo)
Este segundo significado es más teórico y más profundo. Se refiere a qué podemos suponer sobre los tiempos del sistema:
- Sistema síncrono: existen límites conocidos para (a) el tiempo que tarda un mensaje en llegar, (b) el tiempo que tarda un nodo en ejecutar un paso y (c) la deriva de los relojes. Bajo estos supuestos, si un nodo no responde en el tiempo máximo, sabemos con certeza que ha fallado.
- Sistema asíncrono: no existe ningún límite: un mensaje puede tardar un tiempo arbitrario (pero finito) en llegar, y un nodo puede tardar un tiempo arbitrario en dar un paso. Bajo estos supuestos, es imposible distinguir un nodo lento de un nodo caído.
- Parcialmente síncrono: el modelo más realista. El sistema se comporta como asíncrono durante periodos de tiempo (congestión, pausas de recolección de basura, sobrecarga), pero "la mayor parte del tiempo" respeta ciertos límites. Casi todos los sistemas reales (y casi todos los algoritmos prácticos de consenso, que veremos en 03-03) asumen este modelo.
¿Por qué importa? Porque hay un resultado teórico célebre (el resultado de imposibilidad FLP, de Fischer, Lynch y Paterson, 1985) que demuestra que en un sistema puramente asíncrono en el que un solo nodo pueda fallar, no existe ningún algoritmo determinista que garantice alcanzar consenso. No es una limitación de ingeniería: es una imposibilidad matemática. Los sistemas reales la sortean asumiendo sincronía parcial (timeouts) o aceptando garantías probabilísticas. Por eso, cuando en Kilómetro Cero el servicio pedidos espera una respuesta de inventario, la única herramienta que tiene es un timeout: una apuesta sobre cuánto es "demasiado tiempo", nunca una certeza. La lección 01-04 explora esta idea con código.
- Modelos de fallo
Un modelo de fallo describe de qué maneras puede comportarse mal un componente. Cuanto más amplio es el modelo, más difícil (y más caro) es tolerarlo. De menor a mayor gravedad:
| Modelo | Descripción | Ejemplo en Kilómetro Cero | Coste de tolerarlo |
|---|---|---|---|
| Crash-stop (parada por fallo) | El nodo funciona correctamente hasta que se para, y ya no vuelve (o vuelve como nodo nuevo, sin recordar nada) | El servidor de pagos se apaga por un fallo de alimentación |
Bajo: basta con replicar |
| Crash-recovery (fallo y recuperación) | El nodo se para pero puede reiniciarse, conservando su estado persistente (disco) pero no el volátil (memoria) | pedidos se reinicia tras quedarse sin memoria; recuerda los pedidos guardados en base de datos, pero no los que tenía en memoria |
Medio: hay que gestionar la recuperación |
| De omisión | El nodo no recibe o no envía algunos mensajes (pero los que envía son correctos) | La red pierde la petición de pedidos a inventario; o inventario la procesa pero la respuesta se pierde |
Medio: reintentos, confirmaciones |
| De temporización | El nodo responde correctamente pero fuera del tiempo esperado | inventario tarda 8 segundos en responder por una consulta lenta a la base de datos |
Medio: timeouts (y ambigüedad) |
| Arbitrario o bizantino | El nodo puede hacer cualquier cosa: enviar datos incorrectos, mentir, comportarse de forma inconsistente con distintos nodos, ser malicioso | Un nodo comprometido de inventario responde "hay stock" a pedidos y "no hay stock" a analitica |
Muy alto: se necesitan 3f+1 nodos para tolerar f bizantinos (lección 03-03) |
Algunas observaciones prácticas:
- Los fallos de omisión y de temporización, desde el punto de vista del emisor, son indistinguibles de un crash: en los tres casos, lo único que observa es que no llega respuesta a tiempo.
- La inmensa mayoría de los sistemas empresariales (incluido Kilómetro Cero) asumen el modelo crash-recovery con fallos de omisión, y no toleran fallos bizantinos: se confía en que los nodos propios no mienten. La tolerancia bizantina se reserva para sistemas donde participan partes que no se fían entre sí (blockchains, sistemas críticos de aviación).
- Un modelo de fallo también dice cuántos fallos simultáneos toleramos: "el sistema sigue funcionando si fallan hasta f de los n nodos".
- Modelos de consistencia: una primera visión
La tercera familia responde a qué garantías ofrece el sistema cuando hay varias copias de los datos (que, como veremos, es casi inevitable en cuanto distribuimos). Esta es materia del Módulo 3 completo, así que aquí basta con tener una intuición de los dos extremos:
| Modelo | Garantía | Precio | Ejemplo en Kilómetro Cero |
|---|---|---|---|
| Consistencia fuerte | Toda lectura devuelve el último valor escrito, en cualquier copia, como si solo existiera una | Latencia y disponibilidad: hay que coordinar las copias antes de responder | El stock de la última unidad de queso de Quesería Montblanc: Ana y Marc no pueden comprarla los dos |
| Consistencia eventual | Si dejan de producirse escrituras, todas las copias acabarán convergiendo al mismo valor; mientras tanto, una lectura puede devolver un valor antiguo | Lecturas obsoletas durante un tiempo | El número de "me gusta" de un producto, o el catálogo de fotos: no pasa nada si Lucía ve la foto antigua durante unos segundos |
Entre ambos extremos hay una escala de modelos intermedios (lectura de tus propias escrituras, consistencia causal, etc.) que se estudian en la lección 03-01, y el famoso teorema CAP (03-02) explica por qué no se puede tener todo a la vez. Por ahora, quédate con la idea: elegir el modelo de consistencia es una decisión de negocio por dato, no una propiedad global del sistema.
- Ejemplo de código: un servicio
catalogo cliente-servidor
catalogo cliente-servidorVamos a implementar el modelo cliente-servidor en su forma más elemental, con asyncio y sin ninguna dependencia externa. El servidor será una primera versión (muy simplificada) del servicio catalogo de Kilómetro Cero: mantiene un pequeño catálogo en memoria y responde a consultas de texto. El protocolo será deliberadamente primitivo (una línea de texto por petición y una respuesta JSON por línea) porque los protocolos "de verdad" son el tema de la lección 02-01.
Guarda este código como catalogo_servidor.py:
import asyncio
import json
CATALOGO = [
{"id": 1, "nombre": "Tomate rosa", "productor": "Huerta La Vega", "precio": 3.20},
{"id": 2, "nombre": "Queso curado de oveja", "productor": "Quesería Montblanc", "precio": 14.50},
{"id": 3, "nombre": "Queso fresco", "productor": "Quesería Montblanc", "precio": 6.80},
{"id": 4, "nombre": "Vino tinto crianza", "productor": "Bodega Roble Alto", "precio": 9.90},
{"id": 5, "nombre": "Calabacín", "productor": "Huerta La Vega", "precio": 2.10},
]
def buscar(texto: str) -> list[dict]:
"""Devuelve los productos cuyo nombre o productor contiene el texto."""
texto = texto.lower()
return [
p for p in CATALOGO
if texto in p["nombre"].lower() or texto in p["productor"].lower()
]
async def atender_cliente(reader: asyncio.StreamReader, writer: asyncio.StreamWriter):
"""Se ejecuta una vez por cada cliente que se conecta."""
direccion = writer.get_extra_info("peername")
print(f"[servidor] conexión desde {direccion}")
try:
while True:
linea = await reader.readline() # espera una petición
if not linea: # el cliente cerró la conexión
break
consulta = linea.decode().strip()
resultado = buscar(consulta)
respuesta = json.dumps(resultado, ensure_ascii=False) + "\n"
writer.write(respuesta.encode()) # envía la respuesta
await writer.drain() # espera a que salga por la red
print(f"[servidor] '{consulta}' -> {len(resultado)} resultados")
finally:
writer.close()
await writer.wait_closed()
print(f"[servidor] conexión cerrada con {direccion}")
async def main():
servidor = await asyncio.start_server(atender_cliente, "127.0.0.1", 8765)
print("[servidor] catalogo escuchando en 127.0.0.1:8765")
async with servidor:
await servidor.serve_forever()
if __name__ == "__main__":
asyncio.run(main())Y este como catalogo_cliente.py:
import asyncio
import json
async def consultar(consulta: str) -> list[dict]:
"""Abre una conexión, envía una consulta y devuelve la respuesta."""
reader, writer = await asyncio.open_connection("127.0.0.1", 8765)
writer.write((consulta + "\n").encode())
await writer.drain()
linea = await reader.readline() # bloquea hasta recibir la respuesta
writer.close()
await writer.wait_closed()
return json.loads(linea.decode())
async def main():
for consulta in ["queso", "vega", "vino"]:
productos = await consultar(consulta)
print(f"Consulta '{consulta}':")
for p in productos:
print(f" - {p['nombre']} ({p['productor']}): {p['precio']:.2f} €")
if __name__ == "__main__":
asyncio.run(main())Ejecuta primero el servidor en una terminal y luego el cliente en otra. Analicemos las piezas:
asyncio.start_servercrea un servidor que escucha en el puerto 8765 de la máquina local. Por cada cliente que se conecta,asynciollama aatender_clientecon un par de objetos:reader(para leer lo que envía el cliente) ywriter(para responderle).- El bucle
while Truedentro deatender_clientepermite que un mismo cliente envíe varias consultas por la misma conexión. Cuandoreadline()devuelve una cadena vacía, significa que el cliente ha cerrado la conexión. await writer.drain()es la parte "distribuida" del código: el servidor no controla cuándo saldrán los bytes por la red;drain()espera a que el sistema operativo los haya aceptado. Es el primer lugar en el que el código toca la realidad física de la red.- En el cliente,
consultares un ejemplo perfecto de comunicación síncrona (estilo petición-respuesta): envía la consulta yawait reader.readline()no continúa hasta que llega la respuesta. Si el servidor no respondiera nunca, el cliente se quedaría esperando para siempre: no hemos puesto ningún timeout (lo arreglaremos en 01-04). - Los papeles están claramente separados: el servidor espera y el cliente inicia. Ese es el modelo cliente-servidor.
Prueba a lanzar dos o tres clientes a la vez: gracias a asyncio, el servidor los atiende de forma concurrente sin necesidad de hilos. Y prueba también a matar el servidor mientras un cliente está conectado: el cliente recibirá un error de conexión, que es la forma en que el modelo de fallo crash-stop se manifiesta en el código.
- Ejemplo de código: catálogos compartidos en una red P2P simulada
Para el modelo P2P no vamos a usar sockets, sino una simulación en la que cada mercado es un objeto y la "red" es una lista de vecinos. La idea es implementar un intercambio de catálogos por gossip (cotilleo): cada peer, periódicamente, elige un vecino al azar y le cuenta lo que sabe; ambos se quedan con la unión de sus conocimientos.
import random
class MercadoPeer:
"""Un mercado local que conoce su propio catálogo y aprende los de otros."""
def __init__(self, nombre: str, productos_propios: dict[str, str]):
self.nombre = nombre
# catalogo: nombre de producto -> productor. Empieza solo con lo propio.
self.catalogo = dict(productos_propios)
self.vecinos: list["MercadoPeer"] = []
def conectar(self, otro: "MercadoPeer") -> None:
"""Enlace bidireccional: ambos peers se consideran vecinos."""
self.vecinos.append(otro)
otro.vecinos.append(self)
def ronda_gossip(self) -> None:
"""Elige un vecino al azar e intercambia catálogos con él."""
if not self.vecinos:
return
vecino = random.choice(self.vecinos)
# Cada uno aprende lo que no sabía del otro. No hay servidor central:
# ambos son a la vez emisor y receptor.
nuevos_para_mi = set(vecino.catalogo) - set(self.catalogo)
nuevos_para_el = set(self.catalogo) - set(vecino.catalogo)
self.catalogo.update({k: vecino.catalogo[k] for k in nuevos_para_mi})
vecino.catalogo.update({k: self.catalogo[k] for k in nuevos_para_el})
if nuevos_para_mi or nuevos_para_el:
print(f" {self.nombre} <-> {vecino.nombre}: "
f"aprendí {sorted(nuevos_para_mi)}, enseñé {sorted(nuevos_para_el)}")
def todos_convergen(peers: list[MercadoPeer]) -> bool:
"""True si todos los peers tienen exactamente el mismo catálogo."""
referencia = peers[0].catalogo
return all(p.catalogo == referencia for p in peers)
if __name__ == "__main__":
random.seed(3)
girona = MercadoPeer("Girona", {"Queso curado de oveja": "Quesería Montblanc"})
lleida = MercadoPeer("Lleida", {"Tomate rosa": "Huerta La Vega"})
tarragona = MercadoPeer("Tarragona", {"Vino tinto crianza": "Bodega Roble Alto"})
valencia = MercadoPeer("Valencia", {"Naranjas": "Huerta del Turia"})
# Topología: no todos conocen a todos (como en una red P2P real)
girona.conectar(lleida)
girona.conectar(tarragona)
lleida.conectar(tarragona)
tarragona.conectar(valencia)
lleida.conectar(valencia)
peers = [girona, lleida, tarragona, valencia]
ronda = 0
while not todos_convergen(peers):
ronda += 1
print(f"Ronda {ronda}:")
for peer in peers:
peer.ronda_gossip()
print(f"\nTodos los mercados convergieron en {ronda} rondas.")
print("Catálogo completo en Valencia:")
for producto, productor in sorted(valencia.catalogo.items()):
print(f" - {producto} ({productor})")Puntos que conviene destacar:
- No hay ningún nodo especial. Cada
MercadoPeertiene el mismo código y las mismas responsabilidades. Si Girona desapareciera, los demás seguirían intercambiando catálogos. - El conocimiento se propaga por contagio. Valencia no está conectada con Girona, pero acaba conociendo el queso de Quesería Montblanc a través de Tarragona o Lleida. Este es exactamente el mecanismo de gossip que usan sistemas como Cassandra para difundir el estado del clúster (lección 04-04).
- La convergencia es eventual. Durante varias rondas, distintos mercados tienen catálogos distintos: es un ejemplo tangible de consistencia eventual (apartado 5). Al final todos coinciden, pero no sabemos de antemano cuántas rondas harán falta.
- Simplificaciones importantes. Solo añadimos productos, nunca los modificamos ni eliminamos. Si Lleida y Girona tuvieran versiones distintas del precio del mismo producto, ¿cuál gana? Ese problema (resolución de conflictos) requiere los relojes lógicos de la lección 01-05 y las técnicas de replicación de 03-04.
Errores Comunes y Consejos
- Confundir los dos significados de "síncrono". "Llamada síncrona" habla del estilo de programación (bloquear esperando respuesta); "sistema síncrono" habla de supuestos sobre límites de tiempo. Se puede hacer una llamada síncrona en un sistema asíncrono (y es lo normal).
- Asumir que un timeout detecta fallos. Un timeout solo detecta que no ha llegado respuesta a tiempo. En un sistema asíncrono (o parcialmente síncrono), esto no permite concluir que el otro nodo esté caído. Todo diseño que trate "timeout" como "fallo seguro" acabará teniendo problemas de duplicados o de decisiones contradictorias.
- Diseñar para fallos bizantinos cuando no hace falta. Tolerar nodos maliciosos multiplica el coste y la complejidad. Si todos los nodos son tuyos y están en tu infraestructura, el modelo crash-recovery es casi siempre suficiente.
- Tratar el modelo de arquitectura como una elección única. Los sistemas reales combinan modelos: microservicios que internamente son multicapa, con componentes P2P para el descubrimiento y coordinación centralizada para ciertas decisiones. Kilómetro Cero terminará siendo una mezcla.
- Olvidar que las capas no arreglan la base de datos. Un error frecuente al pasar de monolito a multicapa es replicar la aplicación y dejar una única base de datos: la aplicación escala, pero el cuello de botella se traslada a los datos.
- Consejo: cuando documentes un diseño, escribe explícitamente el modelo de fallo asumido ("toleramos la caída de hasta un nodo de cada servicio; no toleramos nodos maliciosos"). Esa frase evita muchas discusiones y muchas sorpresas.
Ejercicios
Ejercicio 1: Elegir arquitectura
Para cada necesidad de Kilómetro Cero, indica qué modelo de arquitectura (cliente-servidor simple, multicapa, P2P o servicios) encaja mejor y por qué:
- Un panel interno para que el equipo de administración consulte los pedidos del día.
- Que las tabletas de los productores en un mercado sin cobertura puedan compartir entre sí las actualizaciones de stock hasta que vuelva la conexión.
- Que el módulo de pagos pueda desplegarse y escalarse sin afectar al resto de la plataforma.
Ejercicio 2: Clasificar fallos
Indica el modelo de fallo (crash-stop, crash-recovery, omisión, temporización, bizantino) que describe mejor cada situación, y explica qué ve el servicio pedidos en cada caso:
- El proceso de
inventariose reinicia automáticamente tras un fallo, recuperando el stock desde disco. - Un cable de red defectuoso descarta el 5 % de los paquetes entre
pedidoseinventario. - Una consulta lenta hace que
inventariotarde 15 segundos en responder. - Un error de programación hace que
inventariodevuelva stock negativo a algunos clientes y positivo a otros para el mismo producto.
Ejercicio 3: Timeout en el cliente
Modifica catalogo_cliente.py para que la espera de la respuesta tenga un límite de 2 segundos usando asyncio.wait_for. Si se supera, el cliente debe imprimir un mensaje de error y continuar con la siguiente consulta. Después, modifica el servidor para que "se duerma" 5 segundos (await asyncio.sleep(5)) antes de responder a la consulta "vino", y comprueba el comportamiento. ¿Qué modelo de fallo estás simulando?
Soluciones
Solución 1:
- Cliente-servidor simple (o multicapa ligera). Es una herramienta interna con pocos usuarios y sin requisitos de escala ni de alta disponibilidad. Añadir complejidad no aporta nada.
- Peer-to-peer. Las tabletas deben funcionar sin servidor central y compartir información directamente entre ellas; un mecanismo de gossip como el del apartado 7 encaja perfectamente. Cuando vuelva la conexión, los cambios se sincronizarán con el servidor (lo que planteará conflictos: Módulo 3).
- Servicios (microservicios). Es exactamente la motivación de este modelo: independencia de despliegue y de escalado por área funcional. Es el camino que tomará Kilómetro Cero.
Solución 2:
- Crash-recovery.
pedidosve que, durante unos segundos,inventariono responde (error de conexión), y luego vuelve a responder con el estado guardado. Los cambios queinventariotenía en memoria y no había persistido se han perdido. - De omisión. Algunas peticiones (o respuestas) no llegan.
pedidosve que, de forma aparentemente aleatoria, una de cada veinte llamadas no recibe respuesta, aunqueinventarioesté perfectamente sano. - De temporización. La respuesta es correcta, pero llega tarde. Si
pedidostiene un timeout de 3 segundos, verá exactamente lo mismo que en el caso 2 (sin respuesta), aunque la causa sea completamente distinta. - Bizantino (aunque no sea malicioso). El nodo devuelve resultados incorrectos e inconsistentes según a quién responde.
pedidosno ve ningún error: recibe respuestas con aspecto válido pero con contenido falso. Es el tipo de fallo más peligroso precisamente porque no se detecta con timeouts ni con reintentos.
Solución 3:
async def consultar(consulta: str, timeout: float = 2.0) -> list[dict]:
reader, writer = await asyncio.open_connection("127.0.0.1", 8765)
try:
writer.write((consulta + "\n").encode())
await writer.drain()
# wait_for cancela la espera si supera el límite y lanza TimeoutError
linea = await asyncio.wait_for(reader.readline(), timeout=timeout)
return json.loads(linea.decode())
finally:
writer.close()
await writer.wait_closed()
async def main():
for consulta in ["queso", "vega", "vino"]:
try:
productos = await consultar(consulta)
except asyncio.TimeoutError:
print(f"Consulta '{consulta}': el servidor no respondió a tiempo")
continue
print(f"Consulta '{consulta}':")
for p in productos:
print(f" - {p['nombre']} ({p['productor']}): {p['precio']:.2f} €")En el servidor, dentro de atender_cliente, añade antes de calcular la respuesta:
Al ejecutar, las consultas "queso" y "vega" funcionan y "vino" produce el mensaje de timeout. Estás simulando un fallo de temporización: el servidor está sano y responderá correctamente (aunque nadie lea ya la respuesta). Fíjate en que el cliente no puede saber si el servidor está lento o caído; es la ambigüedad del modelo asíncrono en acción. Si la consulta hubiera sido una operación con efectos (por ejemplo, "crear pedido"), el cliente no sabría si se ha creado o no, y reintentar podría duplicarlo.
Conclusión
Los modelos nos dan un lenguaje preciso para hablar de sistemas distribuidos. Hemos visto los modelos de arquitectura —cliente-servidor, multicapa, peer-to-peer y servicios— y cómo cada uno reparte los papeles y los riesgos de forma distinta; los modelos de interacción, distinguiendo con cuidado la comunicación síncrona/asíncrona (estilo de llamada) de los sistemas síncronos/asíncronos (supuestos sobre el tiempo), con la consecuencia fundamental de que en un sistema asíncrono no se puede distinguir un nodo lento de uno caído; los modelos de fallo, desde el sencillo crash-stop hasta el bizantino, con su coste creciente de tolerancia; y una primera visión de los modelos de consistencia, que el Módulo 3 desarrollará.
En el código hemos construido un servicio catalogo cliente-servidor con asyncio, y una red P2P simulada de mercados que difunden sus catálogos por gossip, viendo en acción la consistencia eventual.
Con este vocabulario ya podemos evaluar con rigor qué gana y qué pierde Kilómetro Cero al distribuirse. Eso es exactamente lo que haremos en la siguiente lección, Ventajas y Desafíos de los Sistemas Distribuidos, donde cuantificaremos la escalabilidad, la disponibilidad y los costes ocultos de la distribución.
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
