Dos de nuestras propias queries se bloquearon entre sí y tumbaron el checkout
Devrim Ozcay cuenta cómo un deadlock entre dos transacciones perfectamente correctas —cada una revisada y aprobada por separado— tumbó el checkout justo antes de una campaña promocional, y por qué ninguna revisión de código individual lo habría detectado.
La alerta llegó en la peor ventana posible: cuarenta minutos antes de un push promocional programado, con la tasa de error del checkout subiendo más allá del treinta por ciento mientras el tráfico ya estaba escalando de cara a la campaña. Abrí el dashboard esperando a los sospechosos de siempre: una query lenta, un pool de conexiones agotado, un deploy problemático que nadie había marcado.
Lo que encontré en cambio fue un tipo de error muy específico que había leído de pasada, años atrás, y que nunca había vivido en carne propia.
ERROR: deadlock detected
DETAIL: Process 4821 waits for ShareLock on transaction 9012;
blocked by process 5533.
Process 5533 waits for ShareLock on transaction 8877;
blocked by process 4821.
Dos procesos propios, bloqueándose entre sí, cada uno esperando un lock que el otro tenía, ninguno pudiendo avanzar, ninguno equivocado de ninguna forma que pudiera señalar individualmente.
El instinto que desperdició los primeros diez minutos
Mi primer movimiento fue salir a cazar la query mala. En algún lugar de este código, me dije, hay una transacción mal escrita haciendo algo que no debería, y una vez que la encuentre y la arregle, esto se termina.
Primero leí la transacción de procesamiento de pedidos, porque era la más directamente implicada en el flujo de checkout. Limpia. Razonable. Bloqueaba las filas que necesitaba, en un orden que tenía sentido para lo que hacía, y las liberaba con rapidez. Nada obviamente mal.
Después leí la transacción de ajuste de inventario, la otra parte del deadlock. También limpia. También razonable, vista de forma aislada. También haciendo exactamente lo que necesitaba hacer, en un orden que tenía sentido para su propio propósito.
Diez minutos buscando un villano que no existía, porque el problema real no estaba en ninguna de las dos queries por separado. Estaba en lo que pasaba cuando ambas corrían al mismo tiempo.
Por qué esto no es un bug en el sentido tradicional
Acá está la verdad estructural incómoda sobre los deadlocks, la que hizo que esto fuera genuinamente distinto de las fallas que estaba acostumbrado a cazar. Un deadlock no es un error en ninguna pieza individual de código. Es una propiedad emergente de dos piezas de código independientemente correctas tocando los mismos recursos en un orden distinto.
Transacción A (procesamiento de pedidos):
1. Bloquear fila en la tabla `orders`
2. Bloquear fila en la tabla `inventory`
Transacción B (ajuste de inventario):
1. Bloquear fila en la tabla `inventory`
2. Bloquear fila en la tabla `orders`
Leé cada una por separado y no hay nada que objetar. Corrélas de forma concurrente, justo en el momento equivocado, y A tiene el lock que B necesita mientras B tiene el lock que A necesita, y ninguna de las dos va a soltar nunca lo que tiene, porque ninguna puede terminar sin lo que la otra se niega a soltar.
Antes de seguir con cómo arreglamos esto en la práctica, y con el patrón específico de reordenamiento que previene toda esta clase de falla: acá es donde arrancar si querés desarrollar este tipo de instinto diagnóstico antes de que te pase tu propia versión de esto en tu propia ventana de cuarenta minutos más comprometida. Tres tarjetas de campo gratis sobre el incidente, la entrevista y la oferta —los tres momentos que realmente definen una carrera backend—: Senior Backend Field Cards.
Y si querés el playbook completo de PostgreSQL en producción del que salió este incidente —contención de locks, patrones de deadlock, agotamiento de conexiones, y las decisiones a nivel de query que los previenen—, está en PostgreSQL for Senior Engineers: Production & System Design Decisions.
Qué hizo PostgreSQL en realidad, y por qué eso importó
Acá está el detalle que le sacó filo a mi pánico una vez que lo entendí de verdad: PostgreSQL no dejó a las dos transacciones colgadas para siempre. Su detector de deadlocks, que corre en un chequeo periódico, identificó el ciclo, eligió a una de las dos transacciones como víctima, y la mató, a la fuerza, con el mensaje de error exacto que había aparecido en nuestros logs.
Eso no es una falla de la base de datos. Es la base de datos haciendo exactamente lo que se supone que tiene que hacer cuando se le presenta una situación imposible. La falla real estaba río arriba de PostgreSQL, completamente: nuestro código de aplicación no tenía lógica de reintento para un error de “víctima de deadlock”, así que en vez de reintentar en silencio la transacción asesinada, el error salía directo hacia el usuario como una falla de checkout.
Los dos problemas separados escondidos dentro de un solo síntoma
Quiero ser específico acá, porque confundir los dos habría llevado a un arreglo incompleto. Había genuinamente dos problemas separados, uno encima del otro.
El primero era el orden de adquisición de locks en sí mismo. Dos transacciones tocando las mismas dos tablas en órdenes opuestos es un deadlock esperando a pasar, dada suficiente carga concurrente, y era solo cuestión de tiempo antes de que el tráfico subiera lo suficiente como para dispararlo realmente —que es exactamente lo que hace un push promocional.
El segundo, y el que realmente determinó si un usuario veía un error o no, era la ausencia completa de manejo de reintentos para el caso específico, bien conocido y completamente esperable de una víctima de deadlock. PostgreSQL iba a matar a una de estas transacciones bajo carga tarde o temprano, por diseño. La única pregunta era si nuestro código trataba eso como un evento rutinario y reintentable, o como una falla sin manejar que un cliente real vería a mitad de su checkout.
El arreglo, en dos partes
Primero, el orden de los locks: el arreglo más fundamental. Reescribimos ambas transacciones para que adquirieran los locks en un orden consistente y acordado en todo el código base: inventario antes que pedidos, siempre, sin excepciones, documentado explícitamente para que el próximo ingeniero que toque cualquiera de las dos transacciones no reintroduzca la misma asimetría por accidente.
Transacción A (procesamiento de pedidos), corregida:
1. Bloquear fila en la tabla `inventory`
2. Bloquear fila en la tabla `orders`
Transacción B (ajuste de inventario), corregida:
1. Bloquear fila en la tabla `inventory`
2. Bloquear fila en la tabla `orders`
Mismos recursos, mismas operaciones, orden consistente. El ciclo específico que había causado el deadlock se volvió estructuralmente imposible, no solo menos probable.
Segundo, la lógica de reintentos: la red de seguridad para cualquier otro desajuste de orden de locks que todavía no hubiéramos encontrado, y para el hecho de que incluso transacciones perfectamente ordenadas pueden ocasionalmente hacer deadlock bajo patrones de concurrencia genuinamente patológicos. Ahora, un error de víctima de deadlock dispara un reintento automático con un backoff corto, invisible para el usuario, en vez de aparecer como una falla de checkout.
La parte donde casi lo di por arreglado demasiado pronto
Quiero marcar algo que casi se me pasa, porque es la mitad más instructiva de esta historia. El arreglo del orden de locks resolvió el par específico de transacciones que efectivamente había hecho deadlock. No resolvió nada sobre si otros pares de transacciones no relacionados, en otra parte del código, tenían el mismo problema de orden asimétrico, esperando en silencio su propia versión de este pico de tráfico promocional para exponerlos.
Un compañero de equipo, más cuidadoso que mi instinto de cerrar el ticket y seguir adelante, propuso una auditoría real: cada transacción multi-tabla en el camino de checkout e inventario, mapeada contra todas las demás, buscando cualquier par que tocara las mismas tablas en un orden distinto. Encontramos dos más. Ninguna había hecho deadlock todavía. Ambas se arreglaron antes de que tuvieran la oportunidad, durante una semana tranquila en vez de durante un push promocional en vivo.
Por qué esto nunca fue realmente sobre estas dos queries específicas
Esto es, creo, de lo que se trató en realidad todo este incidente, por debajo de un par específico de transacciones. Los deadlocks son una clase de falla que no se anuncia en el code review, porque cada transacción individual se ve completamente correcta vista de forma aislada. El peligro solo existe en la interacción entre dos piezas de código que pueden haber sido escritas con meses de diferencia, por personas distintas, que nunca tuvieron un motivo para coordinar algo tan poco glamoroso como el orden de adquisición de locks.
Esa es la parte incómoda. No podés detectar esto leyendo con cuidado cualquiera de las dos transacciones. Tenés que pensar en el sistema como un grafo de recursos y el orden en que los distintos caminos de código los tocan, que es una disciplina de revisión mucho menos intuitiva que verificar si una función individual es correcta.
La verdad incómoda de la industria
Y acá está la parte que genuinamente me incomoda, mirando hacia atrás. La mayoría de los equipos no tiene ninguna convención explícita y documentada sobre el orden de adquisición de locks en todo su código base. Es el tipo de cosa que funciona bien durante años, hasta que la carga concurrente cruza el umbral específico que hace que la colisión sea estadísticamente probable, y para entonces la convención que la habría prevenido no existe en ningún lugar donde alguien pudiera haberla consultado.
Eso no es un problema de PostgreSQL. Es un problema de coordinación, empeorado por el hecho de que las dos piezas de código en conflicto puede que nunca hayan sido revisadas una al lado de la otra por la misma persona, porque nada en un proceso normal de PR las pondría nunca una junto a la otra.
Lo que realmente te diría que hagas
Andá y encontrá cada lugar en tu código base donde una sola transacción toca más de una tabla, y verificá si hay una convención documentada y aplicada sobre en qué orden se bloquean esas tablas en todo el código base, no solo dentro de una transacción puntual. Si no la hay, probablemente tengas una versión de esto esperando la carga concurrente correcta para exponerse. Y sin importar qué tan cuidadoso sea tu orden de locks, asegurate de que tu aplicación realmente reintente un error de víctima de deadlock en vez de dejarlo salir, porque que PostgreSQL mate una transacción bajo contención es un comportamiento esperado y normal, no un bug que debas tratar como una excepción sin manejar.
El final difícil
Ninguna de las dos queries estaba mal. Esa sigue siendo la parte que más raro me sienta, meses después. Dos piezas de código correctas, escritas por personas que nunca necesitaron coordinar entre sí, esperaron lo suficiente bajo suficiente carga concurrente hasta finalmente colisionar, y la colisión se veía, desde un dashboard cuarenta minutos antes de un push promocional, exactamente como un bug que debería haber sido obvio en el code review.
No era obvio. Nunca iba a ser obvio, leyendo cualquiera de las dos transacciones por separado. Algunas fallas solo existen en el espacio entre dos piezas de código correctas, y la única forma de detectarlas es dejar de revisar funciones de forma aislada y empezar a revisar el orden en que todo tu sistema toca los recursos que comparte.
Adaptación del artículo original de Devrim Ozcay, “Two of Our Own Queries Deadlocked Each Other and Took Down Checkout”, publicado en su Substack.