共计 7583 个字符,预计需要花费 19 分钟才能阅读完成。
简介:本文次要是通过对 PFS 引擎的内存治理的源码的浏览,解读 PFS 内存调配及开释原理,深刻分析其中存在的一些问题,以及一些改良思路。本文源代码剖析基于 Mysql-8.0.24 版本。
作者 | 之枢
起源 | 阿里技术公众号
一 引言
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。欢送有志之士退出咱们,简历请投递到 zetao.wzt@alibaba-inc.com,期待与您独特打造世界一流的下一代云原生分布式关系型数据库。
参考:
[1] MySQL Performance Schema
https://dev.mysql.com/doc/ref…
[2] MySQL · 最佳实际 · 明天你并行了吗?— 洞察 PolarDB 8.0 之并行查问
http://mysql.taobao.org/month…
[3] Source code mysql / mysql-server 8.0.24
https://github.com/mysql/mysq…
原文链接
本文为阿里云原创内容,未经容许不得转载。