Bloqueos distribuidos: coordinar el trabajo entre múltiples nodos
Evita condiciones de carrera en sistemas distribuidos: guía práctica de bloqueos distribuidos con Redis y bloqueos consultivos de PostgreSQL en Node.js.

El escalado horizontal resuelve problemas de rendimiento, pero introduce una nueva clase de errores: dos instancias del mismo servicio haciendo el mismo trabajo al mismo tiempo. Enviar un correo duplicado, cobrar dos veces a un cliente o corromper un estado compartido — estas son condiciones de carrera a nivel de infraestructura, y una sola SELECT ... FOR UPDATE de la base de datos no te salvará cuando el proceso competidor vive en otra máquina.
Los bloqueos distribuidos son la herramienta para esto. También son una de las primitivas más mal utilizadas en la ingeniería de backend. Los modos de fallo son sutiles, la literatura está llena de polémica (véase el debate de Redlock) y la mayoría de tutoriales se detienen antes de cubrir las partes que realmente importan en producción: la expiración del bloqueo, su renovación y qué ocurre cuando el titular del bloqueo muere.
El problema central
Imagina un cron job que se ejecuta cada minuto, procesa facturas vencidas y envía correos de recordatorio. Has escalado a tres instancias. Sin coordinación, las tres instancias se despiertan a la vez, consultan las mismas filas y envían tres correos por cliente.
La solución no es "usa una cola y listo" — a veces el trabajo realmente debe ser un singleton. Los trabajos programados, la elección de líder y las migraciones one-shot comparten esta forma. Necesitas semántica de ejecución exactly-one.
Redis: la opción habitual
El comando SET key value NX PX ttl de Redis es atómico: establece una clave solo si no existe, con una caducidad. Esa es la primitiva sobre la que se construye un bloqueo distribuido.
import { createClient } from "redis";
import { randomUUID } from "crypto";
interface Lock {
key: string;
token: string;
release: () => Promise<void>;
}
const RELEASE_SCRIPT = `
if redis.call("get", KEYS[1]) == ARGV[1] then
return redis.call("del", KEYS[1])
else
return 0
end
`;
async function acquireLock(
redis: ReturnType<typeof createClient>,
key: string,
ttlMs: number,
): Promise<Lock | null> {
const token = randomUUID();
const acquired = await redis.set(key, token, {
NX: true,
PX: ttlMs,
});
if (!acquired) return null;
return {
key,
token,
release: async () => {
// ✅ Lua script ensures we only delete the key we own
await redis.eval(RELEASE_SCRIPT, { keys: [key], arguments: [token] });
},
};
}Aquí merecen atención dos cosas. Primero, el token (un UUID) se almacena como valor del bloqueo, no solo como una bandera. Al liberarlo, el script Lua comprueba que el valor coincide antes de borrarlo — esto evita que un proceso lento libere un bloqueo que expiró y fue readquirido por otro. Segundo, la liberación es atómica: la comprobación y el borrado ocurren en un solo viaje de ida y vuelta.
La trampa de la expiración
Establecer un TTL es obligatorio. Sin él, un proceso estrellado retiene el bloqueo para siempre. Pero los TTL introducen su propio riesgo: ¿qué pasa si el trabajo tarda más de lo esperado?
// ❌ Fixed TTL — expires while work is still in progress
const lock = await acquireLock(redis, "invoice:process", 5_000);
await processAllInvoices(); // takes 12 seconds on a bad day
// ✅ Watchdog extends the lock while work is ongoing
const lock = await acquireLock(redis, "invoice:process", 10_000);
if (!lock) return; // another instance is working
const watchdog = setInterval(async () => {
await redis.pExpire(lock.key, 10_000);
}, 4_000); // renew every 4s, TTL is 10s
try {
await processAllInvoices();
} finally {
clearInterval(watchdog);
await lock.release();
}El patrón watchdog mantiene un bloqueo vivo mientras el proceso esté sano. Si el proceso se cae, el intervalo deja de ejecutarse, el TTL expira de forma natural y otra instancia puede adquirir el bloqueo. Así es como Redlock y la mayoría de bibliotecas serias de bloqueos distribuidos manejan el problema de la expiración.
La renovación del watchdog asume que tu proceso está vivo y progresando. Un proceso puede estar vivo pero atascado — en un interbloqueo, una pausa larga del GC o una llamada externa colgada. Considera añadir heartbeats a nivel de aplicación que confirmen que el trabajo realmente avanza, no solo que el proceso está corriendo.
Bloqueos consultivos de PostgreSQL
Redis no siempre está en la pila. PostgreSQL tiene un mecanismo nativo de bloqueo distribuido: los advisory locks. Son de ámbito de sesión o de transacción, se mantienen en memoria compartida y son visibles en todas las conexiones a la misma base de datos.
-- Session-scoped: held until explicitly released or session ends
SELECT pg_try_advisory_lock(hashtext('invoice:process'));
-- Transaction-scoped: released automatically at commit/rollback
SELECT pg_try_advisory_xact_lock(hashtext('invoice:process'));Desde TypeScript con pg o un query builder:
import { Pool } from "pg";
async function withAdvisoryLock<T>(
pool: Pool,
lockKey: string,
fn: () => Promise<T>,
): Promise<T | null> {
const client = await pool.connect();
try {
// pg_try_advisory_xact_lock is transaction-scoped: auto-released on commit/rollback
await client.query("BEGIN");
const { rows } = await client.query<{ acquired: boolean }>(
"SELECT pg_try_advisory_xact_lock(hashtext($1)) AS acquired",
[lockKey],
);
if (!rows[0].acquired) {
await client.query("ROLLBACK");
return null; // someone else holds it
}
const result = await fn();
await client.query("COMMIT");
return result;
} catch (err) {
await client.query("ROLLBACK");
throw err;
} finally {
client.release();
}
}La variante de ámbito de transacción es más segura: si el proceso se cae en medio del trabajo, PostgreSQL hace rollback de la transacción y libera el bloqueo automáticamente. Sin TTL que ajustar, sin watchdog necesario. La contra es que el bloqueo solo se mantiene durante la transacción de la base de datos — si tu trabajo implica efectos secundarios fuera de la base de datos (llamadas HTTP, escrituras de archivos), el bloqueo de ámbito transaccional no ofrece protección una vez que se hace commit.
Cómo elegir el enfoque adecuado
| Escenario | Enfoque recomendado | Por qué |
|---|---|---|
| El trabajo solo implica escrituras en BD | Bloqueo consultivo de PostgreSQL (xact) | Liberación automática al caerse, sin ajustar TTL |
| El trabajo implica llamadas externas | Redis con watchdog | El TTL sobrevive a la pérdida de conexión |
| Multibase de datos o sin Postgres | Redis con watchdog | Funciona independientemente de la capa de almacenamiento |
| Trabajo en segundo plano de larga duración | Redis con watchdog | Control explícito sobre la renovación |
| Elección de líder | Redis NX o bloqueo de sesión de Postgres | Patrones estándar para ambos |
Cómo manejar el fallo de adquisición
Decidir qué hacer cuando acquireLock devuelve null es tan importante como el propio bloqueo. La mayoría de llamadores deberían saltar, no reintentar a ciegas.
async function runInvoiceJob(redis: ReturnType<typeof createClient>) {
const lock = await acquireLock(redis, "jobs:invoice-reminders", 30_000);
if (!lock) {
// ✅ Another instance is handling it — this is normal, not an error
console.info({ msg: "Lock unavailable, skipping run" });
return;
}
const watchdog = setInterval(
() => redis.pExpire(lock.key, 30_000),
10_000,
);
try {
await sendInvoiceReminders();
} catch (err) {
// Log but don't swallow — let the caller decide on retry policy
console.error({ msg: "Invoice job failed", err });
throw err;
} finally {
clearInterval(watchdog);
await lock.release();
}
}Retornar temprano con null es el comportamiento por defecto correcto para cron jobs y workers en segundo plano. Si necesitas que el trabajo termine eventualmente, usa una cola en lugar de un bloqueo — las colas te dan semántica de reintento duradera, los bloqueos no.
Si estás recurriendo a bloqueos distribuidos para proteger un consumidor de cola, detente. Colas como BullMQ ya serializan el trabajo mediante tiempos de visibilidad de jobs. Añadir un bloqueo encima suele significar que la cola es la abstracción equivocada — o que la lógica de fan-out del job necesita replanteamiento.
Lo que los bloqueos distribuidos no pueden garantizar
Los bloqueos distribuidos proporcionan exclusión mutua en condiciones normales. No la proporcionan incondicionalmente. El desfase de reloj, las particiones de red y las pausas del GC pueden hacer que un proceso crea que tiene un bloqueo válido cuando no es así. La crítica de Martin Kleppmann a Redlock vale la pena leer para ver el panorama completo.
La implicación práctica: para operaciones donde la doble ejecución tiene consecuencias graves (transacciones financieras, eliminaciones irreversibles), combina los bloqueos distribuidos con una clave de idempotencia o una restricción a nivel de base de datos. El bloqueo reduce la probabilidad de ejecución concurrente casi a cero; la restricción hace que la doble ejecución sea imposible incluso si el bloqueo falla.
Conclusiones clave
- Guarda siempre un token único como valor del bloqueo — evita que un proceso lento libere un bloqueo que ya no le pertenece tras expirar.
- Usa un watchdog para trabajos de larga duración — los TTL fijos expiran mientras el trabajo está en curso; renueva de forma proactiva mientras el proceso esté vivo.
- Los advisory locks de PostgreSQL están poco aprovechados — si tu sección crítica ya está dentro de una transacción de base de datos, los bloqueos de ámbito xact te dan limpieza automática gratis.
- Retornar temprano ante un fallo es el comportamiento correcto — un cron job que salta una ejecución porque otra instancia está trabajando está funcionando como se diseñó.
- Los bloqueos reducen la probabilidad, no la imposibilidad — para operaciones realmente críticas, añade una restricción de idempotencia en la capa de almacenamiento como segunda línea de defensa.


