ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

ClickHouse 内存分配 Profile 深度分析方法论:从 jemalloc collapsed 栈到可执行的内存定位工作流

ClickHouse 内存分配 Profile 深度分析方法论:从 jemalloc collapsed 栈到可执行的内存定位工作流 ClickHouse 内存分配 Profile 深度分析方法论从 jemalloc collapsed 栈到可执行的内存定位工作流【免费下载链接】ClickHouseClickHouse® is a real-time analytics database management system项目地址: https://gitcode.com/GitHub_Trending/cli/ClickHouse本文以仓库 .claude/skills/alloc-profile/SKILL.md 为骨架系统讲解如何分析一份 jemalloc或 async-profiler / perf生成的collapsed 栈格式内存分配 profile如何从单行frame1;frame2;...;frameN VALUE的文本出发先并行跑出「统计摘要 / 最外层发起操作 / 最内层分配函数」三份视图再按子系统归组生成报告最后对指定子系统、关键字或全部大栈进行交互式钻取甚至一键渲染火焰图。文章中所有分析脚本均可原样落地执行并结合本仓库 ClickHouse 的分配器与 jemalloc 封装源码src/Common/malloc.cpp、src/Common/Jemalloc.cpp 等说明每一层噪声过滤规则背后的真实调用链帮助你快速定位“谁在 ClickHouse 中吃了内存”。1. 先读懂 collapsed 栈格式Allocation profile 的 collapsed 格式是 jemalloc、async-profiler、perf 等工具共同输出的中间表示通常存于扩展名为.collapsed或.folded的文件中。文件按行组织每行的形式为frame1;frame2;...;frameN VALUE其中VALUE表示归属于这条调用栈的字节数或在以采样次数计数的 profiler 中表示样本数。同一调用链在不同位置命中时会被聚合成一行因此语义上等价于火焰图的输入格式每一条样本等于一行栈帧自根到叶以分号串联数值。在 ClickHouse 场景中这样的文件通常来自 jemalloc 的 heap dump。需要特别强调一个语义关键点详见原文档 Notes 部分VALUE 反映的是采样时刻的「存活live / in-use字节数」。jemalloc 堆 profile 统计的是分配量减去释放量因此高数值直接指向这些调用点造成的实时内存占用压力而不是累积分配总量栈帧顺序为最外层线程根在前最内层分配器在后文档中所有分析脚本都会先将栈反转再输出以便从调用深度索引阅读。2. 分析前的准备与参数约定该 skill 接受一个可选参数$0可选.collapsed文件的路径。若未提供工具会在当前目录搜索.collapsed文件并让用户选择。典型的调用形态/alloc-profile—— 查找.collapsed文件并弹出选择/alloc-profile jemalloc-profile-2026-02-19T13-08-59-825Z.collapsed—— 分析指定文件/alloc-profile /tmp/prod-heap-dump.collapsed—— 分析绝对路径下的文件2.1 在 ClickHouse 里先“造”出这样一份 profile要让上面这些参数变得可用首先得让 ClickHouse 在运行期输出 heap dump。仓库源码中已有一整套 jemalloc 封装直接印证了 profile 的产出机制编译期/启动期开启 profiler。src/Common/Jemalloc.cpp 中的checkProfilingEnabled()通过je_mallctl(opt.prof, ...)检查是否启用了 profiling若未启用会抛出异常并提示设置环境变量MALLOC_CONFbackground_thread:true,prof:true手动触发 dump 的文件命名。src/Common/Jemalloc.cpp 的flushProfile(file_prefix)先读取opt.prof_prefix当前缀不是默认的jeprof时会构造形如{file_prefix}.{pid}.{counter}.heap的路径并调用je_mallctl(prof.dump, ...)落盘。这也解释了为何实际运维中得到的文件名带时间戳/PID/序号。内存超限自动落盘。src/Common/MemoryTracker.cpp 显示当全局内存超过硬限制且开启了jemalloc_flush_profile_on_memory_exceeded或带间隔的jemalloc_flush_profile_on_memory_exceeded_interval_s时MemoryTracker 会读取prof.active与opt.prof_prefix随后调用DB::Jemalloc::flushProfile(flush_prefix)并在日志中打印Flushed memory profile to ... after total memory exceeded。注意它用了MemoryTrackerBlockerInThread防止 flushProfile 自身的分配再次触发递归超限——这在写分析结论时是有用的旁证profile 文件本身可能就是 OOM 现场留下的取证材料。采样率调整。src/Common/Jemalloc.cpp 的setProfileSamplingRate(lg_prof_sample)通过prof.reset动态修改prof.lg_sample意味着采样粒度可在线调节。不落盘、直接在 SQL 侧消费。若不想产生文件仓库还提供了system.jemalloc_profile_text系统表与对应的 src/Processors/Sources/JemallocProfileSource.cpp它会生成 collapsed 字符串并依据采样间隔做修正collapsed_use_count可被设置jemalloc_profile_text_collapsed_use_count控制。也就是说 collapsed 格式在该仓库中是「文件分析」与「系统表查询」两种形态共用的中间语言。3. Step 1 —— 定位 profile 文件拿到需求后第一步是定位输入文件。文档建议用 Task 子代理subagent_typeBash去执行定位避免在主上下文里遍历大目录若调用时未给出$ARGUMENTS则运行find . -maxdepth 3 -name *.collapsed -o -name *.folded | sort -t_ -k1,1将候选文件报告给用户后用AskUserQuestion询问问题「Which profile file do you want to analyze?」选项每个找到的文件一个显示文件名与大小外加 Other — enter path manually一旦确定了文件路径后续所有步骤统一使用该路径。4. Step 2 —— 三路并行初始分析定位到文件后文档要求同时启动三个后台 Task 代理同一消息中三次工具调用均带run_in_background: true并在进入 Step 3 之前并行等待三个代理的 TaskOutput 全部返回。这样做的直接动机写在 Notes 中profile 文件可能有数百 MB绝不能读入主上下文所有分析都在子代理中完成主线程只负责汇总。兜底Fallback如果某个代理失败例如缺少 Bash 权限可在主上下文中直接用 Bash 工具重跑它的 Python 脚本。4.1 Agent A —— 汇总统计Summary statisticsAgent A 计算总量、去重栈数、Top 25 栈与 Top 10 全栈并对已知的分配器噪声帧做标记与剔除python3 - EOF import sys, os, re filepath PATH_TO_FILE # substituted by skill lines open(filepath).read().splitlines() traces [] for line in lines: line line.strip() if not line: continue parts line.rsplit( , 1) if len(parts) ! 2: continue try: traces.append((int(parts[1]), parts[0])) except ValueError: continue total sum(v for v, _ in traces) traces.sort(reverseTrue) # Noise filters — keep in sync with Agent C JEMALLOC_PREFIXES ( prof_backtrace, prof_alloc_prep, prof_tctx, prof_, imalloc, ialloc, irallocx, imallocx, arena_malloc, arena_palloc, arena_ralloc, arena_, tcache_alloc, tcache_, large_malloc, large_palloc, chunk_alloc, huge_malloc, huge_palloc, je_malloc, je_calloc, je_realloc, je_rallocx, je_mallocx, je_posix_memalign, je_aligned_alloc, malloc_default, calloc, ) ALLOC_SUBSTRINGS ( operator new, operator new[], __libcpp_operator_new, __libc_malloc, __libc_calloc, _int_malloc, posix_memalign, aligned_alloc, do_rallocx, do_mallocx, mi_malloc, mi_calloc, __cxx_global_var_init, __cxa_thread_atexit_impl, DB::Memory, Memory::newImpl, Allocatorfalse, Allocatortrue, allocNoTrack, PODArrayBase::realloc, PODArrayBase::alloc, CRYPTO_malloc, std::__detail::_Hash_node, std::_Rb_tree, std::vector, std::string::, # STL and PODArray wrappers — noise for leaf analysis std::__1::, DB::PODArrayBase, ) def is_noise(frame): return (any(frame.startswith(p) for p in JEMALLOC_PREFIXES) or any(s in frame for s in ALLOC_SUBSTRINGS)) def shorten(frame): return re.sub(r[^]{40,}, ..., frame) print(f SUMMARY ) print(fFile: {filepath}) print(fTotal allocated: {total:,} bytes ({total/1024/1024:.2f} MB) ({total/1024/1024/1024:.3f} GB)) print(fUnique stack traces: {len(traces)}) print() print( TOP 25 STACK TRACES ) for i, (v, stack) in enumerate(traces[:25], 1): frames [f for f in stack.split(;) if f] meaningful [f for f in frames if not is_noise(f)] tail_frames meaningful[-4:] if meaningful else frames[-4:] tail - .join(shorten(f) for f in reversed(tail_frames)) print(f{i:3}. {v/1024/1024:8.2f} MB ({100*v/total:5.1f}%) {tail[:120]}) print() print( FULL STACKS FOR TOP 10 ) for i, (v, stack) in enumerate(traces[:10], 1): frames [f for f in stack.split(;) if f] print(f\n--- #{i}: {v/1024/1024:.2f} MB ({100*v/total:.1f}%) ---) for depth, frame in enumerate(reversed(frames), 1): noise_mark [noise] if is_noise(frame) else print(f [{depth:2}] {shorten(frame)}{noise_mark}) EOF实际执行时请把脚本中filepath PATH_TO_FILE替换为第 3 步确定的真实路径。4.2 Agent B —— 最外层有意义帧聚合为什么发生这次分配Agent B 回答的是这笔分配是被什么业务操作发起的——例如加载数据 part、执行一条查询、加载字典。它会跳过线程池脚手架、libc 入口、裸地址与 lambda 包装等帧向上取到第一个有业务含义的外层函数python3 - EOF import sys, re from collections import defaultdict filepath PATH_TO_FILE # substituted by skill lines open(filepath).read().splitlines() traces [] for line in lines: line line.strip() if not line: continue parts line.rsplit( , 1) if len(parts) ! 2: continue try: traces.append((int(parts[1]), parts[0])) except ValueError: continue total sum(v for v, _ in traces) # Frames to skip when looking for the outermost meaningful frame: # thread pool scaffolding, libc entry points, raw addresses, lambda wrappers SKIP_OUTER ( 0000, _start, __libc_start, __GI___clone, start_thread, clone3, ThreadPoolImpl, ThreadFromGlobalPool, std::__1::__function, std::__1::__invoke, decltype, void std::__1::__function, std::__1::__packaged_task_function, DB::ThreadPool, DB::GlobalThreadPool, DB::threadFunction, BaseDaemon, SignalListener, Poco::ThreadImpl::runnableEntry, Poco::PooledThread::run, main, DB::Server::run, Poco::Util::Application::run, ) def is_skip_outer(frame): return any(frame.startswith(p) for p in SKIP_OUTER) or frame.startswith(() def shorten(frame): # Collapse long templates, preserve (anonymous namespace), strip args s re.sub(r[^]{40,}, ..., s) s s.replace((anonymous namespace), {anon}) s re.sub(r\(.*, , s) s s.replace({anon}, (anonymous namespace)) return s[:120] by_outer defaultdict(int) for v, stack in traces: frames [f for f in stack.split(;) if f] outer None for f in frames: if not f or is_skip_outer(f): continue outer f break if outer is None: outer frames[0] if frames else (unknown) by_outer[shorten(outer)] v print( TOP 25 OUTERMOST MEANINGFUL FRAMES (operation that initiated allocation) ) for fn, v in sorted(by_outer.items(), keylambda x: -x[1])[:25]: mb v / 1024 / 1024 pct 100 * v / total bar \u2588 * int(pct / 2) print(f {mb:10.2f} MB {pct:5.1f}% {bar:20} {fn}) EOF上面SKIP_OUTER里的DB::Server::run、BaseDaemon、Poco::Util::Application::run等帧是 ClickHouse 服务进程的固定根路径DB::ThreadPool/DB::GlobalThreadPool/ThreadFromGlobalPool则是 ClickHouse 的线程池基建跳过后才能看到真正启动分配的业务函数。4.3 Agent C —— 叶分配函数聚合哪段代码真的在分配Agent C 从栈底向上找到第一个非噪声帧——即真正发起分配、对性能分析最有意义的函数同时输出它的调用者leaf 的上一个非噪声帧python3 - EOF import sys, re from collections import defaultdict filepath PATH_TO_FILE # substituted by skill lines open(filepath).read().splitlines() traces [] for line in lines: line line.strip() if not line: continue parts line.rsplit( , 1) if len(parts) ! 2: continue try: traces.append((int(parts[1]), parts[0])) except ValueError: continue total sum(v for v, _ in traces) # Aggregate by last meaningful frame (the allocating function) by_leaf defaultdict(int) by_caller defaultdict(int) # caller of the leaf # jemalloc profiling infrastructure — always at the bottom of every stack JEMALLOC_PREFIXES ( prof_backtrace, prof_alloc_prep, prof_tctx, prof_, imalloc, ialloc, irallocx, imallocx, arena_malloc, arena_palloc, arena_ralloc, arena_, tcache_alloc, tcache_, large_malloc, large_palloc, chunk_alloc, huge_malloc, huge_palloc, je_malloc, je_calloc, je_realloc, je_rallocx, je_mallocx, je_posix_memalign, je_aligned_alloc, malloc_default, calloc, ) # libc / C allocator wrappers that add no information ALLOC_SUBSTRINGS ( operator new, operator new[], __libcpp_operator_new, __libc_malloc, __libc_calloc, _int_malloc, posix_memalign, aligned_alloc, do_rallocx, do_mallocx, mi_malloc, mi_calloc, # C static/thread-local initialization wrappers __cxx_global_var_init, __cxa_thread_atexit_impl, # ClickHouse allocator wrappers — informative only as callers, not as leaf DB::Memory, Memory::newImpl, Allocatorfalse, Allocatortrue, allocNoTrack, PODArrayBase::realloc, PODArrayBase::alloc, # Third-party allocators CRYPTO_malloc, # STL internals std::__detail::_Hash_node, std::_Rb_tree, std::vector, std::string::, # STL and PODArray wrappers — noise for leaf analysis std::__1::, DB::PODArrayBase, ) def is_noise(frame): return (any(frame.startswith(p) for p in JEMALLOC_PREFIXES) or any(s in frame for s in ALLOC_SUBSTRINGS)) def meaningful_leaf(frames): # Walk from innermost (last) frame upward, skipping allocator/profiling noise. # In jemalloc collapsed format frames are outermost-first, so the bottom of # the stack (profiling infra raw allocators) is at the end of the list. for f in reversed(frames): if f and not is_noise(f): return f return frames[-1] if frames else (unknown) def meaningful_caller(frames): Second non-noise frame from the bottom. found_leaf False for f in reversed(frames): if f and not is_noise(f): if found_leaf: return f found_leaf True return None def shorten(frame): s re.sub(r[^]{40,}, ..., frame) s s.replace((anonymous namespace), {anon}) s re.sub(r\(.*, , s) s s.replace({anon}, (anonymous namespace)) return s[:120] for v, stack in traces: frames [f for f in stack.split(;) if f] leaf meaningful_leaf(frames) by_leaf[shorten(leaf)] v caller meaningful_caller(frames) if caller: by_caller[shorten(caller)] v print( TOP 25 ALLOCATING FUNCTIONS (first non-trivial frame from bottom) ) for label, bucket in [(Leaf (allocator call site), by_leaf), (Caller of leaf, by_caller)]: print(f\n--- {label} ---) for fn, v in sorted(bucket.items(), keylambda x: -x[1])[:25]: mb v / 1024 / 1024 pct 100 * v / total print(f {mb:8.2f} MB {pct:5.1f}% {fn}) EOF5. 噪声过滤规则背后的 ClickHouse 分配器源码原理三个脚本共享一份噪声清单理解它才能真正读懂输出。在 ClickHouse 中一条分配路径的典型形态是业务代码 → DB::Memory 系列 / Allocator → je_*jemalloc。仓库源码可直接印证每一层的含义je_malloc/je_calloc/je_realloc/je_posix_memalign/je_aligned_alloc等je_*前缀jemalloc 导出 API。ClickHouse 在 src/Common/malloc.cpp 中以extern C重定义malloc等标准函数并在内部调用je_malloc先通过Memory::trackMemoryFromC记录AllocationTrace再真正分配——这正是为何 raw 分配器帧永远贴着栈底、属于基础设施噪声。operator new/__libcpp_operator_new/__libc_malloc/_int_malloclibc/libstdc 的分配包装不携带业务信息。DB::Memory、Allocatorfalse/true、Memory::newImpl、allocNoTrackClickHouse 自有内存基座。src/Common/Allocator.h 的class Allocator有四个实例化组合ClearMemory, MMAP等见其底部 extern template 声明。在 collapsed 分析中它们是有用的调用者能说明走了 mmap 还是 malloc 路径但作为叶则价值有限因此三个脚本都将其列为噪声。PODArrayBase::realloc/PODArrayBase::allocClickHouse 高性能动态数组 src/Common/PODArray.h 的扩容入口。脚本注释明确提示若分析结果中do_rallocx/PODArray::realloc占比异常高往往是过度扩容 / 碎片化的信号属于 Actionable Findings 的重点观察对象。CRYPTO_mallocOpenSSL 第三方分配器std::vector、std::string::、std::_Rb_tree、_Hash_node、std::__1::STL 内部节点/缓冲对叶子是谁的问题无贡献。而 Agent B 的SKIP_OUTER与 Agent C 的JEMALLOC_PREFIXES注释也再次确认了方向collapsed 格式中帧是最外层在前prof_backtrace/prof_alloc_prep等 profiling 基建与 raw 分配器总是在列表末尾因此所有脚本都reversed(frames)后从底部向上找第一个非噪声帧作为 leaf。6. Step 3 —— 综合三路结果并按子系统归组前提约束Step 3 必须在 Step 2 的三个代理全部返回TaskOutput 已读回之后才能开始。综合时利用三类输入分工Agent ATop 25 栈 Top 10 完整调用链提供看全链路的素材Agent B最外层帧 —— 回答why哪条业务操作触发了分配Agent C叶函数 —— 回答how哪段代码实际分配。真正的价值在于语义归组。文档给出一个非常典型的反例来强调不能只看叶子函数做机械归类。例如AggregatedDataVariants::init若由HashedDictionary::loadData调用应归入Dictionary Loading字典加载而非Aggregation聚合Arena::addMemoryChunk若出现在 merge pipeline 中应归入Merges合并而非Arena。判断依据必须是完整调用路径的上下文而不是孤立的函数名。最终报告应包含五部分汇总统计总量total、去重栈数trace countTop 分配方表格Top 15 条栈 可读的简短描述子系统分解与 ASCII 条形图将 Top 25 栈以及叶函数数据按完整调用路径归入语义子系统如 Part Loading、Dictionary Loading、Query Execution、Backup Restore、Merges Mutations、File Cache、IO Buffers、Replication、System Logs 等并输出 ASCII 条形图无法归类的进(other)Top 3–5 条可行动发现例如哪个子系统意外地占据了主导地位是否存在单笔分配占比畸大5% of total重复模式如多种系统日志类型各自预留了大缓冲——对应 ClickHouse 的 system log 表如 src/Storages/System 下的系列表结构碎片化或过度扩容迹象do_rallocx/PODArray::realloc占比过高后续下钻问题清单把不确定的部分转成用户可以继续调查的问题。7. Step 4 —— 提供下钻选项报告呈现后通过AskUserQuestion询问「What would you like to do next?」备选包括四个下钻动作与退出选项 1钻入某个子系统选中后再次AskUserQuestion确认子系统名然后后台启动 Bash 子代理运行 Python 脚本按该子系统关键字过滤所有栈、按值降序输出、打印前 5 条的完整调用栈并给出子系统小计。等待 TaskOutput 后将原始输出交给general-purpose子代理做摘要。选项 2展示 Top N 完整栈确认 N默认建议 10后台启动 Bash 子代理解析文件、按值排序取前 N逐条输出 rank / MB / 百分比与带深度索引的反转完整调用栈再交给general-purpose子代理写叙述式摘要。选项 3按关键字搜索栈通过AskUserQuestion获取关键字并行启动两个后台代理Agent XBash过滤并聚合所有命中栈 —— 总字节、条数、Top 20 大小、Top 5 完整栈Agent YBash扫描所有包含关键字的帧抽取其相邻帧共现函数用于推荐相关调用路径。两者都返回后合并输出交给general-purpose子代理综合。选项 4生成火焰图 SVG后台 Bash 子代理执行要求本机装有 flamegraph.plflamegraph.pl --title Allocation Profile --countname bytes --width 1800 \ PATH_TO_FILE /tmp/alloc_flamegraph.svg等待完成后报告输出路径/tmp/alloc_flamegraph.svg提醒用户在浏览器中打开查看。选项 5Done结束不再继续分析。所有下钻动作的强制纪律原文档以 IMPORTANT 强调分析一律放进 Task 子代理执行绝不在主上下文处理文件Bash 分析任务一律run_in_background: true启动并等待 TaskOutput原始输出必须先经general-purpose子代理转成易读摘要再展示给用户循环重复下钻回到AskUserQuestion直到用户选择 Done。8. 分析要点与注意事项在解读任何结果前务必记住原文档 Notes 中列出的这些约束数值语义collapsed 中的值是存活live字节——jemalloc heap profile 统计分配−释放高值直接反映 dump 时刻这些调用点的实时内存占用。这与第 2.1 节中OOM 时自动落盘的取证场景正好互补limit 触发时刻的 dump 即代表超限当时的 live 组成。帧序帧以最外层线程根在前、最内层分配器在后排列分析脚本为可读性会反转输出。符号问题若二进制缺少调试信息符号名可能是 mangled 的用jeprof --demangle或管道给cfilt还原。始终使用 Task 子代理profile 文件可能数百 MB严禁读入主上下文。脚本可独立执行所有 Python 分析脚本自包含可直接以python3 -运行落地时只需替换PATH_TO_FILE占位符。9. 把整套方法论接回 ClickHouse 实践这套分析工作流与 ClickHouse 内存体系高度咬合可在一次真实的内存排查中串起来确认服务以MALLOC_CONFbackground_thread:true,prof:true启动src/Common/Jemalloc.cpp当 MemoryTracker 在超限时打印Flushed memory profile to ...src/Common/MemoryTracker.cpp或手动通过prof.dump产出.heap文件若是 SQL 侧排查可直接查询system.jemalloc_profile_text拿 collapsed 文本src/Storages/System/StorageSystemJemallocProfileText.cpp将 collapsed 内容交给本文三路并行分析A 看全局与 Top 栈、B 看谁发起的、C 看谁在分配最后按 Part Loading / Dictionary Loading / Query Execution 等子系统归组形成带优先级与可行动结论的报告针对结论进入下钻或直接用flamegraph.pl渲染成火焰图进行人眼比对。至此从一份几百万行的 collapsed 文本到按子系统归组的可执行内存定位报告的完整闭环就建立起来了。整套工作流的核心价值在于它把业务语义what operation与代码事实what code两条正交维度拆开分析、再在综合阶段合并让为什么 ClickHouse 会吃掉这么多内存这个问题第一次可以沿着调用路径一步步回答下去。【免费下载链接】ClickHouseClickHouse® is a real-time analytics database management system项目地址: https://gitcode.com/GitHub_Trending/cli/ClickHouse创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考
返回列表