一台16-vCPU的云实例,你多花的钱本该换来16个核心一起干活。可如果这台机器上跑的是默认配置的PgBouncer,现实是:15个核心闲着,剩下1个被压到97%,而且压力越大,它反而越慢。

这不是危言耸听,是ClickHouse自己压测出来的曲线:单进程PgBouncer的吞吐在64个客户端左右见顶,之后不涨反跌,256个客户端时从8.7万TPS掉到7.7万。CPU利用率全程只有7%到8%——机器几乎是空转的,瓶颈根本不在Postgres,而在这一个被焊死的核心上。

单线程是优点,也是隐藏税

PgBouncer能做到轻量、低延迟,靠的正是单线程模型——没有锁竞争,没有跨核调度开销。这个设计在小机器、单核年代没问题。但云时代的默认实例动辄8核16核,单线程连接池就成了一张看不见的账单:你按16核付费,连接池只用得上其中一个。

ClickHouse给出的解法是so_reuseport:让多个PgBouncer进程绑定同一个端口,内核负责把新连接分发到不同进程上,客户端仍然只看到一个入口。这不是ClickHouse发明的技术,PgBouncer自己的文档里早就写着——单线程绕不开多核,想用满CPU就得靠这条路。ClickHouse做的事,是把它做成Managed Postgres里开箱即用的默认配置,按核心数配出一支进程fleet。

结果是吞吐持续爬升到33.6万TPS,约4倍;CPU利用率(实例内部口径)也从个位数拉到约49%,CloudWatch外部口径则是从16%到60%。同一台机器、同一个Postgres、同一套压测脚本,唯一变量是1个进程还是16个进程。

口径单进程Fleet
实例内部(guest)约8%约49%
CloudWatch外部约16%约60%
吞吐拐点:单进程见顶就往下掉 单进程 · 64并发峰值 8.7万 TPS 256并发反降至7.7万 Fleet · 256并发 33.6万 TPS · 约4倍 CPU约49%,仍有余量

真正的难点不是加进程,是别把连接搞丢

多开几个进程听起来简单,但单线程组件粗暴复制成多进程,最先炸的是状态一致性。PgBouncer要处理的第一个坑,是Postgres的取消请求。

客户端想取消一条正在跑的查询,协议规定要开一条新连接,带上cancel key去发起取消。问题是,so_reuseport把新连接交给内核随机分配——它完全可能把这条取消请求扔给一个从没见过这次会话的PgBouncer进程。取消请求安静地落空,查询继续跑,用户以为取消生效了,其实什么都没发生。

取消请求为什么会失效 查询连接 落在进程A 取消请求 新连接,内核分配 落到进程B 不认识该会话 取消静默失效 查询照常运行 Peering:进程互相知道彼此,转发给A

peering机制解决的正是这个:让进程之间互相知道对方持有哪些会话,取消请求落错地方也能被转发到真正拥有这条连接的进程上。这不是可选的优化,是让fleet模式从demo变成能用的生产配置的必要条件。

第二个坑是配额。单进程有max_client_connmax_db_connections两道闸门,直接复制成16个进程,每个都按原值放行,加起来会把Postgres的连接上限直接击穿。ClickHouse的做法是把这两个配额按进程数拆开分配,fleet作为一个整体不超过原本设定的天花板。

  • 风险.如果只是照抄so_reuseport却不做peering和配额拆分,取消请求会静默失效,连接数也可能超订,这不是理论风险,是协议层面必然会撞上的坑。

别把4倍当银弹

数据里有一处诚实的反例:客户端只有8个的时候,单进程反而比fleet快一点——8,910 TPS对6,450 TPS。并发太低,没什么可并行的,fleet的连接分散得太薄反而吃亏。真正拉开差距的是64个客户端往上,单核开始成为墙。

单进程连接池,是小流量时代的合理默认,大并发时代的隐性枷锁

这也是要老实说清楚的一点:所有这些数字目前只来自ClickHouse自己的一篇博客,压测机型、参数都是自己定的,没有第三方复现或者其他托管Postgres厂商的对照数据。它更像一份说明书,而不是独立评测报告。

自建PgBouncer的运维人员可以照着这个思路自己搭so_reuseport加peering,但取消请求路由和配额拆分这两件事得自己解决,不是照抄一行配置就能拿到。其他做Managed Postgres的厂商是否有类似方案,这场连接池层面的暗中较量,目前还看不清谁跟上了。