当前位置: 首页 > 科技观察

MYSQLDeepDive-分析PerformanceSchema内存管理

时间:2023-03-17 23:45:29 科技观察

1简介MYSQLPerformanceschema(PFS)是mysql提供的一个强大的性能监控和诊断工具,它提供了一种特殊的方法可以在运行时检查服务器的内部执行情况。PFS通过监视服务器内部的注册事件来收集信息。事件理论上可以是服务器内部的任何执行行为或资源占用,例如函数调用、系统调用等待、SQL查询中的解析或排序状态、内存资源使用等。PFS将收集到的性能数据存储在performance_schema存储引擎。performance_schema存储引擎是一个内存表引擎,即所有收集到的诊断信息都会存储在内存中。诊断信息的收集和存储会带来一些额外的开销。为了尽量减少对业务的影响,PFS的性能和内存管理也很重要。本文主要阅读PFS引擎的内存管理源码,解读PFS内存分配和释放的原理,深入分析存在的一些问题,以及一些改进思路。本文源码分析基于Mysql-8.0.24版本。2、内存管理模型PFS内存管理有几个关键特点:内存分配是以Page为单位的,一个Page中可以存储多条记录。系统启动时会预先分配一些页面,运行时根据需要动态增加,但页面只增加不回收。记录申请和释放的模式是无锁的1核心数据结构PFS_buffer_scalable_container是PFS内存管理的核心数据结构,整体结构如下:Container包含多个页面,每个页面有固定数量的记录,每条记录对应一个事件对象,比如PFS_thread。每页的记录数是固定的,但是页数会随着负载的增加而增长。2Allocate的页选择策略PFS_buffer_scalable_container是PFS内存管理的核心数据结构。与内存分配相关的关键数据结构如下:PFS_PAGE_SIZE//每页的大小,global_thread_container中默认为256PFS_PAGE_COUNT//最大页数,global_thread_container中默认为256classPFS_buffer_scalable_container{PFS_cacheline_atomic_size_tm_monotonic;//单调递增原子变量,用于无锁选择pagePFS_cacheline_atomic_size_tm_max_page_index;//当前分配的最大pageindexsize_tm_max_page_count;m_pages[PFS_PAGE_COUNT];//页面数组native_mutex_tm_critical_section;//创建新页面时需要的一个锁}首先,m_pages是一个数组,每个页面可能有空闲记录,也可能整个页面都忙,Mysql用的比较简单策略,循环训练尝试查看每个页面是否空闲,直到分配成功。如果在轮训中所有的页面仍然没有分配成功,此时会创建一个新的页面进行扩展,直到达到页面数的上限。轮换训练并不是每次都从第一页开始查找,而是使用原子变量m_monotonic记录的位置开始查找,每在一页中分配失败时m_monotonic加1。核心简化代码如下:;array=m_pages[index].load();pfs=array->allocate(dirty_state);if(pfs){//分配成功returnreturnpfs;}else{//分配失败,尝试下一页,//因为m_monotonic是并发累加的,有可能局部单调变量不是线性递增,可能直接从1变成3或者更大,//所以当前的while循环并不是严格训练所有页面,很可能是Try跳来跳去,或者说这里是并发访问,大家一起轮流训练所有的页面。//这个算法其实也有一些问题,会导致部分页面被跳过忽略,从而增加展开新页面的概率,后面会详细分析。monotonic=m_monotonic.m_size_t++;}}//轮训后所有页面均未分配成功。如果没有达到上限就开始扩pagewhile(current_page_countalloc_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){//分配成功返回returnpfs;}//分配失败,继续尝试创建新的page直到上限}}下面详细分析一下轮换训练页面策略的问题y,因为m_momotonic原子变量的累加是并发的,会导致部分页面被跳过,轮流训练,从而增加新页面展开的机会。举个极端的例子更容易说明问题。假设当前有4页,第1页和第4页已满无记录,第2页和第3页有可用记录。当4个线程并发发送Allocate请求,同时获取m_monotonic=0.monotonic=m_monotonic.m_size_t.load();此时所有试图从第一页开始分配记录的线程都会失败(因为第一页是Noavailablerecord),然后加起来尝试下一页monotonic=m_monotonic.m_size_t++;这时候问题来了,因为原子变量++返回的是最新的值,4个线程++的成功是有顺序的,第一个++线程的单调值是2,第二个++线程是3,等等。这样可以看出,第3、4个线程跳过了page2和page3,导致第3、4个线程未能完成轮训,进入创建新页面的过程,但此时有空闲记录在page2和page3中可以使用。上面的例子虽然比较极端,但是在Mysql并发访问中,由于同时申请PFS内存,跳过一些页面应该是非常容易的。3Page中的Record选择策略PFS_buffer_default_array是一个管理类,为每个Page维护一组记录。关键数据结构如下:classPFS_buffer_default_array{PFS_cacheline_atomic_size_tm_monotonic;//单调递增原子变量,用于选择空闲的recordsize_tm_max;//最大记录数T*m_ptr;//记录对应的PFS对象,如PFS_thread}每个Page其实就是一个定长数组,每个记录对象都有三种状态:FREE、DIRTY、ALLOCATED,FREE表示空闲记录可以使用,ALLOCATED表示已经分配成功,DIRTY是中间状态,这表示已经被占用但是还没有分配成功。记录选择的本质是轮流训练寻找并抢占状态为空闲的记录的过程。核心化简代码如下:value_type*allocate(pfs_dirty_state*dirty_state){//从m_monotonic记录的位置开始尝试求轮序monotonic=m_monotonic.m_size_t++;monotonicmonotonic_max=monotonic+m_max;while(monotonicm_lock.free_to_dirty(dirty_state)){returnpfs;}//当前记录不空闲,原子变量++尝试下一个monotonic=m_monotonic.m_size_t++;}}选择一条记录的主要过程基本类似与选择页面不同的是,该页面的记录数是固定的,所以没有扩展逻辑。当然,选择策略是一样的,也会出现同样的问题。这里的m_monotonic原子变量++是多线程并发的。同样,如果有大并发的场景,记录会被跳过选择,这会导致即使页面内部有空闲记录。也可以不选。因此,即使不跳过页选择,页中的记录也可能被跳过而没有被选中,从而使情况变得更糟,并进一步增加内存增长。4pfs_lock每条记录都有一个pfs_lock来维护它在页面中的分配状态(free/dirty/allocated)和版本信息。关键数据结构:structpfs_lock{std::atomicm_version_state;}pfs_lock使用一个32位无符号整数来存储版本+状态信息,格式如下:state的低2字节表示分配状态。statePFS_LOCK_FREE=0x00statePFS_LOCK_DIRTY=0x01statePFS_LOCK_ALLOCATED=0x11version初始版本为0,每次分配成功后加1,版本可以表示记录分配成功的次数。主要看状态转换代码://下面三个宏主要用到位操作,方便操作状态或版本#defineVERSION_MASK0xFFFFFFFC#defineSTATE_MASK0x00000003#defineVERSION_INC4boolfree_to_dirty(pfs_dirty_state*copy_ptr){uint32old_val=m_version_state.load();//判断是否当前状态是FREE,如果不是,直接returnMA&valif(SK_failed)=PFS_LOCK_FREE){returnfalse;}uint32new_val=(old_val&VERSION_MASK)+PFS_LOCK_DIRTY;//当前状态是free,尝试把状态改成dirty,atomic_compare_exchange_strong属于乐观锁,多个线程可能同时修改原子变量,但只有一个修改成功。boolpass=atomic_compare_exchange_strong(&m_version_state,&old_val,new_val);if(pass){//freetodirty成功复制_ptr->m_version_state=new_val;}returnpass;}voiddirty_to_allocated(constpfs_dirty_state*copy){/*Makesuretherecord*was>DIRTY(.m_version_state&STATE_MASK)==PFS_LOCK_DIRTY);/*Incrementtheversion,settheALLOCATEDstate*/uint32new_val=(copy->m_version_state&VERSION_MASK)+VERSION_INC+PFS_LOCK_ALLOCATED;m_version_state.store(new_allocate);}状态迁移过程的逻辑比较好理解,通过all_focalated_就更简单了,因为只有记录状态空闲时,它的状态转换才有并发多写的问题。一旦状态变脏,当前记录就相当于被某个线程占用了,其他线程就不会去尝试操作它了。记录。版本的增长是状态变为PFS_LOCK_ALLOCATED5PFS内存释放PFS内存释放比较简单,因为每条记录记录自己的容器和页,调用deallocate接口,最后设置状态为free即可完成。最底层都会进入到pfs_lock来更新状态:structpfs_lock{voidallocated_to_free(void){/*IfthisrecordisnotintheALLOCATEDstateandthecalleristryingtofreeit,thisisabug:thecallerisconfused,andpotentiallydamagingdataownedbyanotherthreadorobject.*/uint32copy=copy_version_state();/*MakesuretherecordwasALLOCATED.*/assert(((copy&STATE_MASK)==PFS_LOCK_ALLOCATED)));/*Keepthesameversion,settheFREEstate*/uint32new_val=(copy&VERSION_MASK)+PFS_LOCK_FREE;m_version_state.store(new_val);}}内存分配的三项优化我们之前分析过不管是page还是record,都有机会跳转training,即使缓存中有空闲成员,分配也会失败,导致创建更多的页面和更多的内存使用。主要问题是这个内存一旦分配就不会释放。为了提高PFS内存命中率,尽量避免上述问题,一些思路如下:while(monotonicallocate(dirty_state);if(pfs){//记录indexm_monotonic.m_size_t.store(index);returnpfs;}else{//自增局部变量,避免并发累加,跳过一些pagesmonotonic++;}}还有一点,每次从最近一次分配成功的位置开始查找,必然会导致并发访问冲突,因为大家从同一个位置开始查找,要给初始查找位置增加一定的随机性,所以以避免大量的冲突重试。总结如下:每次Allocate从最后一个分配成功的索引开始查找,或者从随机位置开始查找。每个Allocate严格循环所有页面或记录。4.优化PFS内存释放。内存释放最大的问题是一旦创建的内存直到关机才释放。如果遇到一个热点业务,在业务高峰期分配了很多页面内存,但在业务低峰期仍然无法释放。要在不影响内存分配效率的情况下实现内存的定时检测和回收,实现一套无锁回收机制还是比较复杂的。主要有以下几点需要考虑:释放必须以页为单位,即释放页中的所有记录必须保证空闲,并且必须保证要空闲的页不会被分配到内存再次分配。随机的,整体内存可以回收,但是每个page可能会有一些忙的,这种情况下如何更好的协调releasethreshold,同时也避免PFS内存释放频繁分配+release的问题为了优化,PolarDB开发了和提供了周期性回收PFS内存的特性。由于本文篇幅所限,后面再介绍。