---
title: "ClickHouse における 32 ms のテールレイテンシを追跡する"
date: "2026-10-14T05:15:11.486Z"
author: "Kavi Kanagaraj"
category: "Engineering"
excerpt: "アイドル状態の後に単純な SELECT クエリで約 32 ms の遅延が発生する事象が発生しました。調査の結果、リセットされた TCP 輻輳ウィンドウから Native プロトコルの非バッチ化メタデータパケットが溢れていたことが判明。ネットワーク名前空間で再現し、バッチ化により修正しました。"
---

# ClickHouse における 32 ms のテールレイテンシを追跡する

TL;DR: ClickHouse Native プロトコルでは、クライアントはクエリを完了するために `EndOfStream` パケットを待つ必要があります。結果の `Data` パケットと `EndOfStream` の間に、ClickHouse はいくつかのメタデータパケット(`Logs`、`Progress`、`ProfileEvents`)を送信します。これらがバッチ化されずに送信されると、`TCP_NODELAY` とアイドル期間後の TCP 輻輳ウィンドウ(`cwnd`)のリセットが組み合わさった際にウィンドウサイズを超過し、不要な追加の往復(ラウンドトリップ)が発生することがあります。私たちは Linux ネットワークネームスペースを使用してこの現象を決定論的に再現し、アプリケーション層でそれらのメタデータパケットをバッチ化することでテールレイテンシを削減しました。

## 問題の概要 {#the_problem}

あるユーザー環境において、クライアントとサーバー間の単純な `SELECT` クエリで、約 32 ms の追加レイテンシが発生していることが確認されました。

この環境では、クライアントとサーバー間の距離は約 32 ms(送信)離れています。接続が 2 秒以上アイドル状態になった後、直後のクエリのエンドツーエンドの処理時間は、期待される約 32 ms(1 RTT)ではなく、往復時間(RTT)の 2 倍に相当する約 64 ms かかっていました。

この追加のラウンドトリップが何によって引き起こされているのか、正確に突き止める必要がありました。

調査を進める中で、Linux の TCP スタックと ClickHouse Native プロトコルの両方に関して、いくつかの興味深い知見が得られました。

## ClickHouse Native プロトコルの基礎 {#clickhouse_native_protocol_101}

ClickHouse サーバーは幅広いプロトコルをサポートしています。ClickHouse サーバーと通信する主な方法は HTTP と Native TCP の 2 つです。今回取り上げる問題には Native TCP プロトコルが関係しているため、そこに焦点を当てます。この問題を理解するには、ClickHouse Native プロトコルの仕組みを少し把握しておくことが重要です。

ClickHouse Native プロトコルは、TCP トランスポートプロトコル上に構築されたステートフルなプロトコルです。クライアントとサーバーは、フェーズ(Handshake、INSERT、SELECT)ごとに異なるプロトコルメッセージを交換します。[TCP Native プロトコルの公式仕様](https://clickhouse.com/docs/ja/resources/develop-contribute/native-protocol/protocol) に、その詳細な動作が記載されています。

プロトコルレベルにおける SELECT クエリの動作を簡略化すると、以下のようになります。

1. クライアントが `Query` を送信します(SQL、設定、フラグなどが含まれます)。
2. サーバーが実際の結果行を含む `Data` パケットをストリーミング送信します。
3. サーバーは中間メタデータパケット(`ProfileEvents`、`Progress`、`Logs`)も送信します。
4. サーバーが `EndOfStream`(EOS)を送信し、該当クエリの完了を通知します。

<video autoplay="1" muted="1" loop="1" controls="0">
  <source src="https://clickhouse.com/uploads/taillatency_oct2026_video1_native_query_phase_static_flow_3d7dc1687c.mp4" type="video/mp4" />
</video>

これはクライアントにとって、対象のクエリが完了したとみなされる前に、そのクエリ向けの `EndOfStream` パケットを受信するまで接続からデータを読み取り続けなければならないことを意味します。

## TCP 輻輳ウィンドウの基礎知識 {#tcp_congestion_window_101}

TCP は[よく知られたトランスポート層プロトコル](https://datatracker.ietf.org/doc/html/rfc9293)ですが、その詳細な動作仕組みはこの記事の対象外です。ここでは、ユーザーが直面した問題を正確に理解するために必要な TCP プロトコルの 2 つの特性に主に注目します。

1. 輻輳ウィンドウ
2. TCP_NODELAY (Nagle アルゴリズム)

### 輻輳ウィンドウ

輻輳ウィンドウは (受信側ウィンドウとは異なり)、クライアント側でもサーバー側でも明示的には設定されません。通常は小さな値 (Linux の初期輻輳ウィンドウ値である `initcwnd` に基づく) から始まり、クライアントとサーバー間のネットワークで輻輳問題が発生しない限り増加し続けます (たとえば、サーバーはクライアントからの ACK を待つ前に、その数の TCP パケットを送信できます)。これは一般に [TCP スロースタート](https://en.wikipedia.org/wiki/TCP_congestion_control#Slow_start)として知られています。

基本的に、TCP はこの輻輳ウィンドウ (`cwnd`) を使用して、未確認の転送中データ量を制限します。

`initcwnd` の値は通常 10 MSS (Maximum Segment Size) ですが、異なる場合もあります。このユーザーのケースでは 8 MSS でした。

輻輳ウィンドウに関して注目すべき重要な点の 1 つは、増加し続ける一方で、接続が一定時間アイドル状態になると (このユーザーのケースでは 2 秒など)、Linux TCP スタックが現在の輻輳ウィンドウサイズを `initcwnd` の値にリセットすることです。このアイドル時間は再送タイムアウト (RTO) と呼ばれ、ネットワークの RTT に基づいて動的に計算されます。Linux TCP スタックでは下限 (200 ミリ秒) と上限 (120 秒) が設けられています。

<video autoplay="1" muted="1" loop="1" controls="0">
  <source src="https://clickhouse.com/uploads/taillatency_oct2026_video2_tcp_congestion_window_50039583bc.mp4" type="video/mp4" />
</video>

### TCP_NODELAY (Nagle アルゴリズム)

TCP が開発された当時の背景から、ネットワークのオーバーヘッドを削減するために、ネットワークへフラッシュする前にソケット書き込みをバッチ処理することがデフォルト動作の 1 つとなっています。これは一般に [Nagle アルゴリズム](https://en.wikipedia.org/wiki/Nagle%27s_algorithm) と呼ばれ、Linux の TCP スタック内部で実行されるため、アプリケーション側からはほとんど制御できません。

低レイテンシを重視する現代のシステム (ClickHouse など) では、`TCP_NODELAY=1` を設定することでこの動作を無効化し、ソケットへの書き込みを即座に送信するようカーネルに指示します。

<video autoplay="1" muted="1" loop="1" controls="0">
  <source src="https://clickhouse.com/uploads/taillatency_oct2026_video3_tcp_nodelay_comparison_4565317d79.mp4" type="video/mp4" />
</video>

ここで問題となるのは、`TCP_NODELAY` が有効で、アプリケーションがこれらメタデータパケット(`ProfileEvents`、`Log`、`Progress`)に対して複数のソケット書き込みを発行した場合、それらの書き込みのそれぞれがネットワーク上で個別の TCP セグメントになってしまう点です。

サーバーが縮小された `cwnd` で収容できる以上の細かな書き込みを発行すると、カーネルは残りのパケットを送信する前にクライアントからの ACK を待つ必要が生じ、余分なラウンドトリップのペナルティが発生します。

<video autoplay="1" muted="1" loop="1" controls="0">
  <source src="https://clickhouse.com/uploads/taillatency_oct2026_video4_tcp_nodelay_window_example_cc37733e24.mp4" type="video/mp4" />
</video>

## ユーザーの問題を正確に再現する {#reproducing_the_exact_customer_problem}

ユーザーの問題を再現して目に見えるようにするには、クライアントとサーバー間のネットワーク RTT(約 32 ms)を同様にシミュレートする必要があります。簡単にするため、クライアントとサーバー間の Native プロトコルによる単一の TCP 接続上で、すべてのクエリを(2 秒のアイドル時間を挟んで)実行します。

ここでは 2 つの要素が関係します:

1. ユーザーが直面した問題(約 32 ms の追加テールレイテンシ)を観察するために実際のクエリを実行する [Go プログラム](https://gist.github.com/kavirajk/62dfcc661ba004cb09f5f59ea5d52750)
2. ユーザーのネットワーク条件を適用した(約 32 ms のネットワーク送信遅延をシミュレートした)ネットワーク名前空間内の ClickHouse サーバー

### ネットワークのセットアップ

ネットワーク名前空間の作成には Docker や Podman も使えましたが、ネットワークのシミュレーションを理解しやすくするため、あえて `netns` を使って手動でセットアップしました。

続いて `tc`(トラフィック制御)を使用して、クライアントとサーバー間の 32 ms の WAN 遅延をシミュレートしました。また、TCP の初期輻輳ウィンドウを 8 MSS(`initcwnd 8`)に設定しました。

1. ClickHouse のバイナリ(< 26.9、問題の再現に使用するもの)を用意します

<pre><code type='click-ui' language='bash'>
mkdir -p ~/chbin && cd ~/chbin
wget https://builds.clickhouse.com/26.8/amd64/clickhouse && chmod +x ./clickhouse
</code></pre>

2. ユーザーのインフラをシミュレートするネットワークを作成します(クライアントとサーバー間が約 32 ms 離れている状態)。

<pre><code type='click-ui' language='bash'>
# 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
</code></pre>

3. シミュレートしたネットワークを確認します(約 32 ms が表示されるはずです)

<pre><code type='click-ui' language='bash'>
ping -c 5 10.99.0.2
</code></pre>

```shell
PING 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 プログラム](https://gist.github.com/kavirajk/62dfcc661ba004cb09f5f59ea5d52750)は、確立された接続上でクエリ間に 2 秒のアイドル時間を挟みながら、単純なクエリを実行します。また、(パッチ適用前後の両方で)テールレイテンシをより明確に観察できるよう、クエリ全体のレイテンシとテールレイテンシ(最後の `Data` パケットから `EndOfStream` パケットまでの時間)を分けています。

この Go プログラムは、再利用された 1 つの接続上で同一の `SELECT max(id)` クエリを N 回実行し、各クエリのレイテンシを次の 2 つに分割します:

1. `first_row`(クエリ結果そのもの)
2. `tail`(最初の行 → ストリームの終了)

**ここで重要なのは `tail` です: 正常な場合は ≈ 0 ms、ストールした場合はきっかり 1× RTT になります。**

### テストの実行

1. シミュレートしたネットワーク内でサーバーを実行します

<pre><code type='click-ui' language='bash'>
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/
</code></pre>

2. 実験用のテーブルと行を作成します

<pre><code type='click-ui' language='bash'>
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)"
</code></pre>

3. [Go プログラム](https://gist.github.com/kavirajk/62dfcc661ba004cb09f5f59ea5d52750)を実行して、実際に発生している問題を確認します

<pre><code type='click-ui' language='bash'>
go run issue.go
</code></pre>

次のような結果が表示されるはずです:

```shell
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(テールレイテンシ)**ストールしました。

## 根本原因 {#root_cause}

この追加の RTT(約 32 ms)は、2 秒のアイドル期間の後に輻輳ウィンドウが(大きな値から、このユーザーの環境では 8 であった `initcwnd` へと)リセットされると、サーバーが残りのパケット(Data + メタデータパケット群)をすべて 1 回のラウンドトリップで送信できなくなることに起因しています。

サーバーは 8 個の TCP セグメントを送信したところで `cwnd` の空きがなくなり、クライアントが最初のバッチに ACK を返すまで一時停止したため、丸々 1 回分(32 ms)のラウンドトリップが余計にかかりました。

## パッチと検証結果 {#patch_and_the_results}

この問題の発生を抑えるワークアラウンドがいくつかあります:

1. [サーバー側](https://clickhouse.com/docs/ja/reference/settings/session-settings/send#send_profile_events)で `ProfileEvents` を無効化する(`ProfileEvents` パケットは回線上の TCP パケット数が多くなる原因の 1 つです)
2. サーバーノード(このユーザーの場合は GKE。[Google Cloud でも高パフォーマンス TCP 向けに推奨](https://docs.cloud.google.com/compute/docs/networking/tcp-optimization-for-network-performance-in-gcp-and-hybrid#disable-slow-start)されています)で、アイドル期間後に接続の輻輳ウィンドウ(`cwnd`)を初期値(`initcwnd`)にリセットする動作を無効化する(`tcp_slow_start_after_idle=0`)
3. サーバーノードで `initcwnd` を増やす(デフォルトは 10、このユーザーのケースでは 8)
4. アプリケーションレベルのバッチ処理により、生成される TCP パケットが少なくなるよう ClickHouse Native プロトコルを改善する
5. `TCP_NODELAY` の代わりに `TCP_CORK` オプションを使用する

残念ながら今回の場合、ユーザー側ではワークアラウンド (1)、(2)、(3) をほとんどコントロールできなかったため、現実的な選択肢は (4) と (5) だけでした。選択肢 5 の `TCP_CORK` は[面白そうに思えましたが](https://catonmat.net/tcp-cork)、いくつか欠点がありました:

- カーネルの TCP スタックが依然として TCP セグメントの詰め方を完全に制御している点
- Linux カーネル固有の機能である点

いずれにせよ、選択肢 (4) はサーバー側の改善としても優れているように思われました。そこで、クエリの終了時にアプリケーションレベルのバッチ処理を行うことで、ClickHouse Native プロトコルが生成する TCP パケットを削減することにしました。

[パッチはシンプルです](https://github.com/ClickHouse/ClickHouse/pull/111034): これらのメタデータパケットをアプリケーションレベルで 1 回のソケット書き込みにまとめることで(結果として TCP パケットが減る可能性があります)、輻輳ウィンドウがリセットされた場合でも最小限のラウンドトリップ数で送信できるようにしました。

ただし 1 つ注意点があります: すべてのメタデータパケットを盲目的にまとめてしまってはならないということです。`Progress` などの一部のパケットは時間に敏感であり、直ちにクライアントへ配信する必要があります(クライアントがクエリの実際の進捗状況を把握したいのに、サーバー側のバッチ処理によってそれが遅れ、誤った進捗状態が伝わってしまうケースを想像してみてください)。

<video autoplay="1" muted="1" loop="1" controls="0">
  <source src="https://clickhouse.com/uploads/taillatency_oct2026_video5_native_metadata_batching_b3c73969fb.mp4" type="video/mp4" />
</video>

キャプチャした TCP パケットを確認することで、バッチ処理が機能していることを検証できます。同じクエリに対してサーバーが生成する TCP パケットの総数はほぼ半減しています。これにより、当初観測されていた約 32 ms の追加のテールレイテンシが解消されます。

### 検証

ClickHouse サーバーがポート 9000 で動作していると仮定します。

#### パッチ適用前

`tshark` を使って TCP PUSH パケット(`PSH`。実際のアプリケーションデータが転送されるパケット)をキャプチャしながら、同じ Go スクリプトを実行してみます。

<pre><code type='click-ui' language='bash'>
tshark -i veth-host -f 'tcp port 9000 and greater 60 and tcp[tcpflags] & tcp-push != 0' -w capture.pcap
</code></pre>

```shell
Capturing on 'veth-host'
50
```

キャプチャした pcap ファイルの中身を見る前であっても、サーバーからクライアントへこのような TCP パケットが合計 50 個キャプチャされたことがわかります。

また、ごく小さな TCP セグメントが多数存在することにも注目してください。

<pre><code type='click-ui' language='bash'>
tshark -r capture.pcap -Y 'tcp.flags.push == 1 && tcp.len &gt; 0' -T fields -e tcp.len |
 sort -n | uniq -c | awk 'BEGIN {print "COUNT\tTCP_PACKET_SIZE_BYTES"} {print $1 "\t" $2}'
</code></pre>

```shell
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 スクリプトを実行してキャプチャします。

<pre><code type='click-ui' language='bash'>
tshark -i veth-host -f 'tcp port 9000 and greater 60 and tcp[tcpflags] & tcp-push != 0' -w patched-capture.pcap
</code></pre>

```shell
Capturing on 'veth-host'
25
```

転送された TCP パケットの数が以前の半分になっていることがはっきりとわかります。

キャプチャしたパケットを見ると、以前の多数の極小パケットと比べて、`PSH` パケットがはるかに「満たされた」状態になっていることが明確に確認できます。

<pre><code type='click-ui' language='bash'>
tshark -r patched-capture.pcap -Y 'tcp.flags.push == 1 && tcp.len &gt; 0' -T fields -e tcp.len | sort -n | uniq -c | awk 'BEGIN {print "COUNT\tTCP_PACKET_SIZE_BYTES"} {print $1 "\t" $2}'
</code></pre>

```shell
COUNT	TCP_PACKET_SIZE_BYTES
3	1
1	36
1	62
5	164
2	2363
2	2417
1	2516
```

### 同一の顧客環境でテールレイテンシが解消されたことの確認

[顧客環境の問題を正確に再現する](#reproducing_the_exact_customer_problem) で説明したのとまったく同じセットアップで同じスクリプトを実行すると、約 32 ms のテールレイテンシがどのように解消されたかがわかります。

```shell
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) を送信するための追加のラウンドトリップが不要になっている点に注目してください。

## まとめ {#summary}

高いテールレイテンシは、必ずしも遅いデータベースエンジンや長時間実行されるクエリによって引き起こされるとは限りません。ときには、アプリケーションレベルのフラッシュとカーネルレベルの TCP の挙動との相互作用の中に原因が隠れていることもあります。

- **アイドル状態の TCP 接続はリセットされる:** RTO のアイドル期間が経過すると、Linux の TCP スタックは `cwnd` を `initcwnd` に縮小し、1 回のラウンドトリップに収められる未確認パケットの数を制限します。
- **`TCP_NODELAY` にはトレードオフがある:** Nagle アルゴリズムを無効にすると個々の書き込みは高速になりますが、小さな `write()` 呼び出しを複数回発行すると予期せぬ数の TCP セグメントが生成され、小さな輻輳ウィンドウが急速に枯渇する可能性があります。
- **アプリケーションレイヤーでバッチ処理を行う:** メタデータパケット(`ProfileEvents`、`Logs`、`Progress`)を単一のバッファ書き込みにまとめてからフラッシュすることで、TCP セグメント数が大幅に削減され、アイドル状態の高 RTT 接続であってもテールレイテンシを予測可能な状態に保つことができます。

---

## 今すぐ始める

ご自身のデータで ClickHouse を試してみませんか？ ClickHouse Cloud を数分で使い始めることができ、300 ドル分の無料クレジットも受け取れます。

[サインアップ](https://console.clickhouse.cloud/signUp?loc=blog-cta-2597&utm_blogctaid=2597)

---