线上ClickHouse偶发进程被操作系统OOM Killer杀死的情况,问题排查记录如下。运行ClickHouse的容器内存大小是64GB,同时ClickHouse设置了进程可以使用的最大内存大小max_server_memory_usage为54GB,理论上应该不会产生OOM。
在这里有两个疑问:
- 为什么 max_server_memory_usage没有生效?
- ClickHouse的内存管理机制 MemoryTracker 能跟踪所有内存么?
内存管理机制
ClickHouse的内存管理主要体现在两个方面统计和限制。统计维度有:线程、查询、用户、进程,通过这些统计方便Admin和用户了解内存使用的情况;同时ClickHouse提供了一些参数可以限制内存使用的大小,比如:max_memory_usage、max_memory_usage_for_all_queries、max_server_memory_usage等。ClickHouse对内存的统计和限制都是基于内存追踪机制MemoryTracker。那么内存追踪是如何实现的?
树形的MemoryTracker
ClickHouse内存追踪的粒度从小到大有:Thread、Process(Query、merge task)、User、Global四个级别,统计的最小粒度是thread,多个thread会组成一个ThreadGroup,ThreadGroup用于服务一个Query,或者后台任务比如merge。Query可以归属到某个用户,后台任务是没有用户归属的。每个MemoryTracker都有一个parent,整体形成一个树形的结构,当MemoryTracker更新的时候,会同时更新它的parent。系统中有一个全局MemoryTracker total_memory_tracker,它是所有MemoryTracker的根,所以它能追踪到整个进程的内存。每个层级的MemoryTracker与对应的追踪类关系如下图:
小块内存追踪
ClickHouse重载了c++的new、delete operator,进程内所有通过new、delete的申请释放都会进行统计,参见:src/Common/new_delete.cpp
void * operator new(std::size_t size)
{
Memory::trackMemory(size);
return Memory::newImpl(size);
}
void operator delete(void * ptr) noexcept
{
Memory::untrackMemory(ptr);
Memory::deleteImpl(ptr);
}
...
内存分配和回收是一个特别高频的动作,如果个new、delete都做一次内存统计,那么会极大的浪费性能,针对这一点,ClickHouse做了一些优化:
1. 个线程内维护一个变量,变量不断累积在一定周期内的内存的变动,当内存变动超过阈值[-4M, 4M]的时候,更新线程的内存统计值,同时更新它的parent以及更上层的统计值;具体可以参考ThreadStatus::untracked_memory_limit;
2.统计的时候使在编码上做了一些优化,比如:使用unlikely和likely来优化分支预测的精准度,增加CPU指令Pipeline的效率。
大块内存追踪
对于大块内存的追踪,ClickHouse通过自定义的Allocator实现,参见:src/Common/Allocator.h,使用场景: PODArray, Arena和hash tables等。
/// Allocate memory range.
void * alloc(size_t size, size_t alignment = 0)
{
checkSize(size);
CurrentMemoryTracker::alloc(size);
return allocNoTrack(size, alignment);
}
/// Free memory range.
void free(void * buf, size_t size)
{
checkSize(size);
freeNoTrack(buf, size);
CurrentMemoryTracker::free(size);
}
内存追踪的覆盖范围
如此的内存追踪方式可以追踪到绝大部分内存,但是也有些场景覆盖不到,以下是两中内存追踪方式的覆盖范围:
- 通过new/delete申请释放的内存(一般是小块内存),包括ClickHouse本身和第三方库,可以全部追踪到。
- 非new/delete的内存(一般是大块内存):可以覆盖ClickHouse本身的场景,但是不能覆盖第三方库的场景。
对于覆盖不到的情况可能会使内存参数失真,进而导致一些内存限制参数失真,比如:max_server_memory_usage。
针对进程使用的总内存(根MemoryTracker)统计,ClickHouse采用了一个定时修正的方式。通过AsynchronousMetrics
每1分钟根据进程实际使用的内存数量进行修正。修正逻辑如下:
// AsynchronousMetrics::update
MemoryStatisticsOS::Data data = memory_stat.get();
new_values["MemoryVirtual"] = data.virt;
new_values["MemoryResident"] = data.resident;
new_values["MemoryShared"] = data.shared;
new_values["MemoryCode"] = data.code;
new_values["MemoryDataAndStack"] = data.data_and_stack;
{
Int64 amount = total_memory_tracker.get();
Int64 peak = total_memory_tracker.getPeak();
Int64 new_amount = data.resident;
total_memory_tracker.set(new_amount); // 内存修正
CurrentMetrics::set(CurrentMetrics::MemoryTracking, new_amount);
}
上图是生产环境中跟MemoryTracker内存统计值与内存修正过程中实际内存大小的差异,从中可以看出,存在统计偏差,max_server_memory_usage=
52GB时内存偏差大的时候有14GB,所以即便配置了max_server_memory_usage
有可能有如下情况:
- 内存没有达到
max_server_memory_usage
限制但是内存超了,被OS OOM killer kill了 - 内存达到了
max_server_memory_usage
限制,但是实际内存没有用那么多
小结:
树形的MemoryTracker模型能够很好的追踪:线程、查询、用户、进程四个层级的内存,但是由于统计机制上的缺陷导致了一些统计的偏差,从而导致有些内存限制参数可能存在的失真的情况,针对这一点ClickHouse做了定时修正,所以整体依然能够有效控制内存。
本作品采用 知识共享署名 4.0 国际许可协议 进行许可, 转载时请注明原文链接。