ClickHouse 查询尾延迟 32ms 根因与修复
DataHot 速览
客户在空闲后执行简单 SELECT 查询时,观察到约 32 ms 的额外尾延迟。ClickHouse 团队将其定位为 Native 协议元数据包未批量发送,导致重置后的 TCP 拥塞窗口被撑满。作者用 network namespace 和 tc 模拟约 32 ms 网络 RTT,并设置 initcwnd=8 复现了该问题。最终通过批量发送这些元数据包修复。
为什么值得关注:这是 ClickHouse 官方对生产环境尾部延迟的完整根因分析、复现和修复,涉及 Native 协议与 TCP 拥塞控制,对数据库性能排查和网络调优有直接参考价值。
译文
AI 逐段翻译复现确切的客户问题
复现客户问题需要在客户端和服务器之间模拟相同的网络 RTT(约 32 毫秒),以使问题可见。为简化起见,我们在客户端和服务器之间的 Native 协议上,通过单个 TCP 连接运行所有查询(空闲期为 2 秒)。
这里涉及两个部分:
- 一个 Go 程序,它运行实际查询以观察客户遇到的问题(额外约 32 毫秒的尾部延迟)
- 一个位于网络命名空间内的 ClickHouse 服务器,并对其应用了客户的网络条件(模拟约 32 毫秒的网络出口延迟)。
网络设置
我们本可以使用 Docker 或 Podman 来创建网络命名空间。但我们特意使用 netns 手动设置,以便让网络模拟更容易理解。
然后我们使用 tc(流量控制)来模拟客户端和服务器之间 32 毫秒的广域网延迟。我们还将初始 TCP 拥塞窗口配置为 8 MSS(initcwnd 8)。
- 准备一个 ClickHouse 二进制文件(< 26.9)(我们用来复现该问题的那个)
1mkdir -p ~/chbin && cd ~/chbin2wget https://builds.clickhouse.com/26.8/amd64/clickhouse && chmod +x ./clickhouse- 创建一个网络来模拟客户基础设施(客户端和服务器相距约 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- 验证模拟网络(你应该会看到约 32 毫秒)
1ping -c 5 10.99.0.21PING 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) 查询,并将每个查询的延迟拆分为:
first_row(查询结果本身)和tail(第一行 → 流结束)。
tail 是关键:健康 ≈ 0 毫秒;停滞 = 恰好 1× RTT。
运行测试
- 在模拟网络内运行服务器
1mkdir -p /tmp/chdata2cd ~/chbin3sudo ip netns exec chsrv sudo -u "$USER" \4 ./clickhouse server -- --listen_host=0.0.0.0 --path=/tmp/chdata/- 为实验创建表和行
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)"- 运行 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 毫秒的完整往返时间。
补丁和结果
有一些变通方法可以降低此问题发生的可能性:
- 在
服务器端禁用 ProfileEvents(ProfileEvents包是线路上大量 TCP 包的原因之一) - 在服务器节点上禁用空闲期后将连接拥塞窗口(
cwnd)重置为其初始值(initcwnd)的行为(tcp_slow_start_after_idle=0)(在客户案例中是 GKE;这也是 Google Cloud 针对高性能 TCP 推荐的做法) - 在服务器节点上增加
initcwnd(默认值为 10,客户案例中为 8) - 改进 ClickHouse Native 协议,通过应用层批处理创建更少的 TCP 包。
- 使用
TCP_CORK选项而不是TCP_NODELAY
不幸的是,在这种情况下,客户几乎无法控制变通方法 (1)、(2) 和 (3),因此唯一可行的选择是 (4) 和 (5)。选项 5(TCP_CORK)听起来很有趣,但它有一些缺点:
- 内核 TCP 栈仍然完全控制着如何填充这些 TCP 段
- 它特定于 Linux 内核
选项 (4) 在服务器端听起来也是一个不错的改进。因此我们决定让 ClickHouse Native 协议在查询结束时通过应用层批处理创建更少的 TCP 包。
补丁很简单:我们在应用层将这些元数据包分组为一次套接字写入(因此可能产生更少的 TCP 包),这样即使拥塞窗口被重置,也能在最小往返次数内发送它们。
有一个陷阱:我们不能盲目地将所有元数据包分组,因为某些包(如 Progress)对时间敏感,必须立即传递给客户端(想象一下,客户端想知道查询的真实进度,而服务器端批处理延迟了它,传达了错误的进度状态)。
这篇内容对你有用吗?
反馈只用于改善内容筛选,不等同于收藏