Capítulo 06 · El último par
Locks distribuidos
Apartar la talla 42 diez minutos. Un BEGIN no vive tanto. Redis SET NX, TTL, y la fila de stock como verdad.
18 min intermedio
El último 42
Imaginate que seguimos en la misma tienda. Ya tenemos base de datos: el pedido nace en Postgres. Ya tenemos load balancer, CDN y caché: la ficha del 123 se sirve desde Redis sin martillar la base de datos. Ahora el problema es de otra clase.
Queda un par talla 42. Ana lo mete en el carrito y la web le dice, en sustancia: «te lo guardo diez minutos mientras pagás». Dos minutos después, Luis abre la misma ficha. Si el sistema no hace nada especial, Luis también ve «disponible». Los dos llegan a Comprar. Uno de dos finales malos:
- Uno paga y el otro se entera de que no hay stock después de poner la tarjeta.
- Los dos pagan, nacen dos pedidos, y en el depósito solo hay un par. El 457 se queda sin zapatilla.
Eso no es un bug raro de Black Friday. Es lo que pasa cuando dos personas pelean por el mismo recurso escaso y el sistema no coordina el «mientras tanto».
Por qué la transacción de Postgres no alcanza
En el capítulo de bases de datos resolvimos el instante del cobro con una transacción corta: bajar stock y crear el pedido juntos, o no hacer ninguna de las dos. Eso dura milisegundos. Es perfecto para el COMMIT. No es perfecto para la promesa de la web.
La web quiere guardar el 42 durante minutos, mientras Ana mira Stripe, mientras busca la tarjeta, mientras el 3D Secure tarda. Si intentás cumplir eso con un BEGIN de Postgres abierto diez minutos, pasa esto:
- Esa conexión de la base queda ocupada.
- La fila del stock queda bloqueada.
- El resto de la tienda — otras tallas, otros productos, otros checkouts — espera o se degrada.
Un BEGIN no vive diez minutos. Las transacciones de base están hechas para operaciones cortas y consistentes, no para «apartar un par mientras el usuario termina de pagar». Hace falta otra herramienta.
Qué es un lock distribuido
Un lock distribuido es un cartel compartido que pueden ver todas las instancias de la API: «el 42 del 123 está cogido por Ana hasta tal hora».
«Distribuido» importa porque Ana puede haber caído en la API-1 y Luis en la API-2. Si el cartel vive solo en la memoria de un proceso, la otra máquina no lo ve. Por eso el cartel suele vivir en Redis (o en ZooKeeper/etcd en diseños más pesados): un sitio que todas las APIs consultan.
En esta tienda, Redis ya estaba. En el capítulo de caché guardaba una copia de la ficha. Acá no copia el producto: coordina quién puede avanzar. Misma pieza, otro trabajo.
Cómo funciona en el checkout
Ana pulsa Reservar. La API no abre una transacción larga. Hace un SET atómico en Redis:
SET lock:product:123:size:42
"user:ana"
NX
EX 600
Leído en castellano:
- La key es el recurso: el SKU
123talla42, no «todo el producto». - El valor dice quién lo tiene:
user:ana. NX= «poné esto solo si la key todavía no existe». Si ya hay cartel, elSETfalla.EX 600= «borrá el cartel solo a los 600 segundos» (diez minutos).
Paso a paso:
- Ana pide el hold. Redis acepta el
SET NX. El cartel queda puesto. - Luis pide el mismo hold. Redis responde que no: la key ya existe. En el front, Luis ve la talla apartada.
- Ana paga. El checkout hace lo de siempre: transacción corta en Postgres, stock de 1 a 0, nace el pedido.
- Después de confirmar el stock, la API borra el cartel — y solo si sigue siendo de Ana.
- Si Ana abandona o el pago falla, no hace falta un worker heroico: a los diez minutos Redis borra el cartel solo y el par vuelve a estar libre.
Ana → API → SET NX EX 600 → OK (cartel puesto)
Luis → API → SET NX → nil (ocupado)
│
▼
Redis: lock:…:42 = user:ana
Tres detalles que en una entrevista de system design (o una entrevista técnica) importan tanto como el comando:
El TTL no es un adorno. Si Ana cierra el portátil o el pod de la API se reinicia, sin TTL el 42 queda preso para siempre: un lock zombie. Con TTL, el abandono se resuelve solo. El negocio acepta: a los diez minutos, el par vuelve al catálogo.
El dueño importa. El valor user:ana evita que Luis haga DEL del lock de Ana. Solo el dueño suelta el suyo. En Redis eso suele ir en un script corto (Lua) para que «leer dueño + borrar» no se corte a la mitad. En la pizarra no hace falta el script. Hace falta poder decir: who owns it, expire it, only the owner unlocks.
El stock sigue en Postgres. El cartel dice «nadie más reserve un rato». No dice «ya vendimos». Si Redis se olvida de la key y vos bajaste el stock solo en Redis, vendiste humo. El lock protege la intención. La verdad del depósito es la fila.
Cuándo sí, cuándo no
No hace falta un lock para cada cosa del catálogo.
Si hay ochocientos pares del 42, casi nadie se pisa. Podés ser más baratos: dejás que la gente llegue al pago y, en la transacción de stock, gana quien todavía encuentra un par. Si el UPDATE baja el stock con éxito, hay pedido. Si no, «sin stock». Correcto y barato.
El cartel brilla cuando el recurso es escaso y codiciado:
- el último 42 de las zapatillas,
- la butaca 14F,
- un cupón de un solo uso,
- un cron de «cerrar carritos abandonados» que corre en cinco servidores y no debe ejecutarse cinco veces.
En una entrevista de system design, lo que diferencia a un mid de alguien que solo memorizó Redis es poder decir cuándo vale la pena el hold, no solo cómo se escribe el SET.
Pesimista u optimista (con el mismo 42)
Hay dos maneras de pensar el choque entre Ana y Luis. Conviene no mezclarlas.
Pesimista: bloqueá primero
Asumís que va a haber pelea. Reservás el recurso y después trabajás.
En el instante del cobro, en Postgres, el pesimista clásico es:
BEGIN
SELECT stock FROM inventory
WHERE sku = '123-42'
FOR UPDATE
UPDATE inventory SET stock = stock - 1 ...
INSERT INTO orders ...
COMMIT
FOR UPDATE bloquea la fila mientras dura la transacción. Quien llega segundo espera. Eso está bien porque dura milisegundos. Es el candado del commit, no el del carrito.
El SET NX de Redis también es pesimista, pero entre APIs y durante minutos. Ana reserva a las 18:01; Luis ni entra al pago. A las 18:03 Ana cobra y la transacción corta baja el inventario. Son dos candados encadenados:
- Hold en Redis → mejor experiencia (Luis no se sienta en la caja en vano).
- Transacción en Postgres → verdad del stock.
Optimista: asumí que casi no chocan
Leés, dejás seguir, y al escribir comprobás que nadie cambió la fila:
UPDATE inventory
SET stock = 0, version = 8
WHERE sku = '123-42' AND version = 7
Si Luis se coló y ya movió la versión, el UPDATE toca cero filas. Ana ve «sin stock» o reintenta. Con ochocientos pares, casi nunca pasa. Con el último par, el choque es el caso normal: el diseño es correcto, la experiencia es mala — los dos llegan a Stripe y uno pierde tarde.
Para la pizarra: optimistic when contention is rare; pessimistic when the resource is scarce. Database FOR UPDATE for the short commit; Redis lock if you must hold across minutes and services.
Un detalle que confunde: «pesimista» no significa automáticamente Redis. Puede ser un FOR UPDATE en la misma base. Redis es pesimista distribuido: cuando el hold cruza minutos y varias máquinas. Optimista casi nunca usa un cartel: usa una versión en la fila, o un WHERE stock = 1.
Problemas típicos (y cómo contarlos)
Igual que el caché trae stampede y stale, el lock trae sus propios bordes. Si lo mencionás en una entrevista de system design o en una entrevista técnica, esperan que nombres al menos un par.
Granularidad: el SKU, no el producto
Si el lock es de todo product:123, Ana reserva la 42 y de rebote bloqueás la 41. Luis quería la 41 y no puede. Una sola compra paralizó de más. La key correcta en esta tienda es 123:42. En entradas: la butaca, no el estadio. The SKU, not the product; the seat, not the venue.
El proceso muere (por eso el TTL)
Ana estaba en la API-1, el pod se reinicia, el carrito se quedó a medias. Sin TTL, el 42 queda zombie. Con TTL, a los diez minutos Luis puede entrar. Ese es el caso bueno del reloj.
El TTL se acaba mientras Ana sigue pagando
El caso feo: Ana sigue en Stripe y el reloj llega a cero. Luis coge el par. Ana termina. En la cabeza de los dos el 42 es suyo; en el depósito, uno. Mitigación: renovar el lock mientras el checkout está vivo (un heartbeat que alarga los diez minutos). Y al cobrar, la transacción de stock es la última palabra: si ya no hay par, Ana recibe el error aunque creyera tener el cartel. El lock reduce la ventana de pelea. No es magia de consistencia absoluta.
Deadlock
Ana quiere el 42 y un cordón extra. Luis quiere el cordón y el 42. Cada uno ya tiene uno y espera el otro. Receta aburrida y correcta: un orden global al pedir locks (siempre el mismo orden), timeouts, y no tomar candados en rincones del código que no se conocen entre sí. En una entrevista de system design, nombrarlo basta.
Redis se cae
No cerrés la tienda porque falta el cartel. Sin hold, Ana y Luis pueden llegar los dos al pago; la transacción de stock decide quién se queda el par. Peor experiencia — alguien ve «sin stock» tarde — inventario correcto. Es la misma idea de degradación que en caché: el coordinador (Redis) no es la fuente de verdad; la fuente de verdad es la base de datos.
Si te preguntan por Redlock (varios Redis votando): es controvertido. El diseño esperado en casi cualquier pizarra es un Redis con SET NX EX y TTL. La frase senior: the database stock transaction is the source of truth; the lock is best-effort to improve UX.
El mismo patrón, otra duración
Fuera de las zapatillas, el patrón se repite con otro reloj:
- Ticketmaster: butaca 14F, ~10 minutos (el carrito del 42 con otro disfraz).
- Matching tipo Uber: un conductor no se asigna a dos riders; hold de segundos.
- Cron en cinco servidores: un lock para que solo uno corra.
- Subasta: lock breve para serializar pujas.
- Stampede del caché: un
SET NXde pocos segundos para que un solo request regenere la key.
En todos: ¿cuánto dura? ¿qué key? ¿qué pasa si el dueño muere? Un lock sin caducidad no es «más seguro». Es un incidente diferido.
El 42, entero
Ana pulsa Reservar. SET NX EX 600 sobre lock:product:123:size:42. Gana. Luis ve la talla ocupada. Mientras Ana paga, el carrito renueva el TTL. Stripe cobra con idempotency key. Postgres, transacción corta: stock 1 → 0, nace el pedido. Se borra el lock solo si el valor sigue siendo user:ana. Si Ana abandona, a los diez minutos el cartel se cae solo. Si las dos APIs disparan el SET a la vez, una sola recibe OK: Redis serializa ese comando.
Reservar
→ SET lock:…:42 NX EX 600
→ OK → Ana paga
→ (renueva TTL mientras vive el checkout)
→ Stripe (idempotency key)
→ BEGIN … stock → COMMIT
→ DEL solo si owner = ana
→ nil → Luis ve «apartada»
Ana abandona → TTL 600 → el cartel se cae solo
Redis cae → sin hold → la tx de stock decide
No hace falta ZooKeeper para unas zapatillas. Hace falta no usar una transacción SQL de diez minutos, no olvidar el TTL, no bloquear todo el producto por una sola talla, y no creer que el cartel sustituye la fila de stock.
Qué espera cada nivel
Un mid explica el problema con Ana y Luis, entiende por qué un BEGIN largo no sirve, nombra Redis SET NX con key por SKU y TTL, y sabe que el stock se confirma igual en Postgres.
Un senior distingue hold de UX vs transacción de stock, elige pesimista u optimista según cuánta pelea espera, renueva el lock mientras el checkout vive, y trata Redis caído como degradación: peor experiencia, inventario correcto.
Si te lo preguntan
¿Cómo apartás el último 42? I’d hold the last size-42 with a Redis lock, SET NX and a 10-minute TTL, keyed by SKU. The checkout still commits stock in Postgres; the lock only prevents two people sitting in payment on the same pair. If the process dies, the TTL releases it. If the TTL fires while she’s still paying, the stock transaction is the final check.
¿Pesimista u optimista? Pessimistic: lock first — FOR UPDATE on the stock row for the short commit, or a Redis lock if I need a ten-minute hold. Optimistic: update where version = n, retry on conflict. I’d be optimistic if there are 800 pairs; pessimistic on the last one.
¿Por qué no un row lock de SQL? Row locks are for short transactions. A ten-minute checkout would hold database connections and block other writes.
¿Y si Redis falla? We degrade: skip the hold, let the purchase transaction win or lose. Worse UX, correct inventory.
¿Redlock? Optional. I’d start with one Redis and treat the DB as truth.
¿Qué key? The SKU, not the whole product. Seat, not venue.