🎯 Meta del capítulo: ver el método del capítulo 23 y las herramientas del 24 aplicados a problemas reales, con las decisiones razonadas en voz alta. Menos teoría, más "así es como se piensa esto".
💡 Cómo sacarle partido: lee el enunciado, cierra la página y diseña tú durante 20 minutos en un papel. Luego compara. Leer la solución sin haberlo intentado te dará la sensación de haber aprendido sin haber aprendido nada — es el mismo efecto que ver a alguien levantar pesas.
26.1 · Caso 1: reservas de asientos (el problema de la concurrencia)
Enunciado. Sistema de venta de entradas para conciertos. Cuando salen a la venta las 5.000 entradas de un artista popular, 80.000 personas entran a la vez. Ni un solo asiento puede venderse dos veces.
Paso 1 — Entender el problema de verdad
El problema aquí no es la escala: 5.000 entradas son 5.000 filas. Es la contención: 80.000 personas peleando por el mismo recurso escaso, a la vez, y con dinero de por medio.
Requisito duro: cero doble-venta (un asiento = un comprador). No negociable.
Requisito blando: que la web no se caiga y la cola sea justa.
Lo que puede fallar: el usuario paga y no recibe entrada → incidente grave.
El usuario recibe entrada y no paga → pérdida directa.Paso 2 — El bug que casi todo el mundo escribe primero
# ❌ ROTO. Y funciona perfectamente en tus pruebas locales.
asiento = db.query("SELECT * FROM asientos WHERE id = %s", asiento_id)
if asiento.estado == "libre": # ← Ana y Beto leen "libre" a la vez
db.execute("UPDATE asientos SET estado = 'vendido', usuario_id = %s WHERE id = %s",
usuario_id, asiento_id) # ← los dos escriben. El segundo gana.Esto es una condición de carrera (race condition). Entre el SELECT y el UPDATE hay unos milisegundos en los que otro proceso puede hacer exactamente lo mismo. Con un usuario nunca falla. Con 80.000, falla constantemente.
🧠 La lección general: comprobar y luego actuar (
check-then-act) nunca es seguro en concurrencia. Si entre la comprobación y la acción alguien puede cambiar el estado, tu comprobación no vale nada. La solución siempre es la misma: hacer que comprobar y actuar sean una sola operación atómica.
Paso 3 — Las tres soluciones correctas
Opción A — Bloqueo pesimista (SELECT ... FOR UPDATE)
BEGIN;
SELECT * FROM asientos WHERE id = 42 FOR UPDATE; -- 🔒 bloquea la FILA
-- cualquier otra transacción que pida esta fila ESPERA aquí
UPDATE asientos SET estado = 'vendido', usuario_id = 7 WHERE id = 42 AND estado = 'libre';
COMMIT; -- 🔓 se liberaAsume que habrá conflicto y lo previene bloqueando. Correcto y fácil de razonar; el coste es que las peticiones se serializan y, con mucha contención, se acumulan esperas.
Opción B — Bloqueo optimista (la condición en el WHERE)
UPDATE asientos
SET estado = 'vendido', usuario_id = 7
WHERE id = 42 AND estado = 'libre'; -- 📌 la comprobación ESTÁ en la escritura
-- Si devuelve 0 filas afectadas → otro llegó antes. Sin bloqueos, sin esperas.Asume que no habrá conflicto y lo detecta si ocurre. Una sola sentencia, atómica por definición. Esta es la solución preferida en la mayoría de los casos: sin bloqueos explícitos, sin deadlocks y sin esperas.
filas = db.execute("UPDATE asientos SET estado='reservado', usuario_id=%s, "
"reservado_hasta = NOW() + INTERVAL '10 minutes' "
"WHERE id=%s AND estado='libre'", usuario_id, asiento_id)
if filas.rowcount == 0:
raise AsientoNoDisponible() # otro se adelantó: dilo con claridadOpción C — Restricción en la base de datos (la red de seguridad definitiva)
CREATE UNIQUE INDEX idx_asiento_unico ON reservas (evento_id, asiento_id)
WHERE estado IN ('reservado', 'vendido');Aunque tu código tenga un bug, la base de datos rechaza el segundo insert. Es la única garantía que no depende de que ningún programador se acuerde de nada.
💡 Úsalas juntas: B para el flujo normal y C como red de seguridad. El bloqueo optimista da un mensaje de error bonito al usuario; la restricción única garantiza que la invariante se cumple pase lo que pase. Las reglas de negocio críticas deben estar en la base de datos, no solo en el código: el código lo escriben cinco personas distintas en tres años, la restricción es una y no se olvida.
Paso 4 — El flujo completo (con el pago)
El problema no acaba en reservar el asiento. Falta el dinero.
sequenceDiagram
participant U as Usuario
participant A as API
participant BD as PostgreSQL
participant P as Pasarela de pago
participant W as Worker
U->>A: POST /reservas {asiento: 42}
A->>BD: UPDATE ... WHERE estado='libre' (atómico)
alt 0 filas afectadas
BD-->>A: sin cambios
A-->>U: 409 Conflict "asiento ya no disponible"
else 1 fila
BD-->>A: reservado 10 min
A-->>U: 201 {reserva_id, expira_en}
U->>A: POST /reservas/{id}/pagar
A->>P: cobrar (idempotency_key = reserva_id)
P-->>A: éxito
A->>BD: UPDATE estado='vendido'
A-->>U: 200 entrada emitida
end
Note over W: cada minuto: libera reservas con<br/>reservado_hasta < NOW()Las tres decisiones clave de este diseño:
- La reserva caduca. Sin
reservado_hasta, quien abre la página y se va a comer bloquea un asiento para siempre. Diez minutos es el estándar del sector. - La clave de idempotencia es el
reserva_id. Si el usuario pulsa "pagar" dos veces o la red reintenta, la pasarela cobra una sola vez (capítulo 24, apéndice P). - Un worker libera lo caducado. No confíes en que el usuario cancele: el sistema debe recuperarse solo.
⚠️ La "sala de espera" virtual. Con 80.000 personas simultáneas, ni la mejor base de datos aguanta el pico. La solución del sector no es escalar la base de datos: es poner una cola delante. Los usuarios entran en una fila virtual y el sistema deja pasar a 500 cada 30 segundos. Esto convierte un pico brutal en un flujo constante y controlado — y de paso el usuario ve "eres el 4.312 de la cola" en vez de un error 503. Rediseñar el problema suele ganarle a optimizar la solución.
26.2 · Caso 2: el feed de una red social
Enunciado. Los usuarios siguen a otros usuarios y ven un feed cronológico con sus publicaciones. 10 millones de usuarios activos al día, media de 200 seguidos por usuario. El feed debe cargar en menos de 200 ms.
La pregunta central: ¿cuándo se construye el feed?
FAN-OUT ON READ (al leer) FAN-OUT ON WRITE (al escribir)
───────────────────────── ──────────────────────────────
Publicar: 1 escritura. Barato. Publicar: N escrituras (una por seguidor)
Leer: consulta cara sobre 200 Leer: ya está precalculado. LEER = 1 lectura
usuarios, ordenar y paginar
SELECT * FROM posts Al publicar, se INSERTA el post en
WHERE autor_id IN (200 ids) el "buzón" de cada seguidor
ORDER BY creado_en DESC LIMIT 20
✅ Escrituras baratas ✅ Lecturas instantáneas
✅ Cero duplicación ✅ Escala con las lecturas (que son el 95%)
❌ Cada lectura es cara ❌ Escribir es caro
❌ No escala con las lecturas ❌ 💀 el problema de los famososEl problema de los famosos: si alguien tiene 50 millones de seguidores, publicar un tuit son 50 millones de escrituras. Eso no es un post: es un ataque de denegación de servicio contra tu propia infraestructura.
La solución real: híbrida
def obtener_feed(usuario_id):
# 1. Lo precalculado (fan-out on write) para los usuarios normales
feed = redis.zrevrange(f"feed:{usuario_id}", 0, 50, withscores=True)
# 2. Los famosos se consultan EN VIVO (fan-out on read) y se mezclan
famosos = db.query("""SELECT seguido_id FROM seguidores
WHERE seguidor_id = %s AND es_famoso = true""", usuario_id)
if famosos:
recientes = db.query("""SELECT * FROM posts WHERE autor_id = ANY(%s)
ORDER BY creado_en DESC LIMIT 50""", famosos)
feed = mezclar_por_fecha(feed, recientes)[:50]
return feeddef publicar(autor_id, contenido):
post_id = db.insert("posts", autor_id=autor_id, contenido=contenido)
n = contar_seguidores(autor_id)
if n < 10_000:
cola.publicar("repartir_feed", post_id=post_id) # fan-out asíncrono
# Si tiene ≥10.000 seguidores: NO se reparte. Se leerá en vivo.
return post_id🧠 La decisión que hay detrás: no existe una arquitectura buena para "el feed". Existen dos, con perfiles opuestos, y la respuesta correcta es aplicar cada una al caso en que gana. Este es exactamente el patrón que verás una y otra vez en sistemas reales: no elegir entre dos diseños, sino identificar el eje que los separa (aquí, el número de seguidores) y poner el umbral en el sitio correcto.
Otras dos decisiones que importan:
- El reparto es asíncrono. Publicar devuelve
201en cuanto el post está guardado; repartirlo a 8.000 buzones ocurre en un worker. El autor no espera. - Los buzones se recortan. Guardar el feed completo de cada usuario para siempre es insostenible. Se conservan los últimos ~500 elementos en Redis (
ZREMRANGEBYRANK); quien quiera ver más atrás, paga una consulta lenta a la base de datos. El 99% no lo hace.
26.3 · Caso 3: pedidos y pagos (transacciones que cruzan sistemas)
Enunciado. E-commerce. Al confirmar un pedido hay que: reservar stock, cobrar la tarjeta, crear el pedido, enviar confirmación y avisar al almacén. El cobro es de un sistema externo: no puede estar en tu transacción de base de datos.
El problema: no hay transacción posible
# ❌ Esto NO es atómico, aunque lo parezca
with db.transaction():
descontar_stock(producto_id) # dentro de la transacción ✅
stripe.cobrar(total) # ⚠️ FUERA. Es una llamada de red.
crear_pedido() # dentro ✅
# Si crear_pedido() falla → ROLLBACK del stock…
# …pero el COBRO YA SE HIZO. Le has quitado el dinero al cliente sin pedido. 💀⚠️ Nunca metas una llamada de red dentro de una transacción de base de datos. Dos razones: no se puede deshacer con un
ROLLBACK, y mantiene abiertos bloqueos de la base de datos durante cientos de milisegundos, lo que bajo carga agota el pool de conexiones. Es una de las causas más comunes de caídas en sistemas de pago.
La solución: máquina de estados + pasos compensables (saga)
En vez de "todo o nada", el pedido avanza por estados y cada paso sabe cómo deshacerse.
pendiente ──► stock_reservado ──► pagado ──► confirmado ──► enviado
│ │ │
│ │ └─ fallo → REEMBOLSAR + liberar stock
│ └────────────────── fallo → liberar stock
└────────────────────────────────── fallo → cancelar sin másdef crear_pedido(usuario_id, items, clave_idempotencia):
# 0. Idempotencia: si el usuario pulsa dos veces, devolvemos el mismo pedido
if (existente := db.buscar_pedido_por_clave(clave_idempotencia)):
return existente
# 1. Crear el pedido en estado "pendiente" — se persiste ANTES de nada más.
# Así, si el proceso muere en cualquier punto, hay rastro de qué pasaba.
pedido = db.crear_pedido(usuario_id, items, estado="pendiente",
clave_idempotencia=clave_idempotencia)
# 2. Reservar stock: atómico, con la condición en el WHERE (caso 1)
if not reservar_stock(items, pedido.id):
db.actualizar(pedido.id, estado="cancelado", motivo="sin_stock")
raise SinStock()
# 3. Cobrar. FUERA de cualquier transacción, con clave de idempotencia.
try:
cobro = stripe.cobrar(pedido.total, idempotency_key=f"pedido-{pedido.id}")
except ErrorPago as e:
liberar_stock(pedido.id) # ← compensación
db.actualizar(pedido.id, estado="pago_fallido", motivo=str(e))
raise
# 4. Confirmar: ahora sí, en una transacción local
with db.transaction():
db.actualizar(pedido.id, estado="confirmado", cobro_id=cobro.id)
db.confirmar_reserva_stock(pedido.id)
# 5. Todo lo demás es asíncrono: el usuario ya tiene su respuesta
cola.publicar("email_confirmacion", pedido_id=pedido.id)
cola.publicar("notificar_almacen", pedido_id=pedido.id)
cola.publicar("actualizar_analitica", pedido_id=pedido.id)
return pedidoLas cuatro ideas que hacen que esto funcione:
- El estado se persiste antes de cada paso arriesgado. Si el servidor muere entre el paso 3 y el 4, existe un pedido en estado
pendientecon un cobro hecho. Un proceso de reconciliación lo detecta y lo resuelve. Sin ese registro, el dinero desaparece en el limbo. - Cada paso tiene su compensación. No hay
ROLLBACKglobal, así que cada acción debe saber deshacerse: liberar stock, reembolsar. - Clave de idempotencia de punta a punta. Tanto a la entrada (el usuario que hace doble clic) como hacia Stripe (el reintento de red).
- Lo no crítico es asíncrono. El email, el almacén y la analítica no pueden hacer fallar un pedido ya cobrado.
💡 El proceso de reconciliación es la pieza que separa un sistema de pagos de juguete de uno real. Un job periódico que busca pedidos "atascados" (más de N minutos en un estado intermedio), consulta el estado real en la pasarela y los resuelve. Siempre habrá casos atascados: la red falla justo entre dos pasos, el proceso muere, la pasarela responde tarde. Diseñar asumiendo que eso ocurrirá es la diferencia entre un incidente rutinario y una llamada del departamento financiero.
26.4 · Caso 4: chat en tiempo real
Enunciado. Chat con conversaciones 1-a-1 y grupos de hasta 500 personas. Los mensajes deben llegar en menos de un segundo. Se debe ver quién está en línea y si el mensaje fue leído.
Decisión 1 — ¿Cómo se entrega en tiempo real?
| Opción | Latencia | Coste | Cuándo |
|---|---|---|---|
| Polling (pedir cada 5 s) | 0-5 s | Terrible: 99% de peticiones vacías | Nunca para chat |
| Long polling | ~0 | Medio | Compatibilidad con clientes antiguos |
| SSE (Server-Sent Events) | ~0 | Bajo | 📌 Solo servidor→cliente |
| WebSocket | ~0 | Bajo | 📌 Bidireccional. Aquí, este. |
Se elige WebSocket porque el chat es intrínsecamente bidireccional: el cliente envía mensajes, indicadores de "escribiendo…" y acuses de lectura, y el servidor envía mensajes y presencia.
🧠 Regla práctica de elección: si solo el servidor tiene algo que decir (notificaciones, métricas en vivo, progreso de una tarea, tokens de un LLM), SSE es más simple, funciona sobre HTTP normal y reconecta solo. Si el cliente también habla constantemente, WebSocket. Elegir WebSocket "porque es más moderno" cuando bastaba SSE es complejidad accidental (capítulo 23).
Decisión 2 — El problema de los múltiples servidores
❌ Ana conectada al servidor 1, Beto al servidor 2.
Ana escribe. El servidor 1 no tiene ni idea de dónde está Beto.
→ El mensaje no llega. La escala horizontal ha roto el chat.Solución: un bus de publicación/suscripción.
Ana ──WS──► Servidor 1 ──publica──► Redis Pub/Sub ──► Servidor 2 ──WS──► Beto
(canal: sala:123)Cada servidor se suscribe a los canales de las salas que tienen usuarios conectados a él. Cuando llega un mensaje, lo publica en Redis y todos los servidores con gente en esa sala lo reciben y lo reenvían por sus WebSockets.
⚠️ Redis Pub/Sub no persiste nada. Si un servidor está reiniciándose en ese instante, el mensaje se pierde para sus usuarios. Por eso el flujo correcto es: guardar en PostgreSQL primero, publicar después. La base de datos es la fuente de verdad; el pub/sub solo acelera la entrega. Al reconectar, el cliente pide los mensajes posteriores a su último
idconocido y recupera lo que se perdió.
Decisión 3 — El modelo de datos
CREATE TABLE mensajes (
id BIGSERIAL PRIMARY KEY,
sala_id BIGINT NOT NULL REFERENCES salas(id),
autor_id BIGINT NOT NULL REFERENCES usuarios(id),
contenido TEXT NOT NULL,
creado_en TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
-- 📌 El índice que define el rendimiento del producto entero:
-- "los últimos N mensajes de esta sala" es EL 95% de las consultas.
CREATE INDEX idx_mensajes_sala_id ON mensajes (sala_id, id DESC);
-- Los acuses de lectura NO son una columna del mensaje:
-- en un grupo de 500, cada mensaje tiene 500 estados de lectura distintos.
CREATE TABLE lecturas (
sala_id BIGINT,
usuario_id BIGINT,
ultimo_leido_id BIGINT, -- ← un solo número por usuario y sala
PRIMARY KEY (sala_id, usuario_id)
);💡
ultimo_leido_iden vez de una fila por mensaje leído. Como los mensajes se leen en orden, "he leído hasta el 4.812" implica que has leído todos los anteriores. Esto convierte una tabla que crecería comomensajes × usuarios(miles de millones de filas) en una que crece comosalas × usuarios(millones). Encontrar esa propiedad del dominio que colapsa el modelo de datos es exactamente en lo que consiste modelar bien.
La presencia va en Redis, no en PostgreSQL:
redis.setex(f"online:{usuario_id}", 60, "1") # heartbeat cada 30 s
# Escribir "última conexión" en PostgreSQL en cada latido de cada usuario
# es una tormenta de escrituras sobre una tabla caliente, para un dato
# que a nadie le importa si es exacto.26.5 · Caso 5: subida y procesado de archivos
Enunciado. Los usuarios suben imágenes (hasta 20 MB) y vídeos (hasta 500 MB). Hay que generar miniaturas, transcodificar el vídeo a varias resoluciones y servirlo rápido en todo el mundo.
Decisión 1 — El archivo NO pasa por tu servidor
❌ Usuario ──► Tu API ──► Almacenamiento
Un vídeo de 500 MB ocupa un worker de tu API durante minutos.
Diez subidas simultáneas y tu API deja de responder a todo lo demás.
✅ Usuario ──(1) pide permiso──► Tu API ──devuelve URL firmada──► Usuario
Usuario ──(2) sube DIRECTAMENTE──► S3 / R2
S3 ──(3) evento──► Tu API/cola: "ya está subido, procésalo"@app.post("/subidas/preparar")
def preparar_subida(nombre: str, tipo: str, tamano: int, usuario=Depends(auth)):
if tipo not in TIPOS_PERMITIDOS: raise HTTPException(400)
if tamano > MAX_BYTES: raise HTTPException(413)
clave = f"subidas/{usuario.id}/{uuid4()}"
url = s3.generate_presigned_url("put_object", Params={
"Bucket": BUCKET, "Key": clave, "ContentType": tipo,
"ContentLength": tamano,
}, ExpiresIn=900) # válida 15 minutos
return {"url": url, "clave": clave}🧠 Este patrón (URL prefirmada) es de los que más veces vas a reutilizar en tu carrera. Tu API decide quién puede subir, qué y de qué tamaño —que es lo único que requiere tu lógica de negocio— y delega el trabajo pesado de transferir bytes al servicio diseñado para eso. Tu API maneja kilobytes de JSON; el almacenamiento maneja los gigabytes.
Decisión 2 — El procesado es asíncrono y por fases
graph LR
A[Subida completada] --> B[(Cola)]
B --> C[Validar de verdad<br/>el contenido]
C -->|imagen| D[Miniaturas<br/>150/600/1200 px]
C -->|vídeo| E[Transcodificar<br/>360p/720p/1080p]
C -->|inválido| F[Rechazar y borrar]
D --> G[Marcar listo + CDN]
E --> G⚠️ "Validar de verdad el contenido" no es comprobar la extensión ni el
Content-Type: ambos los controla el atacante. Hay que leer los primeros bytes del archivo (los magic numbers) y confirmar que es realmente lo que dice ser. Un.jpgpuede contener un script, un ZIP bomba o un SVG con JavaScript dentro. Y para las imágenes: elimina los metadatos EXIF, que incluyen las coordenadas GPS exactas de dónde se tomó la foto.
Las tres reglas del procesado asíncrono aquí:
- El usuario recibe respuesta inmediata con estado
procesando, y la interfaz muestra un marcador de posición. Transcodificar un vídeo tarda minutos: nadie espera con la página abierta. - Cada tarea es idempotente. Si el worker muere a mitad de transcodificar, se reintenta desde cero y el resultado es el mismo (se escribe en una clave determinista y se sobrescribe).
- Las fases son independientes. Que falle la generación de la miniatura de 1200 px no debe invalidar las otras dos ni el archivo original.
Decisión 3 — Servir: CDN y nombres inmutables
/imagenes/a3f9c2e1-600.webp ← el nombre incluye un hash o UUID
Cache-Control: public, max-age=31536000, immutableSi el contenido cambia, cambia el nombre. Así puedes cachear un año en el navegador y en el CDN sin miedo a servir algo obsoleto, y evitas por completo el problema de invalidación de caché del capítulo 24: no se invalida lo que nunca cambia.
✅ Ejercicio del capítulo
Diseña un sistema de reservas de restaurante aplicando todo lo anterior. Es un problema engañosamente rico.
Requisitos
- Restaurantes con N mesas de distinta capacidad (2, 4, 6, 8 comensales).
- Los usuarios reservan para una fecha, hora y número de comensales.
- Turnos de 90 minutos. Un restaurante puede tener varios turnos por servicio.
- Nunca puede haber sobre-reserva.
- El restaurante puede bloquear mesas o días (obras, festivos, eventos privados).
- Se envía recordatorio por email 24 h antes y por SMS 2 h antes.
- Los usuarios pueden cancelar hasta 2 h antes; después, se penaliza.
Entregables
- Preguntas previas. Diez preguntas que harías antes de diseñar (capítulo 23), señalando cuáles cambiarían radicalmente el diseño según la respuesta.
- Estimaciones. Supón 5.000 restaurantes y 50.000 reservas al día. Peticiones por segundo, ratio lectura/escritura, almacenamiento a 3 años. ¿Qué te dicen esos números sobre la arquitectura que necesitas?
- Modelo de datos. Esquema completo con tipos, claves e índices. Justifica cada índice con la consulta que lo usa.
- El núcleo: la asignación de mesa sin sobre-reserva. Escribe la consulta o el pseudocódigo exacto, indicando qué técnica del caso 1 usas y por qué. ¿Qué pasa si dos personas reservan la última mesa en el mismo milisegundo?
- Un caso peliagudo: una reserva de 5 personas puede ocupar una mesa de 6, o dos de 4 unidas. ¿Cómo modelas eso sin que el algoritmo se te vaya de las manos? (Pista: es un problema de asignación; piensa si necesitas la solución óptima o solo una buena.)
- Recordatorios. ¿Cómo los programas? Compara: un cron que consulta cada minuto, una cola con entrega retrasada, y una tarea programada por reserva. ¿Qué pasa si el usuario cambia la hora después de que se haya programado el recordatorio?
- Fallos. ¿Qué ocurre si el proveedor de SMS está caído? ¿Y si el restaurante bloquea un día para el que ya hay reservas confirmadas?
- Diagrama de la arquitectura completa (mermaid), indicando qué es síncrono y qué asíncrono.
- ADR (capítulo 25) de la decisión más importante que hayas tomado, con al menos tres alternativas descartadas.
- Evolución. Etapa 1 (lanzamiento) y qué métrica concreta dispararía cada etapa siguiente.
💡 Pistas de la solución (abre solo si te atascas)
Punto 2 — el resultado que debería sorprenderte. 50.000 reservas/día ≈ 0,6 escrituras por segundo. Con picos, quizá 5. Esto es nada. Un solo PostgreSQL modesto sirve el sistema entero con margen enorme. Toda la dificultad de este problema está en la corrección bajo concurrencia y en el modelado, no en la escala. Reconocer eso pronto evita que diseñes una arquitectura distribuida para un problema que cabe en un servidor — que es exactamente el error que este capítulo intenta enseñarte a no cometer.
Punto 3 — el modelo. La tentación es una tabla mesas con un campo ocupada. Es un error: la ocupación no es una propiedad de la mesa, es una propiedad del par (mesa, turno). Modela reservas(restaurante_id, mesa_id, fecha, turno_id, estado, comensales) y deriva la disponibilidad de la ausencia de reservas. El índice clave es sobre (restaurante_id, fecha, turno_id), porque la consulta dominante es "qué hay libre en este restaurante ese día".
Punto 4 — Bloqueo optimista del caso 1: un INSERT con la condición de disponibilidad, más una restricción EXCLUDE de PostgreSQL como red de seguridad, que es la herramienta pensada exactamente para esto:
ALTER TABLE reservas ADD CONSTRAINT sin_solapamiento
EXCLUDE USING gist (mesa_id WITH =, rango_horario WITH &&)
WHERE (estado IN ('confirmada','pendiente'));&& es el operador de solapamiento de rangos: la base de datos rechaza físicamente dos reservas que se pisen en la misma mesa, aunque tu código tenga un bug. Si dos peticiones llegan en el mismo milisegundo, una obtiene la mesa y la otra recibe una violación de restricción que traduces a un 409 Conflict.
Punto 5 — No busques el óptimo. Un algoritmo voraz que asigne la mesa más pequeña que quepa (best fit) resuelve el 95% de los casos y se explica en una frase. Las combinaciones de mesas unidas se modelan como una tabla de "agrupaciones válidas" que define el restaurante (él sabe qué mesas se pueden juntar; tú no puedes deducirlo). Buscar la asignación óptima es un problema NP-difícil que aquí no aporta nada: el maître mueve mesas a mano todos los días.
Punto 6 — el que más gente falla. Programar una tarea al crear la reserva parece elegante, pero si el usuario cambia la hora tienes que encontrar y cancelar la tarea programada, y si tu cola no lo permite bien, envías recordatorios de una reserva que ya no existe. Lo robusto y aburrido: un cron cada 5 minutos que consulta reservas WHERE recordatorio_24h_enviado = false AND inicio BETWEEN NOW()+23h AND NOW()+25h, envía y marca. El estado vive en la base de datos, no en la cola, así que cualquier cambio de la reserva se refleja automáticamente. Es más simple y falla mejor.
Punto 7 — Bloquear un día con reservas confirmadas no es un problema técnico: es una decisión de producto que debes forzar a que alguien tome. El sistema debe detectarlo, mostrar cuántas reservas afecta y exigir una confirmación explícita, disparando entonces un flujo de notificación y disculpa a los clientes afectados. Un sistema que simplemente borra las reservas en silencio es peor que uno que no permite bloquear.
🧠 Autoevaluación
Porque entre la comprobación y la acción hay una ventana de tiempo en la que otro proceso puede cambiar el estado que acabas de leer. Tu comprobación describía el pasado, no el presente en el que actúas. Con poca carga esa ventana casi nunca se materializa —por eso el bug no aparece en desarrollo—, pero con miles de peticiones concurrentes ocurre constantemente.
Las tres soluciones:
- Bloqueo pesimista (
SELECT ... FOR UPDATE): bloquear la fila para que nadie más la lea hasta terminar. Simple de razonar; serializa el acceso y puede generar esperas. - Bloqueo optimista (la condición dentro del
UPDATE ... WHERE estado='libre'): comprobación y acción pasan a ser una sola operación atómica; si afecta a 0 filas, alguien se adelantó. Suele ser la mejor opción. - Restricciones de la base de datos (índice único,
EXCLUDE): la invariante se garantiza a nivel de motor, aunque el código tenga bugs.
Lo ideal es combinar la 2 (mensajes de error limpios) con la 3 (red de seguridad).
Fan-out on read construye el feed en el momento de leerlo: publicar es una sola escritura, pero cada lectura implica consultar las publicaciones de los cientos de cuentas que sigues, mezclarlas y ordenarlas. Fan-out on write precalcula: al publicar, se inserta una copia en el "buzón" de cada seguidor, de modo que leer el feed es una única lectura ya ordenada.
Como en una red social las lecturas superan a las escrituras en uno o dos órdenes de magnitud, fan-out on write parece la respuesta obvia… hasta que aparece una cuenta con 50 millones de seguidores, donde una sola publicación se convierte en 50 millones de escrituras.
Por eso los sistemas reales usan un híbrido: fan-out on write para las cuentas normales (baratísimo, lecturas instantáneas) y fan-out on read para las cuentas muy seguidas, cuyas publicaciones se consultan en vivo y se mezclan al construir el feed. La lección general es que el eje del problema —el número de seguidores— tiene una distribución tan extrema que ninguna estrategia única funciona en todo el rango.
Por dos razones independientes, y cada una basta.
Corrección: un ROLLBACK deshace lo que ocurrió en la base de datos, pero no puede deshacer un cobro ya ejecutado en Stripe. Si la transacción falla después del cobro, el dinero se ha movido y el pedido no existe — el peor escenario posible.
Operación: una transacción abierta mantiene bloqueos y ocupa una conexión del pool. Una llamada HTTP puede tardar cientos de milisegundos, o segundos si el otro extremo está degradado. Bajo carga, todas las conexiones acaban esperando a un servicio externo lento y la base de datos deja de atender al resto de la aplicación.
La solución es una saga: una máquina de estados persistida donde cada paso se ejecuta fuera de la transacción y define su compensación (liberar stock, reembolsar). Se completa con un proceso de reconciliación que resuelve los casos que quedaron a medias, porque siempre los habrá.
SSE cuando la comunicación es esencialmente unidireccional servidor→cliente: notificaciones, métricas en vivo, progreso de una tarea larga, streaming de tokens de un LLM. Funciona sobre HTTP normal (atraviesa proxies y balanceadores sin configuración especial), reconecta automáticamente y reanuda con Last-Event-ID. El cliente sigue pudiendo enviar datos por peticiones HTTP normales.
WebSocket cuando el cliente también emite constantemente y la latencia de abrir una petición por mensaje sería inaceptable: chat, edición colaborativa, juegos, indicadores de "escribiendo…".
La trampa es elegir WebSocket por defecto "porque es más potente". Trae consigo su propio protocolo, gestión manual de reconexión, latidos para detectar conexiones muertas, y configuración especial en proxies y balanceadores. Si SSE cubre tu caso, es bastante menos complejidad accidental.
Por una propiedad del dominio: los mensajes de una conversación se leen en orden, así que "he leído hasta el mensaje 4.812" implica lógicamente que he leído todos los anteriores. Un solo número contiene la misma información que miles de filas.
La diferencia de escala es determinante. Una fila por (mensaje, usuario) hace crecer la tabla como mensajes × participantes: en un grupo de 500 personas, cada mensaje genera 500 filas, y un chat activo llega a miles de millones. Con ultimo_leido_id, la tabla crece como salas × usuarios, y el "no leídos" se calcula con un simple COUNT(*) WHERE id > ultimo_leido_id, que el índice (sala_id, id) resuelve al instante.
Es un buen ejemplo de que modelar bien no consiste en representar fielmente la realidad, sino en encontrar la propiedad del dominio que permite representarla con muchísimo menos.
Porque transferir bytes es exactamente lo que tu API hace peor. Un vídeo de 500 MB mantiene ocupado un worker durante minutos: memoria, una conexión y un hilo dedicados a copiar datos. Con unas pocas subidas simultáneas, la API deja de responder a todo lo demás, aunque el resto del sistema esté ocioso. Además obliga a escalar tu capa de aplicación por ancho de banda en lugar de por lógica de negocio, y añade un límite de tamaño de request en cada proxy del camino.
Con URLs prefirmadas, tu API solo hace lo que requiere tu lógica de negocio: comprobar quién es el usuario, si tiene permiso, qué tipo y tamaño se admiten, y emitir una URL temporal y acotada. El cliente sube directamente a S3/R2 y el almacenamiento notifica al terminar. Tu API mueve kilobytes de JSON; la infraestructura diseñada para ello mueve los gigabytes.
Es un trabajo periódico que busca operaciones atascadas en un estado intermedio —pedidos que llevan más de N minutos en pendiente o pagando—, consulta el estado real en el sistema externo (la pasarela de pago) y resuelve la discrepancia: confirma el pedido si el cobro se realizó, o lo cancela y libera el stock si no.
Es obligatorio porque no existe forma de hacer atómicas dos operaciones en sistemas distintos. Da igual cómo ordenes los pasos: siempre hay un instante en el que el proceso puede morir, la red cortarse o la pasarela responder después de tu timeout. Con volumen suficiente, esos casos no son hipotéticos, son diarios.
La alternativa a reconciliar no es "que no pase": es que pase y nadie se entere, hasta que un cliente reclama un cobro sin pedido o el departamento financiero encuentra un descuadre semanas después. La diferencia entre un sistema de pagos serio y uno de juguete no es el camino feliz —ese lo escribe cualquiera— sino qué ocurre con el 0,1% que se sale de él.
Siguiente: Proyectos guiados — ahora te toca a ti construir.