1. 程式人生 > 其它 >MySQL深潛|剖析Performance Schema記憶體管理

MySQL深潛|剖析Performance Schema記憶體管理

簡介:本文主要是通過對PFS引擎的記憶體管理原始碼的閱讀,解讀PFS記憶體分配及釋放原理,深入剖析其中存在的一些問題,以及一些改進思路。

一 引言

MySQL Performance schema(PFS)是MySQL提供的強大的效能監控診斷工具,提供了一種能夠在執行時檢查server內部執行情況的特方法。PFS通過監視server內部已註冊的事件來收集資訊,一個事件理論上可以是server內部任何一個執行行為或資源佔用,比如一個函式呼叫、一個系統呼叫wait、SQL查詢中的解析或排序狀態,或者是記憶體資源佔用等。

PFS將採集到的效能資料儲存在performance_schema儲存引擎中,performance_schema儲存引擎是一個記憶體表引擎,也就是所有收集的診斷資訊都會儲存在記憶體中。診斷資訊的收集和儲存都會帶來一定的額外開銷,為了儘可能小的影響業務,PFS的效能和記憶體管理也顯得非常重要了。本文主要是通過對PFS引擎的記憶體管理的原始碼的閱讀,解讀PFS記憶體分配及釋放原理,深入剖析其中存在的一些問題,以及一些改進思路。本文原始碼分析基於MySQL-8.0.24版本。

二 記憶體管理模型

PFS記憶體管理有幾個關鍵特點:

  • 記憶體分配以Page為單位,一個Page內可以儲存多條record
  • 系統啟動時預先分配部分pages,執行期間根據需要動態增長,但page是隻增不回收的模式
  • record的申請和釋放都是無鎖的

1 核心資料結構

PFS_buffer_scalable_container是PFS記憶體管理的核心資料結構,整體結構如下圖:

Container中包含多個page,每個page都有固定個數的records,每個record對應一個事件物件,比如PFS_thread。每個page中的records數量是固定不變的,但page個數會隨著負載增加而增長。

2 Allocate時Page選擇策略

PFS_buffer_scalable_container是PFS記憶體管理的核心資料結構
涉及記憶體分配的關鍵資料結構如下:

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都可能有free的records,也有可能整個page都是busy的,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_momotonic原子變數的累加是併發的,會導致一些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,DIRTY, ALLOCATED,FREE表示空閒record可以使用,ALLOCATED是已分配成功的,DIRTY是一箇中間狀態,表示已被佔用但還沒分配成功。

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資訊,格式如下:

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了。

version的增長是在state變為PFS_LOCK_ALLOCATED時

5 PFS記憶體釋放

PFS記憶體釋放就比較簡單了,因為每個record都記錄了自己所在的container和page,呼叫deallocate介面,最終將狀態置為free就完成了。

最底層都會進入到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,佔用更多的記憶體。最主要的問題是這些記憶體一旦分配就不會被釋放。

為了提升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記憶體的特性,鑑於本篇幅的限制,留在後續再介紹了。

五 關於我們

PolarDB 是阿里巴巴自主研發的雲原生分散式關係型資料庫,於2020年進入Gartner全球資料庫Leader象限,並獲得了2020年中國電子學會頒發的科技進步一等獎。PolarDB 基於雲原生分散式資料庫架構,提供大規模線上事務處理能力,兼具對複雜查詢的並行處理能力,在雲原生分散式資料庫領域整體達到了國際領先水平,並且得到了廣泛的市場認可。在阿里巴巴集團內部的最佳實踐中,PolarDB還全面支撐了2020年天貓雙十一,並重新整理了資料庫處理峰值記錄,高達1.4億TPS。歡迎有志之士加入我們,簡歷請投遞到[email protected],期待與您共同打造世界一流的下一代雲原生分散式關係型資料庫。

參考:
[1] MySQL Performance Schema
MySQL :: MySQL 8.0 Reference Manual :: 27 MySQL Performance Schema

[2] MySQL · 最佳實踐 · 今天你並行了嗎?---洞察PolarDB 8.0之並行查詢
MySQL · 最佳實踐 · 今天你並行了嗎?---洞察PolarDB 8.0之並行查詢

[3] Source code mysql / mysql-server 8.0.24
https://github.com/mysql/mysql-server/tree/mysql-8.0.24

原文連結
本文為阿里雲原創內容,未經允許不得轉載。