• 解决golang 的内存碎片问题


    解决golang 的内存碎片问题

    本文译自Why I encountered Go memory fragmentation? How did I resolve it?,作者通过分析golang的堆管理方式,解决了内存碎片的问题。

    背景

    我们的团队正在搭建运行一个兼容Prometheus的内存时序数据库,该数据库有一个数据结构,称为"chunk"。每个chunk对应一个唯一键值标签对的4个小时的数据点,如:

    {host="host1", env="production"}
    

    可以将一个数据点认为是一个时间戳加数值的组合,一个chunk包含了4个小时的数据点。数据库同一时间只会保存每个(唯一标签对的)指标的8个chunk,且每4小时会对老的chunk进行清除。由于它是一个内存数据库,因此使用快照恢复逻辑来防止数据丢失。

    遇到的问题

    通过观察内存使用发现,在数据库启动32~36小时之后,内存使用一直在增加:

    image

    第1种调试方式 -- Go pprof

    一开始怀疑是内存泄露问题,因此通过每小时采集heap profile来对比内存使用差异,但此时并没有发现任何异常。一开始怀疑可能是chunks没有完全释放,如果长期持有未使用的对象,可能会导致该问题,但通过pprof并没有找到相关线索。

    为什么使用的内存在增加,但总的堆使用却保持不变?

    第2种调试方式 -- Go memstats指标

    通过如下go memstats指标发现可能出现了内存碎片:

    go_memstats_heap_inuse_bytes{…} - go_memstats_heap_alloc_bytes{…}
    
    image

    指标结果显示,堆申请的字节数要少于使用的字节数。这意味着有很多申请的空间没有被有效地利用。通常在chunks过期前的4小时内,该值会增加,但之后会逐步降低。然而在出问题的节点上,该值并没有降低。

    我怀疑它可以为非重启节点使用过期的空间来处理新摄取的数据,但是由于内存碎片而不能为重启过的节点使用过期的空间(即使用恢复逻辑读取快照)。

    之后我将怀疑点转向了快照的恢复逻辑。快照实际上由chunks的字节构成,并放在文件中。在处理过程中会并行写chunk,因此chunk的顺序是随机的,这样可以提高写性能,而读操作则是从文件头按顺序读取的。因此可以想象,每4个小时,当某些零散chunk过期时,就会导致大量内存碎片。

    image

    下面是尝试的解决方式,即在将chunk写入文件之前会按照chunk的时间戳进行排序,这样就可以按照时间顺序来申请字节(恢复期间会从头部读取字节并分配内存),下面是修复后的申请方式:

    image

    经验证发现,问题并没有解决,且写操作性能严重降级。

    第3种调试方式--理解Go 堆管理方式

    至此需要理解Go是如何进行堆管理的。参考golang-memory-allocation

    image

    简单地说,Go运行时管理着大量mspans,每个mspans包含特定数目的连续8KB内存页,不同msapns有着不同的size class(大小),size class决定了mspan中的对象的大小,用于适应不同大小的对象,降低内存浪费。

    假设要申请100字节的对象,则需要选择112字节的size class(参见列表)。

    通常每个chunk都有一个用于内部数据的字节数组,其创建方式为:

    make([]byte, 0, 128)
    

    Go中slice的大小并不是固定不变的,当slice的容量小于1024时会以2的倍数增加,当容量大于1024时,新slice的容量会变为原来的1.25倍。(本文对这部分描述有误,此处纠正),在本场景中,大部分size-classes是固定的:

    image

    而目前恢复使用的chunk的为:

    make([]byte, 0, actual chunk byte size)
    

    这意味着摄取时采用的chunk size classes与恢复是采用的chunk size classes完全不同!恢复时使用未对齐mspan的实际chunk大小来保存数据,导致过期内存重复利用率不高,也导致mspan中出现了大量内存碎片:

    image-20230306095308333

    最后作者,通过如下方式解决了该问题:

    1. 将容量申请设置为128字节,让内存申请模式保持一致(即让系统自动对其mspan),这样就可以尽可能地复用内存
    2. 按照时间顺序来写入快照文件,防止因为数据乱序导致出现chunk层面的内存碎片

    通过如上两种方式解决了该问题:

    image

    这里解释一下文中涉及的mstat的2个指标,更多参见Exploring Prometheus Go client metrics

    • go_memstats_heap_alloc_bytes:为对象申请的堆内存,单位字节。该指标计算了所有GC没有释放的所有堆对象(可达的对象和不可达的对象)
    • go_memstats_heap_inuse_bytes: in-use span中的字节数。go_memstats_heap_inuse_bytes-go_memstats_heap_alloc_bytes表示那些已申请但没有使用的堆内存。

    总结

    • Go将堆分为mspans
    • 一个mspan由特定数目的连续8KB页组成
    • 每个mspan对应特定的size class,用来决定申请创建的对象大小
    • 为么避免在Go 运行时中出现内存碎片,需要同时考虑size classes和时间局部性
  • 相关阅读:
    【SA8295P 源码分析 (二)】37 - OpenWFD Server 启动流程 之 openwfd_server.c main 函数源码分析
    入门学习之社会工程学
    设计模式之访问器模式(Visitor)的C++实现
    Android10 动态修改开机动画(三)命令说明
    RNA-seq 保姆教程:差异表达分析(二)
    【WPF】附加事件
    [MoeCTF 2023] web题解
    泡泡玛特,难成“迪士尼”
    HashMap为什么线程不安全?
    flann的接口介绍
  • 原文地址:https://www.cnblogs.com/charlieroro/p/17183245.html