sqlpostgresqlconcurrencyupsert

Contador atomico: UPDATE SET n = n + 1 sin perder actualizaciones

Por que read-modify-write pierde incrementos bajo carga y como un solo UPDATE SET n = n + 1 mantiene el contador correcto en concurrencia.

3 min de lecturaReferencesql · postgresql · concurrency · upsert · returning

Un contador parece el registro mas simple de una base de datos: un numero que hay que incrementar. Sin embargo, es justo en los contadores donde se rompe la correccion bajo concurrencia, porque el codigo ingenuo lee el valor, suma uno y lo vuelve a escribir en tres pasos separados. La base de datos puede hacer los tres de forma atomica en una sola sentencia.

El bug de la actualizacion perdida

Tomemos una tabla de contadores de visitas por usuario:

CREATE TABLE counters (
    id      bigint PRIMARY KEY,
    n       bigint NOT NULL DEFAULT 0
);

El codigo tipico de la aplicacion es un read-modify-write:

-- step 1: read
SELECT n FROM counters WHERE id = 1;   -- got 41
-- step 2: app adds 1 in memory -> 42
-- step 3: write back
UPDATE counters SET n = 42 WHERE id = 1;

Supongamos que dos procesos leen 41 a la vez. Ambos suman uno y ambos escriben 42. Dos incrementos se reducen a uno: eso es la actualizacion perdida. Bajo carga el contador se queda atras de forma constante, y ningun try/catch lo detecta: no hay error, solo incrementos descartados en silencio.

Una sentencia en lugar de tres

La solucion es empujar la lectura, la aritmetica y la escritura al propio UPDATE:

UPDATE counters SET n = n + 1 WHERE id = 1;

Aqui n + 1 se evalua sobre el valor actual de la fila en el momento de escribir, no sobre lo que la aplicacion leyo hace un segundo. PostgreSQL toma un bloqueo de fila: un segundo UPDATE concurrente espera a que el primero confirme y luego suma sobre el valor ya actualizado. Dos sentencias dan +2, como debe ser. La atomicidad es una propiedad de la sentencia unica, no de tu disciplina.

El mismo truco sirve para cualquier total acumulado, como la suma de pedidos de un cliente:

UPDATE users
SET orders_total = orders_total + (
    SELECT amount FROM orders WHERE id = 5001
)
WHERE id = 42;

Upsert: crear o incrementar

A menudo la fila del contador aun no existe: el primer evento debe crearla y los siguientes incrementarla. INSERT ... ON CONFLICT resuelve ambos casos en una sentencia:

INSERT INTO counters (id, n)
VALUES (1, 1)
ON CONFLICT (id)
DO UPDATE SET n = counters.n + 1;
  • Si la fila no existe, se inserta con 1.
  • Si existe, se ejecuta la rama DO UPDATE, donde counters.n apunta al valor actual de la tabla.
  • Trampa: no escribas SET n = EXCLUDED.n, porque EXCLUDED es la insercion rechazada (1), y el contador quedaria clavado en uno para siempre. Necesitas counters.n + 1.

Este upsert tambien es atomico y seguro ante carreras: las inserciones concurrentes del mismo id se serializan en el indice unico.

Devolver el nuevo valor con RETURNING

Tras incrementar suele hacer falta el resultado, por ejemplo la nueva posicion en una cola. Un SELECT aparte reabriria la ventana de carrera. RETURNING devuelve el valor desde la misma sentencia atomica:

UPDATE counters
SET n = n + 1
WHERE id = 1
RETURNING n;

Obtienes el valor que asigno tu propia transaccion, sin releer. Tambien funciona con el upsert:

INSERT INTO counters (id, n)
VALUES (1, 1)
ON CONFLICT (id)
DO UPDATE SET n = counters.n + 1
RETURNING n;

Puntos calientes y limites

La atomicidad arregla la correccion, pero no elimina por arte de magia la contencion. Si todas las peticiones golpean la misma fila, esa fila se vuelve un punto caliente: las transacciones hacen cola tras el bloqueo de fila y el rendimiento queda limitado por una sola escritura.

  • Trampa: una transaccion larga que incrementa el contador y luego hace mas trabajo mantiene el bloqueo de fila hasta el COMMIT. Incrementa lo mas tarde posible y confirma rapido.
  • Para contadores muy calientes, fragmentalos: N filas parciales (id, shard, n), escribe en un shard al azar y lee con SUM(n). Asi repartes la contencion entre varias filas.
  • Si solo necesitas un numero unico que crece de forma monotona y no un conteo exacto, una SEQUENCE (o GENERATED AS IDENTITY) es mas barata: no bloquea ni se revierte, pero admite huecos en la numeracion.

Diferencias entre motores: en MySQL/InnoDB, UPDATE ... SET n = n + 1 es atomico igual, y el upsert se escribe como INSERT ... ON DUPLICATE KEY UPDATE n = n + 1; no hay RETURNING, asi que se usan trucos con LAST_INSERT_ID() o un nuevo SELECT en la misma transaccion. ClickHouse es un motor analitico: los UPDATE puntuales son caros, asi que los contadores suelen construirse con SummingMergeTree o AggregatingMergeTree, donde los valores se suman al fusionar las partes en lugar de por fila.

La conclusion es simple: nunca calcules el incremento en la aplicacion. Deja que la base haga la lectura, la suma y la escritura en una sola expresion n = n + 1, y el contador seguira siendo correcto bajo cualquier concurrencia.

Practica con ejercicios reales

Resuelve ejercicios en el entrenador de SQL con corrección instantánea y pistas.

Abrir el entrenador