TDSQL-C Serverless 如何“弹”得更稳?
云原生数据库 TDSQL-C 使用计算存储分离的架构,计算资源和存储资源解耦,可以提供PB级的存储容量供用户按需使用。而 Serverless 架构是将计算资源做到极致弹性,和购买的实例规格解耦,根据用户数据库实际的负载,自动启停和自动扩缩容,按使用计费。其中计算资源主要是 CCU(CPU+内存),CPU 可以由 cgroup 或者 docker 等技术限制,内存分配给数据库进程,大部分由 Buffer Pool 模块使用,目的是缓存用户数据,Buffer Pool 内存的分配与释放过程涉及用户数据的分布,搬迁,还有内核中全局资源的互斥等等。本文将详细介绍 TDSQL-C Serverless 在内核做的一系列优化,让数据库弹得更稳。
Buffer Pool 内存管理
首先简单介绍一下 Innodb Buffer Pool 的组织形式,内核中将 Buffer Pool 拆分成多个 Buffer Pool Instance,这样可以减少全局资源的锁粒度。每个 Buffer Pool Instance 会分配多个 chunk,而 chunk 是内存分配和释放的最小单元,其中每个 Instance 拥有的 chunk 数量一致,因此对 Buffer Pool 的扩缩容,大小都要是 chunk size * instance 的整数倍。
Innodb 是 B+ tree 的形式组织表的,默认每个节点都是 16KB 的 page,而管理这些 page 的结构叫做 block,元数据也存在 chunk 里进行持久化。frame 是 16KB 的内存,分配给 block ,用来实际存储表的数据。
数据库启动之后,所有 block 都会使用双向链表挂载 free list 上,当读取/写入数据的时候,会从 free list 分配空闲的 block,从 free list 移除,挂在 lru list 上。当 free list 使用完,需要空闲 block 的时候,就会从 lru list 中淘汰最老的 block。
Buffer Pool resize
MySQL 官方是支持动态 MySQL :: MySQL 8.0 Reference Manual :: 15.8.3.1 Configuring InnoDB Buffer Pool Size 的,使用方式通过直接调整配置参数 innodb_buffer_pool_size,任务将在后台完成,如果在任务完成之前,再次改变参数,那么将被忽略。可以通过查看错误日志内容,或者查询 Innodb_buffer_pool_resize_status 变量值的方式监测 resize 的过程。
mysql> SET GLOBAL innodb_buffer_pool_size=402653184;
扩容逻辑相对简单,设置新的 Buffer Pool 大小后,会计算出需要增加的 chunk 数量,为新的 chunk 分配内存,初始化 block 和 frame,然后把 block 挂到 free list 上,后续使用仍然从 free list 上分配即可。图中绿色为新分配的 chunk 以及 chunk 上初始化的 block:
缩容逻辑相对复杂一些,也是容易出现瓶颈的地方。因为经过一段时间的使用之后,block 会随机散落在 free list 和 lru list 上,需要整理回收。首先也需要计算出需要减少的 chunk 数量,然后确定目标 chunk,这里选择逻辑也比较简单,就是数组中的最后的几个 chunk。例如刚增加的 Chunk 3,缩容的时候也会优先选择它。
为了保证被回收的 block 不会再次被访问到,增加了 withdraw list,管理需要被回收的 block,withdaw list 不会用到,resize 完成后就直接释放了。
主要流程如下:
1. 持有 free_list_mutex,遍历 free list,如果 block 属于被回收的 chunk,就添加到 withdraw list 中。
2. 如果 free list 遍历完之后,未全部回收,说明需要移动 lru list 上的 block。然后释放 free_list_mutex,尝试去 flush lru list,释放一些 block 出来。其中 scan_depth 是扫描 lru list 的长度,这里取 srv_LRU_scan_depth 和剩余需要移动的 block 数量的较大值。
/* cap scan_depth with current LRU size. */
scan_depth = ut_min(ut_max(buf_pool->withdraw_target -
UT_LIST_GET_LEN(buf_pool->withdraw),
static_cast<ulint>(srv_LRU_scan_depth)),
lru_len);
mutex_exit(&buf_pool->free_list_mutex);
buf_flush_do_batch(buf_pool, BUF_FLUSH_LRU, scan_depth, 0, &n_flushed);
buf_flush_wait_batch_end(buf_pool, BUF_FLUSH_LRU);
3. 持有 LRU_list_mutex,遍历 lru list,如果 block 属于被回收的 chunk,就尝试从 free list 分配一个 block,把数据移动过去。
4. 重复上述流程,直到完成所有 block 的回收。
完成后的状态大概是:
接下来是遍历所有 Buffer Pool Instance 释放内存,修改全局资源的逻辑:
- 获取 Buffer Pool Instance 所有的 mutex。
- 缩容:遍历需要被释放的 chunk 中的 block, free mutex/lock。
3.根据新的 chunk 数量,重新分配 reallocate buf_pool->chunks。
4.扩容:分配内存,init block,添加到 free list。
5.设置新的 Buffer Pool size。
6.根据扩缩容大小,超过 2 倍的话,就执行 resize buffer pool page hash 操作。
7.释放 Buffer Pool Instance 所有的 mutex。
8.其它子系统 resize hash。
其中 resize hash 是因为出了 Buffer Pool 本身的 page hash,还有内部多个系统的 hash 表大小和 buffer pool 大小相关联,大小匹配可以保证效率,减少内存浪费。
完成 Buffer Pool resize 流程,线程等待下一次唤醒。
瓶颈分析以及解决方案
IO 瓶颈
在用官方 MySQL 8.0 测试过程中,发现缩容的主要瓶颈在于 flush lru list,因为大部分场景下,第一遍扫描 free list 都是不能满足回收要求的,而且根据需要回收的 block 数量来确认 scan depth,可能是一个比较大的值。buf_flush_do_batch 需要刷脏,持久化 page,在此过程中也会频繁的获取和释放 lru mutex,和用户线程竞争,产生毛刺。其中持久化 page 涉及 IO 操作,是主要瓶颈。
这个问题在 TDSQL-C 的架构上可以完全规避,因为分布式存储上的 page 都是由 redo log 在存储层 apply 异步生成的,计算节点不需要刷脏,需要淘汰的 page 可以直接丢弃。产品架构如下图所示:
free/lru list mutex 瓶颈
在上面缩容的主要流程住可以看到,每次循环都会遍历 free list 和 lru list,遍历的过程中都会持有相应的 mutex,此时用户线程是无法访问的,无论是读操作还是写操作,都可能需要获取 free/lru list mutex。Buffer Pool 中所有的 block 都维护在这两个链表上面,因此 O(N) 的复杂度,其中 N 表示 block 数量,可能是一个非常大的值,持续持有 mutex 时间较长,导致用户出现毛刺。
优化方案
为了减少持锁范围和持锁时间,优化策略为按地址遍历需要被回收的 chunk 中的 block。这样遍历的 block 数量和缩容的大小相关,不依赖整个 Buffer Pool 大小。并且将加锁区间由整个 lru 链表变成单个 block。主要流程如下:
- 尝试 flush lru list,获取足够的空闲 block。和上述缩容流程中第 2 步相同。因为 IO 瓶颈已经从架构上规避,整个过程中也不会长时间持锁,相对可控。
- 遍历 chunk 中的 block:
- 如果已经在 withdraw list 中,跳过。
- 如果属于 free list,获取 free list mutex,移动 block 到 withdraw list,立刻释放 free list mutex。
- 如果属于 lru list,获取 LRU_list_mutex,尝试从 free list 分配一个 block,把数据移动过去,立刻释放 lru list mutex。
- 重复上述流程,直到完成所有 block 的回收。
全局锁瓶颈
无论是扩容还是缩容,有一段逻辑是需要获取 Buffer Pool 全局锁的,因为此时 Buffer Pool 基本对用户不可用,这部分如果执行时间过长,同样会产生毛刺。经过分析,发现这个过程中比较耗时的有三个地方:
- 回收 chunks 内存并且 free blocks mutex
- 分配 chunks 内存并且 init blocks
- Resize Hash
前两个分配与回收都是内存操作,按道理是比较快的,O(N) 的复杂度。但是当 N 足够大,也是一笔不小的开销。以缩容为例,假如从 64G 缩容到 32G。共减少 256 个 chunk,2097152 个 block。如果这些 block 都被使用过,那么 free block mutex 的循环就是百万次,同时释放 256 次内存。即使每个循环是几微秒,加起来也可能是毫秒级,甚至是秒级的开销。
当 resize buffer pool 范围较大时,会调整一系列的 Hash 表, 代码里写死了 2 倍。
const bool new_size_too_diff =
srv_buf_pool_base_size > srv_buf_pool_size * 2 ||
srv_buf_pool_base_size * 2 < srv_buf_pool_size;
调整的 Hash 表包括:
- buf_pool->page_hash/buf_pool->zip_hash
- lock_sys->rec_hash/lock_sys->prdt_hash/lock_sys->prdt_page_hash;
- btr_search_sys->hash_tables
- dict_sys->table_hash/dict_sys->table_id_hash
这些都和 buffer pool size 相关,第一个是 hold buffer pool mutex,其它都是各自的 mutex。逻辑也比较简单,就是 new 一个新的 Hash table,把老的 Hash table 元素一个个导入进来就行了。同样是内存链表操作。但是当 Buffer Pool 较大时,元素太多会导致耗时较长。
优化方案
针对前两个问题,我们采取延迟释放 chunks 和提前预分配 chunks 的策略。这样主要的工作就可以在 buffer pool mutex 外。原本 O(N)的复杂度,初始化 blocks 和 free blocks 导致 N 都是 block 数量,优化话 O(N) 的 N 是 chunks 数量,复杂度相对可控。
预分配主要流程:
- 获取 buffer pool mutex 之前,为每个 chunk 申请空间,初始化所有的 blocks,并且把新分配的 block 假如 temp free list 里。
- 获取 buffer pool mutex。
- 需要为新申请的 chunks 分配空间,直接从预分配的 chunk 里把 mem 指针赋值给需要申请空间的 chunk。
- 把 temp free list 整个链表一起加入 buffer pool free list,O(1) 复杂度。
- 释放 buffer pool mutex。
延迟释放主要流程:
- 获取 buffer pool mutex
- 把需要 free 的 chunks 存储在 temp chunks 里。
- 释放 buffer pool mutex
- 遍历 temp chunks,free block mutex,释放内存。
Resize Hash :
其实这个本质就是 Rehash 的问题,根本的解决方案是优化算法,例如 lock-free Hash 或者一致性 Hash 分配。Hash Table 是 Innodb 里的基础元素,如果改这个复杂度和风险会比较高,周期也较长。Hash 表如果太大会浪费空间,很多 cell 使用不上,如果太小就会有较多的 Hash 碰撞,影响性能。我们采取空间换时间的策略,让触发频率可配置,减少或者在一定扩缩容范围内,不触发 Resize Hash。
优化效果
测试使用 sysbench oltp_read_only ,配置 long_query_time=0.1,查看慢查询的数量。优化前后对比:
文章转载自公众号:腾讯云数据库