【华为云MySQL技术专栏】TaurusDB MDL实现机制解析
1. 背景介绍
为了满足数据库在高并发请求下的事务隔离性和一致性要求,TaurusDB使用MDL(metadata lock,元数据锁)机制来管理对数据库对象的并发访问。使用MDL可以避免以下几类问题的发生:
1)读取结果的不一致性:在可重复读(Repeatable Read,简称RR)隔离级别下,一个事务中的第一次查询可能返回某些结果,但在第二次查询时,由于表被另一个事务删除,导致查询结果为空。
2)二进制日志(Binlog)记录的混乱:在Binlog中,如果先记录了表的删除操作,随后又记录了向同一表中插入数据的操作,导致数据恢复时的逻辑错误。
本文将基于MDL中常用的数据结构及含义,从实现的角度来讨论MDL的获取、释放、升降级与死锁检测,最终聚焦于华为云TaurusDB中MDL在主备同步的实现机制。
2. MDL的实现
MDL 是在 MySQL server 层实现的一个模块,通过定义的接口和server层的其它组件进行交互,核心功能在sql/mdl.h和sql/mdl.cc中实现。
MDL和传统的表锁有以下几点的区别:
在TaurusDB中,无论是DDL、DML还是查询语句,所有操作均需先获取Server层的MDL,然后才能进一步获取InnoDB存储引擎层所需的特定锁。
2.1锁类型
为了提高并发度,MDL被细分为了11种类型/级别,其数据结构为enum_mdl_type。
2.2 持续时间
持续时间代表要持有某个MDL有多久,对应代码enum_mdl_duration,包含以下三种类型:
3. MDL锁的标识
MDL锁的标识,对应代码MDL_key。
/**
Metadata lock object key.
A lock is requested or granted based on a fully qualified name and type.
E.g. They key for a table consists of @<0 (=table)@>+@<database@>+@<tablename@>.
Elsewhere in the comments this triple will be referred to simply as "key" or "name".
*/
'namespace'+'db_name'+'object_name'三元组,是构成一个MDL的唯一标识。即不管请求的key是什么类型或什么时间范围 ,只要三元组相同,所有请求都使用同一个MDL_key对象。其中,namespace是一个系统内部的命名空间,db_name为数据库名,object_name为对象名,如表名、视图名等。
MDL namespace表示一个MDL针对的对象,对应代码enum_mdl_namespace。
其中,GLOBAL, TABLESPACE, SCHEMA, COMMIT(全局唯一,FLUSH TABLES WITH READ LOCK使用,阻止其他事务提交), BACKUP_LOCK(用于阻止可能破坏备份一致性的语句), RESOURCE_GROUPS, FOREIGN_KEY, CHECK_CONSTRAINT, BINLOG属于scoped lock。
而TABLE, FUNCTION, PROCEDURE, TRIGGER,EVENT, USER_LEVEL_LOCK(用于user-level的锁, GET_LOCK(), RELEASE_LOCK()等函数), LOCKING_SERVICE(用于locking service), SRID(空间参照系统), ACL_CACHE ACL(access-control list缓存), COLUMN_STATISTICS属于object lock。
4. MDL锁类型的兼容性
MDL_lock::MDL_lock_strategy类中定义了不同MDL锁的不同处理策略,包括但不限于锁的兼容矩阵。
4.1 Scoped lock
Scoped lock,锁定的是一个较大范围内的对象,而不是某个单独的对象。例如GLOBAL, COMMIT, TABLESPACE, BACKUP_LOCK, SCHEMA 这些namespace。其对应代码MDL_lock_strategy m_scoped_lock_strategy。
Scoped lock有三种类型:IX、S和X。其中,IS锁类型与所有类型都兼容,所以代码中实际没有任何处理。
• MDL请求类型与已授予类型的兼容性
在评估MDL请求类型与已授予类型的兼容性时,"+"被用来表示锁类型间相互兼容,这意味着多个线程可以同时持有与已授予类型兼容的MDL。
The first array specifies if particular type of request can be
satisfied if there is granted scoped lock of certain type.
| Type of active |
Request | scoped lock |
type | IS(*) IX S X |
---------+------------------+
IS | + + + + |
IX | + + - - |
S | + - + - |
X | + - - - |
• MDL请求类型与等待中类型的优先级
在处理MDL请求类型与等待中类型的优先级时,"+"表示某类型的锁具有较高的优先级,更有可能被优先处理。
The first array specifies if particular type of request can be
satisfied if there is granted scoped lock of certain type.
| Type of active |
Request | scoped lock |
type | IS(*) IX S X |
---------+------------------+
IS | + + + + |
IX | + + - - |
S | + - + - |
X | + - - - |
4.2 Object lock
Object lock,对应代码MDL_lock_strategy m_object_lock_strategy
• MDL请求类型与已授予类型的兼容性
Request | Granted requests for lock |
type | S SH SR SW SWLP SU SRO SNW SNRW X SPW|
----------+--------------------------------------------------+
S | + + + + + + + + + - + |
SH | + + + + + + + + + - + |
SR | + + + + + + + + - - + |
SW | + + + + + + - - - - + |
SWLP | + + + + + + - - - - + |
SU | + + + + + - + - - - - |
SRO | + + + - - + + + - - - |
SNW | + + + - - - + - - - - |
SNRW | + + - - - - - - - - - |
X | - - - - - - - - - - - |
SPW | + + + + + - - - - - - |
• MDL请求类型与等待中类型的优先级
Request | Pending requests for lock |
type | S SH SR SW SWLP SU SRO SNW SNRW X SPW|
----------+-------------------------------------------------+
S | + + + + + + + + + - + |
SH | + + + + + + + + + + + |
SR | + + + + + + + + - - + |
SW | + + + + + + + - - - + |
SWLP | + + + + + + - - - - + |
SU | + + + + + + + + + - + |
SRO | + + + - + + + + - - + |
SNW | + + + + + + + + + - + |
SNRW | + + + + + + + + + - + |
X | + + + + + + + + + + + |
SPW | + + + + + + + + + - + |
4.3 unobtrusive(fast path)与obtrusive(slow path)
根据锁类型的兼容性,MDL可以划分为 unobtrusive (非侵入性)和 obtrusive(侵入性) 类型的锁,其获取过程分别对应 fast path(快速路径)和 slow path(慢速路径)。
判断锁类型为unobtrusive或obtrusive的代码:MDL_lock::get_unobtrusive_lock_increment()。
• unobtrusive与fast path
unobtrusive类型的锁,在DML中使用频繁,并与其他类型锁能兼容。unobtrusive锁通过使用fast path来获取和释放,系统不会对等待队列(m_waiting)和已授权队列(m_granted)进行检查,而是替换为整数计数器的来检查,通过递增或递减计数器来管理。
Fast path优化了unobtrusive锁的获取和释放,从而提升了DML的性能。
对于scoped lock,IX类型是unobtrusive,可以使用fast path来获取和释放。
对于object lock,S, SH, SR, SW和SWLP类型是unobtrusive,可以使用fast path进行优化。以object lock为例,其fast_path_state_tm_unobtrusive_lock_increment[MDL_TYPE_END]定义如下:
For per-object locks:
- "unobtrusive" types: S, SH, SR and SW
- "obtrusive" types: SU, SPW, SRO, SNW, SNRW, X
Number of locks acquired using "fast path" are encoded in the following
bits of MDL_lock::m_fast_path_state:
- bits 0 .. 19 - S and SH (we don't differentiate them once acquired)
- bits 20 .. 39 - SR
- bits 40 .. 59 - SW and SWLP (we don't differentiate them once acquired)
Overflow is not an issue as we are unlikely to support more than 2^20 - 1
concurrent connections in foreseeable future.
This encoding defines the below contents of increment array.
*/
{0, 1, 1, 1ULL << 20, 1ULL << 40, 1ULL << 40, 0, 0, 0, 0, 0, 0},
在MDL_lock中使用整型原子变量std::atomic m_fast_path_state ,用来统计该锁授予的所有 unobtrusive锁类型的数量。具体而言,每种 unobtrusive 锁类型的数量由固定长度的bit位来表示,相当于用一个 longlong 类型统计了所有 unobtrusive 锁类型的授予个数,同时可以通过 CAS (Compare-And-Swap,原子操作)进行无锁修改。
根据MDL_request的请求类型,获取对应类型的unobtrusive值。如果值为0,则代表该类型锁为obtrusive,需要走slow path。
• obtrusive与slow path
obtrusive类型的锁与其他类型或自身不兼容。对于DML操作,这类锁并不常见,但因为其获取与释放过程涉及对m_waiting/m_granted进行复杂的检查和操作,即slow path。如果当前锁对象已经存在obtrusive类型的锁,则只能使用slow path处理,因为已经存在不兼容的类型,无法直接授予新的锁。
当前线程如果申请obtrusive的锁,则该线程所有使用fast path获得的unobtrusive的锁,都需要进行物化(对应代码MDL_context::materialize_fast_path_locks()),从fast path的m_fast_path_state中移除,添加到锁对象的m_granted链表中,用于后续检查。由于m_fast_path_state无法区分线程,而当前线程获取的多个锁之间不构成锁冲突,所以在通过bitmap判断锁状态前,需要确保m_fast_path_state中所有的ticket都是属于其他线程的,从而避免当前线程获取多个锁的冲突。
5. MDL状态
MDL状态,即MDL锁的获取结果。对应代码enum enum_wait_status,具体函数如下:
6. 重要数据结构
• MDL_request
MDL_request表示语句对MDL的请求,由MDL_key(锁的唯一标识)、MDL_ticket(锁的授权凭证)、enum_mdl_type(锁的类型)和enum_mdl_duration(锁的持续时间)组成。
MDL_request和MDL_ticket由不同的类表示,生命周期也不同。
MDL_request是在MDL系统外部分配的,可以是一个临时变量;而MDL_ticket的分配由MDL系统内部控制,并不会随着MDL_request的销毁而释放。
• MDL_ticket
MDL_ticket表示当前线程(THD)对数据库对象的访问权限,由MDL_context(上下文信息)和enum_mdl_type(锁的类型)组成。
MDL_ticket由MDL系统分配,在线程请求MDL时被创建,在事务结束时销毁。
• MDL_lock
MDL_lock表示对应名称的锁对象。对于给定对象,系统中只有一个MDL_lock实例存在,并且只有在锁被授予时才处于活动状态,如图1所示。
图 1 MDL_lock
MDL_lock对象被统一保存在全局的MDL_map mdl_locks中,每个MDL_lock实例由MDL_key、两个Ticket_list、MDL_lock_strategy组成。其中,两个Ticket_list分别为m_granted和m_waiting,用于存储已获取和正在等待的MDL_ticket。
• MDL_context
MDL_context是THD获取MDL锁时的上下文环境。
图 2 MDL_context
THD通过MDL_context来申请和释放MDL锁,对于持有的每个MDL锁会有一个对应的MDL_ticket,存放在m_ticket_store中。另外,MDL_context还包含了一个m_waiting_for成员,用于记录当前会话正在等待的MDL_ticket信息。
7. MDL相关代码流程
下面介绍MDL的相关流程函数。
7.1 加锁
加锁的入口函数:MDL_context::acquire_lock()
MDL_context::acquire_lock
|-MDL_context::try_acquire_lock_impl() // 尝试获取锁,如果成功,直接返回
|-MDL_context::find_deadlock() // 死锁检测
|-等待获取锁
|-异常处理逻辑
|-获取成功,保存MDL ticket
加锁的主要实现是在函数MDL_context::try_acquire_lock_impl中,如图3所示。
图 3 MDL加锁流程
当MDL_context::ticket_store()检测当前已持有的MDL能够满足请求的条件时,直接返回成功。若已持有的锁与请求的锁在duration上不匹配(例如,当前锁在事务结束时自动释放,而请求需要的是显式释放的锁),则clone一个MDL_ticket,再返回成功。当MDL_context::ticket_store()检测当前未持有MDL时,则创建新的锁对象。
随后,在static MDL_map::mdl_locks中查找或者新增对应的MDL_lock对象, 对于每个锁请求,评估其是否符合fast path条件。如果符合,将ticket加入m_granted,直接返回成功;如果不符合,只能使用slow path的锁,还需要MDL_lock::can_grant_lock判断能否直接授予。若能直接授予,将ticket加入m_granted后,返回成功。若不能直接授予,ticket加入m_waiting后,调用MDL_context::find_dead_lock进行死锁检测。如果检测到死锁,返回失败。如果没有检测到死锁,请求进入等待状态,直到锁被成功授予或达到超时时间。
7.2 释放锁
释放锁的入口函数:MDL_context::release_lock(),主要作用是移除ticket,并调用MDL_lock::reschedule_waiters唤醒等待队列中的合适线程 。
7.3 锁升级
锁升级的入口函数:MDL_context::upgrade_shared_lock(),如果当前线程已持有共享锁,返回成功。否则,会申请新类型的锁,并通过MDL_context::acquire_lock()更新锁信息。
MDL_context::upgrade_shared_lock
|-MDL_ticket::has_stronger_or_equal_type // 如果已持有更强类型的锁,返回成功
|-MDL_context::acquire_lock // 申请新类型的锁
|-更新锁信息
|-如果获得一个新ticket,将其从m_granted移除,因为还要用原ticket
|-移除原ticket
|-更新原ticket类型,重新加到m_granted
7.4 锁降级
锁降级的入口函数:MDL_ticket::downgrade_lock()。首先,该函数会从granted中移除当前的ticket,再修改状态并重新加入。接着,通过调用MDL_lock::reschedule_waiters()评估是否唤醒合适的等待者。MDL的升降级适用于部分DDL,降级后,DDL与DML可以并行(Online DDL)执行,在DDL提交时,锁会再升级。
7.5 死锁检测
死锁检测的入口函数:MDL_context::find_deadlock(),在加锁前会进行死锁检测。其核心逻辑如下:
首先,搜索深度递增,然后判断是否超过最大搜索深度(MAX_SEARCH_DEPTH= 32),若超过,就无条件认为有死锁,退出;
其次,对当前层进行广度搜索,即遍历当前锁存放ticket的m_granted和m_waiting链表。如果ticket对应的线程和死锁检测的发起线程相同,则说明有回路,随即退出;
最后,再进行深度搜索:重新遍历当前锁的m_granted和m_waiting链表,对每个ticket对应的线程,递归调用MDL_context::visit_subgraph(),实现对死锁的全面检测。
MDL_context::find_deadlock()
|-MDL_context::visit_subgraph() // 如果存在m_waiting_for的话,调用对应ticket的accept_visitor()
|-MDL_ticket::accept_visitor() // 遍历通过该ticket所代表的边可达的等待图,并搜索死锁。
|-MDL_lock::visit_subgraph() // 递归遍历锁的m_granted和m_waiting去判断是否存在等待起始节点(死锁)情况
// 依次递归授予链表和等待链表的MDL_context来寻找死锁
|-Deadlock_detection_visitor::enter_node() // 进入当前节点,
|-Deadlock_detection_visitor::opt_change_victim_to()// 如果发现有死锁,且当前节点死锁权重较低,则将当前节点更改为新的死锁受害者。
|-遍历m_granted,判断兼容性
|-如果不兼容的话,调用Deadlock_detection_visitor::inspect_edge()判断是否死锁
|-遍历m_waiting,同上
|-遍历m_granted,判断兼容性
|-如果不兼容的话,递归调用MDL_context::visit_subgraph()寻找连通子图。如果线程等待的ticket已经有明确的状态,非WS_EMPTY,可以直接返回
|-遍历m_waiting,同上
|-Deadlock_detection_visitor::leave_node() // 离开当前节点
7.6 SQL执行流程中的MDL
下面分别以SELECT语句和INSERT/UPDATE/DELETE语句为例,分析执行流程中MDL的申请释放。
• SELECT查询语句
对于一条简单的SELECT查询语句,其执行过程中的MDL操作如图4所示,对应的MDL锁为TABLE-TRANSACTION-SHARED_READ。
图 4 简单的SELECT查询中的MDL
在SQL解析阶段,LEX 和 YACC会给待访问表初始化 MDL的 锁请求。不同的语句对应的锁类型不同,例如,SELECT 语句对应 SR(共享读锁),而INSERT/UPDATE/DELETE 语句对应 SW(独占写锁)。
在实际执行语句之前,系统将调用 open_tables_for_query 函数来访问所有需要的表,并为它们创建TABLE 表对象。这一步骤的首要任务是获取 MDL,以避免对同一个表的元数据并发读写。只有在成功获取了 MDL之后,系统才会继续进行表资源的获取流程。
在SQL执行结束前,mdl_context.release_transactional_locks函数会被调用,以释放先前获得的所有MDL。
• INSERT/UPDATE/DELETE语句
对于一条简单的INSERT/UPDATE/DELETE语句,其执行过程中的MDL操作如图5所示。
图 5 DML中的MDL操作流程
在open table阶段,INSERT/UPDATE/DELETE语句会先后获取两个锁,分别是GLOBAL-STATEMENT-INTENTION_EXCLUSIVE和TABLE-TRANSACTION-SHARED_WRITE。而在commit阶段,会获取COMMIT-MDL_EXPLICIT-INTENTION_EXCLUSIVE锁。
8. TaurusDB中的MDL主备同步
TaurusDB采用存算分离的架构,其中,Master节点和Replica节点share-storage(共享存储),即它们并没有各自独立的数据存储系统,同时,也没有专门的协调者角色来居中处理各类锁请求。因此,在TaurusDB上存在如下场景:
图 6 备机冲突
如图6所示,当TaurusDB的备机正在执行的查询涉及的表被主机修改了表结构或直接drop时,会产生冲突。针对类似这种场景,需要将主机DDL时涉及的MDL操作同步到备机,以解决上述问题。
TaurusDB基于 “无协调组件”(Coordination free)和“重做一切”(Redo everything)两大原则,通过新增一种MDL类型的redo log,将主节点上的MDL同步到备机:
1)Coordination free,不引入额外的组件作为主备之间的协调者
2)Redo everything,通过redo日志来进行同步,这也是主备之间已有的一个通道
图 7 MDL通过redo同步到备机
如图7所示,通过将主机上MDL的加锁与释放都记录在redo日志里,再于备机中进行回放,确保了TaurusDB备机数据和元数据的一致,并且不阻塞主机上业务的执行。
• Master节点
在执行DDL过程中,主节点会将MDL加锁、失效、放锁的操作添加到一个DDL元数据请求列表meta_request_info_list中。在事务提交时,系统会依据meta_request_info_list中保存的内容,生成响应的MDL类型redo日志,并将其发送给只读节点。
• Read Replica节点
在只读节点中,TaurusDB有一套通用的redo log处理流程,涉及以下多个线程。
Reader线程:负责读取redo日志,并将其交给Dispatcher线程
Dispatcher线程:接收到日志以后,分发给多个Parser线程进行解析,并收集结果。其中,解析后的日志由Advancer线程及Impeller线程进行合并,并做相关处理。MDL相关处理逻辑主要位于Parser线程和Advancer线程。
Parser线程:解析日志,将解析到的MDL类型的redo日志保存到元数据请求列表meta_requests中。
Advancer线程:处理meta_requests中的MDL相关日志,将还未收到释放锁的MDL请求保存到ongoing_meta_request_infos,而将已经收到对应释放锁日志的MDL请求保存到ready_meta_request_infos 。后续再依据ready_meta_request_infos中的内容,进行相应的MDL加锁、失效、释放操作。
9. 总结
本文对TaurusDBMDL源码实现进行了深度分析,同时结合TaurusDB自身的架构设计,剖析实现MDL在主备节点间的高效同步机制,确保了只读节点数据的一致性。
- 点赞
- 收藏
- 关注作者
评论(0)