Cómo escalamos por 4 el throughput de PgBouncer
(clickhouse.com)- ClickHouse Managed Postgres opera de forma predeterminada una flota multiproceso proporcional a la cantidad de núcleos, para evitar la limitación de que PgBouncer, al ser de un solo hilo, quede atado a un único núcleo de CPU
- Todos los procesos se enlazan al mismo puerto con
so_reuseporty el kernel distribuye las conexiones nuevas, por lo que para los clientes se ve como un único endpoint - Las solicitudes de cancelación de Postgres, que llegan por una conexión separada, pueden llegar a un proceso que no posee la sesión; por eso se reenvían al verdadero dueño de la sesión mediante peering entre procesos
- En una
c7i.4xlargede 16 vCPU, un solo proceso alcanzó un pico de alrededor de 87,000 TPS, mientras que una flota de 16 procesos llegó a cerca de 336,000 TPS, registrando aproximadamente 4 veces más throughput - Con pocas conexiones, un solo proceso puede ser similar o un poco más rápido, pero con alta concurrencia un núcleo se vuelve el cuello de botella; por eso hay que repartir el presupuesto de conexiones entre toda la flota para escalar el throughput y el límite de conexiones sin sobreconectar Postgres
El cuello de botella de CPU que crea un solo proceso
- PgBouncer es de un solo hilo, por lo que un proceso usa solo un núcleo, independientemente de la cantidad de CPU del sistema
- Incluso en un servidor de 16 vCPU, el trabajo de connection pooling se concentra en un solo núcleo y los otros 15 núcleos quedan inactivos
- Por eso, antes de que se agote la capacidad de procesamiento de Postgres, el pooler limita el throughput total
- ClickHouse Managed Postgres opera una flota de procesos que define la cantidad de procesos PgBouncer en proporción a la cantidad de núcleos disponibles
Distribución de conexiones con so_reuseport
- Todos los procesos de la flota activan
so_reuseporty se enlazan al mismo puerto - El kernel distribuye las conexiones entrantes entre los procesos, de modo que los clientes se conectan a un único endpoint y no necesitan saber que detrás operan varios PgBouncer
- La documentación de PgBouncer también presenta
so_reuseportcomo una forma de aprovechar varios núcleos con múltiples procesos de un solo hilo
Peering entre procesos para garantizar la cancelación de consultas
- Las solicitudes de cancelación de Postgres llegan junto con una clave de cancelación a través de una nueva conexión, separada de la conexión de la consulta en ejecución
- Al usar
so_reuseport, el kernel puede entregar la nueva conexión de cancelación a un proceso distinto del que posee esa sesión- Si el proceso que recibe la solicitud no conoce esa consulta, la cancelación no se ejecuta
- Si los procesos PgBouncer hacen peering entre sí, una solicitud que llegó al proceso equivocado puede reenviarse al proceso que realmente posee la sesión
- De esta forma, sin importar qué proceso reciba la solicitud, la cancelación de consultas funciona correctamente en toda la flota
Pooling de transacciones y presupuesto de conexiones
- El pooling opera en modo transacción, y la conexión al servidor vuelve al pool en cuanto se confirma la transacción
max_client_connymax_db_connectionsse dividen por la cantidad de procesos y se asignan a cada proceso- Al dividir el presupuesto de conexiones, se puede aumentar el límite agregado de conexiones manteniendo la cantidad total de conexiones de la flota dentro de un límite seguro para Postgres
- Un solo proceso rechaza los clientes nuevos que superan su propio
max_client_conncon el siguiente errorFATAL: no more connections allowed (max_client_conn)
Benchmark en el mismo hardware
- Ambas configuraciones se midieron en el mismo entorno de AWS EC2
- Servidor del pooler:
c7i.4xlargede 16 vCPU - Postgres: servidor separado
- Generador de carga:
pgbenchen un tercer servidor - Carga de trabajo: solo lectura (select-only), modo de pooling de transacciones
- Servidor del pooler:
- La comparación fue entre un solo proceso de PgBouncer y una flota de 16 procesos, con el mismo tipo de instancia, Postgres y carga de trabajo
- Se aumentaron las conexiones de clientes de 8 a 256 y se midieron el throughput y el uso de CPU del servidor de 16 núcleos
| Clientes | TPS de un solo proceso | CPU del servidor con un solo proceso | TPS de la flota | CPU del servidor con flota |
|---|---|---|---|---|
| 8 | 8,910 | 0.8% | 6,450 | 2.9% |
| 32 | 54,203 | 5.2% | 64,244 | 12.3% |
| 64 | 86,570 | 8.3% | 219,439 | 31.9% |
| 128 | 83,463 | 8.1% | 320,547 | 45.9% |
| 256 | 76,893 | 7.7% | 336,469 | 48.9% |
Resultados de throughput y uso de CPU
- El proceso único alcanzó un pico de alrededor de 87,000 TPS y luego, a medida que aumentó la carga, su rendimiento bajó hasta unos 77,000 TPS con 256 clientes
- La flota de 16 procesos aumentó hasta cerca de 336,000 TPS al aprovechar más núcleos, mostrando aproximadamente 4 veces más throughput que el proceso único
- En mediciones con
pidstat, un solo proceso PgBouncer usó alrededor del 97% de CPU durante la carga, ocupando por completo un núcleo, pero el uso total del servidor de 16 vCPU se mantuvo por debajo del 10% - La flota distribuyó el trabajo por todo el servidor y usó unos 8 núcleos, y aún conservaba capacidad adicional cuando Postgres y el generador de carga llegaron a su límite
- En una medición manteniendo 256 clientes, el servidor con proceso único usó de forma sostenida alrededor de 9% de CPU, mientras que el servidor con flota usó cerca de 52% de CPU
- En la medición externa de EC2 CloudWatch, la CPUUtilization promedio de la instancia con proceso único fue de alrededor de 16%, y la de la flota, de alrededor de 60%
- Las cifras de CloudWatch son un poco más altas que las mediciones internas del guest, pero la diferencia es la misma: un solo PgBouncer no puede aprovechar la mayor parte de una máquina de 16 vCPU
Elección de configuración según la concurrencia
- Cuando hay pocas conexiones, no hay trabajo para paralelizar y las conexiones de la flota se distribuyen entre varios procesos, por lo que un solo proceso puede ser suficiente o incluso un poco más rápido
- Con 8 clientes, el proceso único registró 8,910 TPS y la flota 6,450 TPS
- Cuando aumenta la concurrencia, el único núcleo ocupado por el proceso único se convierte en una pared para el throughput, y la brecha de rendimiento frente a la flota crece
- En entornos donde Postgres llega al límite antes que PgBouncer, un solo proceso también es un valor predeterminado adecuado
- Cuando el pooler empieza a limitar el throughput, hay que combinar una flota de procesos ajustada a la cantidad de núcleos, uso compartido de puerto basado en
so_reuseporty peering entre procesos para eliminar el cuello de botella - Todos los servidores de ClickHouse Managed Postgres ofrecen esta configuración de forma predeterminada
1 comentarios
Comentarios en Hacker News
Solo usen https://github.com/yandex/odyssey. Odyssey es un PgBouncer escalable
También tenemos muchos clientes que escalaron las conexiones de PostgreSQL a más de 10 mil, y en el futuro planeamos evaluar alternativas como Odyssey o pgdog. Aun así, personalmente no me gusta la idea de tener más de 10 mil conexiones a PostgreSQL, y creo que con unos pocos cientos ya se puede escalar lo suficiente
Si operas PgBouncer en Kubernetes, es fácil levantar varios procesos en una sola máquina y también distribuirlos entre varias máquinas.
Esto es especialmente útil porque Azure a veces provoca fallas secuenciales en todo el conjunto de servidores durante el mantenimiento de VMs
Entiendo el concepto de peering del que hablan aquí, pero nunca lo he usado en PostgreSQL.
A) ¿Hay algún modo o configuración en PostgreSQL para montar esto fácilmente? Me imagino una estructura donde las solicitudes de cancelación se van pasando secuencialmente a los peers hasta que no den error, o donde la solicitud de cancelación incluye metadatos para que el proceso que la recibió por error pueda identificar el proceso correcto.
B) Si todos los procesos de PostgreSQL reciben solicitudes del cliente con
so_reuseport, parecería que para la comunicación entre peers haría falta algún mecanismo aparte de comunicación entre procesos (IPC); ¿qué mecanismo se usa en la práctica?Parece que se podría implementar de forma relativamente simple incluyendo el ID del proceso de PgBouncer en la clave de cancelación
Puedes ver desde la diapositiva 26: https://www.pgevents.ca/events/pgconfdev2024/sessions/sessio...
También hay un video de la presentación de Jelte, mantenedor de PgBouncer: https://www.youtube.com/watch?v=X-nCHcZ6vQU
Estoy usando pgdog y encaja muy bien con lo que necesito
Me pregunto por qué PgBouncer tiene que preocuparse incluso por las solicitudes de cancelación. ¿No bastaría con reenviarlas tal cual a PostgreSQL, y si PostgreSQL devuelve un error en vez de una respuesta normal a la consulta cancelada, que lo maneje el PgBouncer encargado de esa conexión?
PgBouncer le da al cliente un PID y un valor secreto de cancelación falsos pero rastreables, y cuando vuelve una solicitud de cancelación, busca el PID y valor secreto del servidor/proceso real para reenviarlos. Además, puede darse la situación de que el cliente crea que la consulta sigue ejecutándose, pero PgBouncer ya sabe que terminó y que la conexión fue reutilizada, así que debe verificar si esa conexión y PID todavía están ejecutando esa consulta
¿Esto es para controlar el acceso al servidor PostgreSQL mediante un pool de conexiones desde microservicios? Si fuera un backend monolítico, no parecería necesario.
La mayoría de los frameworks decentes de backend ya traen pool de conexiones integrado, y el 98% de los casos de uso donde no se necesitan ni se recomiendan microservicios se resuelven con eso
PgBouncer es un software excelente. Lo usamos mucho y simplifica bastante la operación de bases de datos
Acabo de enterarme de
so_reuseporty me parece interesante. La clave de la configuración parece ser esto y el peering; ¿el peering viene integrado en PgBouncer y es fácil de configurar?¿También se puede usar peering en Kubernetes? En este entorno no haría falta reutilizar puertos, así que también me pregunto si cada Pod termina funcionando de forma independiente con su propio pool de conexiones
Ahora tengo 46 años, y todavía recuerdo lo impactado que quedé a los 23 cuando vi el modelo de conexiones pesadas de PostgreSQL. ¿Todavía no ha mejorado desde entonces?
Aun así, si no usas un pool de conexiones, PostgreSQL tiene que hacer fork de procesos, así que crear una conexión nueva siempre cuesta varias decenas de milisegundos o más. Las aplicaciones escritas sin pool de conexiones tampoco son ideales, pero son bastante comunes.
Los frameworks de aplicación también cambiaron, y las arquitecturas serverless pueden generar decenas de miles de conexiones, que es donde PostgreSQL empieza a tener problemas. Personalmente no me gustan más de unos pocos cientos de conexiones, pero hoy en día es una situación totalmente realista
Para lograr compatibilidad con Java, muchas empresas tuvieron que cambiar mucho su manera de manejar la concurrencia. PostgreSQL y SQLite también se diseñaron en una época parecida, pero fueron sistemas creados por veteranos de la industria que ya desplegaban sistemas de alta carga antes de que existieran estas discusiones sobre hilos, y que además daban soporte a usuarios con hardware antiguo