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

  1. Para qué sirven los modelos
  2. Modelos de arquitectura
  3. Modelos de interacción: síncrono y asíncrono
  4. Modelos de fallo
  5. Modelos de consistencia: una primera visión
  6. Ejemplo de código: un servicio catalogo cliente-servidor
  7. Ejemplo de código: catálogos compartidos en una red P2P simulada
  8. Errores comunes y consejos
  9. Ejercicios
  10. Conclusión

  1. 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"

  1. 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:

  1. Presentación (web): recibe las peticiones HTTP, sirve contenido estático, gestiona sesiones y TLS.
  2. Lógica de negocio (aplicación): ejecuta las reglas del dominio (calcular precios, validar pedidos, aplicar descuentos de campaña).
  3. 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.

  1. 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: pedidos pregunta a inventario si 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, pedidos publica el evento "PedidoConfirmado" y no espera a que analitica lo 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.

  1. 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".

  1. 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.

  1. Ejemplo de código: un servicio catalogo cliente-servidor

Vamos 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:

  1. asyncio.start_server crea un servidor que escucha en el puerto 8765 de la máquina local. Por cada cliente que se conecta, asyncio llama a atender_cliente con un par de objetos: reader (para leer lo que envía el cliente) y writer (para responderle).
  2. El bucle while True dentro de atender_cliente permite que un mismo cliente envíe varias consultas por la misma conexión. Cuando readline() devuelve una cadena vacía, significa que el cliente ha cerrado la conexión.
  3. 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.
  4. En el cliente, consultar es un ejemplo perfecto de comunicación síncrona (estilo petición-respuesta): envía la consulta y await 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).
  5. 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.

  1. 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:

  1. No hay ningún nodo especial. Cada MercadoPeer tiene el mismo código y las mismas responsabilidades. Si Girona desapareciera, los demás seguirían intercambiando catálogos.
  2. 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).
  3. 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.
  4. 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é:

  1. Un panel interno para que el equipo de administración consulte los pedidos del día.
  2. 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.
  3. 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:

  1. El proceso de inventario se reinicia automáticamente tras un fallo, recuperando el stock desde disco.
  2. Un cable de red defectuoso descarta el 5 % de los paquetes entre pedidos e inventario.
  3. Una consulta lenta hace que inventario tarde 15 segundos en responder.
  4. Un error de programación hace que inventario devuelva 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:

  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.
  2. 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).
  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:

  1. Crash-recovery. pedidos ve que, durante unos segundos, inventario no responde (error de conexión), y luego vuelve a responder con el estado guardado. Los cambios que inventario tenía en memoria y no había persistido se han perdido.
  2. De omisión. Algunas peticiones (o respuestas) no llegan. pedidos ve que, de forma aparentemente aleatoria, una de cada veinte llamadas no recibe respuesta, aunque inventario esté perfectamente sano.
  3. De temporización. La respuesta es correcta, pero llega tarde. Si pedidos tiene un timeout de 3 segundos, verá exactamente lo mismo que en el caso 2 (sin respuesta), aunque la causa sea completamente distinta.
  4. Bizantino (aunque no sea malicioso). El nodo devuelve resultados incorrectos e inconsistentes según a quién responde. pedidos no 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:

            if consulta == "vino":
                await asyncio.sleep(5)   # simula una consulta muy lenta

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

Módulo 2: Comunicación en Sistemas Distribuidos

Módulo 3: Consistencia y Replicación

Módulo 4: Almacenamiento Distribuido

Módulo 5: Computación Distribuida

Módulo 6: Seguridad en Sistemas Distribuidos

Módulo 7: Monitoreo y Mantenimiento

Módulo 8: Casos de Estudio y Aplicaciones

© Copyright 2026. Todos los derechos reservados