Volver a las notas

Concurrencia en PostgreSQL: lost updates, FOR UPDATE y reintentos

7 de agosto de 2026·
postgresqlconcurrenciatransaccionesbackend

Nota revisada y reescrita el 18 de octubre de 2026: la versión original tenía errores y datos sin fuente.

Hay un bug que no aparece en desarrollo y sí en producción: dos peticiones leen el mismo stock a la vez, las dos restan una unidad en la aplicación y las dos guardan el resultado. Has vendido dos y el stock solo ha bajado uno. Es un lost update, y el aislamiento por defecto de PostgreSQL (READ COMMITTED) no lo impide si el código hace leer → calcular → escribir.

// Mal: dos peticiones simultáneas pueden leer el mismo valor
const { rows } = await db.query('SELECT stock FROM products WHERE id = $1', [id]);
await db.query('UPDATE products SET stock = $1 WHERE id = $2', [rows[0].stock - 1, id]);

Opción 1: que la base de datos haga la cuenta

Si la operación cabe en un UPDATE, es lo más sencillo y lo más rápido:

UPDATE products
SET stock = stock - 1
WHERE id = $1 AND stock > 0
RETURNING stock;

En READ COMMITTED, si dos transacciones intentan actualizar la misma fila, la segunda espera a que termine la primera y vuelve a evaluar el WHERE sobre la versión nueva de la fila. Si ya no queda stock, no actualiza nada y RETURNING vuelve vacío: así sabes que no se pudo vender, sin sobreventa.

Opción 2: bloquear la fila mientras decides

Cuando la lógica no cabe en una sentencia (comprobar varias tablas, llamar a otra función), bloquea la fila al leerla:

BEGIN;
SELECT stock, reserved FROM products WHERE id = $1 FOR UPDATE;
-- la aplicación decide con esos datos...
UPDATE products SET reserved = reserved + 1 WHERE id = $1;
COMMIT;

FOR UPDATE hace esperar a cualquier otra transacción que quiera bloquear o modificar esa fila hasta que esta termine. Dos reglas para que no te muerda:

  • Transacciones cortas. Nada de llamadas HTTP a una pasarela de pago con la fila bloqueada.
  • Bloquea siempre en el mismo orden. Si una transacción bloquea A y luego B, y otra bloquea B y luego A, PostgreSQL detecta el deadlock y aborta una de las dos con ERROR: deadlock detected. Ordenar por id al bloquear varias filas lo evita.

Para colas de trabajo donde varios procesos cogen tareas, FOR UPDATE SKIP LOCKED salta las filas que ya ha cogido otro en lugar de esperar.

Opción 3: SERIALIZABLE y reintentar

Con SERIALIZABLE, PostgreSQL garantiza que el resultado es el mismo que si las transacciones hubieran ido una detrás de otra. Cuando detecta que no puede garantizarlo, aborta una con el código 40001 (serialization_failure). Tu código tiene que reintentar:

async function withSerializableRetry(pool, fn, intentos = 3) {
  for (let i = 1; ; i++) {
    const client = await pool.connect();
    try {
      await client.query('BEGIN ISOLATION LEVEL SERIALIZABLE');
      const result = await fn(client);
      await client.query('COMMIT');
      return result;
    } catch (err) {
      await client.query('ROLLBACK').catch(() => {});
      if (err.code !== '40001' || i >= intentos) throw err;
    } finally {
      client.release();
    }
  }
}

Es la opción más general (no hay que pensar qué bloquear), pero solo funciona si todas las transacciones implicadas usan SERIALIZABLE y si tu código está preparado para repetir el trabajo.

Cómo reproducirlo antes de que pase

Lanza la misma operación muchas veces en paralelo y comprueba el total:

await Promise.all(Array.from({ length: 50 }, () => venderUno(productId)));
// con stock inicial 10: deben quedar 0 y haber exactamente 10 ventas

Con la versión mala, el stock final y el número de ventas no cuadran. Con cualquiera de las tres opciones, sí.

Fuentes