🚰 PgBouncerをSO_REUSEPORTで16プロセス並べたら4.36倍になった — ClickHouseの力技が示す接続プーラーの現在地
シングルスレッドのPgBouncerを、SO_REUSEPORTで同一ポートに16プロセス束ねて77k→336k TPSに引き上げたClickHouseの事例。書き換えでもforkでもない「並べるだけ」の解法と、その裏にある根本問題を読む。
16vCPUのマシンでPgBouncerを動かすと、使われるのは1コアだけ。残り15コアはただ眺めている。
PgBouncerはPostgres界の定番接続プーラーだが、設計がシングルスレッド・シングルプロセスなので、どれだけ大きいインスタンスに載せてもスループットの天井は1コア分だ。ClickHouse(そう、あの列指向DBの会社。Managed Postgresもやっている)がこの天井を突いたブログを出していて、HNで166ポイントの議論になっていた。解法が面白い。PgBouncerを一切書き換えていない。
SO_REUSEPORTで「並べるだけ」
Linuxには SO_REUSEPORT というソケットオプションがある。複数のプロセスが同じポートにbindできて、着信した接続はカーネルが勝手に振り分けてくれる仕組みだ。
ClickHouseはこれを使って、同じ5432ポートにPgBouncerを16プロセスぶら下げた。ロードバランサーの追加も、マルチスレッド化のパッチもなし。カーネルがロードバランサーの役をやる。
クライアント → :5432 ─(カーネルが分散)→ pgbouncer × 16 → PostgreSQL
ベンチ結果(AWS c7i.4xlarge・16vCPU・256クライアント)は、シングル構成の77k TPSに対して336k TPS、4.36倍。シングル時代は1コアが97%で飽和していたのが、複数コアにリニアに広がったという。数値はClickHouseの自己申告で第三者検証はまだ、という点は添えておく。
「並べるだけ」で済まない一箇所
素直な発想なのになぜ今まで定番化しなかったかというと、ちゃんと罠があるからだ。クエリキャンセルである。
Postgresのキャンセルリクエストは、元のクエリとは別のTCP接続で飛んでくる。SO_REUSEPORTだとその新規接続はカーネルの気分で別のPgBouncerプロセスに届く。届いた先のプロセスは、キャンセルしたいクエリのことを何も知らない。
ClickHouseはここに、プロセス間でキャンセルを正しい持ち主へ中継するpeering機構を足している。逆に言えば、必要な工夫は実質これだけだった。ただし残念なことに、このパッチや構成がOSSとして公開された形跡は記事にもGitHubにも見当たらない。Managed Postgres内部の実装に留まっているらしい。再現するなら自分でpeering部分を書くことになる。
HNの反応は「そもそも論」だった
スレッドで目立ったのは称賛より代替案だ。Yandex製のOdyssey(最初からマルチスレッド)、SupabaseのSupavisor、そしてRust製のPgDog。PgDogは昨日書いたばかりなんですが、SQLパーサーを内蔵してSETやLISTEN/NOTIFYまで面倒を見る方向で、PgBouncerの限界を作り直しで解こうとしている勢だ。
さらに根っこを突く声もあった。「そもそもPostgresの1接続=1プロセスという20年来の設計が諸悪の根源で、プーラー側で頑張るのは対症療法では」というやつ。正論ではある。ただ、本体のアーキテクチャが変わるのを待てる現場は存在しない。
枯れた定番を並べて使うか(今回の力技)、モダンな作り直しに乗るか(Odyssey/PgDog)、本体の進化を待つか。個人的には、SO_REUSEPORTの「コードを書き換えず配置で解く」姿勢が一番好みだ。道具の限界に当たったとき、その道具を捨てる前にカーネルの機能を一周見直す。この順番は接続プーラーに限らず効く気がしている。あなたの現場のPgBouncer、何コアのマシンで1コアだけ回っていますか。