返回

ClickHouse 查询尾延迟 32ms 根因与修复

RSS ClickHouse Blog AI 逐段翻译 发布 2026-10-13 16:49
DataHot 速览

客户在空闲后执行简单 SELECT 查询时,观察到约 32 ms 的额外尾延迟。ClickHouse 团队将其定位为 Native 协议元数据包未批量发送,导致重置后的 TCP 拥塞窗口被撑满。作者用 network namespace 和 tc 模拟约 32 ms 网络 RTT,并设置 initcwnd=8 复现了该问题。最终通过批量发送这些元数据包修复。

为什么值得关注:这是 ClickHouse 官方对生产环境尾部延迟的完整根因分析、复现和修复,涉及 Native 协议与 TCP 拥塞控制,对数据库性能排查和网络调优有直接参考价值。

本文目录 6 节
  1. 复现确切的客户问题
  2. 网络设置
  3. 客户端程序
  4. 运行测试
  5. 根本原因
  6. 补丁和结果

译文

AI 逐段翻译

复现确切的客户问题

复现客户问题需要在客户端和服务器之间模拟相同的网络 RTT(约 32 毫秒),以使问题可见。为简化起见,我们在客户端和服务器之间的 Native 协议上,通过单个 TCP 连接运行所有查询(空闲期为 2 秒)。

这里涉及两个部分:

  1. 一个 Go 程序,它运行实际查询以观察客户遇到的问题(额外约 32 毫秒的尾部延迟)
  2. 一个位于网络命名空间内的 ClickHouse 服务器,并对其应用了客户的网络条件(模拟约 32 毫秒的网络出口延迟)。

网络设置

我们本可以使用 Docker 或 Podman 来创建网络命名空间。但我们特意使用 netns 手动设置,以便让网络模拟更容易理解。

然后我们使用 tc(流量控制)来模拟客户端和服务器之间 32 毫秒的广域网延迟。我们还将初始 TCP 拥塞窗口配置为 8 MSS(initcwnd 8)。

  1. 准备一个 ClickHouse 二进制文件(< 26.9)(我们用来复现该问题的那个)
1mkdir -p ~/chbin && cd ~/chbin2wget https://builds.clickhouse.com/26.8/amd64/clickhouse && chmod +x ./clickhouse
  1. 创建一个网络来模拟客户基础设施(客户端和服务器相距约 32 毫秒)。
1# Create a dedicated network namespace for ClickHouse2sudo ip netns add chsrv34# Create a virtual ethernet pair5sudo ip link add veth-host type veth peer name veth-ch6sudo ip linkset veth-ch netns chsrv78# Assign IP addresses9sudo ip addr add 10.99.0.1/24 dev veth-host10sudo ip linkset veth-host up11sudo ip netns exec chsrv ip addr add 10.99.0.2/24 dev veth-ch12sudo ip netns exec chsrv ip linkset veth-ch up13sudo ip netns exec chsrv ip linkset lo up1415# Add a 32ms egress delay and set initial cwnd to 816sudo ip netns exec chsrv tc qdisc add dev veth-ch root netem delay 32ms17sudo ip netns exec chsrv ip route change 10.99.0.0/24 dev veth-ch \18    proto kernel scope link src 10.99.0.2 initcwnd 8
  1. 验证模拟网络(你应该会看到约 32 毫秒)
1ping -c 5 10.99.0.2
1PING 10.99.0.2 (10.99.0.2) 56(84) bytes of data.264 bytes from 10.99.0.2: icmp_seq=1 ttl=64 time=32.0 ms364 bytes from 10.99.0.2: icmp_seq=2 ttl=64 time=32.1 ms464 bytes from 10.99.0.2: icmp_seq=3 ttl=64 time=32.0 ms564 bytes from 10.99.0.2: icmp_seq=4 ttl=64 time=32.0 ms664 bytes from 10.99.0.2: icmp_seq=5 ttl=64 time=32.1 ms

客户端程序

这个 Go 程序在已建立的连接上运行简单查询,查询之间有 2 秒的空闲期。我们还将总体查询延迟和尾部延迟(最后一个 Data 包和 EndOfStream 包之间的时间)分开,以便更清楚地观察尾部延迟(补丁前后)。

该 Go 程序在一个复用的连接上运行 N 个相同的 SELECT max(id) 查询,并将每个查询的延迟拆分为:

  1. first_row(查询结果本身)和
  2. tail(第一行 → 流结束)。

tail 是关键:健康 ≈ 0 毫秒;停滞 = 恰好 1× RTT。

运行测试

  1. 在模拟网络内运行服务器
1mkdir -p /tmp/chdata2cd ~/chbin3sudo ip netns exec chsrv sudo -u "$USER" \4    ./clickhouse server -- --listen_host=0.0.0.0 --path=/tmp/chdata/
  1. 为实验创建表和行
1cd ~/chbin2./clickhouse client --host 10.99.0.2 -q \3"CREATE TABLE IF NOT EXISTS benchmark_test (id UInt64) ENGINE=MergeTree ORDER BY id"4./clickhouse client --host 10.99.0.2 -q \5"INSERT INTO benchmark_test SELECT number FROM numbers(939)"
  1. 运行 Go 程序 以实际观察该问题
1go run issue.go

你应该会看到类似以下的结果:

1addr=10.99.0.2:9000  sleep=2s  profile_events=true2query: SELECT max(id) FROM benchmark_test34query  1: first_row=  33.17ms  eos=  65.31ms  tail=  32.13ms  (rows=1)5query  2: first_row=  33.65ms  eos=  65.19ms  tail=  31.54ms  (rows=1)6query  3: first_row=  33.44ms  eos=  65.02ms  tail=  31.57ms  (rows=1)7query  4: first_row=  33.30ms  eos=  65.10ms  tail=  31.80ms  (rows=1)8query  5: first_row=  33.19ms  eos=  65.11ms  tail=  31.92ms  (rows=1)

观察: 第一行在约 33 毫秒(1× RTT)内到达,但连接又停滞了 约 32 毫秒(尾部延迟) 等待 EndOfStream 包序列。

根本原因

额外 RTT(约 32 毫秒)的原因是,在 2 秒空闲期后拥塞窗口被重置(从较大值重置为 initcwnd,在客户案例中为 8)后,服务器无法在一个往返内发送所有剩余的包(Data 加上那些元数据包)。

服务器发送了 8 个 TCP 段,用尽了 cwnd 空间并暂停,直到客户端确认第一批包,从而多花费了 32 毫秒的完整往返时间。

补丁和结果

有一些变通方法可以降低此问题发生的可能性:

  1. 在服务器端禁用 ProfileEvents(ProfileEvents 包是线路上大量 TCP 包的原因之一)
  2. 在服务器节点上禁用空闲期后将连接拥塞窗口(cwnd)重置为其初始值(initcwnd)的行为(tcp_slow_start_after_idle=0)(在客户案例中是 GKE;这也是 Google Cloud 针对高性能 TCP 推荐的做法)
  3. 在服务器节点上增加 initcwnd(默认值为 10,客户案例中为 8)
  4. 改进 ClickHouse Native 协议,通过应用层批处理创建更少的 TCP 包。
  5. 使用 TCP_CORK 选项而不是 TCP_NODELAY

不幸的是,在这种情况下,客户几乎无法控制变通方法 (1)、(2) 和 (3),因此唯一可行的选择是 (4) 和 (5)。选项 5(TCP_CORK)听起来很有趣,但它有一些缺点:

  • 内核 TCP 栈仍然完全控制着如何填充这些 TCP 段
  • 它特定于 Linux 内核

选项 (4) 在服务器端听起来也是一个不错的改进。因此我们决定让 ClickHouse Native 协议在查询结束时通过应用层批处理创建更少的 TCP 包。

补丁很简单:我们在应用层将这些元数据包分组为一次套接字写入(因此可能产生更少的 TCP 包),这样即使拥塞窗口被重置,也能在最小往返次数内发送它们。

有一个陷阱:我们不能盲目地将所有元数据包分组,因为某些包(如 Progress)对时间敏感,必须立即传递给客户端(想象一下,客户端想知道查询的真实进度,而服务器端批处理延迟了它,传达了错误的进度状态)。

这篇内容对你有用吗?

反馈只用于改善内容筛选,不等同于收藏

分享这条资讯
分享海报
保存图片
iOS 也可以长按图片保存