ClickHouse 26.9 合併原生協定尾端訊息,省下閒置連線的一個 RTT
ClickHouse Blog(Kavi Kanagaraj)· 2026-10-13 · PR #111034
ClickHouse 伺服器過去在每個查詢結束時,把 ProfileEvents、Progress、Logs 等小訊息一個一個寫進 socket,現在改為合併成一次寫入。這讓閒置後的第一個查詢不再多花一個 RTT。
修改由 PR #111034 完成,2026-09-01 合併進 master,隨 26.9.1.505 釋出。起因是一位客戶回報,單向延遲約 32 ms 的連線在閒置 2 秒以上後,簡單 SELECT 的耗時從 1× RTT 變成約 64 ms 的 2× RTT。
原本的問題
原生 TCP 協定在查詢回應裡依序送出 Data、一串 metadata 封包,最後才是 EndOfStream(EOS),客戶端要讀到 EOS 才算查詢完成。伺服器開著 TCP_NODELAY,所以每次應用層 flush 都會變成獨立的小 TCP 封包。
連線閒置後,Linux 會把 cwnd 重設回 initcwnd。原文指出該客戶的 initcwnd 是 8 MSS(Linux 預設為 10)。伺服器送完 8 個段就用盡視窗,必須等客戶端 ACK 才能送剩下的 metadata 與 EOS,多出整整一個 RTT。
作者在 network namespace 裡用 tc netem delay 32ms 與 initcwnd 8 重現:修補前 first_row 約 33 ms、eos 約 65 ms,tail 約 31.5 至 32.1 ms;tshark 抓到 50 個伺服器端 PSH 封包,許多只有 1 至 28 bytes。
採用的方法
文中列了五個選項,最後選的是應用層批次寫入:
- 關閉
send_profile_events:只是少送一類訊息,並未解決寫入碎片化。 - 伺服器設
tcp_slow_start_after_idle=0:要動作業系統設定,無法由 ClickHouse 自己保證。 - 調高伺服器
initcwnd:同樣屬於部署端調整。 - 改用
TCP_CORK:作者認為核心仍掌控填充時機,且僅限 Linux,所以放棄。 - 在應用層把結尾 metadata 合併成單次 socket 寫入:即 PR #111034。
PR 標題為 perf(native-proto): batch protocol messages by default,changelog 描述為預設批次處理協定訊息,並提供 sync() API 讓需要的訊息立即送出。改動集中在 src/Server/TCPHandler.cpp,在協商、例外、EOS、資料送出等邊界加入 sync()。
修補前:Query → [Data][Profile][Progress][Logs]...(每個一個 PSH) → EOS
cwnd 用盡 → 等 ACK → 再送 EOS(+1 RTT)
修補後:Query → [Data] → [Profile+Progress+Logs+EOS 一次寫入]實際效果
| 指標 | 修補前 | 修補後 |
|---|---|---|
| 伺服器端 PSH 封包數 | 50 | 25 |
| tail(first_row 之後) | 約 31.5 至 32.1 ms | 約 0.79 至 1.27 ms |
| EOS 到達時間 | 約 65 ms | 約 35 至 36 ms |
PR 內的 ClickBench 結果是 43 個查詢中有 5 個顯示 5 至 7.6% 的 wall time 下降,但全部標為「not_sure」,機器人的整體判定為「no_change」,所以收益只在高 RTT 加閒置連線的情境,一般低延遲環境不會明顯感受到。
影響範圍
受益的是跨區連線、長期持有連線池且查詢間隔超過 RTO 的客戶端,例如從另一個區域打 ClickHouse 的儀表板或 API 服務。升到 26.9 以後不需改設定即可生效;在此之前,可用上面前三項做部署端緩解。
另一個要注意的是 Progress 訊息具時效性,不能無條件批次。審查者 CheSema 在 PR 中提醒,手動 sync socket 時要確保長查詢仍能及時送出進度,團隊同意後續再檢視 WriteBufferFromPocoSocket 的緩衝策略;相關的 #115054、#115473 兩個嘗試已關閉。自行實作原生協定的客戶端或代理,若依賴 metadata 逐個到達的時序,應確認讀取邏輯不假設封包邊界。