Skip to content

新しい async ネイティブな ClickHouse Python クライアントの設計

image from slack 2
2026年3月16日 · 23分で読む

はじめに

clickhouse-connect は、公式の ClickHouse Python クライアントです。オープンソースの Apache-2.0 ライセンスで提供されており、コードは GitHub で公開されています。PyPI から入手可能で、pip install clickhouse-connect でインストールできます。

本プロジェクトの開発は 2022年2月に始まり、2022年9月に v0.2.8 として初めて PyPI に公開されました。当初の作者が個人プロジェクトとして構築を進め、最初の 2年半は機能豊富な同期クライアントを作り上げることに注力し、現在の形へと成熟しました。

async-native(ネイティブ非同期)クライアントへの要望は以前から根強く寄せられていましたが、専任のリソースがなければ迅速な実現は困難でした。そこで回避策として、2024年7月に、同期クライアントをスレッドプールエグゼキューターでラップする一般的なパターンを採用しました。これは十分な性能を発揮し、非同期コンテキストで clickhouse-connect を必要としていたユーザーの課題を解消しました。多くのユースケースでうまく機能し、現在も機能し続けていますが、高同時実行下でのスレッドプールの枯渇、スレッド同士の GIL(グローバルインタプリタロック)の競合、OS スレッドスタックを維持するメモリオーバーヘッドといった本質的な制約を抱えていました。

クラウドの利用状況データによると、clickhouse-connect は約 2,200 の組織で使用され、300 億回近くのクエリを実行してきました。clickhouse-connect ユーザーの 13% が非同期モードで利用していますが、全クエリの 24% を非同期モードが占めています。言い換えれば、非同期ユーザーは平均して 2 倍以上のクエリを実行しており、大量で性能要件の厳しいワークロードで非同期が突出して利用されていることがわかります。

async-native クライアントを開発した動機

I/O 負荷の高い Python ワークロードにおいて、同時実行を管理する効率は イベントループ のほうが OS スレッドよりも大幅に優れています。GIL によってスレッドが並列実行できる処理は制限され、スレッドのスケーリングには自ずと限界があります。イベントループなら、数百件の並行 I/O 操作を効率的に管理できます。同じことを行うために数百の OS スレッドを立ち上げるのは、このユースケースでは現実的ではありません。

そのため、エグゼキューターベースのアプローチでも実用的な非同期クライアントは提供できるものの、高い同時実行性を求めるワークロードには理想的とは言えません。負荷が高まるにつれてスレッドプールは飽和し、I/O がブロックされ、テールレイテンシが悪化します。

Diagram comparing native-async network I/O with CPU parsing offloaded to a thread versus wrapping a synchronous client in a ThreadPoolExecutor

native-async なネットワーク I/O(CPU パース処理はスレッドにオフロード)と、同期クライアントの処理全体を ThreadPoolExecutor でラップする方式の概念的な違いを示す図。記載の数値は説明用です。native-async のホストごとの同時実行数は設定可能です。

高レベルの設計上の選択

非同期 HTTP ライブラリの選定

同期クライアントでは優れた HTTP ライブラリである urllib3 を使用していますが、これは同期処理専用です。そのため、非同期の代替ライブラリが必要でした。Python において本番環境で実用に耐えうる主な選択肢は、aiohttp と httpx の 2 つです。

httpx の主な特徴は以下のとおりです。

  • requests 互換の API
  • クリーンでモダンな設計
  • 統合された同期・非同期インターフェース
  • 純粋な Python によるプロトコルスタック(httpcore および h11)
  • 組み込みの HTTP/2 サポート

aiohttp の主な特徴は以下のとおりです。

  • 長年の実績を持つ asyncio ネイティブな設計
  • HTTP クライアントとサーバーフレームワークの両方を提供
  • 非同期ワークロードにおける高いスループット
  • HTTP パースや URL/ヘッダー処理のためのコンパイル済みアクセラレータ

高スループットなデータベースクライアントでは処理速度が最優先されるため、自然な選択として aiohttp を採用しました。また、クライアントには多数のメソッドやヘルパーが存在するものの、HTTP ライブラリを直接操作する部分は比較的少数です。ほとんどの操作は、実際のリクエストを発行する少数の内部メソッドを経由します。このため、どのライブラリを選んだとしても、将来必要が生じた際に比較的容易に差し替えられる状態にありました(ネタバレになりますが、差し替える必要はありませんでした)。

核心となる課題: 非同期 I/O と CPU 負荷の高いパース処理

clickhouse-connect にはすでに、ClickHouse の Native バイナリ形式 をカラムのデータ型、NULL 許容性、ネスト構造などの Python オブジェクトへとパースする、重い処理を担う十分にテストされた成熟したデータ変換層が存在していました。直面した大きな問題は、このコードが本質的に CPU バウンドかつ同期処理である点です。パース処理中には待機すべき I/O が存在しないため、これを非同期向けに書き直しても、実績ある何千行ものロジックが重複するだけで実質的なメリットはありません。既存のパース機構の再利用は最初から前提でした。問題は「どのように再利用するか」でした。

ナイーブなアプローチ

ひとつのアプローチは、HTTP レスポンスボディ全体を先に読み込み、そのバイト列をエグゼキュータースレッド内の既存パーサーに渡す方法です。これは単純ですが、結果セットが数百メガバイト以上になり得るデータベースクライアントには適していません。パース前にレスポンス全体をバッファリングするとピークメモリ使用量が増加し、ダウンロードが完了するまでパースを開始できないため、最初の 1 行が得られるまでの時間(time-to-first-row)が遅くなります。さらに、パースが別フェーズとして実行されるため、そのクエリにおいてネットワーク I/O と CPU パースが重複して並行処理されません。補足すると、aiohttp の await response.read() は他のコルーチンが動作できるようイベントループに制御を戻しますが、本質的な問題はパイプライン化の喪失と、大きな結果セットに対する回避可能なメモリ圧迫です。

別のアプローチはその逆です。レスポンスをストリーミングし、チャンクが届くたびにイベントループ上で直接パースする方法です。これならメモリの問題は回避できますが、CPU バウンドなパース処理がイベントループをブロックしてしまいます。ClickHouse の Native 形式のパースには、型付きカラム形式の数百万行に及ぶデータのデシリアライズが伴う場合があります。それには相応の CPU 時間を要し、パースに費やされるミリ秒単位の時間はすべて、アプリケーションが他のリクエストを処理できない時間となります。

さらに悪いことに、イベントループ上で CPU 負荷の高いパースを実行すると、クエリ自体の転送速度が低下する可能性があります。イベントループがパースに専念している間、ソケットの読み取り処理が行われません。受信バッファがいっぱいになると TCP フロー制御によって送信側のペースが落ち、スループットが低下して転送がスムーズではなく間欠的になります。サイズの大きいレスポンスにおいてこれは不都合なトレードオフであり、採用できる方法ではありませんでした。

このように、I/O でブロックするか CPU でブロックするかという、2 つの好ましくない選択肢の間で身動きが取れなくなっていました。本当に必要だったのは、別スレッドで同期的にパースを行いながらネットワークから非同期でデータをストリーミングし、双方が相手をブロックすることなく協調して動作する仕組みでした。

Half-Sync/Half-Async パターン

この課題は珍しいものではありません。「Half-Sync/Half-Async(半同期/半非同期)」と呼ばれる確立された並行処理パターンが存在します(学術的な論文はこちら、Java の例を用いた平易な解説はこちら をご覧ください)。その考え方は非常に明快です。非同期 I/O の世界と同期処理の世界を分離し、双方向のバックプレッシャーを提供する有界キュー(サイズ制限付きキュー)で接続します。このパターンは、Android の AsyncTask フレームワーク から、非同期 HTTP 処理と同期 WSGI アプリケーションを橋渡しする ASGI サーバー に至るまで、多くのシステムで見られます。

今回のケースでは、このパターンは 3 つの要素で構成されます。

  1. 第 1 に、イベントループ上で動作する非同期プロデューサーです。これは単にソケットの読み取りを await する純粋な I/O であるため問題ありません。aiohttp のレスポンスストリームからチャンクを読み取り、有界キューへと送ります。キューには最大サイズが設定されているため、コンシューマーの処理が追いつかない場合はプロデューサーの速度が自然と抑えられます。このバックプレッシャーにより、メモリ使用量が予測可能に保たれます。
  2. 第 2 に、スレッドプールエグゼキューター内で動作する同期コンシューマーです。キューからチャンクを取り出し、必要に応じて解凍して、既存の同期パーサーに供給します。この設計の素晴らしい点は、パーサー側から見れば単にバイトストリームを読み込んでいるだけに過ぎない点です。反対側でイベントループがデータを供給している事実を意識することも関知することもありません。
  3. 最後に、架け橋として機能するキューそのものです。同一の基底バッファに対して同期と非同期の両方のインターフェースを公開する AsyncSyncQueue クラスを中心として構築しました。キューの上限は 10 チャンクに設定されており、各チャンクはソケット読み取り時の最大 1MB です。つまり、レスポンスの合計サイズに関係なく、いかなる時点でもバッファリングされるレスポンスデータは最大で約 10MB に抑えられます。境界を越えたエラー処理も行われます。ストリームの途中でサーバーがエラーを返した場合やネットワークが切断された場合、プロデューサーは例外オブジェクトをキュー経由で渡すため、コンシューマーはそれを検知してパーサー側で再送出できます。

明確にしておくと、ネットワーク I/O は async-native で行われる一方、CPU バウンドなパース処理はあえて同期のまま維持され、イベントループから切り離されたエグゼキュータースレッドで実行されます。また、プロデューサーとコンシューマーは並行して動作するため、自然と処理が重複します。つまり、パーサーがチャンク N を処理している間に、イベントループはすでにチャンク N+1 をダウンロードしています。これらの一連の操作が完全に順次実行される同期クライアント(およびそれをラップした従来の非同期クライアント)との違いは次のとおりです。

Animation showing pipelined read and parse with the half-sync/half-async pattern versus sequential processing in the legacy client

順次実行設計と、パイプライン化された読み取り・パース設計の違いを示すアニメーション

この重複処理が実際に効果を発揮する鍵を握るのが、有界キューです。ただし、キューに許可する最大サイズは適切に設定しなければなりません。小さすぎると、順次実行に近い行ったり来たりの動作(ピンポン動作)になってしまいます。大きすぎたり、ましてや無制限にしたりすれば、コンシューマーの処理が追いつかない場合に再びメモリの問題に直面します。サイズが大きな結果セットでは、ネットワーク I/O と CPU パースが順番待ちではなく並列して実行されるため、このパイプライン効果によってクエリ全体の所要時間が大幅に短縮されます。

同じパターンは挿入処理でも逆向きに機能します。既存のシリアライズロジックがエグゼキュータースレッド内で同期的に挿入ブロックを構築し、キューにプッシュします。イベントループがその反対側からブロックを取り出し、aiohttp を介してネットワーク上にストリーミングします。同じキューのプリミティブとバックプレッシャー効果を利用しながら、役割が逆転している形です。

ベンチマーク

理論の説明はこのくらいにして、実際のパフォーマンスを見てみましょう。新しい async-native クライアントと、「従来型」の非同期クライアント(同期クライアントをエグゼキューターでラップしたもの)のベンチマーク測定を行いました。

テスト構成

以下の構成の ClickHouse Cloud インスタンスに対してベンチマークを実行しました。

  • ClickHouse Cloud インスタンス:
    • サーバーバージョン 25.10.1.7462
    • AWS r5ad.2xlarge(フラクショナルポッド)
    • us-west-2(オハイオ)
    • 4 vCPU / 8 GiB RAM
    • 30 GiB ローカル NVMe SSD キャッシュ + S3 ストレージ
  • クライアントマシン:
    • MacOS Tahoe 26.3
    • M4 Max
    • 36 GB RAM
    • 14 CPU コア
    • 所在地: 米国西海岸
  • ネットワーク: 平均レイテンシ 64.4 ms
  • clickhouse-connect バージョン: v0.12.0.rc1
  • Python: 3.12.11

両クライアントとも、接続/スレッドプールのワーカー数を 32 に設定しました。非同期クライアントは connector_limit=32 を指定した aiohttp を使用し、従来型クライアントは同等のプールサイズを指定した urllib3 と 32 個のエグゼキュータースレッドを使用しています。

測定方法についていくつか補足します。各シナリオでは、内容に応じて 1 回の実行あたり個別に計測される操作を 50〜200 回実行し、各シナリオを 5 回ずつ実施しました。スループットは標準偏差とともに平均値を記載しています。P95 レイテンシは実行ごとに算出し、全実行にわたる平均値 ± 標準偏差として報告しています。これにより、単なる速さだけでなく、テールレイテンシの「安定性」も把握できます。シナリオの実行順序および各シナリオ内でどちらのクライアントを先に実行するかはランダム化されています。サーバーの状態を落ち着かせるため、各シナリオの間には短いクールダウン時間を設けています。総合的な高速化比率の算出には相乗平均を用いています。

結果

シナリオ同時実行数Async (op/s)Legacy (op/s)Async P95Legacy P95高速化
100 行の SELECT112.9 ± 0.213.1 ± 0.078.0 ± 0.7 ms77.7 ± 0.4 ms0.99 倍
フィルタリングクエリ16157.0 ± 17.4158.5 ± 12.5139.4 ± 28.9 ms135.2 ± 30.2 ms0.99 倍
JOIN クエリ16139.3 ± 15.7118.6 ± 51.0154.7 ± 68.0 ms439.2 ± 722.2 ms1.17 倍
集約クエリ32290.4 ± 49.1258.5 ± 123.2191.9 ± 60.3 ms882.3 ± 1580.6 ms1.12 倍
大規模結果(1 万行)435.4 ± 3.025.0 ± 3.4209.9 ± 164.1 ms330.3 ± 212.7 ms1.41 倍
10 行の挿入3228.1 ± 0.826.9 ± 0.71276.9 ± 70.6 ms1317.8 ± 11.6 ms1.05 倍
100 行の挿入3228.3 ± 2.024.5 ± 5.81234.2 ± 21.9 ms1955.2 ± 1592.9 ms1.15 倍
混合ワークロード3269.5 ± 15.146.2 ± 10.51160.2 ± 68.8 ms1810.6 ± 1338.3 ms1.51 倍
相乗平均:1.16 倍

数値が示していること

全シナリオにおける相乗平均は 1.16 倍 でした。このベンチマークを複数回実行したところ、個々のシナリオでは実行ごとにばらつきがあるものの(実際のクラウドインスタンスを相手にしているため自然で想定どおりの挙動です)、相乗平均は一貫して 1.16〜1.18 倍の範囲に収まりました。

注目すべき点がいくつかあります。

  1. 同時実行数が増えるほど、非同期クライアントの高速性が際立つ。 concurrency=1 では、両クライアントとも 0.99 倍と互角です。同時実行数が 1 であれば、イベントループが管理すべき負荷が存在しないため当然の結果です。32 同時実行になると差が現れ、集約で 1.12 倍、混合ワークロードで 1.51 倍となっています。GIL がスレッドの実際の並行処理を制限する Python では特に、スレッドプールよりもイベントループのほうが多数の同時 I/O 操作をうまく処理できます。

  2. テールレイテンシにはスループット以上に興味深い傾向が見られる。 P95 のカラム、特にその数値だけでなく ± の標準偏差に注目してください。従来型クライアントの P95 標準偏差は極めて大きく、JOIN で ±722 ms、集約で ±1,581 ms、挿入で ±1,593 ms、混合ワークロードで ±1,338 ms に達しています。そのテールレイテンシは、実行ごとに「良好」か「劣悪」かがコインの裏表のように分かれます。一方、非同期クライアントの P95 標準偏差は、最も大きい大規模結果の場合でも ±164 ms にとどまっています。全シナリオを通じた平均 P95 は、従来型の 869 ms に対して非同期は 556 ms でした。

    これは本番ワークロードにとって重要です。P95 が実行ごとに 200 ms 未満から 4 秒以上にまで変動するようなばらつきは、問題を引き起こしかねません。非同期クライアントは、より高速なテールレイテンシだけでなく、「予測可能」なテールレイテンシを提供します。

  3. スループットもより安定している。 従来型クライアントの集約スループットの標準偏差は ±123.2 であり、平均値 258.5 の半分近くに達します。それに対して非同期クライアントは平均 290.4 に対して ±49.1 です。挿入処理においても、従来型クライアントが ±0.7 および ±5.8 op/s と揺れ動くのに対し、非同期クライアントの変動は ±0.8 および ±2.0 op/s に収まっています。求められるのは単なる高速さではなく、「安定して」高速であることです。

では、この改善はどこからもたらされたのでしょうか。低同時実行環境では非同期クライアントの性能は従来型と同等であり、キューによるブリッジのオーバーヘッドが無視できるほど小さく、基底の HTTP ライブラリの性能もほぼ同等であることがわかります。高同時実行環境で得られるパフォーマンス向上は、主に 2 つの要因によるものです。イベントループが OS スレッドのスケジューリングオーバーヘッドなしに多数の並行接続を処理できること、そしてパイプライン効果によってネットワーク I/O とパース処理が順番待ちではなく同時に重なって処理されることです。従来型クライアントのスレッドプールは、各スレッドが接続を保持し、スタック領域を占有し、GIL を巡って競合するため、早い段階で飽和してしまいます。

なお、今回のベンチマークはあえて控えめな設定にしています。操作ごとの純粋な効率性を公平に比較するため、両クライアントとも接続数/スレッド数を 32 に制限しました。実際の環境では、同時実行数が高まるにつれてイベントループの優位性はさらに広がります。中断されたコルーチンはメモリ上の小さな状態オブジェクトに過ぎないため、イベントループは無視できるオーバーヘッドで数百の並行接続を容易に管理できます。一方でスレッドプールが同じ接続数を処理しようとすると、それぞれが重いスタックを持つ数百の OS スレッドを立ち上げることになり、GIL の獲得を争い、OS スケジューラ上で CPU 時間の奪い合いが発生します。そして最終的には、スレッドが有用な処理を行えず単に待機するだけの状態に陥ります。相乗平均 1.16 倍という結果は、スレッドプールが過負荷に追い込まれていない状態であっても得られるメリットを示しています。さらに高い同時実行レベルでは、その差は一層広がります。

ぜひお試しください

ここまでお読みいただいた方は、この技術に強い関心をお持ちか、あるいは直接関わる業務をお持ちの方でしょう。前者であれば嬉しい限りです。私たちも技術オタクです。後者であれば、ぜひご協力をお願いします。clickhouse-connect v0.12.0rc1 が公開され、テスト可能な状態になっています。リリースノートは GitHub で確認でき、以下のコマンドでインストールできます。

pip install clickhouse-connect[async]==0.12.0rc1

新しい非同期クライアントがお使いのワークロードでどのように機能したか、皆様からのフィードバックを積極的にお待ちしています。

まとめ

補足しておくと、エグゼキューターベースの非同期クライアントは 2 年近くにわたって十分に役割を果たしてきましたし、現在でも完全に有効な選択肢です。多くのユーザーの課題を解消し、実際の本番ワークロードを極めて堅牢に処理してきました。しかし、本プロジェクトに専任のリソースを確保できたことで、これまで着手できなかったレベルの抜本的な改善に投資できるようになりました。その成果が、ゼロから構築され、高速で、負荷耐性が高く、リソース効率にも優れた async-native クライアントです。プロジェクトにおいて最も要望の多かった機能のひとつであり、ついにリリースできることを嬉しく思います。

ClickHouse の普及に伴い、周辺の言語クライアントのエコシステムも拡大しています。clickhouse-connect は ClickHouse の専任チームによってメンテナンスされている公式サポートの ClickHouse Python クライアントです。バグの報告、機能リクエスト、または貢献をご希望の際は、ぜひご連絡ください。GitHub での Issue や PR の投稿をいつでも歓迎しています。

今すぐ始める

自社データで ClickHouse を試してみませんか?ClickHouse Cloud は数分で利用開始でき、300 ドル分の無料クレジットも進呈しています。

サインアップ

この記事をシェア

  • Y Combinator icon
  • X icon
  • Bluesky icon
  • Facebook icon
  • LinkedIn icon

Subscribe to our newsletter

Stay informed on feature releases, product roadmap, support, and cloud offerings!

Follow us

XBlueskySlackGithubTelegramMeetupRSS