Locks, deadlocks y concurrencia en PostgreSQL | Nicolás Garzón
Los locks no son un fallo: son el mecanismo con el que PostgreSQL coordina operaciones incompatibles. El problema aparece cuando la aplicación bloquea más de lo necesario, mantiene locks demasiado tiempo o adquiere recursos en órdenes inconsistentes.
MVCC permite que lecturas normales convivan con escrituras, pero existen operaciones que necesitan exclusión explícita:
Texto
Copiar leer para decidir y después escribir
→ proteger la fila o detectar conflicto
modificar estructura
→ coordinar acceso a la tabla
garantizar una regla lógica
→ constraint, row lock o advisory lock
Un recurso.
Un modo.
Un propietario, normalmente una transacción o sesión.
Una matriz de compatibilidad.
Una duración.
Sin coordinación, dos procesos pueden:
Asignar el mismo job.
Vender la última unidad dos veces.
Sobrescribir cambios.
Eliminar un parent mientras se crea un child.
Ejecutar migraciones sobre una tabla activa de forma incompatible.
Los locks serializan únicamente los conflictos necesarios. No deberían utilizarse para convertir toda la aplicación en ejecución secuencial.
Puedes bloquear filas visibles con:
SQL
Copiar SELECT *
FROM inventory
WHERE branch_id = $1
AND product_id = $2
FOR UPDATE ; La fila continúa siendo legible mediante SELECT normal desde otras transacciones, pero operaciones incompatibles esperan.
Es el modo más fuerte de los locks de fila comunes. Bloquea otros UPDATE, DELETE, SELECT FOR UPDATE y operaciones incompatibles.
Úsalo cuando vas a modificar la fila o necesitas impedir cambios relevantes mientras decides.
Similar, pero permite ciertas operaciones que solo requieren key-share. PostgreSQL lo usa para updates que no modifican keys referenciables.
Puede reducir contención cuando no necesitas proteger identidad referenciada.
Permite varios holders share, pero bloquea modificaciones incompatibles.
Protege principalmente la key frente a delete o cambios que rompan referencias. Las foreign keys pueden utilizar este tipo de coordinación.
No memorices solo nombres: revisa qué operaciones deben coexistir en tu flujo.
SQL
Copiar SELECT o. *
FROM orders o
JOIN customers c ON c. id = o. customer_id
WHERE o. id = $1
FOR UPDATE OF o; OF o limita qué tabla se bloquea. Sin precisión, podrías bloquear filas relacionadas innecesarias.
En outer joins existen restricciones porque algunas filas no corresponden a una tuple real bloqueable.
Texto
Copiar SELECT status
→ lógica en aplicación
→ SELECT FOR UPDATE
→ UPDATEEl estado pudo cambiar antes del lock.
Texto
Copiar SELECT ... FOR UPDATE
→ evaluar estado protegido
→ modificar
→ COMMITOtra opción es una sola sentencia condicional.
SQL
Copiar SELECT *
FROM inventory
WHERE id = $1
FOR UPDATE NOWAIT; Falla inmediatamente si el recurso está bloqueado.
La UI debe responder conflicto rápido.
Esperar no tiene valor.
Existe otro camino o retry posterior.
La aplicación debe manejar SQLSTATE de lock no disponible y no presentarlo como 500 genérico.
SQL
Copiar SELECT id
FROM jobs
WHERE status = 'pending'
ORDER BY priority DESC , created_at, id
FOR UPDATE SKIP LOCKED
LIMIT 10 ; Workers concurrentes pueden reclamar lotes distintos.
Cada worker busca jobs pendientes.
Las filas bloqueadas por otros se omiten.
El worker bloquea su lote.
Actualiza estado o procesa según diseño.
Commit libera locks.
Esto produce una vista deliberadamente inconsistente del conjunto. Es apropiado para colas, no para reportes o reglas que necesitan ver todos los elementos.
Una fila problemática puede quedar bloqueada o fallar repetidamente y ser omitida por muchos workers.
Timeout y reaper.
locked_at y owner.
Intentos máximos.
Dead-letter state.
Métricas de edad del job más antiguo.
Reclamación de jobs abandonados.
SQL
Copiar WITH selected AS (
SELECT id
FROM jobs
WHERE status = 'pending'
ORDER BY priority DESC , created_at, id
FOR UPDATE SKIP LOCKED
LIMIT 10
)
UPDATE jobs j
SET status = 'processing' ,
worker_id = $1 ,
locked_at = now ( )
FROM selected s
WHERE j. id = s. id
RETURNING j. * ; La selección y cambio ocurren en una transacción. Procesar trabajo externo dentro de esa misma transacción puede mantener locks demasiado tiempo; normalmente se reclama, confirma y luego se procesa con idempotencia.
PostgreSQL utiliza varios modos de tabla, desde ACCESS SHARE hasta ACCESS EXCLUSIVE.
SELECT toma ACCESS SHARE.
DML toma ROW EXCLUSIVE además de row locks.
CREATE INDEX normal y muchos DDL toman modos más fuertes.
ALTER TABLE suele requerir ACCESS EXCLUSIVE.
ACCESS EXCLUSIVE entra en conflicto con todos los demás modos, incluido SELECT.
Una migración puede esperar un lock detrás de una transacción larga. Mientras espera, otras consultas que necesitan modos incompatibles pueden acumularse detrás de ella.
Texto
Copiar transacción larga
↓ bloquea ALTER TABLE
ALTER TABLE esperando
↓
nuevos SELECT/UPDATE quedan en cola detrás del DDLAunque el cambio DDL sea rápido, su espera puede provocar incidente.
SQL
Copiar SET lock_timeout = '2s' ;
ALTER TABLE . . . ; Falla rápido y reintenta en una ventana segura.
Un deadlock es un ciclo de espera:
Texto
Copiar T1 posee A y espera B
T2 posee B y espera ANinguna puede avanzar. PostgreSQL detecta el ciclo después de deadlock_timeout y aborta una transacción con SQLSTATE 40P01.
El sistema no puede adivinar cuál operación de negocio importa más; elige una víctima.
SQL
Copiar UPDATE accounts SET balance = balance - 10 WHERE id = 1 ;
SQL
Copiar UPDATE accounts SET balance = balance - 10 WHERE id = 2 ;
Prevención: adquirir cuentas en un orden estable:
SQL
Copiar SELECT id
FROM accounts
WHERE id IN ( $1 , $2 )
ORDER BY id
FOR UPDATE ; La aplicación debe reintentar la transacción completa de manera limitada. Sin embargo, si son frecuentes:
Ordena recursos consistentemente.
Reduce duración.
Elimina locks innecesarios.
Revisa FKs y triggers.
Divide lotes.
Inspecciona planes que bloquean más filas.
Reintentar sin corregir diseño puede ocultar alta contención.
Límite para esperar adquisición de un lock.
Límite total de ejecución del statement, incluida espera.
Tiempo antes de comprobar deadlock y, según logging, registrar waits. Es configuración del servidor; no suele ajustarse por query casualmente.
Configura timeouts con jerarquía coherente.
PostgreSQL ofrece locks sobre números definidos por la aplicación:
SQL
Copiar SELECT pg_advisory_xact_lock( hashtextextended( 'invoice:123' , 0 ) ) ;
Session-level: dura hasta unlock o fin de sesión.
Transaction-level: se libera al commit/rollback.
Shared/exclusive.
Blocking/try.
Evitar dos jobs lógicos sobre el mismo agregado.
Liderazgo simple.
Coordinar una operación que no corresponde a una fila única.
PostgreSQL no conoce la relación entre la clave y los datos.
Si un writer olvida adquirirlo, la garantía desaparece.
Namespace estable.
Función determinista para claves.
Evitar colisiones.
Orden consistente al adquirir múltiples.
Preferir transaction-level.
Documentar el protocolo.
No reemplazan constraints cuando la regla puede expresarse declarativamente.
Cuando los conflictos son raros:
SQL
Copiar UPDATE products
SET price = $1 ,
version = version + 1
WHERE id = $2
AND version = $3
RETURNING version; Cero filas significa que otro proceso modificó la fila o no existe.
No mantiene un lock desde la lectura del cliente.
Funciona bien en formularios de edición.
El usuario puede recibir conflicto al guardar.
Debes distinguir inexistencia de versión obsoleta.
Requiere política de merge/reload.
FOR UPDATE bloquea antes de ejecutar una decisión.
El conflicto es frecuente.
La decisión es corta.
El recurso debe permanecer estable dentro de la transacción.
Esperar es aceptable.
No conviene mantener el lock mientras un humano edita o una API externa responde.
A menudo evita un SELECT lock:
SQL
Copiar UPDATE inventory
SET reserved_quantity = reserved_quantity + $1
WHERE branch_id = $2
AND product_id = $3
AND quantity - reserved_quantity >= $1
RETURNING * ; Es una forma compacta de concurrencia pesimista implícita sobre la fila, con condición reevaluada apropiadamente.
Insertar un child puede tomar key-share sobre el parent. Eliminar o cambiar la key del parent puede esperar.
Triggers también pueden tocar tablas adicionales y crear órdenes de locking no visibles desde el statement original.
Para deadlocks, inspecciona todo el flujo, no solo las queries del handler.
SQL
Copiar SELECT
pid,
usename,
state,
wait_event_type,
wait_event,
xact_start,
query_start,
query
FROM pg_stat_activity
WHERE datname = current_database( ) ; SQL
Copiar SELECT
pid,
pg_blocking_pids( pid) AS blocking_pids,
query
FROM pg_stat_activity
WHERE cardinality( pg_blocking_pids( pid) ) > 0 ; SQL
Copiar SELECT locktype, mode , granted, pid, relation::regclass
FROM pg_locks
ORDER BY granted, pid; Correlaciona PID, transacción, aplicación, user y query.
SQL
Copiar SELECT pg_cancel_backend( pid) ;
SELECT pg_terminate_backend( pid) ; cancel intenta detener la query actual; terminate cierra la sesión y revierte su transacción.
Identifica blocker real.
Revisa si es backup, migración o mantenimiento.
Evalúa rollback costoso.
Coordina con la aplicación.
Conserva evidencia.
SQL
Copiar BEGIN ;
SELECT id, balance
FROM accounts
WHERE id IN ( $1 , $2 )
ORDER BY id
FOR UPDATE ;
UPDATE accounts
SET balance = balance - $3
WHERE id = $1 AND balance >= $3 ;
UPDATE accounts
SET balance = balance + $3
WHERE id = $2 ;
COMMIT ; El orden por ID evita que transferencias inversas adquieran locks en orden contrario. Debes verificar que el débito afectó una fila.
El planner bloquea filas devueltas según ejecución; no asumas un orden sin ORDER BY.
Las filas saltadas por offset pueden bloquearse dependiendo del plan; evita patrones complejos para queues.
Locks adquiridos después de un savepoint pueden liberarse al rollback hacia él según el tipo y operación.
Si devuelves la conexión al pool sin liberar, otro request hereda el lock.
Puede causar cola amplia incluso antes de adquirir el lock.
El ciclo puede involucrar parent/child y no ser evidente desde updates principales.
La lectura ya quedó obsoleta.
Aumenta contención y memoria de locks.
Bloquea otros requests durante una latencia no controlada.
Omite datos deliberadamente.
Puede causar tormenta y ocultar diseño.
Rompe workloads legítimos.
Dos sesiones compiten por la misma fila.
NOWAIT y manejo de error.
Workers con SKIP LOCKED.
Job abandonado.
Deadlock intencional.
Retry completo.
Orden estable de múltiples recursos.
Lock timeout.
DDL detrás de transacción larga.
Advisory lock con pool.
Texto
Copiar regla expresable como constraint
→ constraint
cambio condicional de una fila
→ UPDATE atómico
conflicto raro con edición externa
→ optimistic version
decisión corta sobre fila estable
→ FOR UPDATE
queue concurrente
→ SKIP LOCKED + estado durable
recurso lógico sin fila coordinadora
→ advisory lock documentado
invariante compleja de conjunto
→ SERIALIZABLE o rediseño
Locks coordinan incompatibilidad; no son un error por sí mismos.
Mantén transacciones cortas.
Adquiere recursos en orden consistente.
SKIP LOCKED omite filas y necesita recuperación.
Advisory locks dependen de disciplina de aplicación.
Deadlock aborta una transacción y requiere retry completo.
Diagnostica blockers antes de terminar procesos.
¿Por qué FOR UPDATE debe ejecutarse antes de la decisión?
¿Qué riesgo tiene SKIP LOCKED?
¿Cómo previene deadlocks un orden global de IDs?
¿Cuándo optimistic locking supera a un row lock?
¿Por qué una migración esperando puede bloquear consultas nuevas?
Ver respuestas
Porque una lectura anterior pudo quedar obsoleta antes del lock.
Puede producir starvation u omitir filas indefinidamente sin recuperación.
Todas las transacciones adquieren recursos en la misma dirección y no forman ciclos.
Cuando conflictos son raros y no conviene mantener una transacción durante edición externa.
Porque queda en la cola con un modo fuerte y operaciones posteriores incompatibles esperan detrás.
Índices: familias, diseño y trade-offs explica cómo acelerar patrones de acceso sin aumentar innecesariamente el coste de escritura.