Historia de la pregunta.
Los bloqueos de asesoramiento aparecieron por primera vez en PostgreSQL 8.2 para proporcionar primitivos de sincronización de nivel de aplicación ligeros que operan fuera del sistema de visibilidad de tuplas de MVCC. Fueron diseñados para flujos de trabajo como el procesamiento de colas y la ingestión idempotente donde el bloqueo basado en tablas sería semánticamente inapropiado o prohibitivamente costoso en términos de rendimiento. A diferencia de los bloqueos a nivel de fila que están vinculados a tuplas específicas de tablas y se registran en la columna del sistema xmax, los bloqueos de asesoramiento residen completamente dentro del administrador de bloqueos de memoria compartida, ofreciendo un mecanismo para gestionar el acceso a recursos abstractos sin generar tuplas muertas o tráfico de WAL.
El problema.
En las canalizaciones de ingestión idempotente de alta concurrencia, hacer cumplir la unicidad en las claves comerciales (por ejemplo, UUIDs externos) a través de INSERT ... ON CONFLICT o SELECT FOR UPDATE crea cuellos de botella severos. Los enfoques a nivel de fila requieren escribir en el montón para establecer bits de bloqueo, lo que hincha las tablas, acelera la presión sobre VACUUM y causa puntos críticos en los índices únicos durante la resolución de conflictos. El desafío es proporcionar exclusión mutua para entidades lógicas, como una clave comercial hasheada, sin tocar la capa de almacenamiento, mientras se asegura que los fallos de bloqueo no filtren recursos en grupos de conexiones persistentes.
La solución.
La propiedad crítica es que los bloqueos de asesoramiento se almacenan exclusivamente en la tabla hash de LOCKTAG dentro de la memoria compartida, utilizando LOCKMETHOD_ADVISORY, y por lo tanto nunca modifican las páginas de relación subyacentes. Al emplear pg_advisory_xact_lock(hashtext(business_key)), la aplicación adquiere un mutex de alcance transaccional que se libera automáticamente al hacer COMMIT o ROLLBACK, previniendo la filtración de bloqueos asociados con pg_advisory_lock a nivel de sesión. Este enfoque elimina la hinchazón de la tabla y la contención de índices porque el bloqueo existe solo como una entrada ligera en memoria, como se demuestra a continuación:
BEGIN; -- Adquirir bloqueo vinculado a la transacción en la clave comercial hasheada SELECT pg_advisory_xact_lock(hashtext('a1b2c3d4')); -- Seguro para insertar; no hay contención de índice único si otra sesión tiene el bloqueo INSERT INTO events (business_key, payload) VALUES ('a1b2c3d4', '{"event":"click"}') ON CONFLICT (business_key) DO NOTHING; COMMIT;
El equipo de la plataforma de datos en una empresa de telemetría necesitaba garantizar un procesamiento exactamente una vez para 50,000 eventos por segundo ingeridos desde Kafka en PostgreSQL, donde cada evento llevaba un UUID generado por el cliente que servía como clave de idempotencia. Las pruebas de carga iniciales utilizando INSERT ... ON CONFLICT DO NOTHING en una columna de UUID única causaron una severa latencia en la parte posterior debido a la contención de spinlock en el índice B-tree único y la rápida acumulación de hinchazón por fallos de actualización HOT. La tasa de generación de WAL se duplicó durante las horas pico, amenazando la latencia de replicación y la capacidad de almacenamiento.
Una solución propuesta implicó verificar previamente la existencia de la clave utilizando SELECT * FROM events WHERE business_key = $1 FOR UPDATE, luego insertar solo si el resultado estaba vacío. Si bien esto prevenía duplicados, forzaba a cada escritor a adquirir un bloqueo de fila en la fila existente o en una fila de reserva sustituta, creando un punto crítico masivo en las páginas de la tabla de reservas. El enfoque generaba una hinchazón sustancial de la tabla, requiriendo que VACUUM reclamara tuplas muertas cada quince minutos, y no podía prevenir condiciones de carrera entre la verificación y la inserción sin mantener el bloqueo durante toda la duración de la transacción, limitando severamente el rendimiento.
El equipo de arquitectura sugirió mover la coordinación a un caché externo de Redis utilizando operaciones SETNX para permitir inserciones. Esto eliminó la hinchazón de la base de datos y redujo la carga de PostgreSQL, pero introdujo modos de fallo críticos: las particiones de red entre el clúster de Redis y la base de datos podrían permitir inserciones duplicadas cuando el bloqueo de Redis expiró pero la transacción de PostgreSQL aún no se había confirmado. Además, mantener la consistencia en dos sistemas distribuidos agregó complejidad operativa y requirió implementar Redlock u otros algoritmos similares, aumentando la latencia en aproximadamente 5 milisegundos por operación.
El diseño elegido aprovechó los bloqueos de asesoramiento nativos de PostgreSQL a través de pg_advisory_xact_lock(hashtext(business_key)), adquiriendo un bloqueo vinculado a la transacción en el UUID hasheado antes de intentar la inserción. Dado que estos bloqueos viven solo en memoria compartida y no tocan el montón, imponen cero sobrecarga de almacenamiento y se liberan automáticamente al finalizar la transacción, previniendo la filtración de bloqueos observada con bloqueos a nivel de sesión. Para evitar interbloqueos indetectables, la capa de aplicación ordenó todos los UUIDs en cada lote por su valor entero hasheado antes de adquirir bloqueos, asegurando un protocolo de ordenación global entre todos los trabajadores concurrentes.
Se seleccionaron los bloqueos de asesoramiento porque proporcionaron la latencia más baja (adquisición en sub-milisegundos) y cero efectos secundarios de almacenamiento mientras mantenían una corrección estricta sin dependencias externas. A diferencia del enfoque de Redis, la vida útil del bloqueo estaba vinculada a la transacción de la base de datos, garantizando atomicidad entre la adquisición del bloqueo y la confirmación de la inserción. A diferencia de SELECT FOR UPDATE, no se generó hinchazón de tabla, y a diferencia de ON CONFLICT crudo, el índice único nunca se vio presionado por inserciones concurrentes en conflicto porque la serialización ocurrió antes del acceso al montón.
Después de la implementación, la canalización de ingestión mantuvo 80,000 eventos por segundo con una latencia p99 por debajo de 10 milisegundos, en comparación con picos anteriores de 200 ms durante picos de contención. La hinchazón de la tabla cayó a niveles insignificantes, permitiendo que autovacuum se ejecutara solo durante las horas de menor actividad, y el volumen de WAL disminuyó en un 40%, reduciendo significativamente los costos de almacenamiento en archivo y la latencia de replicación. El sistema mantuvo semántica exactamente una vez a través de múltiples reinicios de la base de datos y cambios en el grupo de conexiones sin un solo evento duplicado o tiempo de espera inducido por interbloqueo.
¿Por qué usar pg_advisory_lock (específico de la sesión) en lugar de pg_advisory_xact_lock arriesga el agotamiento del grupo de conexiones y la ingestión duplicada en una arquitectura de trabajador de alta capacidad?
Los candidatos a menudo no reconocen que pg_advisory_lock persiste hasta que se desbloquea explícitamente o la sesión se desconecta, incluso si la transacción se aborta. En un entorno agrupado donde los trabajadores reutilizan conexiones de larga duración, un error de lógica o una excepción que omite la llamada de desbloqueo deja el bloqueo mantenido indefinidamente, haciendo que los trabajadores posteriores que procesan la misma clave comercial esperen para siempre. En su lugar, se debe usar pg_advisory_xact_lock porque vincula la vida útil del bloqueo al límite de la transacción, asegurando la liberación automática al hacer ROLLBACK y previniendo la fuga de mutex que podría de otro modo agotar el grupo de trabajadores y detener la canalización de ingestión.
¿Cómo la ausencia de una garantía de orden total al adquirir múltiples bloqueos de asesoramiento conduce a interbloqueos indetectables, y qué patrón específico de aplicación elimina este peligro?
A diferencia de los interbloqueos a nivel de fila que el detector deadlock_timeout de PostgreSQL resuelve matando a una transacción víctima, los interbloqueos de bloqueos de asesoramiento son invisibles para el motor porque ocurren en espacios de nombres definidos por el usuario. Si el Trabajador A bloquea el recurso X y luego Y, mientras que el Trabajador B bloquea Y y luego X, ambas sesiones esperan indefinidamente sin error. El patrón obligatorio es ordenar todos los identificadores de recursos (por ejemplo, valores hashtext(uuid)) en un orden monótonico estricto (ascendente o descendente) a través de toda la aplicación antes de emitir cualquier solicitud de bloqueo. Este ordenamiento global asegura que los gráficos de espera permanezcan acíclicos, haciendo que las dependencias circulares sean imposibles y eliminando el riesgo de bloqueos silenciosos.
¿Qué limitación de memoria compartida restringe el número de bloqueos de asesoramiento que una sola transacción puede mantener, y cómo se manifiesta el exceder max_locks_per_transaction en comparación con el agotamiento de bloqueos a nivel de fila?
Muchos candidatos asumen que los bloqueos de asesoramiento son infinitos, pero consumen entradas en la tabla de bloqueos compartidos gobernada por el parámetro de configuración max_locks_per_transaction (64 de forma predeterminada). Mantener más bloqueos que este límite en una transacción provoca ERROR: out of shared memory (SQLSTATE 53200), abortando inmediatamente la transacción. Esto contrasta con los bloqueos a nivel de fila, donde exceder los límites típicamente desencadena una actualización de bloqueo o espera dependiendo de lock_timeout, pero no agota un grupo fijo de memoria compartida. La mitigación implica agrupar operaciones en sub-transacciones más pequeñas o agregar múltiples recursos lógicos bajo una sola clave de bloqueo de asesoramiento a través de hash compuesto, en lugar de intentar bloquear miles de claves individuales simultáneamente.