Blog ·

El pago que se perdió con el lock puesto

Un SELECT ... FOR UPDATE funcionando perfecto, y aun así se perdió un pago. El problema no era el lock: era el nivel de aislamiento.

Un SELECT ... FOR UPDATE estaba haciendo su trabajo a la perfección, y aun así perdimos un pago.

Dos pagos de 100 contra el mismo préstamo, tres cuotas de 100 cada una. El único resultado correcto es un saldo de 100. Obtuvimos 200.

El lock no era el problema

Las dos transacciones tomaban un lock pesimista de escritura sobre el préstamo antes de tocar nada, así que nunca escribieron al mismo tiempo. El lock funcionaba. El problema era el nivel de aislamiento.

En MySQL/InnoDB el default es REPEATABLE READ, y el snapshot de lectura de una transacción queda fijado por su primera lectura no bloqueante — no por el BEGIN, y no por el lock. Entonces:

  1. T2 ejecuta una consulta de validación inofensiva. Snapshot fijado: ve 300.
  2. T1 paga 100 y comitea. El saldo real ahora es 200.
  3. T2 adquiere el lock. Esta parte es correcta: una lectura bloqueante devuelve la última fila comiteada.
  4. T2 relee las cuotas. Sigue viendo 300. Datos viejos.
  5. T2 escribe saldos calculados desde 300. El pago de T1 queda pisado.

Sin excepción. Sin línea de log. Números lo bastante plausibles como para que nadie lo note durante semanas.

El fix era una palabra

READ_COMMITTED en esa transacción, para que cada sentencia se reevalúe contra el estado comiteado. No es un upgrade gratis: se pierden las lecturas repetibles y los gap locks, así que corresponde en los métodos que lockean y después leen, no en toda la aplicación.

La regla que me llevé

Si una transacción adquiere un lock y después lee más filas para decidir qué escribir, esas lecturas necesitan READ COMMITTED o sus propias lecturas bloqueantes. Si no, el snapshot pre-lock reintroduce exactamente el lost update que el lock estaba ahí para prevenir.

En sistemas que tocan dinero, este tipo de bug es el peor de todos: no rompe nada visible, solo deja números incorrectos. Por eso los circuitos de cobranzas de nuestros sistemas tienen tests de integración que corren contra una base de datos real — incluyendo uno que reproduce exactamente este escenario con dos transacciones concurrentes.