背景介绍
MySQL的数据字典(Data Dictionary,简称DD),用于存储数据库的元数据信息,它在8.0版本中被重新设计和实现,通过将所有DD数据唯一地持久化到InnoDB存储引擎的DD tables,实现了DD的统一管理。为了避免每次访问DD都去存储中读取数据,使DD内存对象能够复用,DD实现了两级缓存的架构,这样在每个线程使用DD client访问DD时可以通过两级缓存来加速对DD的内存访问。
整体架构
图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的相关类图结构如下:
图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去缓存中读取元数据对象。读取的整体过程:一级本地缓存 -> 二级共享缓存 -> 存储引擎。流程图如下:
图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访问一次了。时序图如下:
图4 数据字典缓存未命中时序图
缓存修改过程
在一个数据库工作线程对DD进行修改时,DD cache也会在事务commit阶段通过remove_uncommitted_objects()函数进行更新,更新的过程为先把DD旧数据从缓存中删除,再把修改后的DD cache object更新到缓存中去,先更新二级缓存,再更新一级缓存,流程图如下:
图5 数据字典缓存更新流程图
因为这个更新DD缓存的操作是在事务commit阶段进行,所以在更新一级缓存时,会先把更新后的DD cache object放到一级缓存中的m_registry_committed里去,再把m_registry_uncommitted和m_registry_dropped清空。
缓存失效过程
当Dictionary_client的drop方法被调用对元数据对象进行清理时,在元数据对象从DD tables中删除后,会调用invalidate()函数使两级缓存中的DD cache object失效。流程图如下:
图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的访问速度,显著的提升了数据库访问元数据信息的效率。另外本文还提到了元数据锁对数据字典缓存的保护,关于元数据锁的相关机制,会在后续文章陆续介绍。