Capítulo 04 · 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.
11 min intermedio
El último 42
Queda un par talla 42 del 123. Ana lo mete en el carrito. Luis abre la misma ficha. Si los dos pueden pulsar Comprar, uno paga y el otro recibe «sin stock» después de haber puesto la tarjeta. Peor: los dos pagan y tenés un pedido 457 sin zapatilla.
La transacción de Postgres arregla el instante del insert: o baja el stock y nace el pedido, o no. Dura milisegundos. No te sirve para lo que la web promete: «te lo guardo 10 minutos mientras pagás». Dejar una transacción SQL abierta diez minutos es cómo tumbar la base: locks de fila, conexiones ocupadas, todo el mundo esperando.
Un lock distribuido es un cartel visible para todos los procesos: «el 42 del 123 está cogido, hasta tal hora, por Ana». Da igual que el checkout de Ana esté en la API-1 y el de Luis en la API-2.
Un cartel en Redis, no un BEGIN eterno
SET lock:product:123:size:42
"user:ana"
NX
EX 600
NX: solo si no existe. Si Ana llega primera, el SET funciona. Luis recibe nil. EX 600: en diez minutos el cartel se cae solo. Eso es lo más importante. Si Ana cierra el portátil a mitad de pago, no podés dejar el 42 preso para siempre. El TTL es el plan de contingencia. Sin TTL, un crash deja un lock zombie y las zapatillas desaparecen del catálogo hasta que un humano borra la key.
Cuando Ana paga de verdad, el checkout hace lo de siempre: transacción corta en Postgres, stock a cero, pedido. Después sueltás el lock. Si el pago falla o expira el carrito, el TTL suelta solo.
El valor user:ana no es adorno. Luis no puede hacer DEL del lock de Ana. Ana solo borra el suyo. En Redis se hace con un script Lua para que get+comprobar+borrar no se intercalen. En una entrevista no hace falta el Lua. Hace falta: who owns it, expire it, only the owner unlocks.
Lo que no es
No es una transacción. No te da rollback de Stripe. No sustituye el UNIQUE del event_id ni la idempotency key. El lock dice «nadie más reserve este par un rato». El cobro sigue siendo el de colas. El stock real sigue siendo Postgres. Si usás el lock en vez de bajar el stock, y Redis se olvida, vendiste humo. El lock protege la intención. La verdad del inventario es la fila.
Tampoco es un caché. Ahí Redis copiaba la ficha. Acá Redis es un coordinador. Misma pieza, otro job.
Y no es obligatorio para cada producto. Si hay 800 pares del 42, el carrito puede ser optimista: al pagar, la transacción de stock gana o pierde. El lock brilla cuando el recurso es escaso y codiciado: el último par, la butaca 14F, el cupón de un solo uso, el cron que no debe correr dos veces.
Pesimista u optimista
Pesimista: bloqueá primero, trabajá después. Asumís que va a haber pelea. En Postgres, en el instante del cobro:
BEGIN
SELECT stock FROM inventory
WHERE sku = '123-42'
FOR UPDATE
UPDATE inventory SET stock = stock - 1 ...
INSERT INTO orders ...
COMMIT
FOR UPDATE es el bloqueo pesimista de toda la vida: lock de fila, transacción corta. El precio: quien llega segundo espera. Por eso pesimista en SQL es para el commit del cobro, no para los diez minutos del carrito.
El SET NX de Redis es pesimista entre procesos y a otra escala de tiempo. Ana reserva el 42 a las 18:01; Luis ni entra al pago. A las 18:03 Ana cobra y la transacción corta baja el inventario. Dos pesimistas encadenados: uno de UX (hold), uno de verdad (stock).
Optimista: asumís que casi nadie pisa a nadie. Leés, trabajás, 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ó, el UPDATE toca cero filas. Ana reintenta o ve «sin stock». Con 800 pares del 42 casi nunca chocan: optimista es más barato. Con el último par, los choques son el caso normal: correcto, mala experiencia. Ahí el pesimista (hold en Redis + transacción corta) evita que los dos lleguen hasta Stripe.
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.
«Lock pesimista» no es automáticamente Redis. Puede ser FOR UPDATE. Redis es pesimista distribuido. Optimista casi nunca usa un cartel: usa una versión o un WHERE stock = 1.
Granularidad, muertos y abrazos
Qué cerrás importa. Lock de todo product:123 bloquea la talla 41 porque alguien reserva la 42. Lock por SKU (123:42) es el de la tienda. Lock por butaca, no por estadio. Demasiado fino: millones de keys. Demasiado gordo: una sola compra paraliza el catálogo. The SKU, not the product; the seat, not the venue.
El proceso muere. Por eso el TTL. El riesgo inverso: Ana sigue pagando y el TTL se acaba. Luis coge el par, Ana termina, dos dueños. Mitigación: renovar el lock mientras el checkout está vivo. 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 lock. El lock reduce la ventana. No es un milagro de consistencia absoluta.
Deadlock. Ana quiere el 42 y un cordón extra. Luis quiere el cordón y el 42. Cada uno tiene uno, espera el otro. Receta aburrida: un orden global de adquisición, timeouts, no tomar locks en sitios del código que no se conocen entre sí.
Redlock. Varios Redis, mayoría de votos. Es controvertido. En una entrevista, Redis con SET NX EX y TTL es el diseño esperado. Si te aprietan «¿y si Redis pierde el lock en un failover?»: the database stock transaction is the source of truth; the lock is best-effort to improve UX. Esa frase es más senior que un algoritmo de cinco nodos que no operaste.
Dónde sale, además de las zapatillas
Ticketmaster: la butaca 14F, diez minutos, el mismo patrón. Uber: un conductor no se asigna a dos riders a la vez. Un cron de «cerrar pedidos abandonados» en cinco servidores: un lock para que solo uno corra. Una subasta: lock breve para serializar pujas.
En todos, la duración encaja con el negocio (minutos en checkout, segundos en matchmaking, milisegundos en una puja) y siempre hay un TTL. Un lock sin caducidad 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. Ana entra al pago. El carrito refresca el TTL. Stripe cobra con idempotency key. Postgres, transacción corta: stock 1 → 0, nace el pedido. Se borra el lock. Si Ana abandona, a los diez minutos Luis puede cogerlo. Si las dos APIs intentan el SET a la vez, una sola recibe OK: Redis serializa ese comando.
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, y no creer que el lock sustituye el stock.
Qué espera cada nivel
Un mid nombra Redis SET NX y un TTL.
Un senior distingue hold de UX vs transacción de stock, elige pesimista u optimista según contención, y trata Redis caído como degradación (peor UX, inventario correcto).
Si te lo preguntan
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.