第八章 并发访问与锁机制
# 第八章 并发访问与锁机制
当多个客户端有可能同时访问并修改相同数据时,一套合适的锁机制对于确保数据一致性至关重要。解决这个问题主要有三种方法:表级锁、页级锁和行级锁。每种方法都各有优劣。
表级锁的逻辑最为简单,这使得在锁获取方面出现错误的概率更低,性能也更好,并且死锁也相对容易避免。但另一方面,锁定整个表会导致对于大量并发读写操作的应用程序来说,性能表现不佳。
行级锁能在高并发场景下实现高性能,但代价是实现过程更为复杂。这就导致对于锁争用概率较低的应用程序而言,其性能反而会下降,而且出现错误的可能性也更高。此外,完全避免死锁非常困难,因此许多实现方案采用死锁检测机制。
随着锁的粒度减小,锁定相同数量的数据通常需要更多的内存,算法的复杂度也会增加,死锁的潜在风险同样上升。不过,锁粒度的减小增加了并发访问的可能性,这可能会让那些等待锁的应用程序延迟。
行级锁的粒度最小,表级锁的粒度最大,页级锁则介于两者之间,提供了一种折中的方案。
MyISAM和MEMORY存储引擎仅支持表级锁。InnoDB支持行级锁,而Berkeley DB支持页级锁。
当解析器处理查询时,它会根据查询类型,以一种相对简单的方式确定需要获取哪种类型的表锁。一旦执行过程到达锁管理器,锁管理器会给每个相关的存储引擎一个机会,来更新每个表的锁类型。然后,根据一种通用的、与存储引擎无关的算法来获取表锁。支持更细粒度锁的存储引擎会请求一种允许并发读写的锁,然后在内部处理锁定问题。
MySQL中锁机制的架构在很大程度上是其发展历史的产物。MySQL最初构建了一个与存储引擎无关的表锁管理器,而最初的存储引擎(MyISAM和MEMORY)在执行所有操作时都假设表不会被并发修改。在3.23版本早期,引入了一项功能改变了这一假设。在MyISAM中,只要新插入的行被放置在数据文件的末尾,就可以在读取表的同时进行插入操作,这个功能被称为并发插入(concurrent insert)。
并发插入功能的引入要求对表锁管理器进行一些修改,但并未改变其基本架构。在此之前,锁定算法完全由查询决定。新出现的复杂情况通过在锁结构中添加一个回调函数指针来解决,该指针用于检查是否可以进行并发插入。如果指针被设置为0,或者回调报告无法进行并发插入,则将锁升级为常规写锁。
随着支持页级锁的BDB的出现,情况发生了变化。这带来了一个挑战,即要确保在只需要锁定一行或几行数据时,表锁管理器不会尝试锁定整个表。此时,简单的回调已经不够用了。在调用层级的某个地方,存储引擎现在需要检查查询的性质,并告知表锁管理器哪些表不需要锁定。这个问题通过添加新方法handler::store_lock()
得以解决,该方法允许存储引擎更改查询解析器最初请求的锁类型。
# 表锁管理器
如前所述,无论存储引擎支持的锁粒度级别如何,所有涉及到各个存储引擎表的查询都要经过表锁管理器。例如,即使支持行级锁,也会获取一个特殊的表锁,以允许并发写访问。在源代码中,所有可能的锁类型都在include/thr_lock.h
中的enum thr_lock_type
中定义。表8-1列出并介绍了所支持的锁类型。
表8-1 MySQL中的锁类型
锁类型 | 描述 |
---|---|
TL_IGNORE | 在锁定请求中使用的特殊值,表示在锁描述符结构中无需进行任何操作。 |
TL_UNLOCK | 在锁定请求中使用的特殊值,表示应释放锁。 |
表8-1 MySQL中的锁类型(续) | |
锁类型 | 描述 |
--- | --- |
TL_READ TL_READ_WITH_SHARED_LOCKS TL_READ_HIGH_PRIORITY TL_READ_NO_INSERT TL_WRITE_ALLOW_WRITE TL_WRITE_ALLOW_READ TL_WRITE_CONCURRENT_INSERT TL_WRITE_DELAYED TL_WRITE_LOW_PRIORITY TL_WRITE TL_WRITE_ONLY | 常规读锁。 InnoDB用于 SELECT...LOCK IN SHARE MODE 的高优先级锁。SELECT HIGH_PRIORITY... 使用的高优先级读锁。一种特殊的读锁,不允许并发插入。 存储引擎自行处理锁定时使用的特殊锁。持有此锁时,其他线程仍可获取读锁和写锁。用于 ALTER TABLE 的特殊锁。修改表涉及创建一个具有新结构的临时表,用新行填充它,然后将其重命名为原始名称。因此,在大多数操作过程中,表在被修改时仍可被读取。并发插入使用的写锁。如果表上已经存在这种类型的锁,除非请求 TL_READ_NO_INSERT ,否则会立即授予其他线程读锁。INSERT DELAYED... 使用的特殊锁。UPDATE LOW_PRIORITY... 和其他具有LOW_PRIORITY 属性的查询使用的低优先级锁。常规写锁。 在需要关闭表的操作中,用于中止旧锁的内部值。 |
表锁分为两组:读锁和写锁。表锁管理器为每个表维护四个队列:
- 当前读锁队列(
lock->read
) - 等待读锁队列(
lock->read_wait
) - 当前写锁队列(
lock->write
) - 等待写锁队列(
lock->write_wait
)
当前持有读锁的线程按获取锁的顺序存放在当前读锁队列中。当前正在等待读锁的线程存放在等待读锁队列中。当前写锁队列和等待写锁队列的情况也是如此。
锁获取逻辑可以在mysys/thr_lock.c
中的thr_lock()
函数中找到。
# 读锁请求
只要表上没有当前写锁,并且等待写锁队列中没有更高优先级的写锁,读锁请求总是会被授予。如果锁请求可以立即被授予,相应的锁描述符会被放入当前读锁队列。否则,锁请求会进入等待读锁队列,请求线程会挂起自身以等待锁(见mysys/thr_lock.c
中的wait_for_lock()
函数)。
请求的读锁和等待的写锁根据以下规则进行优先级排序:
- 等待写锁队列中的
TL_WRITE
锁优先于除TL_READ_HIGH_PRIORITY
之外的所有读锁。 TL_READ_HIGH_PRIORITY
请求优先于任何等待的写锁。- 等待写锁队列中除
TL_WRITE
之外的所有写锁优先级都低于读锁。
除以下情况外,当前写锁的存在会导致请求线程挂起并等待锁可用:
- 经存储引擎批准(通过
THR_LOCK
描述符中的check_status()
函数指针调用实现),除TL_READ_NO_INSERT
之外的所有读锁都允许一个TL_WRITE_CONCURRENT_INSERT
锁。 TL_WRITE_ALLOW_WRITE
允许除TL_WRITE_ONLY
之外的所有读锁和写锁。TL_WRITE_ALLOW_READ
允许除TL_READ_NO_INSERT
之外的所有读锁。TL_WRITE_DELAYED
允许除TL_READ_NO_INSERT
之外的所有读锁。TL_WRITE_CONCURRENT_INSERT
允许除TL_READ_NO_INSERT
之外的所有读锁。- 冲突的写锁属于请求线程。
# 写锁请求
当请求写锁时,表锁管理器首先检查当前写锁队列中是否已经存在写锁。如果没有,则检查等待写锁队列。如果等待写锁队列不为空,请求会被放入写锁队列,线程挂起等待锁。否则,在等待写锁队列为空的情况下,检查当前读锁队列。如果存在当前读锁,写锁请求会等待,以下情况除外:
- 请求的锁是
TL_WRITE_DELAYED
。 - 请求的锁是
TL_WRITE_CONCURRENT_INSERT
或TL_WRITE_ALLOW_WRITE
,并且当前读锁队列中没有TL_READ_NO_INSERT
锁。
如果满足这些特殊条件,锁请求会被授予并放入当前写锁队列。
如果当前写锁队列中有锁,则首先处理TL_WRITE_ONLY
请求的特殊情况。只有在没有当前写锁时,才会授予TL_WRITE_ONLY
锁。否则,请求会被中止,并向调用者返回一个错误代码。
处理完特殊情况后,表锁管理器现在可以检查请求的写锁与当前写锁队列头部的当前写锁是否可以共存。在以下任一情况下,请求可以在无需等待的情况下被授予:
- 当前写锁队列中冲突的写锁是
TL_WRITE_ALLOW_WRITE
;请求的锁也是TL_WRITE_ALLOW_WRITE
;并且等待写锁队列为空。 - 冲突的写锁由请求线程持有。
# 存储引擎与表锁管理器的交互
表锁管理器提供的锁定机制对许多存储引擎来说并不够。MyISAM、InnoDB、NDB和Berkeley DB存储引擎都提供了某种形式的内部锁定机制。
- MyISAM:MyISAM主要依靠表锁管理器来确保正确的并发访问。不过,有一个例外情况:并发插入。如果插入操作是将记录写入数据文件的末尾,那么可以在不锁定的情况下进行读取。在这种情况下,表锁管理器允许一个并发插入锁和多个读锁。存储引擎通过在并发插入开始前记住文件的旧末尾位置,并在并发插入完成之前不允许读取超过该旧末尾位置的数据,来确保数据一致性。
- InnoDB:InnoDB通过将写锁的类型更改为
TL_WRITE_ALLOW_WRITE
,请求表锁管理器将锁定操作延迟到存储引擎层面处理。在内部,它实现了一个复杂的行级锁定系统,其中包括死锁检测。 - NDB:NDB是一个分布式存储引擎,也支持行级锁。它处理表锁的方式与InnoDB类似。
- Berkeley DB:Berkeley DB在内部支持页级锁,因此和NDB、InnoDB一样,需要将写锁类型变为
TL_WRITE_ALLOW_WRITE
。
# InnoDB锁机制
虽然InnoDB不是唯一支持某种内部锁定机制的存储引擎,但它可能是最值得关注的。作为MySQL中所有事务存储引擎里最稳定和成熟的一个,它通常是关键任务、高负载环境的首选引擎。本节简要概述InnoDB的锁机制。
# 锁类型
行级锁有两种类型:共享锁和排他锁。InnoDB对这两种类型都支持。
为了支持行级锁和表级锁共存,InnoDB还在表上使用了所谓的意向锁。意向表锁也有两种类型:共享意向锁和排他意向锁。
正如意向锁的名称所示,如果一个事务已经持有共享锁,另一个事务仍有可能获取另一个共享锁。然而,在任何时刻,只能有一个事务持有排他锁。
在锁定表中的某一行之前,事务必须先获取该表上适当的意向锁。获取排他意向锁后,可以获取共享行锁。不过,只有排他意向锁才允许事务获取排他行锁。
# 记录锁定
当InnoDB在搜索优化器请求的记录时,会进行记录或行锁定。InnoDB实际锁定的是索引项、索引项之前的空间以及最后一条记录之后的空间。这种方法称为next-key锁定。
next-key锁定对于避免事务中的幻行问题是必要的。如果我们不锁定记录之前的空间,另一个事务就有可能在中间插入一条新记录。这样一来,如果我们再次运行相同的查询,就会看到第一次运行查询时不存在的记录。这将导致无法满足可串行化读事务隔离级别的要求。
# 处理死锁
如果事务A锁定了记录R1,然后尝试锁定R2,而事务B同时先锁定了记录R2,然后尝试锁定R1,会发生什么情况呢?行级锁自然会引发死锁问题。
InnoDB有一个自动死锁检测算法。它通常会回滚涉及死锁的最后一个事务。不过,死锁检测算法在某些情况下会失效,例如,如果使用了其他存储引擎的表,或者某些表使用了LOCK TABLES
进行锁定。此外,有些事务可能会被认为处于虚拟死锁状态。例如,如果一个查询的编写方式使其需要检查几十亿条记录,那么它可能在数周内都不会释放锁,尽管从理论上讲它最终会释放。针对这种情况,InnoDB使用锁超时机制,该机制由配置变量innodb_lock_wait_timeout
控制。
任何事务都有可能陷入死锁。应用程序程序员编写能够处理这种可能性的代码非常重要。通常,重试回滚的事务就足够了。通过精心编写代码,也可以尽量降低死锁的发生几率。始终按照相同的索引顺序访问记录、编写经过适当优化的查询以及频繁提交事务,都是有助于防止潜在死锁的一些技巧。