- El bloqueo exclusivo global de Postgres LISTEN/NOTIFY limita el rendimiento de una implementación simple, pero si se almacenan las notificaciones en búfer y se envían por lotes, un solo servidor puede procesar hasta 60 mil escrituras de streams por segundo
- Una transacción que llama a NOTIFY mantiene el bloqueo global hasta que terminan el commit y
fsync()para garantizar el orden de commit de las notificaciones; esto serializa los commits e impide aprovechar el commit grupal - La implementación inicial, que llamaba a NOTIFY mediante un trigger en cada escritura de la tabla de streams, ofrecía baja latencia, pero se atascaba en 2,900 escrituras por segundo sin usar plenamente CPU, memoria ni IOPS
- Si se toma la tabla de la base de datos, y no las notificaciones, como fuente de verdad, y las notificaciones acumuladas en memoria se envían periódicamente en una sola transacción, se puede reducir mucho la cantidad de veces que se adquiere el bloqueo
- La posible pérdida de notificaciones del búfer por una falla del proceso se compensa con polling de baja frecuencia; incluso con lecturas concurrentes, se logra una latencia de 15 a 100 ms y 20 veces más rendimiento que antes
Streams de baja latencia implementados con Postgres
- Un stream basado en Postgres guarda cada fragmento del stream como una fila nueva en la tabla
streams; un token de respuesta de un LLM también puede ser un fragmento - Del lado de lectura, no se sabe cuándo llegará el siguiente fragmento, por lo que es difícil esperar de forma eficiente solo con consultas simples
- El polling periódico, si el intervalo es largo, aumenta la latencia en usos interactivos como el chat en línea; si es corto, muchos pollers concurrentes pueden saturar la base de datos
- Con LISTEN/NOTIFY, el proceso de lectura puede esperar bloqueado y despertarse de inmediato con una notificación de que se escribió un nuevo fragmento, evitando polling innecesario
El cuello de botella de enviar NOTIFY en cada escritura
- En la implementación inicial, cada vez que se registraba un nuevo fragmento en la tabla
streams, un trigger ejecutaba una función que enviaba un NOTIFY, y el proceso de lectura esperaba la notificación para luego leer el nuevo fragmento - Se logró corrección y baja latencia, pero incluso con una base de datos Postgres grande no se pudo sostener más de 2,900 escrituras de streams por segundo
- Mientras ocurría el cuello de botella, el uso de CPU, memoria e IOPS no aumentaba de forma notable; la causa era el bloqueo global en la ruta de commit de NOTIFY
Bloqueo global para garantizar el orden de commit
- Una transacción que llama a NOTIFY adquiere un bloqueo exclusivo global al iniciar el commit y no lo libera hasta que queda completamente confirmado y su contenido se escribe en disco con
fsync() - Postgres garantiza que las notificaciones se entreguen en el orden de commit de las transacciones y guarda todas las notificaciones salientes en una cola interna global que debe coincidir exactamente con ese orden
- Agregar notificaciones a la cola también debe hacerse de forma transaccional como parte del commit, pero como cada transacción tarda distinto en confirmar, no se puede determinar el orden antes de que termine el commit
- El bloqueo global serializa el commit de las transacciones que incluyen notificaciones para fijar el orden de antemano y hacer que también se agreguen a la cola interna de notificaciones en ese mismo orden
Cómo la serialización de commits limita el rendimiento
- Como todas las escrituras de streams llaman a NOTIFY mediante un trigger, cada transacción de escritura mantiene el bloqueo global durante todo el commit y el flush a disco
- Al confirmarse las transacciones una por una, no se puede aprovechar el commit grupal de Postgres, que procesa varias transacciones con un solo
fsync() - El rendimiento no puede superar la velocidad a la que Postgres confirma transacciones individuales, y las tareas quedan esperando el bloqueo, por lo que tampoco se usan plenamente la CPU ni el disco
- El parche que se incluirá en Postgres 19 no elimina el bloqueo global, así que no resuelve este cuello de botella
- En cambio, optimiza casos limitados donde hay muchos canales de notificación y cada listener espera solo un canal específico
Búfer de notificaciones y envío por lotes
- En muchos usos de LISTEN/NOTIFY, incluidos los streams, las notificaciones no son la fuente de verdad, sino solo una señal para revisar la tabla donde están guardados los datos reales
- En esta estructura, las notificaciones en sí no necesitan tener un orden global perfecto ni durabilidad completa, por lo que pueden almacenarse en memoria y enviarse periódicamente dentro de una única transacción por lote
- El bloqueo global se adquiere solo al vaciar el búfer, no en cada escritura individual del stream
- Las escrituras individuales avanzan rápido al desacoplarse del envío de notificaciones en segundo plano, y se puede aumentar el rendimiento aprovechando optimizaciones de Postgres como el commit grupal
Polling de baja frecuencia para compensar notificaciones perdidas
- Si el proceso se detiene mientras las notificaciones siguen en memoria, esas notificaciones pueden no entregarse
- El proceso de lectura espera notificaciones y, al mismo tiempo, consulta periódicamente la base de datos para comprobar si hay datos de stream registrados sin notificación
- Como este polling es solo un mecanismo auxiliar para recuperar notificaciones perdidas, puede ejecutarse con baja frecuencia y su impacto en el rendimiento no es grande
Rendimiento y latencia
- La implementación optimizada procesó hasta 60 mil escrituras de streams por segundo en un entorno con procesos de lectura concurrentes, 20 veces más rendimiento que la implementación inicial
- Incluso con mayor rendimiento, la latencia se mantuvo en el rango de 15 a 100 ms
- En el rendimiento máximo, la CPU de Postgres se utilizó por completo, lo que muestra que se alcanzó la saturación real de la propia base de datos, no contención por bloqueos
- El código completo del benchmark está disponible en dbos-postgres-benchmark
1 comentarios
Comentarios en Hacker News
La escalabilidad es una escala continua, no algo binario. 60 mil eventos por segundo pueden ser 100 mil veces más de lo necesario para un sistema y 100 mil veces menos para otro. Un error común de los desarrolladores que señalaría incluso más que la optimización prematura es elegir una tecnología con características de escalabilidad inadecuadas
Una tecnología demasiado pequeña falla claramente cuando supera sus límites, pero una tecnología excesivamente escalable también trae carga operativa y restricciones. Introducir una tecnología así en un sistema pequeño donde un modelo más rico podría reducir mucho el esfuerzo de desarrollo también es una mala elección
Los límites de LISTEN/NOTIFY son lo bastante bajos como para tener cuidado, así que conviene calcular la carga máxima pesimista y aun así dejar al menos un margen de 10x, pero para muchos proyectos es suficiente. Por su integración con la base de datos, disponibilidad y la ventaja de no requerir operar un servicio aparte, no es una opción que deba descartarse automáticamente; incluso los 2 mil eventos por segundo mencionados antes son una cifra grande para un sistema que procesa un mensaje por segundo
Conviene diseñar con un margen razonable sobre la escala realmente esperada y elegir algo más allá de eso solo cuando la escalabilidad adicional sea prácticamente gratis. Si se puede comprar hardware más grande por unos miles de dólares o si las opciones son equivalentes salvo por la escalabilidad, entonces sí conviene elegir la más grande
Tuvimos mucho éxito combinando LISTEN/NOTIFY con un broker de suscripciones GraphQL en Rust. Había decenas de miles de suscripciones, pero solo había una conexión LISTEN por host, 3 o 4 en total
Enviábamos todos los cambios a cada host, y el host administraba las suscripciones reales de los usuarios y decidía qué publicar. Si cambias cientos de hosts de Ruby o Node por unos pocos hosts en Rust, la arquitectura puede simplificarse mucho, y un enfoque que suele considerarse no escalable puede funcionar bastante bien
Cuando trabajaba como CTO, procesábamos unas 100 mil operaciones al día en todos los servicios, luego crecimos a millones y finalmente a decenas de millones. En ese proceso, un ingeniero construyó una cola sobre la semántica de LISTEN/NOTIFY para aprovechar la consistencia fuerte con el modelo de datos. No era difícil de entender y además eliminaba la necesidad de una capa separada de almacenamiento y entrega, así que en ese momento parecía razonable
Pero al escalar esa funcionalidad hecha en casa, tuvimos que esquivar el funcionamiento interno de PostgreSQL y se volvió muy incómodo; deberíamos haber migrado antes a otro sistema. Tampoco escalaba bien: en RDS empezó a haber mucha contención de disco difícil de diagnosticar, y el VACUUM de esa tabla era una pesadilla. Como una cola familiar estaba implementada de forma poco familiar con funciones internas de PostgreSQL, otros ingenieros le tenían miedo y evitaban depurarla o asumir su propiedad
La lección principal, aparte de detalles como esquema e índices, es elegir siempre primero tecnología simple y predecible. Si no se necesita una consistencia de datos extremadamente fuerte, es mejor usar una cola con un contrato de API simple, como SQS o Redis queues, y adaptar el resto a eso, aunque implique un componente más en la infraestructura. Cuanto menos responsabilidad mecánica recaiga sobre un único almacén central de datos, mejor
A medida que madura el ecosistema de sistemas distribuidos, entendemos mejor qué puede hacer cada componente. Es más sano empezar con pocos componentes e ir agregando más cuando de verdad hagan falta
Me sigue gustando DBOS, que aprovecha bien Postgres y ahora también SQLite. Se puede introducir en un stack CRUD existente casi sin esfuerzo
Una vez que empiezas a usar workflows durables, sigues encontrando lugares donde aplicarlos. Últimamente estoy experimentando con ver cada email como un workflow durable, donde el usuario, la contraparte, el agente y herramientas como GitHub o Attio participan en el flujo por turnos
https://housecat.com/blog/gmail-durable-workflows-sandbox-vm
Este tipo de artículos suele ser el resultado de que cada quien evalúa de forma independiente su propio problema, entendimiento y solución. Es discutible llamar falta de pericia a esperar cierto rendimiento solo porque venía en la configuración por defecto de una herramienta; todos seguimos aprendiendo a través del fracaso
El hecho de que en el experimento se haya usado un servidor de base de datos de 96 núcleos y 384 GB de RAM (https://github.com/dbos-inc/dbos-postgres-benchmark/blob/mai...) es muy importante y debería haberse dicho claramente. La base de datos puede escalar verticalmente, pero eso también tiene límites. Desde dónde y quién se conecta también afecta el rendimiento y la latencia total
60 mil por segundo puede parecer mucho, pero lo que realmente tumba sistemas en producción son los picos repentinos de tráfico más que el tráfico normal. A menos que seas una gran empresa, no empezarías con un servidor tan grande. Si además incluyes réplicas de lectura y redundancia entre regiones, un solo clúster de base de datos de producción cuesta más de 100 mil dólares
Parece ser el artículo relacionado: Postgres LISTEN/NOTIFY does not scale - https://news.ycombinator.com/item?id=44490510 - julio de 2025, 321 comentarios
Parece que falta la parte más importante del artículo: cómo asignar offsets o números de secuencia que permitan rastrear hasta dónde leyó el consumidor y consultar los mensajes nuevos por una ruta alternativa. Hay varias formas, pero no es fácil resolverlo sin complejidad ni contención por bloqueos, y si se implementa mal también puede generar condiciones de carrera con el consumidor. Normalmente el autor bloquearía una sola fila de una tabla de estado o algo similar para asignar el siguiente número del tema de eventos
Me pregunto cuál sería la mejor forma. Una herramienta que lea captura de datos de cambios (CDC) y escriba los números de evento asignados en otra tabla podría servir, pero puede aumentar la latencia, y ese procesador de CDC también tendría que hacer
NOTIFYDel lado del consumidor, también hay casos de uso donde el procesamiento por lotes puede aumentar mucho el rendimiento. En ese caso, se puede ejecutar el consumidor en bucle sin LISTEN/NOTIFY, procesando cada vez todos los mensajes nuevos no procesados, y guardar el último número de secuencia entre iteraciones
Recuerdo que en la primera versión que soportó LISTEN/NOTIFY la implementación de bloqueos no era buena y había problemas de rendimiento. El artículo anterior que aquí se critica también lo corrigió en una fe de erratas justo después del primer párrafo
Si la corrección es del 8 de mayo, entonces en el artículo del 24 de julio habría que reconocer que el famoso texto que decía que esta función no escala quizá no fue escrito de mala fe ni necesariamente estaba equivocado para ese momento
En cambio, optimiza el caso más limitado donde hay muchos canales de notificación y cada receptor espera solo un canal específico
La última vez que lo revisé, LISTEN/NOTIFY tenía un límite de 8,000 bytes para los datos de la notificación, así que claramente había un aspecto en el que no escalaba. Si los datos no se pueden guardar como filas y solo pasar el ID, sería difícil usarlo
Los eventos de un juego web eran datos efímeros que describían cambios de estado, así que no había razón para guardarlos en la base de datos, y además podían superar los 8,000 bytes, por lo que no servía para ese caso de uso
El artículo trata la contención por bloqueos de la cola global, pero parece no mencionar otro problema de una cola global de tamaño fijo. Un solo receptor lento en un canal podía bloquear las escrituras de todos los canales. Al menos hace algunos años ese tipo de falla era posible, aunque quizá eso ya cambió