本文通过阅读PFS引擎的内存管理源码,解读PFS内存分配及释放原理,深入剖析其中存在的问题以及改进思路。

概述

MySQL Performance Schema(PFS)是MySQL提供的强大的性能监控诊断工具,提供了一种能够在运行时检查server内部执行情况的特方法。PFS通过监视server内部已注册的事件来收集信息,一个事件理论上可以是server内部任何一个执行行为或资源占用,比如一个函数调用、一个系统调用wait、SQL查询中的解析或排序状态,或者是内存资源占用等。

PFS将采集到的性能数据存储在performance_schema存储引擎中,performance_schema存储引擎是一个内存表引擎,所有收集的诊断信息都会保存在内存中。诊断信息的收集和存储都会带来一定的额外开销,为了尽可能缩小对业务的影响,PFS的性能和内存管理变的非常重要。

本文源代码分析基于MySQL-8.0.24版本。

内存管理模型

PFS内存管理有几个关键特点:
  • 内存分配以Page为单位,一个Page内可以存储多条record;
  • 系统启动时预先分配部分Pages,运行期间根据需要动态增长,但Page是只增加不回收的模式;
  • Record的申请和释放都是无锁的。
  1. 核心数据结构

    PFS_buffer_scalable_container是PFS内存管理的核心数据结构,整体结构如下图:

    456789

    Container中包含多个Page,每个Page都有固定个数的records,每个record对应一个事件对象,比如PFS_thread。每个Page中的records数量是固定不变的,但Page个数会随着负载增加而增长。

  2. Allocate时Page选择策略

    PFS_buffer_scalable_container中涉及内存分配的关键数据结构如下:

    PFS_PAGE_SIZE  // 每个page的大小, global_thread_container中默认为256
    PFS_PAGE_COUNT // page的最大个数,global_thread_container中默认为256
    
    class PFS_buffer_scalable_container {
      PFS_cacheline_atomic_size_t m_monotonic;            // 单调递增的原子变量,用于无锁选择page
      PFS_cacheline_atomic_size_t m_max_page_index;       // 当前已分配的最大page index
      size_t m_max_page_count;                            // 最大page个数,超过后将不再分配新page
      std::atomic< array_type *> m_pages[PFS_PAGE_COUNT];  // page数组
      native_mutex_t m_critical_section;                  // 创建新page时需要的一把锁
    }                 

    首先m_pages是一个数组,每个Page都可能有空闲的records,也有可能整个Page都是繁忙的,MySQL采用了比较简单的策略,轮询挨个尝试每个Page是否有空闲,直到分配成功。如果轮询所有Pages依然没有分配成功,这个时候就会创建新的Page来扩充,直到达到Page数的上限。

    轮询并不是每次都是从第1个Page开始寻找,而是从原子变量m_monotonic记录的位置开始查找,m_monotonic每次在Page中分配失败是加1。

    核心简化代码如下:

    value_type *allocate(pfs_dirty_state *dirty_state) {
      current_page_count = m_max_page_index.m_size_t.load();
      
      monotonic = m_monotonic.m_size_t.load();
      monotonic_max = monotonic + current_page_count;
      while (monotonic < monotonic_max) {
        index = monotonic % current_page_count;
        array = m_pages[index].load();
        pfs = array->allocate(dirty_state);
        if  (pfs) {
          // 分配成功返回
          return pfs;
        } else {
          // 分配失败,尝试下一个page, 
          // 因为m_monotonic是并发累加的,这里有可能本地monotonic变量并不是线性递增的,有可能是从1直接变为3或更大,
          // 所以当前while循环并不是严格轮询所有page,很大可能是跳着尝试,或者说这里并发访问下大家一起轮询所有的page。
          // 这个算法其实是有些问题的,会导致某些page被跳过忽略,从而加剧扩容新page的几率,后面会详细分析。
          monotonic = m_monotonic.m_size_t++;
        }
      }
      
      // 轮询所有Page后没有分配成功,如果没有达到上限的话,开始扩容page
      while (current_page_count < m_max_page_count) {
        // 因为是并发访问,为了避免同时去创建新page,这里有一个把同步锁,也是整个PFS内存分配唯一的锁
        native_mutex_lock(&m_critical_section);
        // 拿锁成功,如果array已经不为null,说明已经被其它线程创建成功
        array = m_pages[current_page_count].load();
        if (array == nullptr) {
          // 抢到了创建page的责任
          m_allocator->alloc_array(array);
          m_pages[current_page_count].store(array);
          ++m_max_page_index.m_size_t;
        }
        native_mutex_unlock(&m_critical_section);
        
        // 在新的page中再次尝试分配
        pfs = array->allocate(dirty_state);
        if (pfs) {
          // 分配成功并返回
          return pfs;
        }
        // 分配失败,继续尝试创建新的page直到上限
      }
    }

    我们再详细分析下轮询page策略的问题,因为m_monotonic原子变量的累加是并发的,会导致一些page被跳过轮询,从而加剧了扩容新page的概率。

    举一个极端一些的例子,比较容易说明问题,假设当前一共有4个page,第1、4个page已满无可用record,第2、3个page有可用record。

    当同时来了4个线程并发Allocate请求,同时拿到了的m_monotonic=0.

    monotonic = m_monotonic.m_size_t.load();

    这个时候所有线程尝试从第1个page分配record都会失败(因为第1个page是无可用record),然后累加去尝试下一个page。

    monotonic = m_monotonic.m_size_t++;

    此时因为原子变量++是返回最新的值,4个线程++成功是有先后顺序的,第1个++的线程后monotonic值为2,第2个++的线程为3,依次类推。这样就看到第3、4个线程跳过了page2和page3,导致3、4线程会轮询结束失败进入到创建新page的流程里,但这个时候page2和page3里是有空闲record可以使用的。

    虽然上述例子比较极端,但在MySQL并发访问中,同时申请PFS内存导致跳过一部分page的情况还是非常容易出现的。

  3. Page内Record选择策略

    PFS_buffer_default_array是每个Page维护一组records的管理类。

    关键数据结构如下:

    class PFS_buffer_default_array {
    PFS_cacheline_atomic_size_t m_monotonic;      // 单调递增原子变量,用来选择free的record
    size_t m_max;                                 // record的最大个数
    T *m_ptr;                                     // record对应的PFS对象,比如PFS_thread
    }
    每个Page就是一个定长的数组,每个record对象有3个状态:
    • FREE:空闲record,可以使用;
    • ALLOCATED:已分配成功的record;
    • DIRTY:表示已被占用但还没分配成功的record。

    Record的选择本质就是轮询查找并抢占状态为FREE的record的过程。

    核心简化代码如下:

    value_type *allocate(pfs_dirty_state *dirty_state) {
      // 从m_monotonic记录的位置开始尝试轮序查找
      monotonic = m_monotonic.m_size_t++;
      monotonic_max = monotonic + m_max;
    
      while (monotonic < monotonic_max) {
        index = monotonic % m_max;
        pfs = m_ptr + index;
      
        // m_lock是pfs_lock结构,free/dirty/allocated三状态是由这个数据结构来维护的
        // 后面会详细介绍它如何实现原子状态迁移的
        if (pfs->m_lock.free_to_dirty(dirty_state)) {
          return pfs;
        }
        // 当前record不为free,原子变量++尝试下一个
        monotonic = m_monotonic.m_size_t++;
      }
    }

    选择record的主体流程和选择page基本相似,不同的是page内record数量是固定不变的,所以不需要扩容。

    当然选择策略相同,也会有同样的问题,这里的m_monotonic原子变量++是多线程并发的,如果并发大的场景下会有record被跳过选择了,这样导致page内部即便有free的record也可能没有被选中。

    所以即便page选择时没有被跳过,page内的record也有几率被选不中而跳过,雪上加霜,更加加剧了内存的增长。

  4. pfs_lock

    每个record都有一个pfs_lock,来维护它在page中的分配状态(free/dirty/allocated),以及version信息。

    关键数据结构:

    struct pfs_lock {std::atomic m_version_state;}
    pfs_lock使用1个32位无符号整型来保存version+state信息,格式如下:456789
    • state:低2位字节表示分配状态。
      state PFS_LOCK_FREE = 0x00
      state PFS_LOCK_DIRTY = 0x01
      state PFS_LOCK_ALLOCATED = 0x11
    • version
      初始version为0,每分配成功一次加1,version就能表示该record被分配成功的次数,如下为状态迁移代码:
      // 下面3个宏主要就是用来位操作的,方便操作state或version
      #define VERSION_MASK 0xFFFFFFFC
      #define STATE_MASK 0x00000003
      #define VERSION_INC 4
      
      bool free_to_dirty(pfs_dirty_state *copy_ptr) {
        uint32 old_val = m_version_state.load();
      
        // 判断当前state是否为FREE,如果不是,直接返回失败
        if ((old_val & STATE_MASK) != PFS_LOCK_FREE) {
          return false;
        }
      
        uint32 new_val = (old_val & VERSION_MASK) + PFS_LOCK_DIRTY;
      
        // 当前state为free,尝试将state修改为dirty,atomic_compare_exchange_strong属于乐观锁,多个线程可能同时
        // 修改该原子变量,但只有1个修改成功。
        bool pass =
            atomic_compare_exchange_strong(&m_version_state, &old_val, new_val);
      
        if (pass) {
          // free to dirty 成功
          copy_ptr->m_version_state = new_val;
        }
      
        return pass;
      }
      
      void dirty_to_allocated(const pfs_dirty_state *copy) {
        /* Make sure the record was DIRTY. */
        assert((copy->m_version_state & STATE_MASK) == PFS_LOCK_DIRTY);
        /* Increment the version, set the ALLOCATED state */
        uint32 new_val = (copy->m_version_state & VERSION_MASK) + VERSION_INC +
                         PFS_LOCK_ALLOCATED;
      
        m_version_state.store(new_val);
      }

      状态迁移过程还是比较好理解的,由dirty_to_allocated和allocated_to_free的逻辑是更简单的,因为只有record状态是free时,它的状态迁移是存在并发多写问题的,一旦state变为dirty,当前record相当于已经被某一个线程占有,其它线程不会再尝试操作该record了。

  5. PFS内存释放

    因为每个record都记录了自己所在的container和page,因此调用deallocate接口,最终将状态置为free就完成了PFS的内存释放。

    最底层都会进入到pfs_lock来更新状态:

    struct pfs_lock {
      void allocated_to_free(void) {
        /*
          If this record is not in the ALLOCATED state and the caller is trying
          to free it, this is a bug: the caller is confused,
          and potentially damaging data owned by another thread or object.
        */
        uint32 copy = copy_version_state();
        /* Make sure the record was ALLOCATED. */
        assert(((copy & STATE_MASK) == PFS_LOCK_ALLOCATED));
        /* Keep the same version, set the FREE state */
        uint32 new_val = (copy & VERSION_MASK) + PFS_LOCK_FREE;
    
        m_version_state.store(new_val);
      }
    }

内存分配的优化

通过前文分析,发现无论是page还是record都有几率出现跳过轮询的问题,即便是缓存中有free的page或record也会出现分配不成功,导致创建更多的page,占用更多的内存。并且这些内存一旦分配就不会被释放。

为了提升PFS内存命中率,尽量避免上述问题,有一些思路如下:

  while (monotonic < monotonic_max) {
    index = monotonic % current_page_count;
    array = m_pages[index].load();
    pfs = array->allocate(dirty_state);
    if  (pfs) {
       // 记录分配成功的index
       m_monotonic.m_size_t.store(index);
      return pfs;
    } else {
      // 局部变量递增,避免掉并发累加而跳过某些pages
      monotonic++;
    }
  }

每次查找都是从最近一次分配成功的位置开始,这样必然导致并发访问的冲突,因为大家都从同一个位置开始找,起始查找位置应该加入一定的随机性,这样可以避免大量的冲突重试。

总结如下:
  1. 每次Allocate是从最近一次分配成功的index开始查找,或者随机位置开始查找;
  2. 每个Allocate严格轮询所有pages或records。

内存释放的优化

PFS内存释放的最大的问题就是一旦创建了内存,就得不到释放,直到shutdown。如果遇到热点业务,在业务高峰阶段分配了很多page的内存,在业务低峰阶段依然得不到释放。

要实现定期检测回收内存,又不影响内存分配的效率,实现一套无锁的回收机制还是比较复杂的。

主要有如下几点需要考虑:
  1. 释放要以page为单位的,也就是释放的page内的所有records都必须保证都为free,而且要保证待free的page不会再被分配到。
  2. 内存分配是随机的,整体上内存是可以回收的,但可能每个page都有一些busy的,这种情况如何优化。
  3. 释放的阈值怎么定,如何避免频繁分配和释放的问题。

针对PFS内存释放的优化,PolarDB已经开发并提供了定期回收PFS内存的特性,鉴于本篇幅的限制,留在后续再介绍了。