Как в ClickHouse разогнали PgBouncer в 4 раза

PgBouncer, самый распространённый пулер соединений для Postgres, работает однопоточно: один процесс занимает ровно одно ядро CPU, сколько бы ядер ни было на машине. На сервере с 16 vCPU это означает, что всю нагрузку по пулу соединений тянет одно ядро, а остальные пятнадцать простаивают, и именно пулер, а не сама Postgres, первым упирается в потолок пропускной способности.
В ClickHouse Managed Postgres эту проблему решили, запустив не один процесс PgBouncer, а целую группу процессов, по числу доступных ядер. Все процессы слушают один и тот же порт с включённой опцией сокета SO_REUSEPORT: ядро Linux само распределяет входящие соединения между процессами, а клиент видит только одну точку подключения и не подозревает, что за ней стоит целая группа PgBouncer. Это тот самый способ задействовать больше одного ядра, который рекомендует сама документация PgBouncer.
Проблема в том, что при таком подходе ломается отмена запросов. Команда отмены (cancel) в Postgres приходит по новому, отдельному соединению с ключом отмены, оно не совпадает с тем соединением, на котором выполняется сам запрос. Из-за SO_REUSEPORT ядро может отправить это новое соединение совсем на другой процесс, чем тот, что держит нужную сессию: отмена попадает туда, где о запросе ничего не знают, и просто теряется. Решение, пиринг (peering) между процессами: они знают друг о друге, и если отмена приходит не туда, её пересылают тому процессу, который действительно ведёт эту сессию.
Пулинг работает в режиме транзакций: серверное соединение возвращается в пул сразу после коммита транзакции. А бюджет соединений делится между всеми процессами группы, лимиты max_client_conn и max_db_connections просто делятся на число процессов, поэтому группа в целом никогда не создаёт для Postgres больше соединений, чем допустимо.
Разницу проверили на одинаковых инстансах AWS EC2: 16-ядерный c7i.4xlarge под пулер, отдельная машина под Postgres и третья, для генератора нагрузки pgbench в режиме только чтения (select-only) с пулингом транзакций. На одной машине с пулером запускали один процесс PgBouncer, на другой, группу из 16 процессов. Число клиентских соединений постепенно поднимали с 8 до 256, замеряя пропускную способность и загрузку процессора.
Один процесс вышел на пик около 87 тысяч транзакций в секунду, а затем под нарастающей нагрузкой результат стал только хуже, просел до 77 тысяч при 256 клиентах, потому что всё упиралось в одно ядро. Группа из 16 процессов продолжала расти вплоть до примерно 336 тысяч транзакций в секунду, почти четырёхкратный прирост, потому что ей было куда расширяться.
По утилизации процессора картина та же: при одном процессе утилита pidstat показывала одно ядро, забитое на ~97%, а весь 16-ядерный сервер в целом использовался меньше чем на 10%. Группа процессов разошлась примерно на 8 ядер, и при этом ещё оставался запас, узким местом становились уже Postgres и сам генератор нагрузки. При удержании 256 клиентов один процесс занимал около 9% CPU всей машины, а группа, около 52%. Внешняя метрика AWS CloudWatch, снятая уже не изнутри гостевой системы, показывает тот же разрыв: около 16% CPUUtilization для одного процесса против около 60% для группы, CloudWatch считает чуть иначе и даёт цифры повыше, но разница между конфигурациями сохраняется.
Так же ведёт себя и потолок подключений: один процесс сам ограничивает число клиентов параметром max_client_conn, и как только лимит превышен, новых клиентов отклоняют с ошибкой «FATAL: no more connections allowed (max_client_conn)». Деление бюджета соединений между процессами группы позволяет поднять общий потолок, не выходя за безопасные пределы ни для одного процесса, ни для самой Postgres.
При небольшом числе соединений один процесс работает не хуже группы, а иногда даже чуть быстрее: распараллеливать особо нечего, а соединения группы просто размазаны по процессам тонким слоем. Разрыв в производительности открывается именно там, где это важно, под реальной конкурентной нагрузкой, когда одно ядро становится стеной.
Вывод авторов: один процесс PgBouncer, нормальный выбор по умолчанию до тех пор, пока пропускную способность не начинает ограничивать сам пулер, а не Postgres. Если подогнать размер группы процессов под число ядер, разделить один порт через SO_REUSEPORT и связать процессы пирингом, пулер снова превращается в простую «трубу», а не в узкое место. По умолчанию такая схема уже включена на каждом сервере ClickHouse Managed Postgres.
Ключевые факты
- PgBouncer однопоточен: один процесс занимает одно ядро CPU независимо от того, сколько ядер на сервере, поэтому на многоядерной машине именно пулер, а не Postgres, первым упирается в потолок пропускной способности
- ClickHouse Managed Postgres запускает вместо одного процесса PgBouncer целую группу, по числу ядер, все процессы слушают один порт благодаря опции сокета SO_REUSEPORT, а ядро Linux само распределяет между ними входящие соединения
- Чтобы отмена запроса (cancel) не терялась, когда её маршрутизирует на «чужой» процесс, между процессами группы включён пиринг: они пересылают друг другу отмены, предназначенные не им
- На тесте с 16-ядерным AWS-сервером c7i.4xlarge один процесс PgBouncer вышел на пик около 87 тысяч транзакций в секунду и просел до 77 тысяч при 256 клиентах; группа из 16 процессов выросла до примерно 336 тысяч транзакций в секунду, почти в 4 раза больше
- Лимиты max_client_conn и max_db_connections делятся между процессами группы, поэтому суммарный потолок подключений можно поднять, не превышая безопасную нагрузку на Postgres
Почему это важно
PgBouncer, фактически стандартный пулер соединений для Postgres, и его однопоточная архитектура, давно известное узкое место: на многоядерном сервере он использует только одно ядро, и на нагруженных инстансах именно пулер, а не сама база данных, первым ограничивает пропускную способность. Материал показывает конкретный практический способ снять это ограничение без замены PgBouncer на другой инструмент, через группу процессов и стандартную возможность ядра Linux SO_REUSEPORT, что даёт почти четырёхкратный прирост пропускной способности на одном и том же железе.
Кому это важно
В первую очередь, инженерам, которые администрируют Postgres под высокой конкурентной нагрузкой: много коротких транзакций, много одновременных подключений, характерная картина для бэкендов с большим числом клиентов. Полезно DevOps- и SRE-командам, работающим как с самостоятельно настроенным PgBouncer, так и с управляемыми сервисами баз данных, а также напрямую пользователям ClickHouse Managed Postgres, где такая схема теперь настроена по умолчанию.
Как это применить
Схема воспроизводима не только внутри ClickHouse: поднять несколько процессов PgBouncer (по числу vCPU) с одинаковым конфигом, включить на слушающем сокете SO_REUSEPORT, чтобы все процессы принимали соединения на одном и том же порту, включить между процессами пиринг для корректной обработки отмены запросов, и поделить лимиты max_client_conn и max_db_connections на число процессов, чтобы суммарно не превысить допустимую нагрузку на Postgres. Работает в режиме пулинга транзакций, когда серверное соединение возвращается в пул сразу после коммита. Для тех, кто просто использует ClickHouse Managed Postgres, всё это уже настроено «из коробки», без ручной конфигурации.
Можно ли доверять
Это блог самого ClickHouse, вендора Managed Postgres, поэтому материал не независимый и заканчивается прямой рекламой сервиса. Но методология описана прозрачно: два прогона на одинаковых типах EC2-инстансов, одинаковая версия Postgres, одинаковый бенчмарк (pgbench, только чтение, пулинг транзакций), единственная переменная, число процессов PgBouncer. Цифры подтверждены двумя независимыми замерами: внутренним (pidstat и загрузка CPU внутри гостевой системы) и внешним (метрика AWS CloudWatch), оба показывают один и тот же разрыв между конфигурациями, отличаясь только в абсолютных значениях. Обсуждение на Hacker News набрало 208 баллов и 44 комментария, тема прошла проверку инженерным сообществом.
Риски и подводные камни
Группа из нескольких процессов PgBouncer (в тесте, 16, по числу ядер сервера) сложнее в эксплуатации, чем один процесс: приходится следить за каждым экземпляром отдельно. При небольшом числе подключений один процесс работает не хуже группы и даже немного быстрее, переходить на группу процессов имеет смысл только под реальной конкурентной нагрузкой, когда одно ядро становится узким местом, иначе это лишь усложнение конфигурации без пользы. Без пиринга между процессами отмена запроса может тихо не срабатывать: при самостоятельной настройке (не через готовый сервис ClickHouse) это легко упустить и получить трудноуловимый баг, который проявится только под нагрузкой. И в целом стоит помнить, что источник, публикация вендора управляемого сервиса баз данных, поэтому конкретные цифры (87 и 336 тысяч транзакций в секунду) стоит перепроверять на своём железе и своей нагрузке, а не переносить один в один.
«Один процесс PgBouncer, нормальный выбор по умолчанию до тех пор, пока пропускную способность не начинает ограничивать сам пулер, а не Postgres.»
— ClickHouse, блог Managed Postgres