什么是数据库“锁”?
当我熟悉数据库术语时,我发现非常困惑的一件事是“锁【lock】”这个词在数据库中的含义与在编程中的含义不同。
在编程中,如果你有一个“锁”,那么它就是内存中存储在某个地址下的单个对象,然后有多个线程尝试“锁定【locking】”它,要么成功,要么等待直到成功。 因此,每个资源都有一个锁,而“锁定【locking】”操作是线程执行的操作,您可以使用调试器【debugger】捕获它发生的那一刻,但是没有内存对象(除了调用堆栈外)显式地记录给定线程尝试或成功获取锁的事实。
在 InnoDB 中,上述概念被称为“锁存器【latch】”,将“锁【lock】”一词重新定义为完全不同的东西。 在 InnoDB 的锁系统中,“锁”实际上更像是“特定事务【transaction】对特定资源【resource】的特定类型的访问权【access right】的请求”。 因此,对于某一特定资源,如果有多个事务请求访问该资源,则可能存在数百个“锁【lock】”;如果单个事务需要使用不同的锁定模式【lock modes】访问该资源,则甚至可能会存在多个“锁【lock】”。 “锁【lock】”可以处于等待状态,也可以被授予并记录给定事务对给定资源的访问权【access right】。 你可以把它想象成一张纸质表格,你必须提交申请才能获得某件事的许可,在某个官员的抽屉里等待批准盖章,最终获得批准,并作为证明你的权利的证书。 因此,锁更像是记录请求状态的元组:
<who?, what?, how?, granted?>
例如(大大简化)锁系统可以同时包含以下对单个资源(即 report 表中的 row#2 资源)相关的锁:
<transaction#3305, row#2 of table `report`, shared, granted >
<transaction#3305, row#2 of table `report`, exclusive, granted >
<transaction#3306, row#2 of table `report`, exclusive, waiting >
显式地建模谁(who)请求什么(what),并将其作为内存中的对象的好处之一是您可以通过查看这些对象来检查情况。 特别是,您可以查询 performance_schema.data_locks 表来查看 InnoDB 引擎中的活动事务创建的所有锁:
我稍后将在本文中的“记录锁【Record locks】”部分解释 LOCK_MODE 列中所有可能值的确切含义,现在只要有一个直觉就足够了,即(粗略地说)S 和 X 分别对应于共享和独占。
注: 如果您开始怀疑在另一个表中保留锁来保护对表的访问的悖论,让我安慰你:这不是一个真正的 InnoDB 表。有一些魔法使它看起来像一个表,但它实际上是扫描服务器内存中的实际的低级数据结构【low-level data structures】并将它们呈现为整齐的行的结果。
实际上,这些只是显式锁【explicit locks】——出于性能原因,InnoDB 尝试避免显式表示访问权【access rights】,因为它可以从行本身的状态【state】中隐式推断出访问权【access rights】。 您可以看到,每次事务修改一行【row】时,它都会将自己的 id 放在该行的 header 中,以表明它是最后一个修改该行的人 —— 如果这个事务仍然没有提交,那么它就意味着它仍然拥有对该记录的独占访问权(它也必须拥有它才能修改行,并且“两阶段锁定”中的锁仅在提交时释放),而不需要浪费空间来显式存储此信息。 此类隐式锁不会出现在 performance_schema.data_locks 中(这需要扫描撤消日志【undo logs】以识别所有隐式锁【implicit locks】)。 创建隐式锁的最常见原因是针对 INSERT
操作:在插入事务提交之前,成功插入的行对其他事务是不可见的,并且单个事务插入多行是常见情况,因此不为新插入的行创建显式锁的成本更低,而只是隐式地假设插入事务对所有这些行具有独占访问权,因为它的事务 id 写在这些行的 header 中。 正如下一章节第 3 部分“死锁”中将解释的,正确地建模和监视谁(who)等待谁(who)非常重要,因此每当锁系统识别出隐式锁可能是另一个事务必须等待的原因时,它就会将隐式锁转换为显式锁,以便可以正确地分析、监视、报告等。这被称为隐式到显式的转换,并且在语义上不会改变任何东西——它只是改变了锁的表示。
表锁【table lock】
如前所述,在 InnoDB 中,大部分锁定发生在行【row】的粒度上。 这增加了并行性的机会,因为多个事务可以同时处理不相交的行【rows】集,而服务器仍然可以假装一个事务在另一个事务之后以可序列化的顺序发生。 还有表级锁,它可以让您锁定整个表。 由于 InnoDB 与 Server 集成的方式,这是相当罕见的。 InnoDB 位于 Server 下方,Server 也有自己的锁定机制,因此大多数时候 InnoDB 甚至不知道事务已经锁定了一个表,因为它发生在“上面”。 坦率地说,如果我们现在谈论表锁,我有点纠结:从某种意义上说,它们比记录锁【Record lock】简单得多,另一方面,InnoDB 和 Server 协调对表【table】的访问的方式,各自都试图以自己的方式来做,使对所发生事情的理解变得更加复杂。 特别地是,这意味着 performance_schema.data_locks 不会报告由 Server 本身维护的锁。 因此,在默认配置下,您会看到一些令人困惑的事情发生, 例如:
您可能期望您的事务已锁定表 t,但您看不到任何锁:
con2> SELECT * FROM performance_schema.data_locks;
Empty set (0.00 sec)
而且你甚至看不到事物!
con2> SELECT * FROM information_schema.innodb_trx;
Empty set (0.00 sec)
这是因为默认情况下,Server 和 InnoDB 的配置方式是:如果您执行 LOCK TABLES, Server 将提交【commit】当前事务。 事实上,即使我们自己没有发出 COMMIT 指令,任何其他客户端都已经可以看到我们刚刚插入的行,这意味着事务被认为已经提交(哎呀!):
“LOCK TABLES 机制”与“事务机制”有些不同,其默认行为是在开始处理另一个之前要先完成当前这个。 您可以将 LOCK TABLES + UNLOCK TABLES 视为临界区,并且您可以以同样的方式思考 BEGIN + COMMIT 。 但是,默认情况下你不能将两者交错。
您可以通过查询一个完全不同的名为 performance_schema.metadata_locks 的表来验证 LOCK TABLES t READ 是否有效(请注意名称中的“meta”):
MySQL 的架构是 Server 和 InnoDB 是完全独立的,我不想假装我对 Server 的内部结构了解很多。 所以,我只想说这个表显示了 Server 所获取的锁,并且它们确实阻止了其他客户端尝试修改该表:
con3> insert into test.t values (10);
⌛
将会等待,您可以通过以下方式验证:
但请注意,这与 InnoDB 的锁系统无关:实际上 InnoDB 目前没有正在进行的事务:
on2> SELECT * FROM information_schema.innodb_trx;
Empty set (0.00 sec)
一旦你试图在 con1 中启动一个执行 LOCK TABLES 的事务…
con1> BEGIN;
Query OK, 0 rows affected (0.00 sec)
con3 中的插入将继续并成功:
con3> insert into test.t values (10);
Query OK, 1 row affected (3 min 19.60 sec)
(这 3 分钟是我在 con3 中输入 INSERT
后,到在 con1 中输入 BEGIN 所花费的时间)
因此,看起来使用 BEGIN
启动事务会隐式 UNLOCK TABLES
。 事实上:默认情况下,如果您开始处理事务,那么您就会结束锁定表【locking tables】。
这里还要注意的另一件事是,con3 在发出 INSERT
之前不必使用任何 LOCK TABLES
语句,但在 con1 释放表之前阻止 con3 操作的机制是有效的。 这意味着参与这种 Servel 级别的表锁定机制【Servel-level table locking mechanism】是强制性的和隐式的,而不是您必须选择加入并且可能会错过的事情。 我们说这种锁定是强制性的,而不是建议性的。
另外,请注意, INSERT
语句所需的锁类型是 SHARED_WRITE ,这可能听起来令人困惑,因为到目前为止我们通常将“shared”等同于“reading”,将“exclusive”等同于“write”。 这里正确的解释是,在同一张表中可能有多个事务在编辑行。 因此,他们可以彼此共享对表的访问,即使他们每个人都想写入,只要他们写入不同的行即可。 因此它同时是“(在表级别)shared”和“(在行级别)write”。
但是, SHARED_WRITE 与 SHARED_READ_ONLY 冲突,这也是有道理的,因为 con1 希望阻止对整个表的任何写入。 这样的 SHARED_READ_ONLY 在名称中带有“shared”,因为它可以与其他也需要 SHARED_READ_ONLY 的事务兼容,因为它们的利益一致:它们都希望防止修改。
InnoDB 里的表锁 (hopefully less confusing now!)
好的,以上讨论的这些是由 Server 维护的锁,但是本系列博客文章的目的是讨论 InnoDB 的锁系统。 我们如何在 InnoDB 中创建表级锁?
官方文档中给出的技巧是禁止使用 BEGIN(及其同义的 START TRANSACTION ),这会隐式导致 UNLOCK TABLES。 相反,我们将禁用自动提交【auto commit】,以便隐式地表明我们所做的一切都是事务的一部分。 我们将处于事务内部,而无需显式启动它。太酷辣~
所以,我们现在得到了我们想要的:一个活跃的 InnoDB 事务(InnoDB 中的 ID 为 3851),它拥有 InnoDB 内的显式表锁,对应于相应的 Server 线程(ID 为 49)所持有的锁。 是的,表被锁定两次:在 Server 级别和 InnoDB 级别:
table | lock held by Server layer | lock held inside InnoDB engine |
t | SHARED_READ_ONLY | S |
t1 | SHARED_NO_READ_WRITE | X |
InnoDB 里的表意图锁【Table Intention locks】
在 InnoDB 中获取表锁的另一种方式是,当您尝试读取或修改表的某个部分时,也就是当您尝试 SELECT 一行、UPDATE 现有行或 INSERT 新行时,这种情况经常发生,并且不需要任何 LOCK TABLES
或 autocommit
技巧。
例如,假设我们启动一个全新的事务,并插入一个新行:
为了能够尝试向表 t 插入任何内容,该事务将需要获得对表的特定权限:
InnoDB 的 IX 对应于 Server 的 SHARED_WRITE ,我们之前看到过。为了继续这个例子,让我们假设这个事务从 t1 执行读取:
令人惊讶的是,在这种情况下,InnoDB 没有获取表级锁。这是有道理的:行也没有被锁定,因为它是一个非锁定选择【on-locking select】,并且查询【query】在 Server 级别受到保护:
如果您尝试通过执行锁定选择【locking select】(SELECT ... FOR SHARE / UPDATE)来锁定表的一部分以进行读取,则行为会有所不同,例如:
这一次,当我们试图尝试读取并锁定表 t1 的一部分时,我们为它请求 IS。
这里出现的一件事是,当我们尝试在表级别指定所需的访问权限时,“整个表【whole table】”和“部分表【part of the table】”之间存在区别。您可以想象以下组合:
- X → I want to be the only one who has access to the whole table
- S → I want to be able assume that whole table is protected from modification
- IX → I intend to modify some part of the table
- IS → I intend to read some part of the table
(这些名称((X, S, IX, IS)是InnoDB谈论表锁的方式)
让我们花一点时间来弄清楚哪些锁请求是相互兼容的,哪些是不能同时授予的,就好像我们要自己设计一样。这种访问权限之间的兼容性关系可以巧妙地总结为一个兼容性矩阵,该矩阵有一行代表您可能想要请求的每种可能的访问权限,还有一列代表另一个事务已经拥有的每种可能的访问权限:
↓my request \ held by other→ | X | S | IX | IS | AUTO_INC |
X | ? | ? | ? | ? | ? |
S | ? | ? | ? | ? | ? |
IX | ? | ? | ? | ? | ? |
IS | ? | ? | ? | ? | ? |
AUTO_INC | ? | ? | ? | ? | ? |
让我们弄清楚如何在上面的矩阵中使用⌛(新请求必须等待)和 ✅(新请求可以继续)。
显然,X 似乎与其他任何东西都不兼容。S 似乎与其他 S 和 IS 均兼容,但它无法应对另一个线程对表的一小部分进行的修改,因此它与 IX 冲突。
↓my request \ held by other→ | X | S | IX | IS | AUTO_INC |
X | ⌛ | ⌛ | ⌛ | ⌛ | ⌛ |
S | ⌛ | ✅ | ⌛ | ✅ | ? |
IX | ⌛ | ⌛ | ? | ? | ? |
IS | ⌛ | ✅ | ? | ? | ? |
AUTO_INC | ⌛ | ? | ? | ? | ? |
IX 是否应与其他 IX 或 IS 冲突?不——拥有这样一个细粒度系统的全部意义在于允许对表进行并发修改。当然,我们必须以某种方式确保两个事务不会修改冲突的行集【set of rows】,但这可以在较低粒度级别上处理,即当它们尝试请求访问单个行时。请求 IX 的事务所要求的只是“请求将来访问行的许可/权限”。这种“请求许可/权限”可能听起来很愚蠢,但它至少有两个目的:
- 我们可以在事务开始搜索要访问的实际行【row】之前快速响应“不行,你的 IS 必须等待,因为有人用 X 锁定了整个表”,从而为所有人省去麻烦。
- 授予 IS 或 IX 锁是一个明确的信号,表明表内正在进行工作,任何其他试图锁定整个表的事务都必须考虑到这一点,因此它可能必须等到它们完成为止(“危险,下面的工人!”)
可以想象一种不同的设计,其中不存在意向锁(IS 和 IX),每次事务尝试锁定单个行时,它必须首先检查是否存在冲突的 S 或 X 表锁,每次事务尝试 X 或 S 锁定表时,它必须首先检查是否存在任何冲突的记录级锁【record-level locks】。预先指定意图的一个好处是,它通常会减少死锁(或更快地暴露它们)。另一个好处是,如果您仔细考虑“首先检查是否存在现有记录级锁”的设计,您会意识到您可能希望缓存此问题的答案,以避免昂贵的查找,尽量减少更新此信息的同步工作,并采用某种合理的方式报告正在发生的事情……最终您将得到一些相当于 IS 和 IX 锁的东西。
因此,我们最终得到以下兼容性矩阵:
↓my request \ held by other→ | X | S | IX | IS | AUTO_INC |
X | ⌛ | ⌛ | ⌛ | ⌛ | ⌛ |
S | ⌛ | ✅ | ⌛ | ✅ | ⌛ |
IX | ⌛ | ⌛ | ✅ | ✅ | ✅ |
IS | ⌛ | ✅ | ✅ | ✅ | ✅ |
AUTO_INC | ⌛ | ⌛ | ✅ | ✅ | ⌛ |
(我已将尚未提及的 AUTO_INC 锁放入此矩阵中,以使其完整,以供将来参考。我希望您现在有足够的直觉来自己弄清楚为什么 AUTO_INC 锁必须与 S 冲突,以及为什么它与 IX 略有不同,因为它与自身冲突。)
剧透:
当在表的末尾插入一行并通过 AUTO INCREMENT 为其分配主键时,就会使用 AUTO_INC,因此,自然必须注意同步两个并行执行此操作的事务,这样它们就不会以相同的键结束。
请注意,这个矩阵具有对称性这一良好特性:如果 A 与 B 冲突,则 B 也与 A 冲突。在处理记录级锁【record-level locks】时,我们将看到没有此特性的矩阵,您将学会体会到能够使用对称冲突关系并漫不经心地说出“A 和 B 相互冲突”之类的话而不指定方向是多么令人欣慰。
看待表锁的另一种视角是将其概括为嵌套作用域【nested scope】的任意层次结构(例如:数据中心【datacenter】 > 数据库【database】 > 表【table】 > 分区【partition】 > 索引【index】 > 行【row】 > 字段【foeld】),并尝试找出一种系统,在该系统中,您可以以这样的方式锁定任何这些作用域【scope】,以便发现冲突。比如说,我想删除一个分区【partition】,而其他人正在尝试对整个数据库进行快照?如何对其进行建模以跟踪正在发生的事情并确定是否有人应该等待?我们的想法是允许人们在给定的较低级别请求 X 或 S 锁,前提是他们已经获得了所有以上级别的 IX 或 IS(分别)锁。因此,要删除分区,您肯定希望拥有对该分区的 X 访问权限,但您首先需要拥有对该表的 IX、对该数据库的 IX 和对该数据中心的 IX。如果有人想要对数据库进行快照,他们需要拥有对该数据库的 S 访问权限和对该数据中心的 IS 访问权限。数据库级别的 S 和 IX 之间的冲突很快就会被检测到,并且有人必须等待。在 InnoDB 中,我们只有这个层次结构的两个级别:表级别和行级别。(实际上,如果您发现这种“嵌套作用域”类比很有帮助,那么您可能会喜欢这样一种观点,其中“行之前的间隙”也是一个作用域,S,GAP 和 X,GAP 锁是“间隙级别的 S 锁”,而 INSERT_INTENTION 锁就像“间隙级别的 IX 锁”。请注意名称中的“INTENTION”,这不是巧合!)
Short note on AUTO_INC locks
它们与其他任何东西都不同。有很多特殊情况的代码和逻辑可以使插入大量行尽可能高效。您可能会认为我在本系列中写的任何内容都不一定适用于它们,除非我这么说。首先,它们通常根本不被占用——在增量持续时间内获取保护序列计数器的短暂闩锁【latch】,并尽快释放。如果被占用,它们可能会在语句结束时被释放,而不是被保留到事务结束。有关更多详细信息,请参阅我们的参考手册中的 InnoDB 中的 AUTO_INCREMENT 处理。
Record locks
如前所述,InnoDB 中的大多数锁定活动都发生在记录级别,但我发现 InnoDB 表锁更容易解释,因为其中可能的锁定模式较少(只有 5 种:X、S、IS、IX、AUTO_INC),并且冲突关系是对称的,这使得理解必要的概念更容易。
InnoDB 是一个庞大的软件,因此必须讨论一些关于正在发生的事情的抽象,而不是被细节淹没。因此,请原谅我的过度简化:我们将想象索引中的一行只是轴上的一个点。也就是说,每个索引都被建模为一个单独的轴,如果你按升序列出索引中的行,则会得到一些沿此轴从左到右的离散点集:
可以概念化为:
--(5)---(10)-----(42)---> id
我们的心理形象应该由点和它们之间的间隙组成:
最右边的间隙比较特殊,因为它不在任何实际行之前。你可以想象一个“无穷大”的伪记录,它比任何其他记录都大,因此最右边的间隙是“伪记录之前”。
实际上,在非过于简化的 InnoDB 中,这个问题发生在每个数据页中:有时我们需要讨论这个特定页面上最后一条记录后的间隙。是的,从概念上讲,这与下一页上第一条记录之前的间隙相同。但是,我们经常处于无法访问下一页的情况,但需要以某种方式讨论/识别/操作此间隙。因此,InnoDB 中的每个页面都有一个最高伪记录。有一种普遍的误解,认为“最高伪记录”标志着整个索引的结束。这是不对的,索引的每个叶节点中都有一个伪记录。
即使不太了解像 InnoDB 等这样的数据库如何运作,我们也可以猜测,有时操作只涉及记录,有时涉及记录之前的间隙,而有时我们需要访问记录和间隙。一种建模方法是将记录和间隙视为两种可以独立锁定的不同类型的资源。当前的 InnoDB 实现采用了一种不同的方法:每个点只有一个资源,但您可以为其请求多种访问权限,该访问权限指定您是否需要行、间隙还是两者都需要。这样做的一个好处是,它针对最常见的需要两者的情况进行了优化。
InnoDB 中目前定义了许多不同的访问权限,它们在 performance_schema.data_locks.lock_mode 列中使用以下字面值表示:
- S,REC_NOT_GAP → 对记录本身的共享访问
- X,REC_NOT_GAP → 对记录本身的独占访问
- S,GAP → 阻止任何人在行前的间隙中插入任何内容的权限
- X,GAP → 同上。是的,“S” 和 “X” 是“shared”和“exclusive”的缩写,但鉴于此访问权限的语义是“防止插入发生”,多个线程都可以同意在没有任何冲突的情况下防止相同的事情发生,因此目前 InnoDB 以相同的方式处理 S,GAP 和 X,GAP (或简称 *,GAP 锁):与 *,INSERT_INTENTION 冲突
- S → 就像是 S,REC_NOT_GAP 和 S,GAP 的组合。所以它是对行的共享访问,并阻止该行之前的间隙插入。
- X → 就像是 X,REC_NOT_GAP 和 X,GAP 的组合。因此,它是对该行的独占访问,并阻止该行之前的间隙插入。
- X,GAP,INSERT_INTENTION → 有权将新行插入到该行之前的间隙中。尽管名称中带有“X”,但它实际上与同时尝试插入的其他线程相互兼容。
- X,INSERT_INTENTION → 在概念上与上述相同,但只发生在“最大伪记录”中,它是“大于页面上任何其他记录”的伪记录,因此“它之前”的间隙实际上是“最后一条记录之后的间隙”。
以上列表是实现细节,将来可能会发生变化。可能会保留的想法是,存在许多“锁定模式【lock modes】”和一组规则来决定模式 A 中的访问请求是否必须等待模式 B 中访问资源的事务完成。这可以通过类似以下矩阵给出:
↓requested \ held→ | S,REC_NOT_GAP | X,REC_NOT_GAP | *,GAP | S | X | *,INSERT_INTENTION |
S,REC_NOT_GAP | ✅ | ⌛ | ✅ | ✅ | ⌛ | ✅ |
X,REC_NOT_GAP | ⌛ | ⌛ | ✅ | ⌛ | ⌛ | ✅ |
*,GAP | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
S | ✅ | ⌛ | ✅ | ✅ | ⌛ | ✅ |
X | ⌛ | ⌛ | ✅ | ⌛ | ⌛ | ✅ |
*,INSERT_INTENTION | ✅ | ✅ | ⌛ | ⌛ | ⌛ | ✅ |
需要注意以下几点:
- 没有人关心已授予的 INSERT_INTENTION。这是因为此访问权限在授予后立即被“使用【consumed】”:事务立即将新记录插入数据库,这会导致(旧)行之前的间隙分裂成两个间隙,因此在某种意义上,旧访问权限不再需要/有效,因此被忽略。
- 无论如何,*,GAP 锁都会立即被授予。这在稍后我将描述的“锁分裂”技术中被大量使用
- 特别是, INSERT_INTENTION 必须等待 *,GAP ,但反过来则不必如此——冲突关系不对称【symmetrical】!
- INSERT_INTENTION 必须等待 S, S 必须等待 X,REC_NOT_GAP,但是INSERT_INTENTION 不必等待 X,REC_NOT_GAP——冲突关系不是传递的【transitive】!
INSERT_INTENTION 必须等待 S,S 必须等待 X,REC_NOT_GAP,但 INSERT_INTENTION 不必等待 X,REC_NOT_GAP
再说一遍:这些是实现细节,可能会在未来版本中发生变化。重要的是要认识到,数据库引擎可以具有比简单的 Read 和 Write 更复杂的访问权限集,并且它们之间的冲突关系可以是任意的(甚至不是对称的或传递的)。