分类
Articles

ClickHouse内幕(4)内存管理机制

线上ClickHouse偶发进程被操作系统OOM Killer杀死的情况,问题排查记录如下。运行ClickHouse的容器内存大小是64GB,同时ClickHouse设置了进程可以使用的最大内存大小max_server_memory_usage为54GB,理论上应该不会产生OOM。

在这里有两个疑问:

  1. 为什么 max_server_memory_usage没有生效?
  2. ClickHouse的内存管理机制 MemoryTracker 能跟踪所有内存么?

内存管理机制

ClickHouse的内存管理主要体现在两个方面统计和限制。统计维度有:线程、查询、用户、进程,通过这些统计方便Admin和用户了解内存使用的情况;同时ClickHouse提供了一些参数可以限制内存使用的大小,比如:max_memory_usagemax_memory_usage_for_all_queriesmax_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);
    }

内存追踪的覆盖范围

如此的内存追踪方式可以追踪到绝大部分内存,但是也有些场景覆盖不到,以下是两中内存追踪方式的覆盖范围:

  1. 通过new/delete申请释放的内存(一般是小块内存),包括ClickHouse本身和第三方库,可以全部追踪到。
  2. 非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 有可能有如下情况:

  1. 内存没有达到max_server_memory_usage限制但是内存超了,被OS OOM killer kill了
  2. 内存达到了max_server_memory_usage限制,但是实际内存没有用那么多

小结:

树形的MemoryTracker模型能够很好的追踪:线程、查询、用户、进程四个层级的内存,但是由于统计机制上的缺陷导致了一些统计的偏差,从而导致有些内存限制参数可能存在的失真的情况,针对这一点ClickHouse做了定时修正,所以整体依然能够有效控制内存。

本作品采用 知识共享署名 4.0 国际许可协议 进行许可, 转载时请注明原文链接。

发表回复

您的电子邮箱地址不会被公开。 必填项已用 * 标注