本文字数:23644;估计阅读时间:60 分钟
Meetup活动
ClickHouse 深圳第二届 Meetup 讲师招募中,欢迎讲师在文末扫码报名!
在 Google Cloud Platform (GCP) 上运行的 ClickHouse Cloud 遭遇了一次神秘的 CPU 异常,工程师们为此调试了数月,最终揭开了 Linux 内核内存管理中的一个深层次缺陷。这一切始于偶发的性能下降,却最终演变成对内核机制的深入探索。在这次调试过程中,工程师 Sergei Trifonov 发现了一个隐藏的活锁 (livelock)。他借助 eBPF 追踪、perf 分析,并构造了可复现的测试案例,最终找到了一个出人意料的修复方案。然而,刚解决一个内核漏洞,另一个问题又随之浮现。这到底是怎么回事?接着看下去……
一切始于一次异常——ClickHouse Cloud 的基础设施偶尔会出现无法解释的小故障。某些时候,一个 ClickHouse 实例会突然占满 CPU 资源,完全无响应,并无限期地卡在这个状态。我们的监控系统检测到性能下降,触发了告警,通知工程师。在最严重的情况下,实例直接崩溃。唯一的解决方案是重启受影响的 Pod,但这并不能告诉我们问题的根源,也无法预测它何时再次发生。
很明显,系统出了问题,但最初我们毫无头绪。更棘手的是,这个问题是间歇性的。有时几天都不会发生,随后又毫无预兆地重现。每次发生都会耗费大量工程师的时间,而我们的支持团队也在积极与客户沟通,以获取更多背景信息并提供最新进展。
随着观察的深入,问题的模式逐渐清晰。受影响的实例 CPU 使用率飙升,始终达到配额上限并被限流。内存占用同样令人担忧,始终接近 cgroup 限制,但 Pod 并未因 OOM(内存溢出)而被重启。
但最离奇的是,我们在不同云环境中的对比分析。所有的异常案例都发生在 Google Cloud Platform (GCP),而在 AWS 和 Azure 上,这种情况一次都没有出现。
在 ClickHouse Cloud 中,我们有一个简单的工具,可以收集运行在 Pod 内的 ClickHouse 服务器进程的所有当前堆栈跟踪。这个工具基于 gdb,一款广泛使用的 Linux 调试器。我们只需将 gdb 附加到进程,收集所有线程的回溯信息,然后分离。每当 ClickHouse 服务器出现挂起,我们都会使用它,在检测死锁方面,它尤为有用。借助这个工具,我们迅速获得了一些关键线索,推动了调查深入进行。我们发现,活动线程的数量异常之高——超过一千个,它们要么在执行查询,要么在运行后台任务。许多线程似乎都在等待进入关键区,但这并不是死锁——仍有线程持有相应的锁并在正常运行。更诡异的是,很多堆栈跟踪指向了意料之外的代码位置,比如 libunwind,这是一个用于在异常处理过程中生成堆栈跟踪的库。
这个问题会随机影响 Pod,使其几乎失去响应。服务器变得极其缓慢,所有查询都会一直挂起,但它并不会自动重启。也就是说,每次故障发生,都必须手动快速重启受影响的 Pod 才能恢复服务。然而,排查工作异常艰难——受影响的 Pod 反应极其迟缓,甚至连 perf 和 ps aux 这样的基本调试工具都难以运行。更令人费解的是,执行 ps aux 时,它本身竟然也会挂起。起初,我们以为这只是 CPU 负载过高导致的副作用,但我们当时还没意识到,这正是解开谜团的关键线索。
更棘手的是,这个问题毫无规律。今天它可能出现在某个集群,明天又会影响另一个,完全没有固定模式。不同服务在不同时间受到影响,使得我们无法将问题与最近的部署或变更相关联。更复杂的是,可能的诱因太多——CPU 峰值、高内存占用、大量线程、异常频发……要分清哪些是症状,哪些是根本原因,并非易事。
我们尝试了许多假设。最初收集的堆栈跟踪显示,在问题发生时,异常处理过程中发生了大量的堆栈展开 (stack unwinding),这表明系统可能触发了大量异常。于是,我们认为优化这些操作或许能有所帮助。我们尝试改进 ClickHouse 的堆栈符号解析机制,优化内部符号缓存,并减少全局锁的竞争。然而,这并未能解决问题。
此外,我们还调查了 mincore 系统调用 (syscall),因为我们知道它可能导致资源竞争。原因是,在首次发现 CPU 卡顿问题的两个月前,我们曾经历过一次服务性能下降的事故。调查后发现,mincore 系统调用是罪魁祸首,因此我们当时决定在全局范围内禁用性能分析器 (profiler)。
在 ClickHouse 服务器内部,有一个查询性能分析器,它会定期向查询的每个线程发送信号,以获取堆栈信息。
实际上,ClickHouse 具有两种性能分析器:CPU 分析器和实时分析器。前者仅向活跃线程发送信号,类似于大多数 CPU 性能分析器的工作方式。而后者则会向所有线程(无论是否活跃)发送信号,以捕获诸如等待互斥锁 (mutex) 或阻塞系统调用等非 CPU 活动事件。
mmap_lock 是内核为每个进程维护的读写信号量 (read-write semaphore),用于保护内存映射。如果该锁发生竞争,就可能导致内存分配变慢,因为 mmap 系统调用需要修改内存映射,而进程则必须等待 mmap_lock 释放。当大量线程并发运行,并且实时分析器启用时,这种锁竞争可能会引发严重的性能下降。
后来,我们修复了这个问题,现在 ClickHouse 版本的 libunwind 已不再使用 mincore。禁用后,我们确信问题已解决。然而,我们忽略了一个关键细节:与性能分析器相关的性能下降只发生在 GCP 实例上,而 AWS 并未受到影响。当时,我们不清楚原因,便假设这仅仅是由于 GCP 新客户的工作负载模式不同。随着性能分析器被禁用,我们认为这个因素已经被排除。然而,几个月后,一个全然不同但依然仅在 GCP 发生的问题浮现,说明我们一定遗漏了什么关键线索。
我此前负责调查由性能分析器 (profiler) 引发的问题,并开始着手分析当前的故障。首先,我检查了 mincore 系统调用的情况——它的调用次数以及执行速度。由于我们之前在堆栈跟踪中观察到了大量的堆栈展开 (stack unwinding),我们自然怀疑这些系统调用可能与问题有关。
下面是使用 bpftrace 监测系统调用执行时间的方法。bpftrace 是一个强大的工具,它可以运行 eBPF 代码,在内核中追踪系统行为,并提供深入的内核分析数据。
tracepoint:syscalls:sys_enter_mincore pid==$1/ {
@start[tid] = nsecs;
}
tracepoint:syscalls:sys_exit_mincore pid==$1 && @start[tid]/ {
@latency_us = hist((nsecs - @start[tid]) 1000);
delete(@start[tid]);
}
复制
然而,运行 bpftrace 需要特权,无法直接在标准 Kubernetes Pod 内执行。因此,你需要先进入运行该 Pod 的节点。在该节点上,最简单的 bpftrace 运行方式如下(示例输出为正常情况下的结果):
# docker-bpf() { docker run -ti --rm --privileged -v lib/modules:/lib/modules -v sys/fs/bpf:/sys/fs/bpf -v sys/kernel/debug:/sys/kernel/debug --pid=host quay.io/iovisor/bpftrace bpftrace "$@"; }
# docker-bpf -e 'tracepoint:syscalls:sys_enter_mincore pid==$1/ { @start[tid] = nsecs; } tracepoint:syscalls:sys_exit_mincore pid==$1 && @start[tid]/ { @latency_us = hist((nsecs - @start[tid]) 1000); delete(@start[tid]); }' $PID
Attaching 2 probes...
^C
@latency_us:
[0] 10402 |@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@|
[1] 1452 |@@@@@@@ |
[2, 4) 113 | |
[4, 8) 220 |@ |
[8, 16) 123 | |
[16, 32) 17 | |
[32, 64) 5 | |
复制
需要注意,节点上可能运行着许多无关的 Pod,因此你需要确定目标进程的 PID。以下是我在 Kubernetes 环境中,基于 Pod 名称查找 PID 的常用方法:
pod-pids() {
ps ax | grep "/usr/bin/clickhouse" | grep -v grep \
| awk '{print $1}' \
| while read pid; do
echo $pid `nsenter -t ${pid} -u hostname`;
done;
}
# Outputs the PID of POD whose name match given argument substring
ppgrep() { pod-pids | grep $1 | cut -f 1 -d ' '; }
复制
然而,尽管我最初怀疑 mincore 系统调用可能是导致问题的关键因素,但事实并非如此。这次排查后,我们没有发现任何 mincore 调用,哪怕一次都没有。
在应对高 CPU 占用问题时,perf 是最有效的分析工具之一,它能够帮助我们找出 CPU 时间的主要消耗点。相比之下,gdb 只能提供某一时刻的堆栈快照,而 perf 则能持续对程序进行采样,从而构建更完整的 CPU 使用情况分析。
我们尝试使用 perf top 收集更多数据。然而,大多数情况下,它根本没有任何反应——既没有输出,也没有错误提示,终端直接卡死。这本身就很反常,但当时我们无法找到合理的解释。直到一次看似普通的事故中,我们终于设法让 perf 正常运行,而结果让人大跌眼镜。
PerfTop: 8062 irqs/sec kernel:100.0% exact: 0.0% lost: 0/0 drop: 0/0 [4000Hz cpu-clock]
-----------------------------------------------------------------------------------
64.28% [kernel] [k] shrink_lruvec
28.81% [kernel] [k] lru_note_cost
2.91% [kernel] [k] shrink_node
1.84% [kernel] [k] _raw_spin_unlock_irqrestore
0.38% [kernel] [k] count_shadow_nodes
0.33% [kernel] [k] shrink_page_list
0.15% [kernel] [k] _find_next_bit
0.15% [kernel] [k] __remove_mapping
复制
CPU 资源并没有被 ClickHouse 本身消耗。相反,所有的处理能力都被内核占用了。其中一个函数异常突出:shrink_lruvec。它负责内存回收,但在这里却消耗了惊人的 CPU 资源。这是否意味着问题出在内核本身?这里有一个重要的区别需要注意:gdb 和 perf 的回溯方式不同。当你使用 gdb(或基于信号的内部分析器)暂停进程时,回溯信息中只会显示用户态的函数,而不会包含内核函数。因此,之前 gdb 的堆栈跟踪可能误导了我们,它们只揭示了完整堆栈的用户态部分。而现在,perf 采样的 100% 数据都落在了内核代码上,这意味着之前的分析完全站不住脚。
尽管如此,我们仍然无法确定问题的真正原因。这是根本原因,还是仅仅是某种副作用?是 ClickHouse 让系统陷入了内存压力,还是内核本身的异常行为?更让人疑惑的是,我们只成功运行过一次 perf。也许那次情况有所不同,或者我们无意中发现了某个关键因素却未能察觉。在得出最终结论之前,我们需要进一步的验证。
这个问题已经持续了五个月,间歇性地在 GCP 的不同实例上出现和消失,让我们难以捉摸。为了验证新的假设,我们为值班工程师编写了一份排查手册,列出了问题发生时需要执行的步骤,并在接下来的几天或一周内等待问题复现。
为了尽快找到答案,我整理了一份详尽的运维指南,涵盖了所有可用的诊断方法——包括 dmesg、基于 eBPF 的内核堆栈追踪、pidstat、mpstat、perf,甚至 sar 和 iostat——任何可能提供新见解的工具。这一思路借鉴了 Brendan Gregg 著名的《60 秒 Linux 性能分析》,因为在服务性能下降时,快速诊断至关重要。我们的目标是在不影响客户的情况下,尽快收集关键数据。不久后,问题再次出现,而这一次,我们已经做好了充分准备。
我们发现,受影响的 Pod 有 30 个 CPU 完全被内核代码占用,且几乎全部在处理缺页异常 (page faults)。然而,这些缺页异常的发生速率却异常缓慢——10 秒内仅发生 90 次,即使所有 CPU 都在全负荷运行。这不仅低效,甚至可以说是完全异常。
# perf stat -p $PODPID -- sleep 10
Performance counter stats for process id '864720':
304675.80 msec task-clock # 29.130 CPUs utilized
97934 context-switches # 0.321 K/sec
175 cpu-migrations # 0.001 K/sec
90 page-faults # 0.000 K/sec
复制
借助 perf,我们捕获到了关键的内核堆栈跟踪,终于揭示了问题的真相。
#0 shrink_lruvec
#1 shrink_node
#2 do_try_to_free_pages
#3 try_to_free_mem_cgroup_pages
#4 try_charge_memcg
#5 charge_memcg
#6 __mem_cgroup_charge
#7 __add_to_page_cache_locked
#8 add_to_page_cache_lru
#9 page_cache_ra_unbounded
#10 do_sync_mmap_readahead
#11 filemap_fault
#12 __do_fault
#13 handle_mm_fault
#14 do_user_addr_fault
#15 exc_page_fault
#16 asm_exc_page_fault
复制
我们还成功绘制了一张火焰图 (flamegraph)。这是反向火焰图,底部显示的是调用栈的起点 (frame #0)。
我们还找到了一种简单的方法,值班工程师可以使用 pidstat 监测 %system 消耗,以快速判断问题是否发生:
# nsenter -a -t $PODPID pidstat 1
14:35:24 UID PID %usr %system %guest %wait %CPU CPU Command
14:35:25 101 66 0.00 3000.00 0.00 0.00 3000.00 39 clickhouse-serv
复制
这大幅提升了故障响应速度,降低了值班工程师的压力,使他们能够迅速进行初步补救,同时让团队继续深入分析问题的根本原因。我们曾考虑过在所有实例上添加存活探针 (liveness probe),用于检测此问题并自动重启受影响的 Pod。然而,我们意识到,这种方法只是暂时掩盖问题,而不会真正解决它,同时可能会掩盖未来其他导致实例无响应的问题。因此,我们决定彻底查明问题并解决根本原因。
内核漏洞的排查和修复从来都不是一件简单的事情,但工程师可以遵循一些常见的策略来提高效率。
首先,使用 bpftrace 或 perf 等工具收集内核堆栈跟踪至关重要,它可以帮助识别性能瓶颈。这一步我们已经完成。基于 eBPF 的工具尤其强大,不仅可以用于定位瓶颈,还能深入分析内核的运行机制。它们可以监测几乎所有内核组件,包括系统调用、内存管理、进程调度和网络活动,从而帮助我们准确理解内核的行为。
其次,在分析内核堆栈跟踪时,寻找关键的内核跟踪点 (trace points) 以获取有价值的信息。在我们的案例中,我们发现页面回收 (page reclaiming) 占用了所有 CPU 资源。内核在回收页面之前,需要通过 vmscan 子系统进行扫描,而这个子系统中包含多个跟踪点:
# bpftrace -l 'tracepoint:vmscan:*'
tracepoint:vmscan:mm_vmscan_direct_reclaim_begin
tracepoint:vmscan:mm_vmscan_direct_reclaim_end
tracepoint:vmscan:mm_vmscan_lru_shrink_inactive
tracepoint:vmscan:mm_vmscan_lru_shrink_active
tracepoint:vmscan:mm_vmscan_writepage
tracepoint:vmscan:mm_vmscan_wakeup_kswapd
复制
这些内核跟踪点提供了参数信息,可以通过 bpftrace 脚本轻松获取。例如,下面是一个用于监测页面回收时间的脚本,同时还能记录回收的页面数量、回收过程的输入参数等。
tracepoint:vmscan:mm_vmscan_memcg_reclaim_begin pid == $1/ {
printf("%-8d %-8d %-8d %-16s mm_vmscan_memcg_reclaim_begin order=%-10d gfp_flags=%-10ld\n",
(nsecs - @epoch) 1000000,
pid,
tid,
comm,
args.order,
args.gfp_flags
);
@memcg_begin[tid] = nsecs
}
tracepoint:vmscan:mm_vmscan_memcg_reclaim_end pid == $1/ {
$elapsed = -1;
if (@memcg_begin[tid] > 0) {
$elapsed = nsecs - @memcg_begin[tid];
@mm_vmscan_memcg_reclaim_ns = hist($elapsed);
@memcg_begin[tid] = 0;
}
printf("%-8d %-8d %-8d %-16s mm_vmscan_memcg_reclaim_end nr_reclaimed=%-10ld elapsed_ns=%-10ld\n",
(nsecs - @epoch) 1000000,
pid,
tid,
comm,
args.nr_reclaimed,
$elapsed
);
}
复制
我们在一个无响应的服务器进程上运行了该脚本,得到如下结果。例如,某个线程(第一列为时间,单位毫秒)的大部分时间都花在页面回收过程中,并且每次回收结束后,几乎立即重新进入下一次回收。此外,尽管回收操作持续 1 到 4 秒,但回收的页面数量并不多:
346 874011 874761 Fetch mm_vmscan_memcg_reclaim_end nr_reclaimed=28 elapsed_ns=4294967295
346 874011 874761 Fetch mm_vmscan_memcg_reclaim_begin order=0 gfp_flags=17902666
3545 874011 874761 Fetch mm_vmscan_memcg_reclaim_end nr_reclaimed=14 elapsed_ns=3199515681
3545 874011 874761 Fetch mm_vmscan_memcg_reclaim_begin order=0 gfp_flags=17902666
7345 874011 874761 Fetch mm_vmscan_memcg_reclaim_end nr_reclaimed=56 elapsed_ns=3799726515
7345 874011 874761 Fetch mm_vmscan_memcg_reclaim_begin order=0 gfp_flags=17902666
10145 874011 874761 Fetch mm_vmscan_memcg_reclaim_end nr_reclaimed=15 elapsed_ns=2800475796
10145 874011 874761 Fetch mm_vmscan_memcg_reclaim_begin order=0 gfp_flags=17902666
13946 874011 874761 Fetch mm_vmscan_memcg_reclaim_end nr_reclaimed=17 elapsed_ns=3799783134
13946 874011 874761 Fetch mm_vmscan_memcg_reclaim_begin order=0 gfp_flags=17902666
14849 874011 874761 Fetch mm_vmscan_memcg_reclaim_end nr_reclaimed=60 elapsed_ns=903085555
14849 874011 874761 Fetch mm_vmscan_memcg_reclaim_begin order=0 gfp_flags=17902666
15645 874011 874761 Fetch mm_vmscan_memcg_reclaim_end nr_reclaimed=45 elapsed_ns=795982877
15645 874011 874761 Fetch mm_vmscan_memcg_reclaim_begin order=0 gfp_flags=17902666
17442 874011 874761 Fetch mm_vmscan_memcg_reclaim_end nr_reclaimed=4 elapsed_ns=1796359224
17442 874011 874761 Fetch mm_vmscan_memcg_reclaim_begin order=0 gfp_flags=17902666
19941 874011 874761 Fetch mm_vmscan_memcg_reclaim_end nr_reclaimed=16 elapsed_ns=2499699344
19941 874011 874761 Fetch mm_vmscan_memcg_reclaim_begin order=0 gfp_flags=17902666
@mm_vmscan_memcg_reclaim_ns:
[128K, 256K) 11 |@ |
[256K, 512K) 28 |@@@ |
[512K, 1M) 11 |@ |
[1M, 2M) 36 |@@@@ |
[2M, 4M) 16 |@@ |
[4M, 8M) 8 |@ |
[8M, 16M) 6 | |
[16M, 32M) 4 | |
[32M, 64M) 1 | |
[64M, 128M) 36 |@@@@ |
[128M, 256M) 58 |@@@@@@@ |
[256M, 512M) 161 |@@@@@@@@@@@@@@@@@@@@ |
[512M, 1G) 354 |@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@ |
[1G, 2G) 403 |@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@|
[2G, 4G) 300 |@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@ |
[4G, 8G) 139 |@@@@@@@@@@@@@@@@@ |
[8G, 16G) 29 |@@@ |
复制
我们向 Google Cloud Support 提交了工单,报告某些 Pod 似乎正在耗尽内存,但并未按预期被 OOM 杀死 (OOM-killed)。他们首先要求提供 sosreport,这是一款用于故障排查的诊断工具,可收集系统日志、硬件信息和内核状态。我们收集并提交了多份报告,希望能获得更深入的分析结果。然而,Google Cloud Support 在分析数据后表示,他们未发现任何异常,认为 Pod 只是正常耗尽内存,并按预期被 OOM 机制回收。
这一结论与我们的观察结果完全不符。为了证明问题并非仅仅是应用层异常,我们需要提供更详细的分析数据。Google Cloud Support 随后要求提供一个可复现的测试案例,以便深入分析问题,这最终推动了调查的进展。
在排查内核问题时,首先想到的方案通常是尝试升级或降级内核版本,以验证问题是否仍然存在。然而,在我们的情况下,升级并非易事,因为 Google Kubernetes Engine (GKE) 对底层操作系统的管理方式限制了这一选项。
GKE 节点运行的是 Container-Optimized OS (COS),这是一款深度集成到 GKE 的定制版 Linux 发行版。用户无法自由选择内核版本,而是必须使用由 Google 预先测试和验证的版本。每个 GKE 版本都会附带特定的 COS 版本,这意味着在 GKE 上运行时,我们无法简单地构建一个带有不同内核版本的自定义镜像,而只能使用官方支持并与特定 Kubernetes 版本绑定的内核。GKE 提供的唯一“内核选择”方式是选择一个 GKE 发布通道 (release channel),并在该通道内选择一个版本。然而,即便如此,仍然无法自由指定内核版本。我们的集群运行的是 GKE 1.27,它附带 COS-105 和内核 5.15。最近的可用升级选项是 GKE 1.28,它附带 COS-109 和内核 6.1。尽管升级到该版本可能是一个潜在的解决方案,但它并不能立刻修复问题——这需要在新的区域部署更新版本的 Kubernetes,并迁移现有工作负载,而这并不是一项轻松的任务。此外,虽然理论上我们可以运行一个自定义的内核镜像,但这将不再是 COS,而这种方案在我们的环境中不可行。
即便升级可行,也无法确保它真的能解决问题。我们推测内核 5.10 版本不受影响,因为我们在 AWS 上并未观察到此问题,但这并不意味着内核 6.1 就没有类似的漏洞。理想情况下,Google Cloud 技术支持应该能提供关于较新版本是否受影响的分析。然而,在那个阶段,他们仍在调查,我们也无从得知修复方案是否存在,或者何时会推出。
为了彻底理解并确保能够稳定复现该问题,我基于已有线索尝试了多种方法。我测试了不同的压力模式,调整了内存访问行为,并研究了线程争用对系统的影响。在 ClickHouse Cloud 中,我们禁用了交换分区 (swap),并使用 mlock 将可执行文件锁定在内存中,以防止其被分页出内存,从而避免回收 (reclaiming) 造成的性能损失。尽管我们通常不会使用内存映射文件 (memory-mapped files),但要完全避免 Linux 页面缓存 (page cache) 仍然极具挑战性。
无论进程执行何种 I/O 操作,即便只是读取配置文件,内核的页面缓存都会存储相应的页面。在多次尝试未能复现问题后,我开始研究内存映射文件——这成为了突破的关键!
虽然生产环境中并未使用内存映射文件,但它是一种简单的方法,可以创建可回收的文件页 (file-backed pages)。于是,我编写了一个小型 C++ 程序,能够 100% 复现该问题,并且只需 1 分钟即可触发。该程序运行在一个配置了 4 个 CPU 和 4GB 内存的 Docker 容器中,采用两阶段执行模式:
第一阶段(0-30 秒): 启动 1,000 个线程,每个线程写入 1,000 个文件(总计 4GB)。 这些文件随后被内存映射,并按顺序不断循环访问。 第二阶段(30 秒后): 再创建 1,000 个线程,分配匿名内存 (anonymous memory)。 这些线程随机访问内存,从而触发缺页异常 (page faults)。
当第二批线程启动后,我们观察到了以下系统行为,这些现象与生产环境中的问题完全一致:
ps aux 在整个系统范围内卡死,表明进程信息查询被阻塞。 perf 无法运行,无法采集系统活动的性能数据。 进程停止执行任何实际任务,但仍然占用所有可用的 CPU 资源。
在代码能够稳定复现问题后,我开始深入分析系统在卡死 (stall) 期间的具体情况。运行 strace ps aux 发现,它阻塞在读取 /proc/$PODPID/cmdline 这一操作上。深入研究内核 procfs 代码后,我发现该进程尝试获取目标进程的 mmap_lock,但该锁被持有的时间超乎寻常——在我的测试中,持续时间从 10 秒到 2 小时不等。
这就解释了 ps aux 挂起的原因:它需要获取 mmap_lock,但该锁一直未释放。同样,perf 也依赖进程信息,因此它也陷入了卡死状态,这解释了第二个异常现象。
为了弄清楚 mmap_lock 为什么会被持有如此长时间,我编写了多个 bpftrace 脚本来深入检查内核活动。这些分析提供了关键的洞察信息。
第一个 bpftrace 脚本确认 mmap_lock 在单个线程中被长时间持有:
# docker-bpf -e '
tracepoint:mmap_lock:mmap_lock_acquire_returned /pid == $1/ {
@start[tid] = nsecs;
}
tracepoint:mmap_lock:mmap_lock_released /pid == $1 && @start[tid] > 0/ {
$us = (nsecs - @start[tid])/1000;
if ($us > 50000) { // Print if held longer than 50ms
printf("mmap_lock hold duration in PID %d TID %d COMM %s: %d us\n", pid, tid, comm, $us);
}
@hold_us = hist($us); // Collect histogram of locking duration
@hold_avg_us = avg($us); // Aggregate average hold time
}
END { clear(@start); }
' $PODPID
mmap_lock hold duration in PID 2395806 TID 2396744 COMM r_file: 101349 us
mmap_lock hold duration in PID 2395806 TID 2396804 COMM r_file: 97864 us
mmap_lock hold duration in PID 2395806 TID 2396852 COMM r_file: 97305 us
mmap_lock hold duration in PID 2395806 TID 2396906 COMM r_file: 195284 us
mmap_lock hold duration in PID 2395806 TID 2396943 COMM r_file: 97771 us
mmap_lock hold duration in PID 2395806 TID 2396976 COMM r_file: 81960 us
mmap_lock hold duration in PID 2395806 TID 2396885 COMM r_file: 100698 us
mmap_lock hold duration in PID 2395806 TID 2396979 COMM r_file: 76614 us
mmap_lock hold duration in PID 2395806 TID 2396980 COMM r_file: 87041 us
mmap_lock hold duration in PID 2395806 TID 2397036 COMM r_file: 1297950 us
mmap_lock hold duration in PID 2395806 TID 2397378 COMM r_file: 199357 us
mmap_lock hold duration in PID 2395806 TID 2397385 COMM r_file: 118996 us
mmap_lock hold duration in PID 2395806 TID 2397409 COMM r_file: 791663 us
mmap_lock hold duration in PID 2395806 TID 2397286 COMM r_file: 93871 us
mmap_lock hold duration in PID 2395806 TID 2397959 COMM r_memory: 401062 us
mmap_lock hold duration in PID 2395806 TID 2398050 COMM r_memory: 230818460 us <---- 230 seconds
mmap_lock hold duration in PID 2395806 TID 2397984 COMM r_memory: 5490031 us
mmap_lock hold duration in PID 2395806 TID 2398045 COMM r_memory: 81746 us
mmap_lock hold duration in PID 2395806 TID 2397972 COMM r_memory: 387430 us
mmap_lock hold duration in PID 2395806 TID 2398040 COMM r_memory: 75750 us
mmap_lock hold duration in PID 2395806 TID 2397975 COMM r_memory: 76566 us
mmap_lock hold duration in PID 2395806 TID 2398142 COMM r_memory: 71836 us
mmap_lock hold duration in PID 2395806 TID 2398056 COMM r_memory: 77675 us
...
^C
@hold_avg_us: 1190
@hold_us:
[0] 6949 |@@@ |
[1] 98632 |@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@|
[2, 4) 40750 |@@@@@@@@@@@@@@@@@@@@@ |
[4, 8) 31606 |@@@@@@@@@@@@@@@@ |
[8, 16) 27092 |@@@@@@@@@@@@@@ |
[16, 32) 7208 |@@@ |
[32, 64) 1305 | |
[64, 128) 244 | |
[128, 256) 123 | |
[256, 512) 303 | |
[512, 1K) 276 | |
[1K, 2K) 151 | |
[2K, 4K) 62 | |
[4K, 8K) 47 | |
[8K, 16K) 16 | |
[16K, 32K) 4 | |
[32K, 64K) 0 | |
[64K, 128K) 53 | |
[128K, 256K) 8 | |
[256K, 512K) 13 | |
[512K, 1M) 5 | |
[1M, 2M) 2 | |
[2M, 4M) 0 | |
[4M, 8M) 1 | |
[8M, 16M) 0 | |
[16M, 32M) 0 | |
[32M, 64M) 0 | |
[64M, 128M) 0 | |
[128M, 256M) 1 | | <---- 230 seconds
复制
第二个脚本揭示,该锁是在处理缺页异常 (page fault) 时获取的:
# docker-bpf -e '
tracepoint:mmap_lock:mmap_lock_acquire_returned /pid == $1/ {
@start[tid] = nsecs;
}
tracepoint:mmap_lock:mmap_lock_released /pid == $1 && @start[tid] > 0/ {
$us = (nsecs - @start[tid])/1000;
if ($us > 1000000) { // Save stack if lock was held longer than 1 second
@[kstack] = count();
}
}
END { clear(@start); }
' $PODPID
@[
__mmap_lock_do_trace_released+113
__mmap_lock_do_trace_released+113
do_user_addr_fault+727
exc_page_fault+120
asm_exc_page_fault+34
]: 1
复制
最后,第三个脚本分析了 mmap_lock 持有期间的 CPU 活动。下面展示了一种实用技巧,可用于此类调查——通过 bpftrace 进行 CPU 采样,并仅在锁被持有时收集样本:
tracepoint:mmap_lock:mmap_lock_acquire_returned /pid == $1/ {
@is_holding[tid] = 1;
}
tracepoint:mmap_lock:mmap_lock_released /pid == $1 && @is_holding[tid] == 1/ {
@is_holding[tid] = 0
}
profile:hz:99 /pid == $1 && @is_holding[tid] == 1/ {
@under_lock[kstack, comm] = count();
}
END { clear(@start); }
复制
我们可以基于 bpftrace 获取的堆栈跟踪 (stack traces) 构建火焰图 (flamegraph),方法非常简单。在我们的案例中,火焰图清晰地显示,大部分时间都消耗在 shrink_lruvec 进行页面回收的过程中。这表明,当某个进程达到内存限制时,整个 cgroup 的页面回收操作都在该进程的 mmap_lock 保护下执行。然而,cgroup 可能包含多个进程,而被回收的页面并不一定属于持有 mmap_lock 的进程。除了缺页异常处理,实际上并没有理由长时间持有该锁。
另一个关键发现解释了为什么该问题只在运行数千个线程时才会出现。持有 mmap_lock 的线程可能会调用 __cond_resched() 主动让出 CPU,以避免长时间阻塞并确保调度公平性。然而,在数千个线程同时运行的情况下,该线程只有在所有其他线程用完 CPU 时间配额(每个最多 4ms)或进入阻塞状态后,才会再次被调度执行。这导致 shrink_lruvec 运行效率极低。任何有经验的程序员都知道,在持有锁的情况下阻塞极易导致严重问题!然而,Linux 内核 5.15 竟然在这个场景下出现了类似的情况。
这次调查澄清了我们之前观察到的许多现象。在 GCP 上,mincore 调用响应变慢,实际上也是 mmap_lock 争用过度导致的另一个症状。
然而,仍然有一些未解之谜:
为什么 mmap_lock 在极少数情况下会被持有长达 2 小时? 为什么 shrink_lruvec 在 GCP 上执行时间异常之长,而在 AWS 上却没有类似问题?
我们在不同环境中运行测试案例,以验证哪些系统配置会受到该漏洞的影响。AWS 的 Elastic Kubernetes Service (EKS) 运行在 Amazon Linux 2(内核 5.10)或 Amazon Linux 2023(内核 6.1)上,这两个内核均未出现该问题。我将代码分享给 Google 支持团队,他们确认 Container-Optimized OS (COS) 在 COS 101(5.10)、105(5.15)和 109(6.1)版本中确实会触发该问题,但在 COS 113(6.1)中问题已消失。在 Azure 上,我们使用的是基于内核 5.15 的 Azure Linux,按理说测试案例应该能触发该漏洞,但在生产环境中我们从未观察到类似现象。
正如所见,不同 Kubernetes 提供商使用的 Linux 内核分支各不相同,并且与官方主线内核 (vanilla kernel) 存在一定差异。这也表明,调试此类问题的难度非常高,即使使用相同的内核版本,不同的发行版可能会表现出完全不同的行为。尽管所有主要云提供商都开源了各自的内核修改版本,但这些内核通常包含额外的补丁,并可能采用特殊的编译配置,这进一步增加了问题的复杂性。
为了理解我们遇到的问题,首先需要了解 Linux 如何管理内存,以及在内存压力下如何回收页面。在 Linux 中,内存页面 (pages) 组织在最近最少使用 (Least Recently Used, LRU) 列表中。需要分别管理的页面有两种类型:匿名内存(如堆和栈分配)和文件映射内存(如页面缓存、mmap 文件、二进制文件)。值得注意的是,在 ClickHouse Cloud 中,交换分区 (swap) 已被禁用,因此匿名内存无法被回收。
每种类型的页面都有两个 LRU 列表:➀ 活跃列表 (active) 和 ➁ 非活跃列表 (inactive)。使用这两个列表的主要目的是高效跟踪页面的使用情况。Linux 内存管理的一个重要特点是,它与非内核开发者的直觉可能有所不同。应用程序级别的缓存通常在访问某个项目时立即将其移动到列表头部,而 Linux 内核不会在每次访问内存时执行代码。相反,当页面被读取或写入时,处理器会在 ➂ 页表项 (Page Table Entry, PTE) 中设置访问位 (accessed bit, A bit)。这个过程完全由硬件完成,每当页面被访问时,访问位会被设置。随后,内核会周期性地扫描内存,并清除该访问位,以判断该页面是否仍在使用中。内核只能在内存扫描期间观察和重置这些位,从而确定页面的使用情况。
如果你想查看你的进程所属 cgroup 的当前状态,可以使用以下命令打印所有相关文件的内容(仅适用于 cgroup v1):
(cd /sys/fs/cgroup/memory/$(cat /proc/$PODPID/cgroup | grep memory | cut -d : -f 3); tail -n 100 *)
复制
cgroup 的内存控制器包含许多文件,用于描述其内存使用情况。但以下几个是最值得关注的:
==> memory.failcnt <==
31 # the number of times cgroup hit the limit
==> memory.limit_in_bytes <==
4294967296 # the cgroup limit
==> memory.usage_in_bytes <==
4294967296 # current memory usage of the cgroup
==> memory.max_usage_in_bytes <==
4294967296 # maximum memory usage observed over cgroup lifetime
复制
我们可以看到当前内存使用量已经达到限制,因此任何新的内存分配都会触发回收。另一个文件显示了每个 LRU 列表的总大小(以字节为单位):
==> memory.stat <==
cache 4001792000
rss 128729088
mapped_file 4001792000
inactive_anon 128729088
active_anon 0
inactive_file 2411884544
active_file 1589776384
unevictable 0
复制
在问题发生时,我观察到的一个有趣现象是,除了 inactive_file 和 active_file 之外,其他值几乎没有变化。这表明 cgroup 进入了一种完全冻结的状态,除了执行回收操作外,没有进行任何其他操作。
在 Linux 中,页面回收 (page reclaiming) 由 vmscan 子系统负责,该子系统在需要释放内存时启动。vmscan 主要有两种回收方式:
异步回收 (Asynchronous reclaim):当整个系统面临内存压力时触发,由 kswapd 内核线程处理。它会主动回收内存,但并不会强制执行 cgroup 的内存限制。需要注意的是,cgroup 本身并没有异步回收机制。
同步回收 (Synchronous reclaim):当特定的 cgroup 超过其内存限制(memcg 回收)或整个系统的内存压力过高(直接回收 (direct reclaim))时触发。在这种情况下,请求内存的进程会在自身上下文中执行回收操作,并在足够的内存被回收前暂停执行。
无论哪种回收方式,vmscan 运行时都会执行扫描。首先,内核扫描非活跃列表 (inactive list) 中的页面。如果某个页面的访问位已被设置,则它会被标记为已引用 (referenced) ➀,并被移至非活跃列表的头部,而不会立即提升到活跃列表。在下一轮扫描时,如果该页面再次被访问,则它会被移动到 活跃列表 (active list) ➁,这意味着它是最近被使用的,不应被回收。而对于非活跃列表中未被访问的页面,vmscan 会直接回收它们 ➃。如果在扫描过程中发现没有可回收的非活跃页面,那么 vmscan 会检查活跃页面,并可能会忽略访问位 ➂,直接将其降级到非活跃列表。需要注意的是,每次扫描时,内核都会重置页面的访问位,而不管页面的状态如何。
需要注意的是,除非页面至少被扫描两次,否则它不会被回收。因为当页面发生缺页异常 (page fault) 时,它会被放入非活跃列表 (inactive list),并且其访问位已被设置。此外,内核不会主动扫描 cgroup 的页面,除非该 cgroup 触及其内存限制。而在这一刻,所有页面看起来都是一样的——它们都位于非活跃列表,并且访问位已被设置。
为了分析在测试案例中长时间挂起时,内核到底在执行什么操作,我编写了一个 bpftrace 脚本,该脚本按 mmap_lock 采集所有相关事件和指标,即在每次 mmap_lock 获取和释放的周期内收集数据。在 GKE (COS 109) 节点上运行该脚本后,我们成功复现并捕获了该问题。
=== [READ] mmap_lock hold stats in TID 1891547 COMM r_memory ===
number of context switches: 2569
number of preemptions: 0
reclaim_throttle() calls: 0 WRITEBACK, 0 ISOLATED, 0 NOPROGRESS, 0 CONGESTED
__cond_resched() calls: 2255178
shrink_lruvec() calls: 13
shrink_list() calls: 34172
nr_taken: 0
nr_active: 0
nr_deactivated: 0 ⓷
nr_referenced: 0
nr_scanned: 1093267 ⓵ + ⓶ + ⓷ + ⓸
nr_reclaimed: 32 ⓸
nr_dirty: 0
nr_writeback: 0
nr_congested: 0
nr_immediate: 0
nr_activate0: 0
nr_activate1: 564142 ⓶
nr_ref_keep: 529093 ⓵
nr_unmap_fail: 0
runtime: 3699226 us
duration: 563997395 us
复制
从输出结果中,我们发现线程当时持有 mmap_read_lock。起初,读锁 (read lock) 似乎不会引发问题,但一旦有写者 (writer) 线程等待获取该锁,所有新的读者线程都会被阻塞,直到写者线程完成锁获取。这就导致 mmap_lock 被单个线程长期占用。通常,mmap_lock 在处理缺页异常 (page fault) 时以读模式获取,而在 mmap 或 munmap 操作时以写模式获取。
该锁被持有了 564 秒,但实际 CPU 执行时间 仅 3.7 秒,说明该线程平均与 约 150 个其他线程竞争。这符合我们的测试环境:1000 个线程在 4 核容器内扫描文件内存(每个 CPU 理论上负载 250 线程)。该线程并未被强制抢占,而是通过 __cond_resched() 主动让出 CPU,这有时会触发线程上下文切换。
在这一次关键的锁持有周期(从获取到释放)内,内核扫描了 1,093,267 个页面(约 4.27 GB),试图为该 cgroup 释放内存。然而,最终只有 32 个页面 ⓸ 成功被回收,而其余页面要么被保留在非活跃 LRU 列表 (inactive LRU list) ⓵,要么被移动到活跃 LRU 列表 (active LRU list) ⓶。
进一步分析后,我确认内核在不断地在非活跃列表和活跃列表之间移动页面,这与 Linux 的内存管理机制有关。只有在进程触及内存限制时,才会触发扫描,并且页面必须至少被扫描两次才可能被回收,因此整个内存空间都会被扫描。页面是否会被移动到活跃列表,还是被回收,取决于它在两次扫描之间是否被访问。
通过对比不同的事件,证据十分明显。在第一次扫描时,我们观察到大量页面仍然留在非活跃 LRU 列表 ⓵。在第二次扫描时,许多这些页面被提升到活跃列表 ⓶,说明它们在两次扫描之间被访问过。两次扫描之间的间隔约为 3 分钟,这使后台线程有足够的时间访问几乎所有页面,导致它们被激活。这也解释了为什么回收操作迟迟不结束——它只有在成功回收页面后才会停止。如果回收失败,系统将触发 OOM (Out of Memory),但在本例中,回收确实成功了(尽管只有 32 页),因此最终没有触发 OOM。
虽然我们已经深入了解了问题的根本原因,但 Linux 现有的内存管理机制仍然难以应对这种极端情况。换句话说,内核的内存管理能力需要进一步优化。事实上,我们已经发现,这个内核漏洞在较新的内核版本中不会复现。那么,是什么原因让新内核避免了这个问题呢?
最直接的方法是在正常工作的内核上重复相同的实验,并对比其行为。经过相同的统计分析流程(针对每次获取和释放 mmap_lock 的周期),我发现健康的内核与出现问题的内核有两个主要区别:
首先,mmap_lock 在回收过程中不再被持有。 这一点显然是之前版本的错误行为,而在新内核中,mmap_lock 会在启动页面回收前被释放。所有迹象表明,Amazon Linux 2 已经修复了这个问题,但由于它基于内核 5.10,而该版本并不包含 vmscan 相关的跟踪点,我们无法直接在生产环境中验证这一点。
其次, 新内核采用了不同的页面回收机制。 内存管理的一项关键性改进是引入了 Multi-Gen LRU (MGLRU),这是一种全新的 LRU 页面回收机制。MGLRU 在 Linux 6.1 内核中被首次引入,并逐渐成为多个 Linux 发行版的默认配置。我决定在 COS 109 上尝试启用这一功能,结果发现它彻底解决了问题!
如果你的 Linux 发行版支持 CONFIG_LRU_GEN,你可以通过以下命令启用 MGLRU:
echo y >/sys/kernel/mm/lru_gen/enabled
cat /sys/kernel/mm/lru_gen/enabled
0x0007
复制
该更改立即生效,无需重启服务器。我们验证了它不会对 ClickHouse Cloud 造成任何性能下降,于是在 GCP 整个服务器集群中启用了该特性。整个过程非常顺利,在接下来的一周内,问题没有再次复现。我还向团队做了一次技术分享,详细介绍了调查过程以及最终的修复方案。至此,这场长达 8 个月的问题排查,终于以一个简单且高效的修复方案画上了句号。
至少,我当时是这么认为的……直到相同的告警再次触发!
尽管感到失望,但我别无选择,只能重新分析这个问题。而这次的发现完全不同——一个全新的漏洞,表现出几乎相同的故障现象。CPU 被限流 (throttled),几乎所有资源都被内核代码占用。它仍然在进行内存回收,但这次的回收逻辑发生了彻底变化:它采用了 MGLRU。不过,这一次 perf 和 ps 之类的工具依然可以正常运行,没有发生挂起。而在进一步检查后,我发现 shrink_lruvec 对 mmap_lock 的调用极少,大多数情况下甚至不再需要这个锁。
意识到自己面对的是一个全新的问题,我只能重新展开调查。我首先从 CPU 性能分析 (profiling) 入手:
55.47% [kernel] [k] _raw_spin_unlock_irq
12.05% [kernel] [k] _raw_spin_unlock_irqrestore
复制
这次的问题表明,占用所有 CPU 资源的并不是 shrink_lruvec,而是某种自旋锁 (spinlock)。这个发现完全出乎意料。进一步的调查持续了几周时间,因为问题发生毫无规律,而我之前用于复现的代码在这个案例中完全无效。
不过,并非一切都朝着糟糕的方向发展。好消息是,自从启用了 MGLRU,问题的告警频率已经大幅下降。在分析了更多案例后,我终于找到了一条关键线索。
我再次收集了内核堆栈跟踪 (kernel stack traces),发现最常见的调用路径如下:
#0 _raw_spin_unlock_irq
#1 evict_folios
#2 shrink_lruvec
#3 shrink_node
#4 do_try_to_free_pages
#5 try_to_free_mem_cgroup_pages
#6 try_charge_memcg
#7 charge_memcg
#8 __mem_cgroup_charge
#9 __filemap_add_folio
#10 filemap_add_folio
#11 page_cache_ra_unbounded
#12 do_sync_mmap_readahead
#13 filemap_fault
#14 __do_fault
#15 handle_mm_fault
#16 do_user_addr_fault
#17 exc_page_fault
#18 asm_exc_page_fault
复制
通过重建反向火焰图 (reverse flamegraph)(帧 #0 位于底部),可以清楚地看到,多个调用路径最终都指向了同一个自旋锁,并且它们的行为高度相似:
为什么火焰图显示的是 unlock,而不是 lock?通常情况下,等待自旋锁 (spinlock) 时,CPU 资源应该主要消耗在 lock 操作上,而不是 unlock。然而,在火焰图 (flamegraph) 中,我们看到的却是 unlock,这有些令人费解。不过,无论如何,我还是根据堆栈跟踪 (stack trace) 进一步分析了内核代码。最简单的方法是使用在线内核源码浏览器(例如 [Elixir](https://elixir.bootlin.com/)),它允许我们快速切换不同的内核版本,并查找堆栈跟踪中出现的符号。尽管它不包含 GCP 或 AWS 使用的专有内核代码,但在问题排查中依然非常有用。
最终,我找到了问题的根源:一个名为 lru_lock 的自旋锁,它负责保护 struct lruvec 结构体。内核会为每个 cgroup 和 NUMA 节点创建一个 lruvec 结构,其中包含 MGLRU 和 传统活跃/非活跃列表 机制共用的字段。在 MGLRU 机制中,evict_folios 函数负责页面回收,而它在执行过程中会获取 lru_lock。
在内核中,folio 是一组连续的页面,内核将它们作为一个单元进行管理,以减少开销。这使得多个线程可以并发扫描 LRU 列表,每个线程会首先隔离一批私有页面进行扫描,完成后再决定哪些页面需要回收,哪些需要放回原始 LRU 列表。
然而,我们观察到 lru_lock 存在严重争用,因为它被频繁加锁和解锁——为什么会这样呢?
其实,这并不奇怪。考虑到当前环境中有大量并发线程,每个线程都可以独立执行内存回收。任何有经验的程序员都知道,自旋锁 (spinlock) 只能用于低争用场景,否则会导致 CPU 资源的极端浪费。我用 top 命令进行了快速检查,发现在 3 秒内,至少有 138 个线程处于活跃状态。根据火焰图分析,84% 的堆栈跟踪都涉及到 evict_folios,这意味着很可能有超过 100 个线程在不断尝试获取 lru_lock,导致严重的 CPU 资源竞争。
一周后,Google 支持团队建议我们启用 MGLRU,但他们也指出,这可能无法彻底解决问题。此外,针对 COS 105 的 慢速页面回收 (slow page reclaim) 问题,现在已经有了一个 公开的 Issue。我向 Google 团队分享了我们的调查结果,随后该工单被关闭。然而,目前并没有任何修复进展,所以我们只剩下两个选择:要么升级到更新的内核,要么实现自动化机制来检测活锁并重启 Pod。目前,我们已经在 GCP 集群中部署了存活检测 (liveness detection) 机制,并配置了自动重启系统。如果你使用的是 cgroups v2,可以借助相关工具实现类似的方案。
众所周知,内核漏洞极难处理。定位问题本身就是一项挑战——你习惯使用的工具可能会完全失效,比如 ps aux 和 perf 直接挂起;又或者,它们可能会误导你,比如 gdb 的堆栈跟踪根本不会显示内核堆栈帧。 而修复内核漏洞的难度更甚,尤其是对于 非内核开发者 而言。我并不是内核开发者,但我们的软件始终运行在操作系统之上,理解内核的工作原理至关重要。
即便找到了问题,真正部署内核修复仍然困难重重。在 托管 Kubernetes 环境中,你通常无法随意升级内核,而必须依赖云厂商提供的受管理版本。
内核漏洞永远无法完全避免,毕竟,它和其他软件一样,都会存在漏洞。 我们唯一能做的,就是未雨绸缪。这次调查不仅让我掌握了 调试技巧,更重要的是让我深入理解了 Linux 内核的底层原理。 希望这个故事不仅能引起你的兴趣,也能在你未来面对类似挑战时,提供有价值的参考和思路。
我们正为上海活动招募讲师,如果你有独特的技术见解、实践经验或 ClickHouse 使用故事,非常欢迎你加入我们,成为这次活动的讲师,与大家分享你的经验。
/END/
注册ClickHouse中国社区大使,领取认证考试券

ClickHouse社区大使计划正式启动,首批过审贡献者享原厂认证考试券!
试用阿里云 ClickHouse企业版
轻松节省30%云资源成本?阿里云数据库ClickHouse 云原生架构全新升级,首次购买ClickHouse企业版计算和存储资源组合,首月消费不超过99.58元(包含最大16CCU+450G OSS用量)了解详情:https://t.aliyun.com/Kz5Z0q9G

征稿启示
面向社区长期正文,文章内容包括但不限于关于 ClickHouse 的技术研究、项目实践和创新做法等。建议行文风格干货输出&图文并茂。质量合格的文章将会发布在本公众号,优秀者也有机会推荐到 ClickHouse 官网。请将文章稿件的 WORD 版本发邮件至:Tracy.Wang@clickhouse.com