关于内存管理:一文揭秘高效稳定的-Apache-Doris-内存管理机制

63次阅读

共计 7869 个字符,预计需要花费 20 分钟才能阅读完成。

作者:SelectDB 高级研发工程师、Apache Doris Committer 邹新一

背景

Apache Doris 作为基于 MPP 架构的 OLAP 数据库,数据从磁盘加载到内存后,会在算子间流式传递并计算,在内存中存储计算的两头后果,这种形式缩小了频繁的磁盘 I/O 操作,充分利用多机多核的并行计算能力,可在性能上出现微小劣势。

在面临内存资源耗费微小的简单计算和大规模作业时,无效的内存调配 、统计、 管控对于零碎的稳定性起着非常要害的作用——更快的内存调配速度将无效晋升查问性能,通过对内存的调配、跟踪与限度能够保障不存在内存热点,及时精确地响应内存不足并尽可能躲避 OOM 和查问失败,这一系列机制都将显著进步零碎稳定性;更准确的内存统计,也是大查问落盘的根底。

问题和思考

  • 在内存短缺时内存治理通常对用户是无感的,但实在场景中往往面临着各式各样的极其 Case,这些都将为内存性能和稳定性带来挑战,在过来版本中,用户在应用 Apache Doris 时在内存治理方面遭逢了以下挑战:
    • OOM 导致 BE 过程解体。内存不足时,用户能够承受执行性能稍慢一些,或是让后到的工作排队,或是让大量工作失败,总之心愿无效限度过程的内存应用而不是宕机;
    • BE 过程占用内存较高。用户反馈 BE 过程占用了较多内存,但很难找到对应内存耗费大的查问或导入工作,也无奈无效限度单个查问的内存应用;
    • 用户很难正当的设置每个 query 的内存大小,所以经常出现内存还很短缺,然而 query 被 cancel 了;
    • 高并发性能降落重大,也无奈疾速定位到内存热点;
    • 构建 HashTable 的两头数据不反对落盘,两个大表的 Join 因为内存超限无奈实现。

针对开发者而言又存在另外一些问题,比方内存数据结构性能重叠且应用凌乱,MemTracker 的构造难以了解且手动统计易出错等。

针对以上问题,咱们经验了过来多个版本的迭代与优化。从 Apache Doris 1.1.0 版本开始,咱们逐步对立内存数据结构、重构 MemTracker、开始反对查问内存软限,并引入过程内存超限后的 GC 机制,同时优化了高并发的查问性能等。在 Apache Doris 1.2.4 版本中,Apache Doris 内存管理机制已趋于欠缺,在 Benchmark、压力测试和实在用户业务场景的反馈中,根本打消了内存热点以及 OOM 导致 BE 宕机的问题,同时可定位内存 Top 的查问、反对查问内存灵便限度。而在最新的 Doris 2.0 alpha 版本中,咱们实现了查问的异样平安,并将逐渐配合 Pipeline 执行引擎和两头数据落盘 让用户不再受内存不足困扰。

在此咱们将零碎介绍 Apache Doris 在内存治理局部的实现与优化。

内存治理优化与实现

Allocator 作为零碎中大块内存申请的对立的入口从零碎申请内存,并在申请过程中应用 MemTracker 跟踪内存申请和开释的大小,执行算子所需批量申请的大内存将交由不同的数据结构治理,并在适合的机会干涉限度内存调配的过程,确保内存申请的高效可控。

内存调配

晚期 Apache Doris 内存调配的核心理念是尽可能接管零碎内存本人治理,应用通用的全局缓存满足大内存申请的性能要求,并在 LRU Cache 中缓存 Data Page、Index Page、RowSet Segment、Segment Index 等数据。

随着 Doris 应用 Jemalloc 替换 TCMalloc,Jemalloc 的并发性能已足够优良,所以不在 Doris 外部持续全面接管零碎内存,转而针对内存热点地位的特点,应用多种内存数据结构并接入对立的零碎内存接口,实现内存对立治理和部分的内存复用。

内存数据结构

查问执行过程中大块内存的调配次要应用 Arena、HashTable、PODArray 这三个数据结构治理。

  1. Arena

Arena 是一个内存池,保护一个内存块列表,并从中分配内存以响应 alloc 申请,从而缩小从零碎申请内存的次数以晋升性能,内存块被称为 Chunk,在内存池的整个生命周期内存在,在析构时对立开释,这通常和查问生命周期雷同,并反对内存对齐,次要用于保留 Shuffle 过程中序列化 / 反序列化数据、HashTable 中序列化 Key 等。

Chunk 初始 4096 字节,外部应用游标记录调配过的内存地位,如果以后 Chunk 残余大小无奈满足以后内存申请,则申请一个新的 Chunk 增加到列表中,为缩小从零碎申请内存的次数,在以后 Chunk 小于 128M 时,每次新申请的 Chunk 大小加倍,在以后 Chunk 大于 128M 时,新申请的 Chunk 大小在满足本次内存申请的前提下至少额定调配 128M,避免浪费过多内存,默认之前的 Chunk 不会再参加后续 alloc。

  1. HashTable

Doris 中的 HashTable 次要在 Hash Join、聚合、汇合运算、窗口函数中利用,次要应用的 PartitionedHashTable 最多蕴含 16 个子 HashTable,反对两个 HashTable 的并行化合并,每个子 Hash Join 独立扩容,预期可缩小总内存的应用,扩容期间的提早也将被摊派。

在 HashTable 小于 8M 时将以 4 的倍数扩容,在 HashTable 大于 8M 时将以 2 的倍数扩容,在 HashTable 小于 2G 时扩容因子为 50%,即在 HashTable 被填充到 50% 时触发扩容,在 HashTable 大于 2G 后扩容因子被调整为 75%,为了避免浪费过多内存,在构建 HashTable 前通常会根据数据量预扩容。此外 Doris 为不同场景设计了不同的 HashTable,比方聚合场景应用 PHmap 优化并发性能。

  1. PODArray

PODArray 是一个 POD 类型的动静数组,与 std::vector 的区别在于不会初始化元素,反对局部 std::vector 的接口,同样反对内存对齐并以 2 的倍数扩容,PODArray 析构时不会调用每个元素的析构函数,而是间接开释掉整块内存,次要用于保留 String 等 Column 中的数据,此外在函数计算和表达式过滤中也被大量应用。

对立的内存接口

Allocator 作为 Arena、PODArray、HashTable 的对立内存接口,对大于 64M 的内存应用 MMAP 申请,并通过预取减速性能,对小于 4K 的内存间接 malloc/free 从零碎申请,对大于 4K 小于 64M 的内存,应用一个通用的缓存 ChunkAllocator 减速,在 Benchmark 测试中这可带来 10% 的性能晋升,ChunkAllocator 会优先从以后 Core 的 FreeList 中无锁的获取一个指定大小的 Chunk,若不存在则有锁的从其余 Core 的 FreeList 中获取,若仍不存在则从零碎申请指定内存大小封装为 Chunk 后返回。

Allocator 应用通用内存分配器申请内存,在 Jemalloc 和 TCMalloc 的抉择上,Doris 之前在高并发测试时 TCMalloc 中 CentralFreeList 的 Spin Lock 能占到查问总耗时的 40%,尽管敞开 aggressive memory decommit 能无效晋升性能,但这会节约十分多的内存,为此不得不独自用一个线程定期回收 TCMalloc 的缓存。Jemalloc 在高并发下性能优于 TCMalloc 且成熟稳固,在 Doris 1.2.2 版本中咱们切换为 Jemalloc,调优后在大多数场景下性能和 TCMalloc 持平,并应用更少的内存,高并发场景的性能也有显著晋升。

内存复用

Doris 在执行层做了大量内存复用,可见的内存热点根本都被屏蔽。比方对数据块 Block 的复用贯通 Query 执行的始终;比方 Shuffle 的 Sender 端始终保持一个 Block 接收数据,一个 Block 在 RPC 传输中,两个 Block 交替应用;还有存储层在读一个 Tablet 时复用谓词列循环读数、过滤、拷贝到下层 Block、Clear;导入 Aggregate Key 表时缓存数据的 MemTable 达到肯定大小预聚合膨胀后持续写入,等等。

此外 Doris 会在数据 Scan 开始前根据 Scanner 个数和线程数预调配一批 Free Block,每次调度 Scanner 时会从中获取一个 Block 并传递到存储层读取数据,读取实现后会将 Block 放到生产者队列中,供下层算子生产并进行后续计算,下层算子将数据拷走后会将 Block 从新放回 Free Block 中,用于下次 Scanner 调度,从而实现内存复用,数据 Scan 实现后 Free Block 会在之前预调配的线程对立开释,防止内存申请和开释不在同一个线程而导致的额定开销,Free Block 的个数肯定水平上还管制着数据 Scan 的并发。

内存跟踪

Doris 应用 MemTracker 跟踪内存的申请和开释来实时剖析过程和查问的内存热点地位,MemTracker 记录着每一个查问、导入、Compaction 等工作以及 Cache、TabletMeta 等全局对象的内存大小,反对手动统计或 MemHook 主动跟踪,反对在 Web 页面查看实时的 Doris BE 内存统计。

MemTracker 构造

过来 Doris MemTracker 是具备档次关系的树状构造,自上而下蕴含 process、query pool、query、fragment instance、exec node、exprs/hash table/etc. 等多层,上一层 MemTracker 是下一层的 Parent,开发者应用时需理清它们之间的父子关系,而后手动计算内存申请和开释的大小并生产 MemTracker,此时会同时生产这个 MemTracker 的所有 Parent。这依赖开发者时刻关注内存应用,后续迭代过程中若 MemTracker 统计谬误将产生连锁反应,对 Child MemTracker 的统计误差会一直累积到他的 Parent MemTracker 中,导致整体后果不可信。

在 Doris 1.2.0 中引入了新的 MemTracker 构造,去掉了 Fragment、Instance 等不必要的层级,依据应用形式分为两类,第一类 Memtracker Limiter,在每个查问、导入、Compaction 等工作和全局 Cache、TabletMeta 惟一,用于观测和管制内存应用;第二类 MemTracker,次要用于跟踪查问执行过程中的内存热点,如 Join/Aggregation/Sort/ 窗口函数中的 HashTable、序列化的两头数据等,来剖析查问中不同算子的内存应用状况,以及用于导入数据下刷的内存管制。后文没独自指明的中央,统称二者为 MemTracker。

二者之间的父子关系只用于快照的打印,应用 Lable 名称关联,相当于一层软链接,不再依赖父子关系同时生产,生命周期互不影响,缩小开发者了解和应用的老本。所有 MemTracker 寄存在一组 Map 中,并提供打印所有 MemTracker Type 的快照、打印 Query/Load/Compaction 等 Task 的快照、获取以后应用内存最多的一组 Query/Load、获取以后适量应用内存最多的一组 Query/Load 等办法。

MemTracker 统计形式

为统计某一段执行过程的内存,将一个 MemTracker 增加到以后线程 Thread Local 的一个栈中,应用 MemHook 重载 Jemalloc 或 TCMalloc 的 malloc/free/realloc 等办法,获取本次申请或开释内存的理论大小并记录在以后线程的 Thread Local 中,在以后线程内存使用量累计到肯定值时生产栈中的所有 MemTracker,这段执行过程完结时会将这个 MemTracker 从栈中弹出,栈底通常是整个查问或导入惟一的 Memtracker,记录整个查问执行过程的内存。

上面以一个简化的查问执行过程为例:

  • Doris BE 启动后所有线程的内存将默认记录在 Process MemTracker 中。
  • Query 提交后,将 Query MemTracker 增加到 Fragment 执行线程的 Thread Local Storage(TLS) Stack 中。
  • ScanNode 被调度后,将 ScanNode MemTracker 持续增加到 Fragment 执行线程的 TLS Stack 中,此时线程申请和开释的内存会同时记录到 Query MemTracker 和 ScanNode MemTracker。
  • Scanner 被调度后,将 Query MemTracker 和 Scanner MemTracker 同时增加到 Scanner 线程的 TLS Stack 中。
  • Scanner 完结后,将 Scanner 线程 TLS Stack 中的 MemTracker 全副移除,随后 ScanNode 调度完结,将 ScanNode MemTracker 从 Fragment 执行线程中移除。随后 AggregationNode 被调度时同样将 MemTracker 增加到 Fragment 执行线程中,并在调度完结后将本人的 MemTracker 从 Fragment 执行线程移除。
  • 后续 Query 完结后,将 Query MemTracker 从 Fragment 执行线程 TLS Stack 中移除,此时 Stack 应为空,在 QueryProfile 中即可看到 Query 整体、ScanNode、AggregationNode 等执行期间内存的峰值。

可见为跟踪一个查问的内存应用,在查问所有线程启动时将 Query MemTracker 绑定到线程 Thread Local,在算子执行的代码区间内,将算子 MemTracker 同样绑定到线程 Thread Local,尔后这些线程所有的内存申请和开释都将记录在这个查问中,在算子调度完结和查问完结时别离解除绑定,从而统计一个查问生命周期内各个算子和查问整体的内存应用。

期待开发者能将 Doris 执行过程中长时间持有的内存尽可能多地统计到 MemTracker 中,这有助于内存问题的剖析,不用放心统计误差,这不会影响查问整体统计的准确性,也不用放心影响性能,在 ThreadLocal 中按批生产 MemTracker 对性能的影响微不足道。

MemTracker 应用

通过 Doris BE 的 Web 页面能够看到实时的内存统计后果,将 Doris BE 内存分为了 Query/Load/Compaction/Global 等几局部,并别离展现它们以后应用的内存和历史的峰值内存,具体应用办法和字段含意可参考 Doris 治理手册:

Global 类型的 MemTracker 中,包含全局的 Cache、TabletMeta 等。

Query 类型的 MemTracker 中,能够看到 Query 和其算子以后应用的内存和峰值内存,通过 Label 将他们关联,历史查问的内存统计能够查看 FE 审计日志或 BE INFO 日志。

内存限度

内存不足导致 OOM 引起 BE 宕机或查问大量失败始终是用户的痛点,为此在 Doris BE 大多数内存被跟踪后,开始着手改良查问和过程的内存限度,在要害内存调配时检测内存限度来保障内存可控。

查问内存限度

每个查问都能够指定内存下限,查问运行过程中内存超过下限会触发 Cancel。从 Doris 1.2 开始查问反对内存超发(overcommit),旨在容许查问设置更灵便的内存限度,内存短缺时即便查问内存超过下限也不会被 Cancel,所以通常用户无需关注查问内存应用。内存不足时,任何查问都会在尝试调配新内存时期待一段时间,如果期待过程中内存开释的大小满足需要,查问将继续执行,否则将抛出异样并终止查问。

Doris 2.0 初步实现了查问的异样平安,这使得任何地位在发现内存不足时随时可抛出异样并终止查问,而无需依赖后续执行过程中异步的查看 Cancel 状态,这将使查问终止的速度更快。

过程内存限度

Doris BE 会定时从零碎获取过程的物理内存和零碎以后残余可用内存,并收集所有查问、导入、Compaction 工作 MemTracker 的快照,当 BE 过程内存超限或零碎残余可用内存有余时,Doris 将开释 Cache 和终止局部查问或导入来开释内存,这个过程由一个独自的 GC 线程定时执行。

若 Doris BE 过程内存超过 SoftMemLimit(默认零碎总内存的 81%)或零碎残余可用内存低于 Warning 水位线(通常不大于 3.2GB)时触发 Minor GC,此时查问会在 Allocator 分配内存时暂停,同时导入强制下刷缓存中的数据,并开释局部 Data Page Cache 以及过期的 Segment Cache 等,若开释的内存不足过程内存的 10%,若启用了查问内存超发,则从内存超发比例大的查问开始 Cancel,直到开释 10% 的过程内存或没有查问可被 Cancel,而后调低零碎内存状态获取距离和 GC 距离,其余查问在发现残余内存后将继续执行。

若 BE 过程内存超过 MemLimit(默认零碎总内存的 90%)或零碎残余可用内存低于 Low 水位线(通常不大于 1.6GB)时触发 Full GC,此时除上述操作外,导入在强制下刷缓存数据时也将暂停,并开释全副 Data Page Cache 和大部分其余 Cache,如果开释的内存不足 20%,将开始按肯定策略在所有查问和导入的 MemTracker 列表中查找,顺次 Cancel 内存占用大的查问、内存超发比例大的导入、内存占用大的导入,直到开释 20% 的过程内存后,调高零碎内存状态获取距离和 GC 距离,其余查问和导入也将继续执行,GC 的耗时通常在几百 us 到几十 ms 之间。

总结布局

通过上述一系列的优化,高并发性能和稳定性有明显改善,OOM 导致 BE 宕机的次数也明显降低,即便产生 OOM 通常也可根据日志定位内存地位,并针对性调优,从而让集群复原稳固,对查问和导入的内存限度也更加灵便,在内存短缺时让用户无需感知内存应用。

后续咱们将让 Apache Doris 从“能无效限度内存”转为“内存超限时能实现计算”,尽可能减少查问因内存不足被 Cancel,次要工作将聚焦在异样平安、资源组内存隔离、两头数据落盘上:

  1. 查问和导入反对异样平安,从而能够随时随地的抛出内存调配失败的 Exception,内部捕捉后触发异样解决或开释内存,而不是在内存超限后单纯依赖异步 Cancel。
  2. Pipeline 调度中将反对资源组内存隔离,用户能够划分资源组并指定优先级,从而更灵便的治理不同类型工作应用的内存,资源组外部和资源组之间同样反对内存的“硬限”和“软限”,并在内存不足时反对排队机制。
  3. Doris 将实现对立的落盘机制,反对 Sort,Hash Join,Agg 等算子的落盘,在内存缓和时将两头数据长期写入磁盘并开释内存,从而在无限的内存空间下,对数据分批解决,反对超大数据量的计算,在防止 Cancel 让查问能跑进去的前提下尽可能保障性能。

以上方向的工作都已处于布局或开发中,如果有小伙伴对以上方向感兴趣,也欢送参加到社区中的开发来。期待有更多人参加到 Apache Doris 社区的建设中,欢送你的退出!

为了让用户能够体验社区开发的最新个性,同时保障最新性能能够播种到更广范畴的应用反馈,咱们建设了 2.0 Alpha 版本的专项反对群,请大家戳此填写申请,欢送宽广社区用户在应用最新版本过程中多多反馈应用意见,帮忙 Apache Doris 继续改良。

Apache Doris 2.0 Alpha 版本:https://github.com/apache/doris/releases/tag/2.0.0-alpha1

# 相干链接:

SelectDB 官网

https://selectdb.com

Apache Doris 官网

http://doris.apache.org

正文完
 0