本文通過閱讀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內存的特性,鑒于本篇幅的限制,留在后續再介紹了。