paint-brush
告别 OOM 崩溃经过@wydfy111
719 讀數
719 讀數

告别 OOM 崩溃

经过 Jiafeng Zhang11m2023/06/12
Read on Terminal Reader

太長; 讀書

一个更健壮和灵活的内存管理解决方案,在内存分配、内存跟踪和内存限制方面进行了优化。
featured image - 告别 OOM 崩溃
Jiafeng Zhang HackerNoon profile picture

大数据查询任务的系统稳定性靠什么保证?它是一种有效的内存分配和监控机制。它是您如何加快计算速度、避免内存热点、及时响应内存不足以及最大限度地减少 OOM 错误的方法。




从数据库用户的角度来看,他们是如何遭受不良内存管理的困扰的?这是曾经困扰我们用户的事情列表:


  • OOM 错误导致后端进程崩溃。引用我们的一位社区成员的话:嗨,Apache Doris,当你内存不足时,可以放慢速度或让一些任务失败,但造成停机时间并不酷。


  • 后端进程消耗了太多内存空间,但没有办法找到确切的责任或限制单个查询的内存使用量。


  • 很难为每个查询设置合适的内存大小,因此即使有足够的内存空间,查询也有可能被取消。


  • 高并发查询异常慢,内存热点难以定位。


  • HashTable创建过程中的中间数据无法刷入磁盘,所以两个大表之间的join查询经常会因为OOM而失败。


幸运的是,那些黑暗的日子已经过去了,因为我们已经自下而上地改进了我们的内存管理机制。现在准备好;事情会很密集。

内存分配

在 Apache Doris 中,我们有一个唯一的内存分配接口: Allocator 。它会在它认为合适的时候进行调整,以保持内存使用的高效性和可控性。


此外,MemTrackers 用于跟踪分配或释放的内存大小,并且三种不同的数据结构负责运算符执行中的大内存分配(我们将立即了解它们)。




内存中的数据结构

由于不同的查询在执行时有不同的内存热点模式,Apache Doris 提供了三种不同的内存数据结构: ArenaHashTablePODArray 。它们都在分配器的统治之下。



  1. 竞技场

Arena 是一个内存池,它维护一个块列表,这些块将根据分配器的请求进行分配。块支持内存对齐。它们存在于 Arena 的整个生命周期中,并在销毁时被释放(通常是在查询完成时)。


Chunks主要用于存储Shuffle时序列化或反序列化后的数据,或者HashTables中序列化后的Key。


块的初始大小为 4096 字节。如果当前块小于请求的内存,一个新的块将被添加到列表中。


如果当前 chunk 小于 128M,新的 chunk 将其大小增加一倍;如果大于 128M,则新块最多比要求的大 128M。


不会为新请求分配旧的小块。有一个游标标记已分配块和未分配块之间的分界线。


  1. 哈希表

哈希表适用于哈希连接、聚合、集合操作和窗口函数。 PartitionedHashTable 结构支持不超过 16 个子 HashTable。它还支持HashTables的并行合并,每个子Hash Join可以独立扩展。


这些可以减少整体内存使用和缩放引起的延迟。


如果当前HashTable小于8M,则缩放4倍;

如果大于8M,则缩放2倍;

如果小于2G,会在50%满的时候缩放;

如果大于2G,会在75%满的时候缩放。


新创建的哈希表将根据它将拥有的数据量进行预先缩放。我们还针对不同的场景提供了不同类型的 HashTable。例如,对于聚合,您可以应用 PHmap。


  1. POD阵列

PODArray,顾名思义,就是POD的动态数组。它与std::vector区别在于 PODArray 不初始化元素。它支持内存对齐和std::vector的一些接口。


它按 2 倍缩放。在析构中,不是为每个元素调用析构函数,而是释放整个 PODArray 的内存。 PODArray 主要用于按列保存字符串,适用于很多函数计算和表达式过滤。

内存接口

Allocator作为唯一协调Arena、PODArray和HashTable的接口,对大于64M的请求执行内存映射(MMAP)分配。


小于4K的会直接通过malloc/free从系统中分配;而介于两者之间的将通过通用缓存 ChunkAllocator 加速,根据我们的基准测试结果,这会带来 10% 的性能提升。


ChunkAllocator 会尝试以无锁的方式从当前核心的 FreeList 中获取指定大小的块;如果不存在这样的块,它将以基于锁的方式从其他内核尝试;如果仍然失败,它将向系统请求指定的内存大小并将其封装成一个块。


在体验了两者之后,我们选择了 Jemalloc 而不是 TCMalloc。我们在高并发测试中尝试了 TCMalloc,发现 CentralFreeList 中的 Spin Lock 占用了总查询时间的 40%。


禁用“积极的内存回收”让事情变得更好,但这带来了更多的内存使用,所以我们不得不使用一个单独的线程来定期回收缓存。另一方面,Jemalloc 在高并发查询中性能更高、更稳定。


在针对其他场景进行微调后,它提供了与 TCMalloc 相同的性能,但消耗的内存更少。

内存重用

内存复用在 Apache Doris 的执行层广泛执行。例如,数据块将在整个查询执行过程中重复使用。在Shuffle过程中,Sender端会有两个block,它们交替工作,一个接收数据,一个在RPC传输。


Doris 在读取一个 tablet 时,会复用 predicate 列,实现循环读取,过滤,将过滤后的数据复制到上块,然后清空。


在向Aggregate Key表中摄取数据时,一旦缓存数据的MemTable达到一定大小,就会进行预聚合,然后写入更多的数据。


在数据扫描中也执行内存重用。在扫描开始之前,一些空闲块(取决于扫描器和线程的数量)将被分配给扫描任务。


每次扫描器调度时,都会将其中一个空闲块传递给存储层进行数据读取。


数据读取完成后,块会被放入生产者队列,供上层算子在后续计算中消费。一旦上层操作员从块中复制了计算数据,该块将返回到空闲块中以进行下一次扫描器调度。


预分配空闲块的线程也将负责在数据扫描后释放它们,因此不会有额外的开销。空闲块的数量在某种程度上决定了数据扫描的并发性。

记忆追踪

Apache Doris 在分析内存热点的同时,使用 MemTrackers 跟踪内存的分配和释放。 MemTrackers记录了每个数据查询、数据摄取、数据压缩任务,以及每个全局对象(如Cache和TabletMeta)的内存大小。


它支持手动计数和 MemHook 自动跟踪。用户可以在网页上查看 Doris 后台的实时内存使用情况。

MemTracker 的结构

Apache Doris 1.2.0之前的MemTracker系统是一个层级树状结构,由process_mem_tracker、query_pool_mem_tracker、query_mem_tracker、instance_mem_tracker、ExecNode_mem_tracker等组成。


两个相邻层的 MemTracker 是父子关系。因此,子 MemTracker 中的任何计算错误都会向上累积,并导致更大范围的不可信。



在 Apache Doris 1.2.0 及更新版本中,我们简化了 MemTracker 的结构。 MemTrackers只根据作用分为两种: MemTracker Limiter和其他。


MemTracker Limiter,监控内存使用情况,在每个query/ingestion/compaction任务和全局对象中都是唯一的;而其他的MemTrackers则是追踪查询执行中的内存热点,例如Join/Aggregation/Sort/Window函数中的HashTables和序列化中的中间数据,以了解不同算子的内存使用情况或为内存控制提供参考数据刷新。


MemTracker Limiter 与其他MemTracker 的父子关系仅体现在快照打印中。您可以将这种关系视为符号链接。它们不会同时被消费,一个的生命周期不会影响另一个的生命周期。


这使开发人员更容易理解和使用它们。


MemTrackers(包括MemTracker Limiter等)被放入一组Map中。它们允许用户打印整体 MemTracker 类型快照、Query/Load/Compaction 任务快照,并找出内存使用最多或内存过度使用最多的 Query/Load。



MemTracker 的工作原理

为了计算某次执行的内存使用情况,在当前线程的Thread Local中的一个栈中添加了一个MemTracker。 MemHook通过重新加载Jemalloc或TCMalloc中的malloc/free/realloc,获取实际分配或释放的内存大小,记录在当前线程的Thread Local中。


执行完成后,相关的 MemTracker 将从堆栈中删除。堆栈底部是 MemTracker,它记录了整个查询/加载执行过程中的内存使用情况。


现在,让我用一个简化的查询执行过程来解释一下。


  • 一个Doris后端节点启动后,所有线程的内存使用情况都会记录在Process MemTracker中。


  • 当一个查询被提交时,一个Query MemTracker将被添加到片段执行线程中的Thread Local Storage(TLS) Stack中。


  • 一旦一个 ScanNode 被调度,一个ScanNode MemTracker将被添加到片段执行线程中的 Thread Local Storage(TLS) Stack。然后,在此线程中分配或释放的任何内存都将记录到 Query MemTracker 和 ScanNode MemTracker 中。


  • 一个Scanner被调度后,一个Query MemTracker和一个Scanner MemTracker会被添加到Scanner线程的TLS Stack中。


  • 扫描完成后,扫描程序线程 TLS 堆栈中的所有 MemTracker 都将被删除。当ScanNode调度完成后,ScanNode MemTracker将从片段执行线程中移除。然后类似地,当一个聚合节点被调度时,一个AggregationNode MemTracker会被添加到分片执行线程TLS Stack中,并在调度完成后被移除。


  • 如果查询完成,则Query MemTracker将从片段执行线程TLS Stack中移除。此时,这个栈应该是空的。然后,从QueryProfile中,可以查看整个查询执行过程中以及每个阶段(扫描、聚合等)的内存使用峰值。



如何使用 MemTracker

Doris 后端网页演示实时内存使用情况,分为:Query/Load/Compaction/Global。显示当前内存消耗和峰值消耗。



Global类型包括Cache和TabletMeta的MemTrackers。



从Query types中可以看到当前query的当前内存消耗和峰值消耗以及涉及到的operators(从labels可以看出它们之间的关系)。历史查询的内存统计可以查看 Doris FE 审计日志或 BE INFO 日志。



内存限制

随着 Doris 后端广泛实施内存跟踪,我们离消除 OOM、导致后端停机和大规模查询失败的原因又近了一步。下一步是优化查询和进程的内存限制,以控制内存使用。

查询的内存限制

用户可以对每个查询设置内存限制。如果在执行期间超过该限制,查询将被取消。但是从1.2版本开始,我们允许了Memory Overcommit,这是一种更加灵活的内存限制控制。


如果有足够的内存资源,查询可以消耗超过限制的内存而不被取消,因此用户不必额外关注内存使用情况;如果没有,查询将等待直到分配新的内存空间,只有当新释放的内存不足以进行查询时,查询才会被取消。


而在 Apache Doris 2.0 中,我们已经实现了查询的异常安全。这意味着任何内存分配不足都会立即导致查询被取消,这样就省去了后续步骤检查“取消”状态的麻烦。

进程内存限制

Doris 后端会定期从系统中获取进程的物理内存和当前可用内存大小。同时,它收集所有 Query/Load/Compaction 任务的 MemTracker 快照。


如果后端进程超出内存限制或内存不足,Doris 会通过清除 Cache 并取消一些查询或数据摄取任务来释放一些内存空间。这些将由单独的 GC 线程定期执行。



如果消耗的进程内存超过 SoftMemLimit(默认为系统总内存的 81%),或者可用系统内存低于 Warning Water Mark(小于 3.2GB),将触发Minor GC


此时查询执行会在内存分配步骤暂停,数据摄取任务中的缓存数据会被强制刷新,部分Data Page Cache和过时的Segment Cache会被释放。


如果新释放的内存未覆盖进程内存的 10%,启用 Memory Overcommit 后,Doris 将开始取消最大“overcommitter”的查询,直到达到 10% 目标或取消所有查询。


然后,Doris 会缩短系统内存检查间隔和 GC 间隔。有更多内存可用后,查询将继续。


如果消耗的进程内存超出 MemLimit(默认为系统总内存的 90%),或者可用系统内存低于 Low Water Mark(小于 1.6GB),将触发Full GC


这时,数据摄取任务会停止,所有的Data Page Cache和其他大部分Cache都会被释放。


如果经过所有这些步骤,新释放的内存没有覆盖进程内存的 20%,Doris 将查看所有 MemTrackers,找到最耗内存的查询和摄取任务,并一一取消。


只有达到 20% 的目标后,系统内存检查间隔和 GC 间隔才会延长,并继续执行查询和摄取任务。 (一次垃圾收集操作通常需要数百微秒到几十毫秒。)

影响和结果

经过内存分配、内存跟踪、内存限制等方面的优化,我们大幅提升了Apache Doris作为实时分析数仓平台的稳定性和高并发性能。后台OOM crash现在已经是少见的场景了。


即使出现OOM,用户也可以根据日志定位问题根源,然后进行修复。此外,通过对查询和数据摄取更灵活的内存限制,用户无需在内存空间充足的情况下花费额外的精力来维护内存。


在下一阶段,我们计划确保在内存过量使用时完成查询,这意味着由于内存不足而不得不取消的查询更少。


我们将这个目标分解为具体的工作方向:异常安全、资源组间的内存隔离、中间数据的flush机制。


如果您想见见我们的开发人员,可以在这里找到我们