在PolarDB 8.0中,我们领先社区支持了并行查询,Performance schema也添加了对并行查询的支持,因为并行的原因,对PFS带来了更大的并发访问的挑战。

MySQL · 引擎特性 · Performance_schema 内存分配 这篇已经对PFS的内存分配做了整体流程的分析,本文主要更深入分析下pfs的内存管理的底层机制,以及一些改进思路。 本文代码基于Mysql-8.0.24版本。

简单总结一下PFS内存分配模型:

  • 一旦分配直到shutdown不会释放,但会复用已分配的内存
  • 内存占用会随着负载升高而增加

核心数据结构

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

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

Allocate时Page选择策略

是PFS内存管理的核心数据结构 涉及内存分配的关键数据结构如下:

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

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

核心简化代码如下:

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

我们再详细分析下轮训page策略的问题,因为m_momotonic原子变量的累加是并发的,会导致一些page被跳过轮训它,从而加剧了扩容新page的几率。 举一个极端一些的例子,比较容易说明问题,假设当前一共有4个page,第1、4个page已满无可用record,第2、3个page有可用record。

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

  1. monotonic = m_monotonic.m_size_t.load();

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

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

Page内Record选择策略

PFS_buffer_default_array是每个Page维护一组records的管理类。 关键数据结构如下:

  1. class PFS_buffer_default_array {
  2. PFS_cacheline_atomic_size_t m_monotonic; // 单调递增原子变量,用来选择free的record
  3. size_t m_max; // record的最大个数
  4. T *m_ptr; // record对应的PFS对象,比如PFS_thread
  5. }

每个Page其实就是一个定长的数组,每个record对象有3个状态FREEDIRTY, ALLOCATEDFREE表示空闲record可以使用,ALLOCATED是已分配成功的,DIRTY是一个中间状态,表示已被占用但还没分配成功。

Record的选择本质就是轮训查找并抢占状态为free的record的过程。 核心简化代码如下:

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

选择record的主体主体流程和选择page基本相似,不同的是page内record数量是固定不变的,所以没有扩容的逻辑。 当然选择策略相同,也会有同样的问题,这里的m_monotonic原子变量++是多线程并发的,同样如果并发大的场景下会有record被跳过选择了,这样导致page内部即便有free的record也可能没有被选中。

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

pfs_lock

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

关键数据结构:

pfs_lock使用1个32位无符号整型来保存version+state信息,格式如下: pic

state 低2位字节表示分配状态。

  1. state PFS_LOCK_FREE = 0x00
  2. state PFS_LOCK_DIRTY = 0x01
  3. state PFS_LOCK_ALLOCATED = 0x11

主要看一下状态迁移代码:

  1. // 下面3个宏主要就是用来位操作的,方便操作state或version
  2. #define VERSION_MASK 0xFFFFFFFC
  3. #define STATE_MASK 0x00000003
  4. #define VERSION_INC 4
  5. bool free_to_dirty(pfs_dirty_state *copy_ptr) {
  6. if ((old_val & STATE_MASK) != PFS_LOCK_FREE) {
  7. return false;
  8. }
  9. uint32 new_val = (old_val & VERSION_MASK) + PFS_LOCK_DIRTY;
  10. // 当前state为free,尝试将state修改为dirty,atomic_compare_exchange_strong属于乐观锁,多个线程可能同时
  11. // 修改该原子变量,但只有1个修改成功。
  12. bool pass =
  13. atomic_compare_exchange_strong(&m_version_state, &old_val, new_val);
  14. if (pass) {
  15. // free to dirty 成功
  16. copy_ptr->m_version_state = new_val;
  17. }
  18. return pass;
  19. }
  20. void dirty_to_allocated(const pfs_dirty_state *copy) {
  21. /* Make sure the record was DIRTY. */
  22. assert((copy->m_version_state & STATE_MASK) == PFS_LOCK_DIRTY);
  23. /* Increment the version, set the ALLOCATED state */
  24. uint32 new_val = (copy->m_version_state & VERSION_MASK) + VERSION_INC +
  25. PFS_LOCK_ALLOCATED;
  26. m_version_state.store(new_val);
  27. }

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

version的增长是在state变为PFS_LOCK_ALLOCATED时

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

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

前面我们分析到无论是page还是record都有几率出现跳过轮训的问题,即便是缓存中有free的成员也会出现分配不成功,导致创建更多的page,占用更多的内存。最主要的问题是这些内存一旦分配就不会被释放。

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

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

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

总结如下:

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

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

要实现定期检测回收内存,又不影响内存分配的效率,实现一套无锁的回收机制还是比较复杂的。 主要有如下几点需要考虑:

  1. 释放肯定是要以page为单位的,也就是释放的page内的所有records都必须保证都为free,而且要保证待free的page不会再被分配到
  2. 释放的阈值怎么定,也要避免频繁分配+释放的问题