TL;DR: ClickHouse Native プロトコルでは、クライアントはクエリを完了するために EndOfStream パケットを待つ必要があります。結果の Data パケットと EndOfStream の間に、ClickHouse はいくつかのメタデータパケット(Logs、Progress、ProfileEvents)を送信します。これらがバッチ化されずに送信されると、TCP_NODELAY とアイドル期間後の TCP 輻輳ウィンドウ(cwnd)のリセットが組み合わさった際にウィンドウサイズを超過し、不要な追加の往復(ラウンドトリップ)が発生することがあります。私たちは Linux ネットワークネームスペースを使用してこの現象を決定論的に再現し、アプリケーション層でそれらのメタデータパケットをバッチ化することでテールレイテンシを削減しました。
問題の概要
あるユーザー環境において、クライアントとサーバー間の単純な SELECT クエリで、約 32 ms の追加レイテンシが発生していることが確認されました。
この環境では、クライアントとサーバー間の距離は約 32 ms(送信)離れています。接続が 2 秒以上アイドル状態になった後、直後のクエリのエンドツーエンドの処理時間は、期待される約 32 ms(1 RTT)ではなく、往復時間(RTT)の 2 倍に相当する約 64 ms かかっていました。
この追加のラウンドトリップが何によって引き起こされているのか、正確に突き止める必要がありました。
調査を進める中で、Linux の TCP スタックと ClickHouse Native プロトコルの両方に関して、いくつかの興味深い知見が得られました。
ClickHouse Native プロトコルの基礎
ClickHouse サーバーは幅広いプロトコルをサポートしています。ClickHouse サーバーと通信する主な方法は HTTP と Native TCP の 2 つです。今回取り上げる問題には Native TCP プロトコルが関係しているため、そこに焦点を当てます。この問題を理解するには、ClickHouse Native プロトコルの仕組みを少し把握しておくことが重要です。
ClickHouse Native プロトコルは、TCP トランスポートプロトコル上に構築されたステートフルなプロトコルです。クライアントとサーバーは、フェーズ(Handshake、INSERT、SELECT)ごとに異なるプロトコルメッセージを交換します。TCP Native プロトコルの公式仕様 に、その詳細な動作が記載されています。
プロトコルレベルにおける SELECT クエリの動作を簡略化すると、以下のようになります。
- クライアントが
Queryを送信します(SQL、設定、フラグなどが含まれます)。 - サーバーが実際の結果行を含む
Dataパケットをストリーミング送信します。 - サーバーは中間メタデータパケット(
ProfileEvents、Progress、Logs)も送信します。 - サーバーが
EndOfStream(EOS)を送信し、該当クエリの完了を通知します。
これはクライアントにとって、対象のクエリが完了したとみなされる前に、そのクエリ向けの EndOfStream パケットを受信するまで接続からデータを読み取り続けなければならないことを意味します。
TCP 輻輳ウィンドウの基礎知識
TCP はよく知られたトランスポート層プロトコルですが、その詳細な動作仕組みはこの記事の対象外です。ここでは、ユーザーが直面した問題を正確に理解するために必要な TCP プロトコルの 2 つの特性に主に注目します。
- 輻輳ウィンドウ
- TCP_NODELAY (Nagle アルゴリズム)
輻輳ウィンドウ
輻輳ウィンドウは (受信側ウィンドウとは異なり)、クライアント側でもサーバー側でも明示的には設定されません。通常は小さな値 (Linux の初期輻輳ウィンドウ値である initcwnd に基づく) から始まり、クライアントとサーバー間のネットワークで輻輳問題が発生しない限り増加し続けます (たとえば、サーバーはクライアントからの ACK を待つ前に、その数の TCP パケットを送信できます)。これは一般に TCP スロースタートとして知られています。
基本的に、TCP はこの輻輳ウィンドウ (cwnd) を使用して、未確認の転送中データ量を制限します。
initcwnd の値は通常 10 MSS (Maximum Segment Size) ですが、異なる場合もあります。このユーザーのケースでは 8 MSS でした。
輻輳ウィンドウに関して注目すべき重要な点の 1 つは、増加し続ける一方で、接続が一定時間アイドル状態になると (このユーザーのケースでは 2 秒など)、Linux TCP スタックが現在の輻輳ウィンドウサイズを initcwnd の値にリセットすることです。このアイドル時間は再送タイムアウト (RTO) と呼ばれ、ネットワークの RTT に基づいて動的に計算されます。Linux TCP スタックでは下限 (200 ミリ秒) と上限 (120 秒) が設けられています。
TCP_NODELAY (Nagle アルゴリズム)
TCP が開発された当時の背景から、ネットワークのオーバーヘッドを削減するために、ネットワークへフラッシュする前にソケット書き込みをバッチ処理することがデフォルト動作の 1 つとなっています。これは一般に Nagle アルゴリズム と呼ばれ、Linux の TCP スタック内部で実行されるため、アプリケーション側からはほとんど制御できません。
低レイテンシを重視する現代のシステム (ClickHouse など) では、TCP_NODELAY=1 を設定することでこの動作を無効化し、ソケットへの書き込みを即座に送信するようカーネルに指示します。
ここで問題となるのは、TCP_NODELAY が有効で、アプリケーションがこれらメタデータパケット(ProfileEvents、Log、Progress)に対して複数のソケット書き込みを発行した場合、それらの書き込みのそれぞれがネットワーク上で個別の TCP セグメントになってしまう点です。
サーバーが縮小された cwnd で収容できる以上の細かな書き込みを発行すると、カーネルは残りのパケットを送信する前にクライアントからの ACK を待つ必要が生じ、余分なラウンドトリップのペナルティが発生します。
ユーザーの問題を正確に再現する
ユーザーの問題を再現して目に見えるようにするには、クライアントとサーバー間のネットワーク RTT(約 32 ms)を同様にシミュレートする必要があります。簡単にするため、クライアントとサーバー間の Native プロトコルによる単一の TCP 接続上で、すべてのクエリを(2 秒のアイドル時間を挟んで)実行します。
ここでは 2 つの要素が関係します:
- ユーザーが直面した問題(約 32 ms の追加テールレイテンシ)を観察するために実際のクエリを実行する Go プログラム
- ユーザーのネットワーク条件を適用した(約 32 ms のネットワーク送信遅延をシミュレートした)ネットワーク名前空間内の ClickHouse サーバー
ネットワークのセットアップ
ネットワーク名前空間の作成には Docker や Podman も使えましたが、ネットワークのシミュレーションを理解しやすくするため、あえて netns を使って手動でセットアップしました。
続いて tc(トラフィック制御)を使用して、クライアントとサーバー間の 32 ms の WAN 遅延をシミュレートしました。また、TCP の初期輻輳ウィンドウを 8 MSS(initcwnd 8)に設定しました。
- ClickHouse のバイナリ(< 26.9、問題の再現に使用するもの)を用意します
mkdir -p ~/chbin && cd ~/chbin
wget https://builds.clickhouse.com/26.8/amd64/clickhouse && chmod +x ./clickhouse- ユーザーのインフラをシミュレートするネットワークを作成します(クライアントとサーバー間が約 32 ms 離れている状態)。
# ClickHouse 専用のネットワーク名前空間を作成
sudo ip netns add chsrv
# 仮想イーサネットペアを作成
sudo ip link add veth-host type veth peer name veth-ch
sudo ip link set veth-ch netns chsrv
# IP アドレスを割り当て
sudo ip addr add 10.99.0.1/24 dev veth-host
sudo ip link set veth-host up
sudo ip netns exec chsrv ip addr add 10.99.0.2/24 dev veth-ch
sudo ip netns exec chsrv ip link set veth-ch up
sudo ip netns exec chsrv ip link set lo up
# 32ms の送信遅延を追加し、初期 cwnd を 8 に設定
sudo ip netns exec chsrv tc qdisc add dev veth-ch root netem delay 32ms
sudo ip netns exec chsrv ip route change 10.99.0.0/24 dev veth-ch \
proto kernel scope link src 10.99.0.2 initcwnd 8- シミュレートしたネットワークを確認します(約 32 ms が表示されるはずです)
ping -c 5 10.99.0.2PING 10.99.0.2 (10.99.0.2) 56(84) bytes of data.
64 bytes from 10.99.0.2: icmp_seq=1 ttl=64 time=32.0 ms
64 bytes from 10.99.0.2: icmp_seq=2 ttl=64 time=32.1 ms
64 bytes from 10.99.0.2: icmp_seq=3 ttl=64 time=32.0 ms
64 bytes from 10.99.0.2: icmp_seq=4 ttl=64 time=32.0 ms
64 bytes from 10.99.0.2: icmp_seq=5 ttl=64 time=32.1 msクライアントプログラム
この Go プログラムは、確立された接続上でクエリ間に 2 秒のアイドル時間を挟みながら、単純なクエリを実行します。また、(パッチ適用前後の両方で)テールレイテンシをより明確に観察できるよう、クエリ全体のレイテンシとテールレイテンシ(最後の Data パケットから EndOfStream パケットまでの時間)を分けています。
この Go プログラムは、再利用された 1 つの接続上で同一の SELECT max(id) クエリを N 回実行し、各クエリのレイテンシを次の 2 つに分割します:
first_row(クエリ結果そのもの)tail(最初の行 → ストリームの終了)
ここで重要なのは tail です: 正常な場合は ≈ 0 ms、ストールした場合はきっかり 1× RTT になります。
テストの実行
- シミュレートしたネットワーク内でサーバーを実行します
mkdir -p /tmp/chdata
cd ~/chbin
sudo ip netns exec chsrv sudo -u "$USER" \
./clickhouse server -- --listen_host=0.0.0.0 --path=/tmp/chdata/- 実験用のテーブルと行を作成します
cd ~/chbin
./clickhouse client --host 10.99.0.2 -q \
"CREATE TABLE IF NOT EXISTS benchmark_test (id UInt64) ENGINE=MergeTree ORDER BY id"
./clickhouse client --host 10.99.0.2 -q \
"INSERT INTO benchmark_test SELECT number FROM numbers(939)"- Go プログラムを実行して、実際に発生している問題を確認します
go run issue.go次のような結果が表示されるはずです:
addr=10.99.0.2:9000 sleep=2s profile_events=true
query: SELECT max(id) FROM benchmark_test
query 1: first_row= 33.17ms eos= 65.31ms tail= 32.13ms (rows=1)
query 2: first_row= 33.65ms eos= 65.19ms tail= 31.54ms (rows=1)
query 3: first_row= 33.44ms eos= 65.02ms tail= 31.57ms (rows=1)
query 4: first_row= 33.30ms eos= 65.10ms tail= 31.80ms (rows=1)
query 5: first_row= 33.19ms eos= 65.11ms tail= 31.92ms (rows=1)観察結果: 最初の行は約 33 ms(1× RTT)で到着しましたが、接続は EndOfStream パケット群を待つためにさらに**約 32 ms(テールレイテンシ)**ストールしました。
根本原因
この追加の RTT(約 32 ms)は、2 秒のアイドル期間の後に輻輳ウィンドウが(大きな値から、このユーザーの環境では 8 であった initcwnd へと)リセットされると、サーバーが残りのパケット(Data + メタデータパケット群)をすべて 1 回のラウンドトリップで送信できなくなることに起因しています。
サーバーは 8 個の TCP セグメントを送信したところで cwnd の空きがなくなり、クライアントが最初のバッチに ACK を返すまで一時停止したため、丸々 1 回分(32 ms)のラウンドトリップが余計にかかりました。
パッチと検証結果
この問題の発生を抑えるワークアラウンドがいくつかあります:
- サーバー側で
ProfileEventsを無効化する(ProfileEventsパケットは回線上の TCP パケット数が多くなる原因の 1 つです) - サーバーノード(このユーザーの場合は GKE。Google Cloud でも高パフォーマンス TCP 向けに推奨されています)で、アイドル期間後に接続の輻輳ウィンドウ(
cwnd)を初期値(initcwnd)にリセットする動作を無効化する(tcp_slow_start_after_idle=0) - サーバーノードで
initcwndを増やす(デフォルトは 10、このユーザーのケースでは 8) - アプリケーションレベルのバッチ処理により、生成される TCP パケットが少なくなるよう ClickHouse Native プロトコルを改善する
TCP_NODELAYの代わりにTCP_CORKオプションを使用する
残念ながら今回の場合、ユーザー側ではワークアラウンド (1)、(2)、(3) をほとんどコントロールできなかったため、現実的な選択肢は (4) と (5) だけでした。選択肢 5 の TCP_CORK は面白そうに思えましたが、いくつか欠点がありました:
- カーネルの TCP スタックが依然として TCP セグメントの詰め方を完全に制御している点
- Linux カーネル固有の機能である点
いずれにせよ、選択肢 (4) はサーバー側の改善としても優れているように思われました。そこで、クエリの終了時にアプリケーションレベルのバッチ処理を行うことで、ClickHouse Native プロトコルが生成する TCP パケットを削減することにしました。
パッチはシンプルです: これらのメタデータパケットをアプリケーションレベルで 1 回のソケット書き込みにまとめることで(結果として TCP パケットが減る可能性があります)、輻輳ウィンドウがリセットされた場合でも最小限のラウンドトリップ数で送信できるようにしました。
ただし 1 つ注意点があります: すべてのメタデータパケットを盲目的にまとめてしまってはならないということです。Progress などの一部のパケットは時間に敏感であり、直ちにクライアントへ配信する必要があります(クライアントがクエリの実際の進捗状況を把握したいのに、サーバー側のバッチ処理によってそれが遅れ、誤った進捗状態が伝わってしまうケースを想像してみてください)。
キャプチャした TCP パケットを確認することで、バッチ処理が機能していることを検証できます。同じクエリに対してサーバーが生成する TCP パケットの総数はほぼ半減しています。これにより、当初観測されていた約 32 ms の追加のテールレイテンシが解消されます。
検証
ClickHouse サーバーがポート 9000 で動作していると仮定します。
パッチ適用前
tshark を使って TCP PUSH パケット(PSH。実際のアプリケーションデータが転送されるパケット)をキャプチャしながら、同じ Go スクリプトを実行してみます。
tshark -i veth-host -f 'tcp port 9000 and greater 60 and tcp[tcpflags] & tcp-push != 0' -w capture.pcapCapturing on 'veth-host'
50キャプチャした pcap ファイルの中身を見る前であっても、サーバーからクライアントへこのような TCP パケットが合計 50 個キャプチャされたことがわかります。
また、ごく小さな TCP セグメントが多数存在することにも注目してください。
tshark -r capture.pcap -Y 'tcp.flags.push == 1 && tcp.len > 0' -T fields -e tcp.len |
sort -n | uniq -c | awk 'BEGIN {print "COUNT\tTCP_PACKET_SIZE_BYTES"} {print $1 "\t" $2}'COUNT TCP_PACKET_SIZE_BYTES
7 1
5 8
6 9
4 10
5 12
5 28
6 36
1 62
5 164
1 475
1 2261
2 2328
1 2357
1 2896パッチ適用後
バッチ処理を適用したパッチ適用済みサーバーで、同じ Go スクリプトを実行してキャプチャします。
tshark -i veth-host -f 'tcp port 9000 and greater 60 and tcp[tcpflags] & tcp-push != 0' -w patched-capture.pcapCapturing on 'veth-host'
25転送された TCP パケットの数が以前の半分になっていることがはっきりとわかります。
キャプチャしたパケットを見ると、以前の多数の極小パケットと比べて、PSH パケットがはるかに「満たされた」状態になっていることが明確に確認できます。
tshark -r patched-capture.pcap -Y 'tcp.flags.push == 1 && tcp.len > 0' -T fields -e tcp.len | sort -n | uniq -c | awk 'BEGIN {print "COUNT\tTCP_PACKET_SIZE_BYTES"} {print $1 "\t" $2}'COUNT TCP_PACKET_SIZE_BYTES
3 1
1 36
1 62
5 164
2 2363
2 2417
1 2516同一の顧客環境でテールレイテンシが解消されたことの確認
顧客環境の問題を正確に再現する で説明したのとまったく同じセットアップで同じスクリプトを実行すると、約 32 ms のテールレイテンシがどのように解消されたかがわかります。
query 1: first_row= 34.53ms eos= 35.65ms tail= 1.12ms (rows=1)
query 2: first_row= 34.79ms eos= 35.58ms tail= 0.79ms (rows=1)
query 3: first_row= 35.23ms eos= 36.12ms tail= 0.90ms (rows=1)
query 4: first_row= 34.72ms eos= 35.95ms tail= 1.23ms (rows=1)
query 5: first_row= 34.98ms eos= 36.26ms tail= 1.27ms (rows=1)サーバーがクライアントに EndOfStream (eos) を送信するための追加のラウンドトリップが不要になっている点に注目してください。
まとめ
高いテールレイテンシは、必ずしも遅いデータベースエンジンや長時間実行されるクエリによって引き起こされるとは限りません。ときには、アプリケーションレベルのフラッシュとカーネルレベルの TCP の挙動との相互作用の中に原因が隠れていることもあります。
- アイドル状態の TCP 接続はリセットされる: RTO のアイドル期間が経過すると、Linux の TCP スタックは
cwndをinitcwndに縮小し、1 回のラウンドトリップに収められる未確認パケットの数を制限します。 TCP_NODELAYにはトレードオフがある: Nagle アルゴリズムを無効にすると個々の書き込みは高速になりますが、小さなwrite()呼び出しを複数回発行すると予期せぬ数の TCP セグメントが生成され、小さな輻輳ウィンドウが急速に枯渇する可能性があります。- アプリケーションレイヤーでバッチ処理を行う: メタデータパケット(
ProfileEvents、Logs、Progress)を単一のバッファ書き込みにまとめてからフラッシュすることで、TCP セグメント数が大幅に削減され、アイドル状態の高 RTT 接続であってもテールレイテンシを予測可能な状態に保つことができます。



