MYSQL 深潛 - 剖析 Performance Schema 內(nèi)存管理
一 引言
MYSQL Performance schema(PFS)是mysql提供的強(qiáng)大的性能監(jiān)控診斷工具,提供了一種能夠在運(yùn)行時(shí)檢查server內(nèi)部執(zhí)行情況的特方法。PFS通過監(jiān)視server內(nèi)部已注冊(cè)的事件來收集信息,一個(gè)事件理論上可以是server內(nèi)部任何一個(gè)執(zhí)行行為或資源占用,比如一個(gè)函數(shù)調(diào)用、一個(gè)系統(tǒng)調(diào)用wait、SQL查詢中的解析或排序狀態(tài),或者是內(nèi)存資源占用等。
PFS將采集到的性能數(shù)據(jù)存儲(chǔ)在performance_schema存儲(chǔ)引擎中,performance_schema存儲(chǔ)引擎是一個(gè)內(nèi)存表引擎,也就是所有收集的診斷信息都會(huì)保存在內(nèi)存中。診斷信息的收集和存儲(chǔ)都會(huì)帶來一定的額外開銷,為了盡可能小的影響業(yè)務(wù),PFS的性能和內(nèi)存管理也顯得非常重要了。
本文主要是通過對(duì)PFS引擎的內(nèi)存管理的源碼的閱讀,解讀PFS內(nèi)存分配及釋放原理,深入剖析其中存在的一些問題,以及一些改進(jìn)思路。本文源代碼分析基于Mysql-8.0.24版本。
二 內(nèi)存管理模型
PFS內(nèi)存管理有幾個(gè)關(guān)鍵特點(diǎn):
內(nèi)存分配以Page為單位,一個(gè)Page內(nèi)可以存儲(chǔ)多條record
系統(tǒng)啟動(dòng)時(shí)預(yù)先分配部分pages,運(yùn)行期間根據(jù)需要?jiǎng)討B(tài)增長,但page是只增不回收的模式
record的申請(qǐng)和釋放都是無鎖的
1 核心數(shù)據(jù)結(jié)構(gòu)
PFS_buffer_scalable_container是PFS內(nèi)存管理的核心數(shù)據(jù)結(jié)構(gòu),整體結(jié)構(gòu)如下圖:
Container中包含多個(gè)page,每個(gè)page都有固定個(gè)數(shù)的records,每個(gè)record對(duì)應(yīng)一個(gè)事件對(duì)象,比如PFS_thread。每個(gè)page中的records數(shù)量是固定不變的,但page個(gè)數(shù)會(huì)隨著負(fù)載增加而增長。
2 Allocate時(shí)Page選擇策略
PFS_buffer_scalable_container是PFS內(nèi)存管理的核心數(shù)據(jù)結(jié)構(gòu)
涉及內(nèi)存分配的關(guān)鍵數(shù)據(jù)結(jié)構(gòu)如下:
- PFS_PAGE_SIZE // 每個(gè)page的大小, global_thread_container中默認(rèn)為256PFS_PAGE_COUNT // page的最大個(gè)數(shù),global_thread_container中默認(rèn)為256class PFS_buffer_scalable_container { PFS_cacheline_atomic_size_t m_monotonic; // 單調(diào)遞增的原子變量,用于無鎖選擇page PFS_cacheline_atomic_size_t m_max_page_index; // 當(dāng)前已分配的最大page index size_t m_max_page_count; // 最大page個(gè)數(shù),超過后將不再分配新page std::atomic< array_type *> m_pages[PFS_PAGE_COUNT]; // page數(shù)組 native_mutex_t m_critical_section; // 創(chuàng)建新page時(shí)需要的一把鎖}
首先m_pages是一個(gè)數(shù)組,每個(gè)page都可能有free的records,也有可能整個(gè)page都是busy的,Mysql采用了比較簡單的策略,輪訓(xùn)挨個(gè)嘗試每個(gè)page是否有空閑,直到分配成功。如果輪訓(xùn)所有pages依然沒有分配成功,這個(gè)時(shí)候就會(huì)創(chuàng)建新的page來擴(kuò)充,直到達(dá)到page數(shù)的上限。
輪訓(xùn)并不是每次都是從第1個(gè)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(); monotonicmonotonic_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 { // 分配失敗,嘗試下一個(gè)page, // 因?yàn)閙_monotonic是并發(fā)累加的,這里有可能本地monotonic變量并不是線性遞增的,有可能是從1 直接變?yōu)?nbsp;3或更大, // 所以當(dāng)前while循環(huán)并不是嚴(yán)格輪訓(xùn)所有page,很大可能是跳著嘗試,換者說這里并發(fā)訪問下大家一起輪訓(xùn)所有的page。 // 這個(gè)算法其實(shí)是有些問題的,會(huì)導(dǎo)致某些page被跳過忽略,從而加劇擴(kuò)容新page的幾率,后面會(huì)詳細(xì)分析。 monotonic = m_monotonic.m_size_t++; } } // 輪訓(xùn)所有Page后沒有分配成功,如果沒有達(dá)到上限的話,開始擴(kuò)容page while (current_page_count < m_max_page_count) { // 因?yàn)槭遣l(fā)訪問,為了避免同時(shí)去創(chuàng)建新page,這里有一個(gè)把同步鎖,也是整個(gè)PFS內(nèi)存分配唯一的鎖 native_mutex_lock(&m_critical_section); // 拿鎖成功,如果array已經(jīng)不為null,說明已經(jīng)被其它線程創(chuàng)建成功 array = m_pages[current_page_count].load(); if (array == nullptr) { // 搶到了創(chuàng)建page的責(zé)任 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; } // 分配失敗,繼續(xù)嘗試創(chuàng)建新的page直到上限 }}
我們?cè)僭敿?xì)分析下輪訓(xùn)page策略的問題,因?yàn)閙_momotonic原子變量的累加是并發(fā)的,會(huì)導(dǎo)致一些page被跳過輪訓(xùn)它,從而加劇了擴(kuò)容新page的幾率。
舉一個(gè)極端一些的例子,比較容易說明問題,假設(shè)當(dāng)前一共有4個(gè)page,第1、4個(gè)page已滿無可用record,第2、3個(gè)page有可用record。
當(dāng)同時(shí)來了4個(gè)線程并發(fā)Allocate請(qǐng)求,同時(shí)拿到了的m_monotonic=0.
monotonic = m_monotonic.m_size_t.load();
這個(gè)時(shí)候所有線程嘗試從第1個(gè)page分配record都會(huì)失敗(因?yàn)榈?個(gè)page是無可用record),然后累加去嘗試下一個(gè)page
monotonic = m_monotonic.m_size_t++;
這個(gè)時(shí)候問題就來了,因?yàn)樵幼兞?+是返回最新的值,4個(gè)線程++成功是有先后順序的,第1個(gè)++的線程后monotonic值為2,第2個(gè)++的線程為3,以次類推。這樣就看到第3、4個(gè)線程跳過了page2和page3,導(dǎo)致3、4線程會(huì)輪訓(xùn)結(jié)束失敗進(jìn)入到創(chuàng)建新page的流程里,但這個(gè)時(shí)候page2和page3里是有空閑record可以使用的。
雖然上述例子比較極端,但在Mysql并發(fā)訪問中,同時(shí)申請(qǐng)PFS內(nèi)存導(dǎo)致跳過一部分page的情況應(yīng)該還是非常容易出現(xiàn)的。
3 Page內(nèi)Record選擇策略
PFS_buffer_default_array是每個(gè)Page維護(hù)一組records的管理類。
關(guān)鍵數(shù)據(jù)結(jié)構(gòu)如下:
- class PFS_buffer_default_array {PFS_cacheline_atomic_size_t m_monotonic; // 單調(diào)遞增原子變量,用來選擇free的recordsize_t m_max; // record的最大個(gè)數(shù)T *m_ptr; // record對(duì)應(yīng)的PFS對(duì)象,比如PFS_thread}
每個(gè)Page其實(shí)就是一個(gè)定長的數(shù)組,每個(gè)record對(duì)象有3個(gè)狀態(tài)FREE,DIRTY, ALLOCATED,F(xiàn)REE表示空閑record可以使用,ALLOCATED是已分配成功的,DIRTY是一個(gè)中間狀態(tài),表示已被占用但還沒分配成功。
Record的選擇本質(zhì)就是輪訓(xùn)查找并搶占狀態(tài)為free的record的過程。
核心簡化代碼如下:
- value_type *allocate(pfs_dirty_state *dirty_state) { // 從m_monotonic記錄的位置開始嘗試輪序查找 monotonic = m_monotonic.m_size_t++; monotonicmonotonic_max = monotonic + m_max; while (monotonic < monotonic_max) { index = monotonic % m_max; pfs = m_ptr + index; // m_lock是pfs_lock結(jié)構(gòu),free/dirty/allocated三狀態(tài)是由這個(gè)數(shù)據(jù)結(jié)構(gòu)來維護(hù)的 // 后面會(huì)詳細(xì)介紹它如何實(shí)現(xiàn)原子狀態(tài)遷移的 if (pfs->m_lock.free_to_dirty(dirty_state)) { return pfs; } // 當(dāng)前record不為free,原子變量++嘗試下一個(gè) monotonic = m_monotonic.m_size_t++; }}
選擇record的主體主體流程和選擇page基本相似,不同的是page內(nèi)record數(shù)量是固定不變的,所以沒有擴(kuò)容的邏輯。
當(dāng)然選擇策略相同,也會(huì)有同樣的問題,這里的m_monotonic原子變量++是多線程并發(fā)的,同樣如果并發(fā)大的場景下會(huì)有record被跳過選擇了,這樣導(dǎo)致page內(nèi)部即便有free的record也可能沒有被選中。
所以也就是page選擇即便是沒有被跳過,page內(nèi)的record也有幾率被跳過而選不中,雪上加霜,更加加劇了內(nèi)存的增長。
4 pfs_lock
每個(gè)record都有一個(gè)pfs_lock,來維護(hù)它在page中的分配狀態(tài)(free/dirty/allocated),以及version信息。
關(guān)鍵數(shù)據(jù)結(jié)構(gòu):
struct pfs_lock {
std::atomic m_version_state;
}
pfs_lock使用1個(gè)32位無符號(hào)整型來保存version+state信息,格式如下:
state
低2位字節(jié)表示分配狀態(tài)。
state PFS_LOCK_FREE = 0x00
state PFS_LOCK_DIRTY = 0x01
state PFS_LOCK_ALLOCATED = 0x11
version
初始version為0,每分配成功一次加1,version就能表示該record被分配成功的次數(shù)
主要看一下狀態(tài)遷移代碼:
- // 下面3個(gè)宏主要就是用來位操作的,方便操作state或version#define VERSION_MASK 0xFFFFFFFC#define STATE_MASK 0x00000003#define VERSION_INC 4bool free_to_dirty(pfs_dirty_state *copy_ptr) { uint32 old_val = m_version_state.load(); // 判斷當(dāng)前state是否為FREE,如果不是,直接返回失敗 if ((old_val & STATE_MASK) != PFS_LOCK_FREE) { return false; } uint32 new_val = (old_val & VERSION_MASK) + PFS_LOCK_DIRTY; // 當(dāng)前state為free,嘗試將state修改為dirty,atomic_compare_exchange_strong屬于樂觀鎖,多個(gè)線程可能同時(shí) // 修改該原子變量,但只有1個(gè)修改成功。 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);}
狀態(tài)遷移過程還是比較好理解的, 由dirty_to_allocated和allocated_to_free的邏輯是更簡單的,因?yàn)橹挥衦ecord狀態(tài)是free時(shí),它的狀態(tài)遷移是存在并發(fā)多寫問題的,一旦state變?yōu)閐irty,當(dāng)前record相當(dāng)于已經(jīng)被某一個(gè)線程占有,其它線程不會(huì)再嘗試操作該record了。
version的增長是在state變?yōu)镻FS_LOCK_ALLOCATED時(shí)
5 PFS內(nèi)存釋放
PFS內(nèi)存釋放就比較簡單了,因?yàn)槊總€(gè)record都記錄了自己所在的container和page,調(diào)用deallocate接口,最終將狀態(tài)置為free就完成了。
最底層都會(huì)進(jìn)入到pfs_lock來更新狀態(tài):
- 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); }}
三 內(nèi)存分配的優(yōu)化
前面我們分析到無論是page還是record都有幾率出現(xiàn)跳過輪訓(xùn)的問題,即便是緩存中有free的成員也會(huì)出現(xiàn)分配不成功,導(dǎo)致創(chuàng)建更多的page,占用更多的內(nèi)存。最主要的問題是這些內(nèi)存一旦分配就不會(huì)被釋放。
為了提升PFS內(nèi)存命中率,盡量避免上述問題,有一些思路如下:
- 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 { // 局部變量遞增,避免掉并發(fā)累加而跳過某些pages monotonic++; } }
另外一點(diǎn),每次查找都是從最近一次分配成功的位置開始,這樣必然導(dǎo)致并發(fā)訪問的沖突,因?yàn)榇蠹叶紡耐粋€(gè)位置開始找,起始查找位置應(yīng)該加入一定的隨機(jī)性,這樣可以避免大量的沖突重試。
總結(jié)如下:
每次Allocate是從最近一次分配成功的index開始查找,或者隨機(jī)位置開始查找
每個(gè)Allocate嚴(yán)格輪訓(xùn)所有pages或records
四 內(nèi)存釋放的優(yōu)化
PFS內(nèi)存釋放的最大的問題就是一旦創(chuàng)建出的內(nèi)存就得不到釋放,直到shutdown。如果遇到熱點(diǎn)業(yè)務(wù),在業(yè)務(wù)高峰階段分配了很多page的內(nèi)存,在業(yè)務(wù)低峰階段依然得不到釋放。
要實(shí)現(xiàn)定期檢測回收內(nèi)存,又不影響內(nèi)存分配的效率,實(shí)現(xiàn)一套無鎖的回收機(jī)制還是比較復(fù)雜的。
主要有如下幾點(diǎn)需要考慮:
釋放肯定是要以page為單位的,也就是釋放的page內(nèi)的所有records都必須保證都為free,而且要保證待free的page不會(huì)再被分配到
內(nèi)存分配是隨機(jī)的,整體上內(nèi)存是可以回收的,但可能每個(gè)page都有一些busy的,如何更優(yōu)的協(xié)調(diào)這種情況
釋放的閾值怎么定,也要避免頻繁分配+釋放的問題
針對(duì)PFS內(nèi)存釋放的優(yōu)化,PolarDB已經(jīng)開發(fā)并提供了定期回收PFS內(nèi)存的特性,鑒于本篇幅的限制,留在后續(xù)再介紹了。