Dos escrituras idénticas llegaron a la vez sobre la misma fila y una murió con un error que no esperábamos ahí: deadlock detected. Debía toparse con el registro de la primera y responder «duplicado»; en su lugar la transacción se deshizo entera y quien llamaba recibió un error de servidor.

No había dos tablas bloqueadas en orden contrario, el ejemplo de todos los manuales: había una sola fila y dos transacciones idénticas.

Una clave foránea es un candado

Insertar una fila que apunta a otra obliga al motor a comprobar que la referenciada existe y a impedir que desaparezca mientras la transacción sigue abierta. Postgres lo hace bloqueando la fila del padre: desde la versión 9.3 con el modo KEY SHARE, y sus notas de versión lo dicen así, «foreign key checks use the new KEY SHARE lock mode».

Ese candado es débil a propósito, para que dos inserciones sobre el mismo padre no se estorben. El choque llega después, al tocar el saldo de esa fila, que pide el candado exclusivo: cada transacción quiere subir de nivel un candado que la otra ya tiene sobre la misma fila, ninguna avanza y el motor mata a una.

Se reproduce en un Postgres limpio con dos sesiones, sin nada nuestro de por medio:

-- sesión A                        -- sesión B
begin;                             begin;
insert into movimiento             insert into movimiento
  (cuenta) values (1);               (cuenta) values (1);
select id from cuenta              select id from cuenta
  where id = 1 for update;           where id = 1 for update;

Con las dos inserciones hechas antes de cualquiera de los dos for update, una sesión recibe:

ERROR:  deadlock detected
DETAIL:  Process 111 waits for ShareLock on transaction 726; blocked by process 110.
Process 110 waits for ShareLock on transaction 727; blocked by process 111.
CONTEXT:  while locking tuple (0,1) in relation "cuenta"

El consejo que todo el mundo cita no cubre este caso

«Toma los candados siempre en el mismo orden» es la primera recomendación del manual y aquí no sirve: las dos transacciones recorrieron la misma secuencia sobre la misma fila. El orden consistente evita el ciclo entre dos objetos distintos, no que dos quieran subir el nivel del mismo candado.

La frase que sí lo cubre está unas líneas más abajo: el primer candado que se toma sobre un objeto conviene que sea ya del modo más restrictivo que vaya a necesitarse. Si vas a pedir el exclusivo, tómalo antes de que la inserción te deje el débil en la mano. Es lo que está hoy en producción: la que pierde la carrera espera y responde «duplicado».

La prueba lo tapaba, y esa es la otra lección

La prueba que lanzaba las dos escrituras a la vez existía, y fallaba unas corridas y otras no. La primitiva que espera a varias tareas devuelve al primer error, así que la limpieza corría con la otra transacción abierta y chocaba contra la clave foránea: lo que se leía era ese choque, no el deadlock. Y su clave fija hacía que los restos de una corrida anterior dieran un tercer error, verosímil.

Una prueba de concurrencia intermitente es un sospechoso, no ruido. La limpieza va después de esperar a todas las tareas, no a la primera que termine.

Qué revisar en tu propio sistema

Busca las transacciones que insertan un hijo y después bloquean al padre para actualizarlo: un contador, un saldo, un cupo, el stock. Ese orden se mata consigo mismo en cuanto llegan dos a la vez.

Escribimos desde la operación de agentes en producción (casos) y lo que aplicamos ahí está en seguridad. Del mismo cuaderno: el secreto del webhook viaja en la URL y los comodines que un buscador deja pasar a un LIKE.