走进RDS之MySQL内存分配与管理(上)

简介: MySQL的内存分配、使用、管理的模块较多,本篇文章主要介绍InnoDB层和SQL层内存分配管理器,主要包括ut_allocator、mem_heap_allocator和MEM_ROOT,代码版本主要基于8.0.25。

1. InnoDB层内存分配管理器


1.1 ut_allocator

在非UNIV_PFS_MEMORY模式下,UT_NEW等都是调用原始的new、delete、malloc、free等接口进行内存的申请和释放,在UNIV_PFS_MEMORY编译模式下,采用内部封装的ut_allocator分配器进行管理,加入了内存追踪等信息,可以通过PFS表进行展示。

ut_allocator可以作为std容器的内存分配(如std::map),让容器内部的内存通过innodb提供的内存可追踪的方式进行分配。下面分别就ut_allocator提供的不同内存分配方式作进一步介绍。

#ifdef UNIV_PFS_MEMORY
#define UT_NEW(expr, key) ::new (ut_allocator<decltype(expr)>(key).allocate(1, NULL, key, false, false)) expr
...
#define ut_malloc(n_bytes, key) static_cast<void *>(ut_allocator<byte>(key).allocate(n_bytes, NULL, UT_NEW_THIS_FILE_PSI_KEY, false, false))
...
#else /* UNIV_PFS_MEMORY */
#define UT_NEW(expr, key) ::new (std::nothrow) expr
...
#define ut_malloc(n_bytes, key) ::malloc(n_bytes)
...
#endif

1.1.1 单块内存分配

allocate

内存申请时多分配了一块ut_new_pfx_t数据(开启PFS_MEMORY),其中保存了key、size、owner等信息

// 比实际申请多出一块pfx的内存
total_bytes+=sizeof(ut_new_pfx_t)
// 申请内存
...
// 返回实际内存开始的地址
return (reinterpret_cast<pointer>(pfx + 1));

加入了内存分配重试机制

for (size_t retries = 1;; retries++) {
  // 内存分配malloc/calloc
  malloc(); // calloc()...
  if (ptr != nullptr || retries >= alloc_max_retries) break;
  std::this_thread::sleep_for(std::chrono::seconds(1));
}

deallocate

先释放pfx、再释放实际内存数据

deallocate_trace(pfx);
free(pfx);

reallocate

类似allocate,重新计算size、换入新的ut_new_pfx_t(pfx_old--pfx_new)

1.1.2 large内存分配

allocate_large

申请大块内存(used in buf_chunk_init())、添加pfx信息需要注意的是,mmap的方式没有消耗实际的物理内存,该部分的内存无法通过jemalloc等方式追踪。

pointer ptr = reinterpret_cast<pointer>(os_mem_alloc_large(&n_bytes));
    |->mmap()/shmget()、shmat()、shmctl()
...
allocate_trace(n_bytes, PSI_NOT_INSTRUMENTED, pfx);

deallocate_large

释放pfx指针,释放large内存

deallocate_trace(pfx);
os_mem_free_large(ptr, pfx->m_size);
  |->munmap()/shmdt()

1.1.3 aligned_memory分配

在代码中实际上aligned_memory系列(aligned_pointer、aligned_array_pointer、)是做了单独的封装的,但其底层依旧是ut_alloc和ut_free,此处就不展开了。例如在log_t结构的构建中采用此方法,对齐的内存方式在IO写操作时能够和sector size匹配,提高IO效率。

1.2 mem_heap_allocator

类似ut_allocator,mem_heap_allocator也可以作为stl的allocator来使用。但要要注意的是,该类型的分配器只提供mem_heap_alloc函数进行内存的申请,没有内存的释放、复用和合并等操作。

class mem_heap_allocator {
...
  pointer allocate(size_type n, const_pointer hint = nullptr) {
    return (reinterpret_cast<pointer>(mem_heap_alloc(m_heap, n * sizeof(T)))); // 内存申请调用mem_heap_alloc
  }
  void deallocate(pointer p, size_type n) {}; // 内存释放等为空操作
...
}

1.2.1 mem_heap_t

数据结构

该结构结构是一个非空的内存块链表,由一个个的大小不一的mem_block_t线性连接。重点关注free_block和buf_block,某种程度上来说,这两个指针定义了实际数据存放的位置。根据申请类型的不同,数据存放在两者之一指向的内存。利用mem_heap_t进行内存分配的方式可以将多次的内存分配合并为单次进行,之后的内存请求就可以在InnoDB引擎内部进行,从而减小了频繁调用函数malloc和free带来的时间与性能的开销

typedef struct mem_block_info_t mem_block_t;
typedef mem_block_t mem_heap_t;
...
/** The info structure stored at the beginning of a heap block */
struct mem_block_info_t {
...
  UT_LIST_BASE_NODE_T(mem_block_t) base; /* 链表基节点,只在第一个block定义 */
  UT_LIST_NODE_T(mem_block_t) list;   /* block链表 */
  ulint len;        /*!< 当前block大小 */
  ulint total_size; /*!< 所有block总大小 */
  ulint type;       /*!< 分配类型 */
  ulint free;       /*!< 当前block的可用位置 */
  ulint start;      /*!< block构建时free的起始位置(没看到较多的用途) */
  void *free_block; /* 包含有 MEM_HEAP_BTR_SEARCH 类型的heap中,
                      heap root挂着free_block用以申请更多的空间,其他类型该指针为空 */
  void *buf_block;  /* 内存从buffer pool申请,保存buf_block_t指针,否则为空 */
};

内存类型

根据申请的内存来源,mem_heap_t可以分为下面几种类型:

#define MEM_HEAP_DYNAMIC 0 /* 原始申请,调用innodb内存申请ut_allocator相关 */
#define MEM_HEAP_BUFFER 1 /* 从buffer_pool获取内存 */
#define MEM_HEAP_BTR_SEARCH 2/* 使用free_block中的内存 */

在此基础上,组合定义了更多的分配方式,让内存的分配更加灵活。

/** Different type of heaps in terms of which data structure is using them */
#define MEM_HEAP_FOR_BTR_SEARCH (MEM_HEAP_BTR_SEARCH | MEM_HEAP_BUFFER)
#define MEM_HEAP_FOR_PAGE_HASH (MEM_HEAP_DYNAMIC)
#define MEM_HEAP_FOR_RECV_SYS (MEM_HEAP_BUFFER)
#define MEM_HEAP_FOR_LOCK_HEAP (MEM_HEAP_BUFFER)

1.2.2 mem_heap_t的构建:mem_heap_create_func

根据传入的size和heap类型,构建一个memory heap结构,size最小为64。实际上在内部的构建逻辑中可以知道单个mem_block最大的size和定义的page_size相同(一般为16K)。

创建mem_heap_t首先需要构建一个root节点,即前文所提到的链表根节点。通过控制block创建函数    mem_heap_create_block传入的第一个参数heap=nullptr,表明该block为mem_heap_t中的第一个节点。在type包含MEM_HEAP_BTR_SEARCH操作位的情况下,可能会出现构建失败的情况,详细的逻辑和失败原因会在后文提出。

创建完第一个block后,将其置为base节点,同时更新链表信息,完成mem_heap_t (根结点)的创建。

mem_heap_t *mem_heap_create_func(ulint size, ulint type) {
  mem_block_t *block;
  if (!size) {
    size = MEM_BLOCK_START_SIZE;
  }
  // 创建mem_heap的第一个block,传入的第一个参数是nullptr
  block = mem_heap_create_block(nullptr, size, type, file_name, line);
  // 在MEM_HEAP_BTR_SEARCH模式下,存在构建失败的可能性,返回空指针
  if (block == nullptr) {
    return (nullptr);
  }
  // 由于BP resize的可能性,因此第一个block不能从BP中获取
  ut_ad(block->buf_block == nullptr);
  // 初始化链表基节点(base不为空,标志该节点为基节点)
  UT_LIST_INIT(block->base, &mem_block_t::list);
  UT_LIST_ADD_FIRST(block->base, block);
    
  return (block);
}

1.2.3 mem_heap_t的释放:mem_heap_free

前文提及,若type包含MEM_HEAP_BTR_SEARCH的操作位,则数据有可能保存在free_block对应的内存单元中。此时需要单独释放创建的free_block,然后由后往前,逐个释放mem_heap_t链表上的各个block。

void mem_heap_free(mem_heap_t *heap) {
  ...
// 获取链表中最后一个节点
  block = UT_LIST_GET_LAST(heap->base);
    
// 释放free_block节点(MEM_HEAP_BTR_SEARCH模式创建)
  if (heap->free_block) {
    mem_heap_free_block_free(heap);
  }
    
// 由后往前逐个释放block
  while (block != nullptr) {
    /* Store the contents of info before freeing current block
    (it is erased in freeing) */
    prev_block = UT_LIST_GET_PREV(list, block);
    mem_heap_block_free(heap, block);
    block = prev_block;
  }
}

1.2.4 block的构建:mem_heap_create_block

1. block的申请

这个函数是整个mem_heap_t内存分配的核心,针对不同的type,实现了不同策略的内存分配。具体为:

  • case 1 - MEM_HEAP_DYNAMIC或是size较小时:使用ut_malloc_nokey
  • case 2 - 包含MEM_HEAP_BTR_SEARCH且当前block不为根block,从free_block指向的内存块分配
  • case 3 - 其他情况:使用buf_block,由buf_block_alloc从buffer pool中分配
// case 1
if (type == MEM_HEAP_DYNAMIC || len < UNIV_PAGE_SIZE / 2) {
  ut_ad(type == MEM_HEAP_DYNAMIC || n <= MEM_MAX_ALLOC_IN_BUF);
  block = static_cast<mem_block_t *>(ut_malloc_nokey(len));
} else {
  len = UNIV_PAGE_SIZE;
    
  // case 2
  if ((type & MEM_HEAP_BTR_SEARCH) && heap) {
    // 从heap root的free_block获取内存
    buf_block = static_cast<buf_block_t *>(heap->free_block);
    heap->free_block = nullptr;
    if (UNIV_UNLIKELY(!buf_block)) {
      return (nullptr);
    }
  } else {
    // case 3
    buf_block = buf_block_alloc(nullptr);
  }
  block = (mem_block_t *)buf_block->frame;
}

这段代码做了以下几件事:

  • 控制了单个block的上限值UNIV_PAGE_SIZE
  • heap->free_block = nullptr确保root节点的free_block不会再次被使用,同时也解释了为什么在type存在MEM_HEAP_BTR_SEARCH位的时候可能引起内存分配的失败,原因有两个:
  • 当前block类型和mem_heap_t->base的类型不兼容:原始的根结点申请时若不包含MEM_HEAP_BTR_SEARCH位,则构建时free_block是nullptr,在line 12就会获得空指针而直接返回;
  • 当前block依托的mem_heap_t->base对应的free_block已被使用:从line 13可以看到,只要是用过一次,free_block就会被标志为空,而真正的数据转移到了buf_block上。

2. block的初始化

这一步主要包括block几mem_heap_t节点对象中的各个参数的设置,简单的包括len、type、free的设置,重点分析一下buf_block、和free_block的设置,同样十分精妙。

UNIV_MEM_FREE(block, len);
UNIV_MEM_ALLOC(block, MEM_BLOCK_HEADER_SIZE);
block->buf_block = buf_block;
block->free_block = nullptr;

前面两句是将block对应的数据置为free状态,同时初始化头部的数据,为后面的len等数据的初始化做准备;后两句的设置分几种情况一一说明:

  • case 1 - type为MEM_HEAP_DYNAMIC:此时block->buf_block=nullptr,block->free_block=nullptr符合mem_heap_t对该类型的定义,此时block的内存结构如下(头部已经被初始化)。

  • case 2 - type为MEM_HEAP_BTR_SEARCH:block的内存从free_block中分配,此时free_block中的内存就转移到了buf_block中,并从buf_block构造了block所需的数据。

  • case 3 - type为MEM_HEAP_BUFFER:内存由buf_block_alloc从buffer pool中分配。

case 2/3内存结构最终形态是一致的,区别在于case2是从free_block转换得到buf_block,而case3是从BP中直接申请得到。其中free_block一般在构建mem_heap_t时由外部指定。

可以看到无论是case1、case2、case3或是多种case的组合,buf_block和free_block的修改都能达到正确设置数据的目的。

1.2.5 block的释放:mem_heap_block_free

  • 获取buf_block(alloc方式获取的将会是nullptr)
  • 从mem_heap_t链表移除、修改total_size
  • ut_alloc方式申请的block,则调用ut_free方式释放block;否则初始化block数据(因为在从bP/free_block获取之后,block除头部之外的部分可能是是free的状态)并用buf_block_free方式释放,使之成为BP中直接可用的free page。

1.2.6 从mem_heap_t申请内存:mem_heap_alloc

  • 获取最后一个block,从最后一个block分配
  • 申请给定大小的内存区域,不够则调用mem_heap_add_block添加新的block,MEM_HEAP_BTR_SEARCH下可能会失败,原因同上
  • 更新free值(申请后可用空间变小了),初始化内存区域并返回数据指针buf(block+free偏移)

1.2.7 block添加策略:mem_heap_add_block

  • 每次新添加的block size是上一个block的2倍,到达上限则保持不变
  • 调用mem_heap_create_block并添加新的block到链表尾部
  • 最后返回新的block


2. SQL层内存分配管理器MEM_ROOT


     sql层的内存分配管理除了基础的alloc/free的形式外,主要应用了MEM_ROOT这一结构,降低了内存操作的时间和资源的损耗。本文中主要针对MEM_ROOT的相关内容进行介绍。

     MEM_ROOT作为一种通用的内存管理对象,大量使用于sql层,如在THD、TABLE_SHARE等结构中都包含了其作为内存分配器。事实上,MEM_ROOT只是负责管理内存,实际分配的内存来源是其结构成员Block,MEM_ROOT中只包含一块Block且只对当前唯一的Block负责,Block则是含有指向前一Block节点的指针,串成一条链表。

     和1.2.1小结提到的mem_heap_t不同,MEM_ROOT主要负责sql层相关的内存分配,mem_heap_t在innodb中单独实现,负责innodb相关的内存分配,但两者的结构和实现模式上是类似的。

2.1 MEM_ROOT数据结构

  • Block是其核心结构,所有的内存分配都源自于此。Block中包含了指向前1Block的指针prev,同时保留了end作为地址范围的标志,表明Block所管理的内存范围。
  • m_block_size记录了MEM_ROOT下一次要分配和管理的Block内存块的总大小,当申请新的Block块时,该值都会更新为原值的1.5倍
  • m_allocated_size记录了MEM_ROOT从OS分配出的内存总量,每次分配新的Block时该值也会进行更新。
  • m_current_block、m_current_free_start、m_current_free_end分别记录了当前管理block的起始地址、空闲地址和结束地址。
  • m_max_capacity定义了MEM_ROOT的管理的最大内存,m_error_for_capacity_exceeded是内存超出最大限制的控制开关,m_error_handler是内存超出的错误处理函数指针;m_psi_key是PFS内存监测点。

2.2 MEM_ROOT关键接口

2.2.1 构造函数 && 赋值操作

MEM_ROOT的原始构造方式内容很简单,只对m_block_size、m_orig_block和m_psi_key进行赋值,同时MEM_ROOT采用了移动构造和移动赋值的方式,对持有的MEM_ROOT进行接管,主要逻辑如下:

// 移动构造函数
MEM_ROOT(MEM_ROOT &&other)
  noexcept
      : m_xxx(other.m_cxxx),
        ...{
    other.m_xxx = nullptr/0/origin_value;
  ...
  }
// 移动赋值
MEM_ROOT &operator=(MEM_ROOT &&other) noexcept {
    Clear();
    ::new (this) MEM_ROOT(std::move(other));
    return *this;
  }

2.2.2 Alloc

该函数是根据传入的所需内存空间大小从当前所管理的、已有的Block块上返回一块新的起始地址,同时对内存使用信息进行更新。当MEM_ROOT所管理的Block大小不满足要求时,则会调用AllocSlow函数进行新Block的分配和管理。同时需要注意的是,返回的地址总是8-aligned。

2.2.3 AllocSlow

该函数用于申请新的Block,根据使用场景的差异,底层调用了两种分配模式,返回的内存地址同样是对齐的。

  • 当所需的内存很大时或是有独占一块内存的需求时,在申请完新的内存块后,并不会将新生请的Block置为当前所管理的Block(除非是MEM_ROOT首次申请),而是将其置为链表中的倒数第2块(即current_block的前一节点)。设计者不希望大内存申请和独占内存的形式对后续的内存分配造成干扰,大内存的申请会导致后续分配Block时x1.5的基数变大,难以控制内存申请量的增长;同时,若后续的内存分配和有独占内存需求的内存块相接,会导致内存的控制复杂。通过保持原有的current_block的方式,能够很好地避免上述问题的发生。
  • 在非上述的情况下,优先使用追加内存块到current_block尾部并更新current_block的方式进行分配。
void *MEM_ROOT::AllocSlow(size_t length) {
  // 本次申请的内存很大或是要求是独占一块内存的形式
  if (length >= m_block_size || MEM_ROOT_SINGLE_CHUNKS) {
    Block *new_block =
        AllocBlock(/*wanted_length=*/length, /*minimum_length=*/length);
    if (new_block == nullptr) return nullptr;
    if (m_current_block == nullptr) {
      new_block->prev = nullptr;
      m_current_block = new_block;
      m_current_free_end = new_block->end;
      m_current_free_start = m_current_free_end;
    } else {
      // Insert the new block in the second-to-last position.
      new_block->prev = m_current_block->prev;
      m_current_block->prev = new_block;
    }
    return pointer_cast<char *>(new_block) + ALIGN_SIZE(sizeof(*new_block));
  } else { // 常规情况
    if (ForceNewBlock(/*minimum_length=*/length)) {
      return nullptr;
    }
    char *new_mem = m_current_free_start;
    m_current_free_start += length;
    return new_mem;
  }
}

2.2.4 AllocBlock

该函数是Block分配的基础函数,底层是调用my_malloc函数进行内存的申请,根据PSI的信息和PFS开关等会对数据进行统计。my_malloc和my_free函数在后续会做简单的介绍,此处不再赘述。

在设置了内存超出限制的错误标志下,大内存的申请可能会导致失败。同时AllocBlock支持传入wanted_length和minium_length参数,在某些情况下能够分配出minium_length的内存大小。在每次分配完毕后,m_block_size都会调整为当前的1.5倍,避免后续频繁的调用alloc。

2.2.5 ForceNewBlock

该函数对应上文AllocSlow的第二种内存分配方式,直接调用AllockBlock进行内存块的申请,然后将其挂在Block链表的尾部,并设置其为MEM_ROOT所管理的当前Block。

2.2.6 Clear

Clear函数执行的逻辑较为简单,主要做了两件事:

  • 将MEM_ROOT的所有状态置为初始状态
  • 遍历Block链表节点并释放

2.2.7 ClearForReuse

当此前使用的内存不再需要试图释放,但又不想再MEM_ROOT再次被使用时重新走一遍Alloc...的流程时,ClearForReuse起了很大的作用。和Clear函数free所有Block不同,ClearForReuse会保持当前的Block,,而释放其他节点。换言之,经过ClearForReuse操作后,Block链表中只留下了最后的节点。但是在独占内存的场景下,代码逻辑依旧会走到Clear()。

2.2.8 其他

MEM_ROOT的内存分配方式都是字节对齐的,处理方式是在上层的Alloc等接口中对所需要的内存length进行圆整操作。但同时MEM_ROOT提供了“非标”操作的接口,提供了Peek、RawCommit等函数,支持直接对底层的Block进行操作,需要注意的是,这类操作的发生频率不高,并且下一次使用Alloc等操作时,会重新将内存做圆整处理。

2.3 MEM_ROOT在THD中的应用

MEM_ROOT在sql层的使用十分频繁,常用在THD、THD::transactions、Prepared_statement:、TABLE_SHARE、sp_head、sp_head、table_mapping等结构中,下面以最常见的使用场景THD为例,简要介绍MEM_ROOT在sql层中的应用。

THD中包括了三个MEM_ROOT(包括对象和指针),main_mem_root,user_var_events_alloc和mem_root。

2.3.1 main_mem_root

MEM_ROOT对象,随THD结构析构,主要用于执行sql过程中涉及的解析、运行时数据的存储。

This memory root is used for two purposes: - for conventional queries, to allocate structures stored in main_lex during parsing, and allocate runtime data (execution plan, etc.) during execution. - for prepared queries, only to allocate runtime data. The parsed tree itself is reused between executions and thus is stored elsewhere.

THD::THD(bool enable_plugins)
    : Query_arena(&main_mem_root, STMT_REGULAR_EXECUTION),
      ...
      lex_returning(new im::Lex_returning(false, &main_mem_root)),
      ... {
  main_lex->reset();
  set_psi(nullptr);
  mdl_context.init(this);
  init_sql_alloc(key_memory_thd_main_mem_root, &main_mem_root,
                  global_system_variables.query_alloc_block_size,
                  global_system_variables.query_prealloc_size);
  ...
 }

2.3.2 mem_root

当前mem_root的指针,在THD初始化时指向main_mem_root,但实际应用时会发生变化,通过临时改变mem_root指向的方式使用其他对象的MEM_ROOT来申请内存,使用完毕后再将mem_root指向初始内存地址(main_mem_root)。

问:为什么要把mem_root设计成可变动的对象?为什么要把mem_root的内存指针嵌入到THD?

答:方便控制内存大小,若thd->mem_root始终指向main_mem_root,相应的内存会一直存在直到THD析构,改变mem_root指向可以更好地控制内存生存周期,让临时的内存占用得以释放,和长期存在的内存分离。嵌入到THD(实际上是其父类Query_arena)中,可以让THD占用的内存统计信息更清晰、管理过程更简洁,即尽管该部分内存不是直接由THD产生,而是在执行语句的过程中产生的,同样需要把“责任”归属在THD上。简化函数传参,减少一个MEM_ROOT的参数,传入THD即可。

THD::THD(bool enable_plugins)
    : Query_arena(&main_mem_root, STMT_REGULAR_EXECUTION),
  ...
MEM_ROOT* old_mem_root = thd->mem_root; // 保存原来的mem_root(main_mem_root)
thd->mem_root = xxx_mem_root; // mem_root大多是临时性的MEM_ROOT
// do something using memory
...
thd->mem_root = old_mem_root; // 恢复成原来的mem_root(main_mem_root)

mem_root临时置换的操作发生在以下的几个位置,但由于MEM_ROOT本身的设计(移动构造等),会让内存资源的统计继续使用之前的PSI_MEMORY_KEY而不至于造成统计数据的复杂和混乱。

// sql/dd_table_share.cc
open_table_def() 
// sql/sp_head.cc
sp_parser_data::start_parsing_sp_body() &&
sp_parser_data::finish_parsing_sp_body()
// sql/sp_instr.cc PSI_NOT_INSTRUMENTED
LEX *sp_lex_instr::parse_expr() 
// sql/sql_cursor.cc
Query_result_materialize::start_execution()
// sql/sql_table.cc
rm_table_do_discovery_and_lock_fk_tables()
drop_base_table()
lock_check_constraint_names()
// sql/thd_raii.h 该类及其调用之处(sql/auth/sql_auth_cache.cc:grant_load())
class Swap_mem_root_guard; 
// sql/auth/sql_authorization.cc
mysql_table_grant() // 存储表级、行级权限
mysql_routine_grant() // 存储routine级权限
/* sql/dd/upgrade_57/global.h  storage/ndb/pligin/ndb_dd_upgrade_table.cc 
    该类及其调用之处 */
class Thd_mem_root_guard

2.2.3 user_var_events_alloc

memroot指针,用于分配THD中的Binlog_user_var_event数组元素,通常和thd->mem_root指向相同。


3. 总结


MySQL在内存的分配、使用、管理上做了很多工作和优化,各模块单独抽离出来也是一套内存分配管理系统,其设计方式和使用策略都有值得学习的地方。

InnoDB层中ut_allocator在最新的8.0版本中已经删去,对应的内存申请和释放代码修改为模版函数。mem_heap_t有效减少了内存碎片,比较适用于短周期多次分配小内存的场景。但其在使用过程中不会free内存,当单个block出现空闲较大的情况时,会有一定程度的内存浪费。

MEM_ROOT是SQL层中使用最多的内存分配器,类似mem_heap_t,其同样存在Block碎片问题,但其在设计时提供了ClearForReuse这样的接口,可以及时释放前面所占用的内存;此外,MEM_ROOT在设计中考虑了独占内存和大内存的场景,降低了一次后续申请的内存大小。同时在THD结构中,MEM_ROOT指针的灵活使用给内存的运用提供了新的思路,值得借鉴。



中篇在这里【走进RDS】之MySQL内存分配与管理(中)

下篇在这里《【走进RDS】之MySQL内存分配与管理(下)》

作者介绍
目录

相关产品

  • 云数据库 RDS MySQL 版
  • 云数据库 RDS