背景介绍

MySQL 的数据字典(Data Dictionary,简称 DD),用于存储数据库的元数据信息,它在 8.0 版本中被重新设计和实现,通过将所有 DD 数据唯一地持久化到 InnoDB 存储引擎的 DD tables,实现了 DD 的统一管理。为了避免每次访问 DD 都去存储中读取数据,使 DD 内存对象能够复用,DD 实现了两级缓存的架构,这样在每个线程使用 DD client 访问 DD 时可以通过两级缓存来加速对 DD 的内存访问。

整体架构


解读MySQL 8.0数据字典缓存管理机制_一级缓存

图 1 数据字典缓存架构图


需要访问 DD 的数据库工作线程通过建立一个 DD client(DD 系统提供的一套 DD 访问框架)来访问 DD,具体流程为通过与线程 THD 绑定的类 Dictionary_client,来依次访问一级缓存和二级缓存,如果两级缓存中都没有要访问的 DD 对象,则会直接去存储在 InnoDB 的 DD tables 中去读取。后文会详细介绍这个过程。

DD 的两级缓存底层都是基于 std::map,即键值对来实现的。

  • 第一级缓存是本地缓存,由每个 DD client 线程独享,核心数据结构为 Local_multi_map,用于加速当前线程对于同一对象的重复访问,以及在当前线程执行 DDL 语句修改 DD 对象时管理已提交、未提交、删除状态的对象。
  • 第二级缓存是共享缓存,为所有线程共享的全局缓存,核心数据结构为 Shared_multi_map,保存着所有线程都可以访问到的对象,因此其中包含一些并发控制的处理。

整个 DD cache 的相关类图结构如下:

解读MySQL 8.0数据字典缓存管理机制_元数据_02

图 2 数据字典缓存类图

Element_map 是对 std::map 的一个封装,键是 id、name 等,值是 Cache_element,它包含了 DD cache object,以及对该对象的引用计数。DD cache object 就是我们要获取的 DD 信息。

Multi_map_base 中包含了多个 Element_map,可以让用户根据不同类型的 key 来获取缓存对象。Local_multi_map 和 Shared_multi_map 都是继承于 Multi_map_base。

两级缓存

第一级缓存,即本地缓存,位于每个 Dictionary_client 内部,由不同状态(committed、uncommitted、dropped)的 Object_registry 组成。


class Dictionary_client {
 private:
  std::vector<Entity_object *> m_uncached_objects;  // Objects to be deleted.
  Object_registry m_registry_committed;    // Registry of committed objects.
  Object_registry m_registry_uncommitted;  // Registry of uncommitted objects.
  Object_registry m_registry_dropped;      // Registry of dropped objects.
  THD *m_thd;                        // Thread context, needed for cache misses.
  ...
};

代码段 1

其中 m_registry_committed,存放的是 DD client 访问 DD 时已经提交且可见的 DD cache object。如果 DD client 所在的当前线程执行的是一条 DDL 语句,则会在执行过程中将要 drop 的旧表对应的 DD cache object 存放在 m_registry_dropped 中,将还未提交的新表定义对应的 DD cache object 存放在 m_registry_uncommitted 中。在事务 commit/rollback 后,会把 m_registry_uncommitted 中的 DD cache object 更新到 m_registry_committed 中去,并把 m_registry_uncommitted 和 m_registry_dropped 清空。

每个 Object_registry 由不同元数据类型的 Local_multi_map 组成,通过模板的方式,实现对不同类型的对象(比如表、schema、tablespace、Event 等)缓存的管理。

第二级缓存,即共享缓存,是全局唯一的,使用单例 Shared_dictionary_cache 来实现。

Shared_dictionary_cache *Shared_dictionary_cache::instance() {
  static Shared_dictionary_cache s_cache;
  return &s_cache;
}

代码段 2

与本地缓存中 Object_registry 相似,Shared_dictionary_cache 也包含针对各种类型对象的缓存。与本地缓存的区别在于,本地缓存可以无锁访问,而共享缓存需要在获取/释放 DD cache object 时进行加锁来完成并发控制,并会通过 Shared_multi_map 中的条件变量来完成并发访问中的线程同步与缓存未命中情况的处理。

缓存读取过程

逻辑流程

DD 对象主要有两种访问方式,即通过元数据的 id,或者 name 来访问。需要访问 DD 的数据库工作线程通过 DD client,传入元数据的 id,name 等 key 去缓存中读取元数据对象。读取的整体过程:一级本地缓存 -> 二级共享缓存 -> 存储引擎。流程图如下:


解读MySQL 8.0数据字典缓存管理机制_数据字典_03

图 3 数据字典缓存读取流程图


由上图所示,在 DD cache object 加入到一级缓存时,已经确保其在二级缓存中也备份了一份,以供其他线程使用。

代码实现如下:

// Get a dictionary object.
template <typename K, typename T>
bool Dictionary_client::acquire(const K &key, const T **object,
                                bool *local_committed,
                                bool *local_uncommitted) {
  ...


  // Lookup in registry of uncommitted objects
  T *uncommitted_object = nullptr;
  bool dropped = false;
  acquire_uncommitted(key, &uncommitted_object, &dropped);


  ...


  // Lookup in the registry of committed objects.
  Cache_element<T> *element = NULL;
  m_registry_committed.get(key, &element);


  ...


  // Get the object from the shared cache.
  if (Shared_dictionary_cache::instance()->get(m_thd, key, &element)) {
    DBUG_ASSERT(m_thd->is_system_thread() || m_thd->killed ||
                m_thd->is_error());
    return true;
  }


  ...
}

代码段 3

在一级本地缓存中读取时,会先去 m_registry_uncommitted 和 m_registry_dropped 中读取(均在 acquire_uncommitted()函数中实现),因为这两个是最新的修改。之后再去 m_registry_committed 中读取,如果读取到就直接返回,否则去二级共享缓存中尝试读取。共享缓存的读取过程在 Shared_multi_map::get()中实现。就是加锁后直接到对应的 Element_map 中查找,存在则把其加入到一级缓存中并返回;不存在,则会进入到缓存未命中的处理流程。

缓存未命中

当本地缓存和共享缓存中都没有读取到元数据对象时,就会调用 DD cache 的持久化存储的接口 Storage_adapter::get()直接从存储在 InnoDB 中的 DD tables 中读取,创建出 DD cache object 后,依次把其加入到共享缓存和本地缓存中。

DD client 对并发访问未命中缓存的情况做了并发控制,这样做有以下几个考量:

1.因为内存对象可以共用,所以只需要维护一个 DD cache object 在内存即可。

2.访问持久化存储的调用栈较深,可能涉及 IO,比较耗时。

3.不需要每个线程都去持久化存储中读取数据,避免资源的浪费。

并发控制的代码如下:

// Get a wrapper element from the map handling the given key type.
template <typename T>
template <typename K>
bool Shared_multi_map<T>::get(const K &key, Cache_element<T> **element) {
  Autolocker lock(this);
  *element = use_if_present(key);
  if (*element) return false;


  // Is the element already missed?
  if (m_map<K>()->is_missed(key)) {
    while (m_map<K>()->is_missed(key))
      mysql_cond_wait(&m_miss_handled, &m_lock);


    *element = use_if_present(key);


    // Here, we return only if element is non-null. An absent element
    // does not mean that the object does not exist, it might have been
    // evicted after the thread handling the first cache miss added
    // it to the cache, before this waiting thread was alerted. Thus,
    // we need to handle this situation as a cache miss if the element
    // is absent.
    if (*element) return false;
  }


  // Mark the key as being missed.
  m_map<K>()->set_missed(key);
  return true;
}


代码段 4

第一个访问未命中缓存的 DD client 会将 key 加入到 Shared_multi_map 的 m_missed 集合中,这个集合包含着现在所有正在读取 DD table 中元数据的对象 key 值。之后的 client 在访问 DD table 之前会先判断目标 key 值是否在 m_missed 集合中,如在,就会进入等待。当第一个 DD client 构建好 DD cache object,并把其加入到共享缓存之后,移除 m_missed 集合中对应的 key,并通过条件变量通知所有等待的线程重新在共享缓存中获取。这样对于同一个 DD cache object,就只会对 DD table 访问一次了。时序图如下:


解读MySQL 8.0数据字典缓存管理机制_一级缓存_04

图 4 数据字典缓存未命中时序图

缓存修改过程

在一个数据库工作线程对 DD 进行修改时,DD cache 也会在事务 commit 阶段通过 remove_uncommitted_objects()函数进行更新,更新的过程为先把 DD 旧数据从缓存中删除,再把修改后的 DD cache object 更新到缓存中去,先更新二级缓存,再更新一级缓存,流程图如下:


解读MySQL 8.0数据字典缓存管理机制_一级缓存_05

图 5 数据字典缓存更新流程图


因为这个更新 DD 缓存的操作是在事务 commit 阶段进行,所以在更新一级缓存时,会先把更新后的 DD cache object 放到一级缓存中的 m_registry_committed 里去,再把 m_registry_uncommitted 和 m_registry_dropped 清空。

缓存失效过程

当 Dictionary_client 的 drop 方法被调用对元数据对象进行清理时,在元数据对象从 DD tables 中删除后,会调用 invalidate()函数使两级缓存中的 DD cache object 失效。流程图如下:


解读MySQL 8.0数据字典缓存管理机制_一级缓存_06

图 6 数据字典缓存失效流程图


这里在判断 DD cache object 在一级缓存中存在,并在一级缓存中删除掉该对象后,可以直接在二级缓存中完成删除操作。缓存失效的过程受到元数据锁(Metadata lock, MDL)的保护,因为元数据锁的并发控制,保证了一个线程在删除共享缓存时,不会有其他线程也来删除它。实际上本地缓存的数据有效,就是依赖于元数据锁的保护,否则共享缓存区域的信息,是可以被其他线程更改的。

缓存容量管理

一级本地缓存为 DD client 线程独享,由 RAII 类 Auto_releaser 来负责管理其生命周期。其具体流程为:每次建立一个 DD client 时,会定义一个对应的 Auto_releaser 类,当访问 DD 时,会把读取到的 DD cache object 同时加到 Auto_releaser 里面的 m_release_registry 中去,当 Auto_releaser 析构时,会调用 Dictionary_client 的 release()函数把 m_release_registry 中的 DD 缓存全部释放掉。

二级共享缓存会在 Shared_dictionary_cache 初始化时,根据不同类型的对象设定好缓存的容量,代码如下:

void Shared_dictionary_cache::init() {
  instance()->m_map<Collation>()->set_capacity(collation_capacity);
  instance()->m_map<Charset>()->set_capacity(charset_capacity);
  ...
}

代码段 5

在二级缓存容量达到上限时,会通过 LRU 的缓存淘汰策略来淘汰最近最少使用的 DD cache 对象。在一级缓存中存在的缓存对象不会被淘汰。

// Helper function to evict unused elements from the free list.
template <typename T>
void Shared_multi_map<T>::rectify_free_list(Autolocker *lock) {
  mysql_mutex_assert_owner(&m_lock);
  while (map_capacity_exceeded() && m_free_list.length() > 0) {
    Cache_element<T> *e = m_free_list.get_lru();
    DBUG_ASSERT(e && e->object());
    m_free_list.remove(e);
    // Mark the object as being used to allow it to be removed.
    e->use();
    remove(e, lock);
  }
}

代码段 6

总结

MySQL 8.0 中的数据字典,通过对两级缓存的逐级访问,以及精妙的对缓存未命中情况的处理方式,有效的加速了在不同场景下数据库对 DD 的访问速度,显著的提升了数据库访问元数据信息的效率。另外本文还提到了元数据锁对数据字典缓存的保护,关于元数据锁的相关机制,会在后续文章陆续介绍。

点击关注,第一时间了解华为云新鲜技术~