一 引言
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++;
}
}
另外一點,每次查找都是從最近一次配置設定成功的位置開始,這樣必然導緻并發通路的沖突,因為大家都從同一個位置開始找,起始查找位置應該加入一定的随機性,這樣可以避免大量的沖突重試。
總結如下:
- 每次Allocate是從最近一次配置設定成功的index開始查找,或者随機位置開始查找
- 每個Allocate嚴格輪訓所有pages或records
四 記憶體釋放的優化
PFS記憶體釋放的最大的問題就是一旦建立出的記憶體就得不到釋放,直到shutdown。如果遇到熱點業務,在業務高峰階段配置設定了很多page的記憶體,在業務低峰階段依然得不到釋放。
要實作定期檢測回收記憶體,又不影響記憶體配置設定的效率,實作一套無鎖的回收機制還是比較複雜的。
主要有如下幾點需要考慮:
- 釋放肯定是要以page為機關的,也就是釋放的page内的所有records都必須保證都為free,而且要保證待free的page不會再被配置設定到
- 記憶體配置設定是随機的,整體上記憶體是可以回收的,但可能每個page都有一些busy的,如何更優的協調這種情況
- 釋放的門檻值怎麼定,也要避免頻繁配置設定+釋放的問題
針對PFS記憶體釋放的優化,PolarDB已經開發并提供了定期回收PFS記憶體的特性,鑒于本篇幅的限制,留在後續再介紹了。
五 關于我們
PolarDB 是阿裡巴巴自主研發的雲原生分布式關系型資料庫,于2020年進入Gartner全球資料庫Leader象限,并獲得了2020年中國電子學會頒發的科技進步一等獎。PolarDB 基于雲原生分布式資料庫架構,提供大規模線上事務處理能力,兼具對複雜查詢的并行處理能力,在雲原生分布式資料庫領域整體達到了國際領先水準,并且得到了廣泛的市場認可。在阿裡巴巴集團内部的最佳實踐中,PolarDB還全面支撐了2020年天貓雙十一,并重新整理了資料庫處理峰值記錄,高達1.4億TPS。歡迎有志之士加入我們,履歷請投遞到[email protected],期待與您共同打造世界一流的下一代雲原生分布式關系型資料庫。
參考:
[1] MySQL Performance Schema
https://dev.mysql.com/doc/refman/8.0/en/performance-schema.html[2] MySQL · 最佳實踐 · 今天你并行了嗎?---洞察PolarDB 8.0之并行查詢
http://mysql.taobao.org/monthly/2019/11/01/[3] Source code mysql / mysql-server 8.0.24
https://github.com/mysql/mysql-server/tree/mysql-8.0.24