InnoDB Data Locking – Part 3 “Deadlocks“

在本系列博客中,我将阐述 InnoDB 如何锁定数据(表和行),从而让客户端产生查询是按顺序依次执行的错觉,并介绍近期版本中对此机制的改进。

在 InnoDB 数据锁定系列的第一篇"简介"中,我已经介绍了理解本文所需的基本概念:

  • 数据库【database】、表【table】和行【row】(类比:共享驱动器上的文件、文件中的电子表格,以及电子表格中的行)
  • 事务的可串行化(即能够通过对并发操作相对顺序的合理解释,来说明随时间推移观察到的状态)
  • 超时机制(用于处理行为异常的锁持有者,并解决死锁问题)
  • 读写锁(共享/排他访问权限)
  • 饥饿(持续不断的读操作导致等待轮次的写操作无法执行)
  • 排队(先进先出 FIFO 或优先级队列)
  • 读视图(Read Views,即只读快照,允许在进行新写入的同时读取旧数据)
  • 锁的粒度(对所有资源的访问权限,还是仅对所需资源的访问权限)
  • 由锁粒度问题引起的死锁,以及通过锁顺序解决死锁的方法

本文摘要:我将介绍 InnoDB 8.0.18 中的死锁检测机制,并借此引入以下概念:

  • 等待图(Wait-for graph)
  • 死锁循环(Deadlock cycle)
  • 死锁受害者(deadlock victim)

死锁例子

在简介篇中,我们见识过一个简单的死锁场景:两个人因互相等待对方释放正被占用的资源而无法完成各自的任务。具体来说,ABe 已经拥有了文件 A 的读取权限,并请求获取文件 B 的写入权限,但他必须等待 BAsil 先释放对文件 B 的读取权限;然而,BAsil 只有在完成其既定任务------即获取文件 A 的写入权限------之后才能释放该权限,而文件 A 正被 ABe 占用。

为什么他们就不能更有礼貌一点呢?

也许首先回答一个挥之不去的问题会更有启发性:为什么其中一个不能在读【Read 】完第一个文件后,在请求下一个文件的写【Write 】访问权之前,简单地释放读【Read 】访问权?例如,为什么不让 ABe 释放对文件 A 的读【Read 】访问权限,以便 BAsil 可以获得对文件 A 的写【Write】访问权限,完成他的工作,释放所有持有的访问权限,从而让 ABe 继续进行,而不再受到任何延迟?这样做无疑可以消除死锁,那么为什么不做这个看似聪明的事情呢?简短的回答是:这实际上与将一个事务分割成两个较小的事务没有什么区别,而这可能会带来意想不到的结果。请记住,要实现所承诺的可序列化性,服务器必须编造一个令人信服的说法来说明所有事务的执行顺序。如果我们把 ABe 的事务拆分成两个独立的事务,那么服务器就可以假装这些事务的执行顺序是:

|-------------------------------------|
| ABe.part1 << BAsil << ABe.part2 |

(或者,如果服务器真的非常狡猾,它甚至可以伪造 ABe.part2 发生在 ABe.part1 之前,但幸运的是,单主 InnoDB 从不会这么作弊。)

这种拆分和重新排序在实践中意味着什么?假设 ABe 的计划是"将文件 B 中的余额设置为文件 A 中的苹果数量":

|-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
| ABe1. open file A for Read ABe2. read number of Apples from file A #let's call this number ApplesSeenByABe ABe3. open file B for Write ABe4. write ApplesSeenByABe to file B as the Balance |

而 BAsil 的计划是"将文件 A 中的苹果数量设定为文件 B 中的余额":

|-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
| BAsil1. open file B for Read BAsil2. read the Balance from file B #let's call this number BalanceSeenByBAsil BAsil3. open file A for Write BAsil4. write BalanceSeenByBAsil to file A as the number of Apples |

显然,两者都希望让两个文件中的数值相同,你可以自行验证:无论两个事务(计划)的相对顺序如何,最终结果都应使两个文件中的数值一致,而不管初始条件如何。

例如,假设初始时文件 A 中有 10 个苹果,而文件 B 中的 Balance(余额)为 0。

如果 ABe 的操作先于 BAsil 的操作执行,那么 ABe 会将 Balance 修改为 10,而 BAsil 的操作则不会产生实质性改变,最终状态将是 Apples = Balance = 10。

反之,如果 BAsil 的操作先于 ABe 的操作执行,BAsil 会读取到 Balance 为 0,并将 Apples 设为 0;随后 ABe 会徒劳地将这个 0 写回文件 B,导致最终状态变为 Apples 和 Balance 均为 0。

但是,如果允许服务器过早释放 ABe 对文件 A 持有的读权限,就可能出现以下交错执行的情况:

|------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
| ABe1. open file A for Read ABe2. read number of Apples from file A #ApplesSeenByABe=10 BAsil1. open file B for Read BAsil2. read the Balance from file B #BalanceSeenByBAsil=0 ABe2b. prematurely close file A #<-- HERE !!! BAsil3. open file A for Write BAsil4. write BalanceSeenByBAsil (which is 0) to file A as the number of Apples ABe3. open file B for Write ABe4. write ApplesSeenByABe (which is 10) to file B as the Balance |

最终结果是:文件 A 中的 `Apples=0` 与文件 B 中的 `Balance=10` 出现了不匹配。然而且没有人意识到存在这个问题,直到再次查看文件时,在做月度报告时才发现,哎呀!

这种结果无法用这两个事务的任何先后顺序来解释,但它符合这样一种操作历史:ABe 的事务被拆分成了两个事务:

ABe 的第一个事务:

|-------------------------------------------------------------------------------------------------------------|
| ABe1. open file A for Read ABe2. read number of Apples from file A #let's call this number $ApplesSeenByABe |

ABe 的第二个事务:

|-----------------------------------------------------------------------------------|
| ABe3. open file B for Write ABe4. write $ApplesSeenByABe to file B as the Balance |

而且服务器坚持它是按以下顺序执行这些操作的:

|--------------------------------------|
| ABe.first << BAsil << ABe.second |

(因此,从直觉上讲,过早释放锁可以被视为提交一个事务并开始一个新事务)

如何避免死锁

既然无法提前释放访问权限(锁),那么还有哪些避免死锁的方案呢?

我们之前已经了解过一种方案:预先请求所有必要的访问权限。但在许多情况下,这说起来容易做起来难,而且仍需谨慎的按正确的顺序进行权限请求。就 ABe 和 BAsil 的情况而言,遵循修订后的计划(简单说就是资源获取顺序),起码可确保不会发生死锁(即实现"无死锁"):

|---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
| new-ABe1. open file A for Read new-ABe2. open file B for Write new-ABe3. read number of Apples from file A #let's call this number ApplesSeenByABe new-ABe4. write ApplesSeenByABe to file B as the Balance |

|---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
| new-BAsil1. open file A for Write new-BAsil2. open file B for Read new-BAsil3. read the Balance from file B #let's call this number BalanceSeenByBAsil new-BAsil4. write BalanceSeenByBAsil to file A as the number of Apples |

我们该如何证明系统无死锁?为此,引入"等待图"(waits-for graph)这一概念会很有帮助。有向图由一组点(即"节点")以及连接其中部分点的箭头(即"边")构成。关键在于节点间的关系------即哪些节点相连、哪些不相连------而非它们在图上的具体位置;因此,你可以根据需要以任意方式布局这些关系。

我们的"等待"关系将涉及事务(可视为"人")等待获取资源(可视为"文件"),而这些资源当前正被其他事务占用。因此,图中包含两类节点(事务和资源)以及两类边:

(T --waits-for-access-to--> R and R ---is-accessed-by--> T)

例如,下图展示了我们故事开端时的情形:涉及 ABe、BAsil、文件 A 和文件 B,此时尚未有任何人获取任何资源的访问权限。

|-----------------------|
| ABe fileA BAsil fileB |

现在,让我们按照顺序执行他们原始计划中的步骤,看看这张图会如何演变:

ABe1.. 打开文件 A 以进行读取:

|--------------------------------------------|
| ABe <--is-accessed-by-- fileA BAsil fileB |

ABe2. 从文件 A 读取苹果数量(此操作既不请求也不授予任何新的访问权限,因此我们继续下一步)

BAsil1. 以读取模式打开文件 B:

|-----------------------------------------------------------------|
| ABe <--is-accessed-by-- fileA BAsil <--is-accessed-by-- fileB |

BAsil2. 从文件 B 读取余额(画面无变化)

ABe3. 以写入模式打开文件 B(此操作将被阻塞,ABe 必须等待)

|-----------------------------------------------------------------------------------------------------|
| ABe <--is-accessed-by-- fileA \ \--waits-for-access-to--\ \ v BAsil <--is-accessed-by-- fileB |

BAsil3.. 打开文件 A 用于写入(此操作也将被阻塞,BAsil3 需要等待)

|----------------------------------------------------------------------------------------------------------------------------------------------------------------------|
| ABe <--is-accessed-by-- fileA <-. \ | \--waits-for-access-to--\ | \ | v | BAsil <--is-accessed-by-- fileB | \ | \-----waits-for-access-to-----------/ |

在现实世界中,我们观察到的情况是:ABe 和 BAsil 都在相互等待中陷入停滞,最终至少其中一方会发生超时。

那么,这一现实问题在示意图上是如何体现的呢?它表现为一个循环:

|---------------------------------------------------|
| ABe --> fileB --> BAsil --> fileA --> ABe ... |

每当我们观察到这种循环时,就可以确定现实中存在一组因死锁而无法继续执行的事务,因此我们将其称为"死锁循环"。

为什么会这样呢?因为从事务出发的边仅限于"等待访问(waits-for-access)"类型并指向资源,而从资源出发的边仅限于"被......访问(is-accessed-by)"类型并指向事务;因此,该循环必然在事务和资源之间交替出现,其长度为偶数,且涉及的事务数量与资源数量相等。循环中的每个事务都因无法获取所需资源而被阻塞,而该资源正被另一个同样处于等待状态的事务占用。循环中的任何事务都无法继续执行,因此该循环将持续存在,直到我们解决死锁问题。

那么,为什么修改后的计划版本(new-ABe1..new-ABe4 和 new-BAsil1..new-BAsil4)中不会发生死锁呢?因为如果观察这张图:

|-----------------------|
| ABe fileA BAsil fileB |

试想一下涉及 ABe 和 BAsil 的任何死锁循环,很快就会发现,对于 ABe 和 BAsil 而言,该循环必然至少包含一条指向它们的入边和一条从它们出发的出边:

|---------------------------------------------------------------|
| ABe <--... fileA \ \___.. BAsil <--... fileB \ \... |

否则它们就不会构成环路。然而,存在入边意味着已持有某种资源,而存在出边则意味着正在请求资源;因此,我们可以将那个尚未完成的草图进一步细化为:

|-------------------------------------------------------------------------------------------------------------------------------|
| ABe <--is-accessed-by-.. fileA \ \--waits-for-access-to-.. BAsil <--is-accessed-by-.. fileB \ \--waits-for-access-to-.. |

如果我们查看它们的执行计划,就能轻易辨别出这些边(edges)的来源与去向,因为我们知道,每条边都是先获取了对文件 A 的访问权限(步骤 new-ABe1 和 new-BAsil1),随后才请求访问文件 B(步骤 new-ABe2 和 new-BAsil2)。这意味着完成这个图唯一的路径是:

|----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
| _____________________________ / \ | ABe <--is-accessed-by-------------- fileA | \ \ \--waits-for-access-to--------------------\ \___________________ | \ v BAsil <--is-accessed-by--/ fileB \ ^ \--waits-for-access-to------------------/ |

但这并不构成死锁循环------请注意箭头的方向,它们并未形成闭环!🙂

此外,这张图违反了对"文件 A 的写访问应具有排他性"的假设:我们可以看到有两条箭头从文件 A 指出,这与我们的假设相矛盾。

这张图也可以解读为存在两条从文件 A 指向文件 B 的路径。

总的来说,如果所有人都遵循按相同顺序获取资源的规则,那么图中的所有路径都会呈现出这种顺序特征。正因如此,循环是不可能发生的:要形成循环,就必须从靠后的资源反向指向靠前的资源,但所有的边都只指向前方。

死锁检测

我们如何自动发现死锁??可能存在许多算法,但必须考虑到图可能非常庞大(存在大量并发事务和大量被占用的资源),并且随着新边和新节点的出现和消失而不断变化。

处理这些动态变化可以通过观察以下事实来简化:移除一条边或一个节点不可能引入死锁环。只有当新边被添加时,才可能形成新的环,而且这条新边必须恰好是闭合该环所缺失的那一环。这提示我们,应该仅在添加新边时才进行死锁环的搜索------这种情况通常发生在事务 T 请求访问资源 R 且必须等待时------并且应聚焦于回答一个可达性问题:是否已存在一条从 R 到 T 的路径?

(另一个可能出现新边的特殊情况是,当另一个事务已经在等待对某资源的写访问时,一个事务被授予对该资源的读访问------我们在此忽略这种情况,假设防止饥饿的算法会排除此情形。更复杂的情况是,当一个事务请求 LOCK_GAP 时,而已经有一个事务在等待对该间隙的LOCK_INSERT_INTENTION。InnoDB允许"读取者"在这种情况下绕过"插入者",这意味着会添加一条指向我们节点的新边,这也要求我们检查反方向是否存在死锁。该方面的一个 bug 是重新实现死锁检测算法的动机之一)。

如果这样的路径存在,那么添加该边将导致死锁;否则,该特定请求不会引入死锁,因此我们可以继续。

一旦识别出死锁环,我们需要以某种方式"解决它"。如前所述,我们不能仅仅移除一条边(即提前释放单个访问权限)。我们需要移除一个完整的节点(回滚其中一个事务)。我们选择环上的一个节点作为"死锁牺牲者",并牺牲它以让其他事务继续。存在多种选择合适死锁牺牲者的策略。(InnoDB 避免杀死用于组复制的高优先级事务,并倾向于牺牲由INFORMATION_SCHEMA.INNODB_TRX.TRX_WEIGHT定义的"更轻"的事务。)

如果事情就这么简单就好了。按照我刚才的描述,你可能会这样觉得:每个节点总是至多只有一条出边。虽然事务确实可以明确声明它当前正在等待的单个资源,但通常情况并非每个资源在同一时刻只被一个事务访问。换句话说,可能存在多条"--is-accessed-by->"的出边,从单个资源指向多个事务。原因包括:

  • 可能存在多个事务同时拥有对同一资源的共享(读取)访问权限。

  • 为了避免已经等待资源的事务挨饿,新来的事务必须等到现有等待者完成等待后才能获得资源,这在某种意义上就像是旧的等待者"阻塞"了它们"等待"的资源。所幸的是,这只是在图中添加了一些循环,而这些循环是可以忽略的。

  • 实际上,在 InnoDB 中,事务常常同时请求访问一行数据及其前面的间隙。从建模角度看,你可以选择将其视为一个事务有两条出边,分别指向两个独立的资源(一个间隙和一个行),也可以选择将其视为单个资源(间隙+行),并附加复杂的访问权限(如ReadRow,ReadGap,ReadGapAndRow,WriteRow,WriteGap,WriteGapAndRow,WriteRowAndReadGap,... 等),同时引入"兼容性"规则------这些规则可能允许多个事务共享该资源,从而使得从该资源出发存在多条出边。目前,InnoDB 采用后一种方法,但通过"锁拆分"(Lock splitting)技术灵活地获得前一种方法的好处。

然而,基本事实依然是:我们必须使用某种图搜索(如 BFS、DFS 等)来检查,以判断在添加从 T 到 R 的新边之后,现在是否存在一条路径能回到我们开始的地方。这大致就是 MySQL 在 8.0.17 及更早版本中所采用的旧实现方式。

为了更详细地描述它的工作方式,我们需要回顾一下之前的文章:锁系统(Lock System)将请求的访问权限表示为内存中的对象。在 8.0.17 中,"等待图"(wait-for graph)并未显式存储在内存中,而是可以通过每个资源的锁对象列表,以及每个事务存储的一个指向它当前等待的锁的指针,来动态推断。给定事务当前正在等待的锁,你可以检查与该资源相关的所有锁的列表,找出哪些锁与你的锁冲突,以及谁拥有这些锁------这样你就能推断出你的事务在等待哪个事务。这意味着,虽然概念上很简单,但在 8.0.17 中对"等待图"进行 DFS 需要相当复杂的底层代码,这些代码需要遍历锁列表,并在过程中对整个锁系统加闩(latching)。此外,为了避免在同一个队列中多个事务多次检查同一锁是否存在冲突而产生 O(N²) 的性能开销,实际执行是采用的基于锁进行深度优先搜索(DFS)的方法------标记已访问的锁,而不是标记已访问的事务或资源。鉴于锁本质上是"访问请求",它们更像是"等待图"中的边而非节点,这使得该 DFS 相当难以理解。为了避免栈溢出,它同时做了两方面处理:将递归展平为带有手动栈管理的循环,并且对搜索步数设置了各种硬限制。说到底:归根结底,这段代码算不上优雅,且成为了关键路径上的性能瓶颈。我不太指望这个视频能帮你理解它的工作原理,但我们不妨尝试一下:

在该视频中,事务(T1、T2 等)用三角形表示,锁用圆形表示:绿色代表"已获授权"(Granted),红色代表"等待中"(Waiting)。当某个事务正在等待某把锁时,它会指向该锁。算法会扫描与 T1 所等待资源相关的锁列表 row1;对于每一把与 T1 所需之锁存在冲突的锁,算法会检查其持有者(例如 T2)是否也在等待其他资源------若确实如此,则会进行"递归"深入检查。在按此方式检查完目标锁(等待中的锁)之前的所有锁之后,算法会进行回溯。如果在检查过程中发现了一条可返回 T1 的路径,算法便会报告检测到了死锁循环。

现在,让我为您概括介绍一下我们在 8.0.18 版本中引入的变更,帮助您建立直观的认识。

新的死锁检测算法

旧算法的一个问题在于,为了确保安全且正确地运行,它必须在图遍历期间"暂停整个系统"(stop the world);而这张图可能极其庞大,因为它既包含事务,也包含这些事务所持有的资源。

首先可以观察到,我们完全可以在一张规模小得多的图上进行操作------这张图仅包含事务,而不显式表示资源:从事务 A 到事务 B 的一条边,意味着事务 A 正在"等待事务 B 正在访问的资源"。

简而言之:A--等待-->B。用更数学化的语言表述就是:存在某个资源 R,使得 A--等待访问-->R--被访问-->B。因此,大图中的环必然对应小图中的环,反之亦然。

由此可见,我们可以设想一种在较小图上运行并能产生相同结果的死锁检测算法。但这引出了一个问题:如何在不浪费过多资源的前提下构建并维护这种"等待图"(waits-for graph)?

值得注意的是,尽管节点集(或者说节点数量)缩小了,但边数可能依然很多(例如,如果从 R 引出多条边,这些边就会被 A "继承")。

而且,如果每次重新构建该图时仍需"暂停整个系统",那么这种做法可能就失去了意义。

第二个观察结果则不那么显而易见:我们可以构建一张"稀疏图"------即仅为每个事务选择其最老的一条出边------并利用这张图(而非原始的"稠密图")来检测死锁。

让我先用直观的、非严谨的方式解释一下其中的原理:

在"稠密图"中构成环的那些边,绝不会从图中消失,正是因为这些边所连接的节点处于死锁状态。而属于未阻塞事务的边最终会从"稠密图"中消失,因此在有限时间内,"稀疏图"必然会选中那些构成死锁环的边。

关于死锁检测可局限于"稀疏图"的证明

为了使证明更加严谨,我们先做如下假设:

  • 系统中的事务总数是有界的(上限为 Tnum),
  • 单个事务所访问的资源数量是有界的(上限为 Rnum),
  • 资源的分配方式能防止饥饿(至少满足:在事务 T 轮到获取资源之前,最多只有 Tnum 个其他事务能先获得该资源),
  • 事务除了等待资源外,不会在任何其他事情上等待(具体而言,它们会在有限时间内完成或请求下一个资源),

证明将采用反证法,所以我们假设在某个时间点,在"稠密图"中出现了一个死锁循环,并且它永远未被发现:没有任何节点曾被选为死锁牺牲者。(如果有多个循环,任选其中一个,比如最早形成的那一个,若仍有歧义,可按涉及事务 ID 的序列进行字典序排序,或采用其他确定性规则,从而避免依赖选择公理 :D)。

让我们将该环路中的边和节点标记为红色。

现在,让我们来看"稀疏图"(sparse graph):显然,它必然缺失至少一条红色边。否则,算法为了解决这个红色死锁环路,就必须选择至少一个红色节点作为牺牲者,但这将与"红色节点永远未被察觉"的假设相矛盾。

稀疏图中很可能已经包含了一些红色边------请注意,一旦某条红色边出现在稀疏图中,它就会永久保留,因为红色边消失的唯一途径是其端点处的某个事务完成,而我们已假设这种情况不会发生。因此,稀疏图中的红色边集合只会随时间增长;由于该集合是有界的(上限为 Tnum),它最终必然停止增长。

让我们将时间快进到这一时刻。

让我们将此时稠密图中尚未被标记为红色的所有节点和边标记为蓝色,这样每个节点和边此时要么是红色,要么是蓝色。未来新增的边和节点将被标记为黑色。

现在,用到集合序理论中的一个小技巧:我们将在每个节点中放置一对存储自然数的计数器,并按照一些规则更新它们。直观上,第一个计数器限制该事务还需获取多少资源,而第二个计数器则限制了在发生饥饿之前,有多少其他事务可以在等待资源时绕过它。

初始时,我们将所有计数器设置为 <Rnum, Tnum>。每当出现一个新的(黑色)节点时,我们也向其放入 <Rnum, Tnum>。每当稀疏图中出现一条新边时,可能是因为事务请求了一个新资源(我们将这对计数器中的第一个减一,并将第二个重置为 Tnum),或者是因为它等待的资源被另一个更幸运的事务获得了(我们将第二个计数器减一)。我们不知道发生的是哪种情况,让对手选择最坏的情况。但我们知道,由于无饥饿性,第二个计数器不能降到零以下(因为轮到我们之前最多有 Tnum 个事务),并且由于每个事务请求的资源数量有界,第一个计数器也不能降到零以下。

所以,现在我们来看一个在稀疏图中没有红色出边的红色节点。(回想一下,"稀疏图"包含了"稠密图"的所有节点,因此特别地,它包含了所有红色节点)。

它必定至少有一条出边,因为在稠密图中至少有一条出边(我们未选择的那条红色边)。由于我们总是选择最老的边,而黑色边比仍可用的红色边更新,因此我们必定选择了一条蓝色边。

由于稀疏图中的每个节点最多只有一条出边,让我们沿着从该节点出发的唯一可能路径继续追踪。

由于节点数量有限,这条路径要么循环回之前访问过的某个节点,要么在一个没有出边的节点处终止。

我们记下路径上(直到出现循环)的数字序列:<R1,T1>,...,<Rk,Tk>。

如果路径形成循环,算法将检测到该环,并选择其中一个节点作为死锁牺牲者(而我们假设这个节点不是我们的红色节点)。这意味着循环中的一个节点将从图中移除,而它之前的节点需要进行更新:要么该节点获得了该资源,从而不再有出边(至少在有限时间内如此),要么由于其他人先获得了资源而必须继续等待(此时我们将其第二个计数器减一);无论如何,路径的字典序都会变小。

如果路径终止于一个没有出边的节点,则意味着在有限时间内,该最后一个节点将要么完成执行(因此路径会变短,或者其前一个节点会被更新为更小的字典序),要么请求某个资源,在这种情况下路径可能会变长,但第一个计数器必须减少,因此整体字典序也会变小。

好的,所以在有限时间内,与路径相关联的数字序列将变得更小(字典序)。

现在,您预料到的时刻来了:不存在有界序列的无限递减链!哈哈。毕竟,每个这样的序列都可以看作是一个极大的 2*Tnum 位长的整数,基数为 Tnum+Rnum+1,并且每次至少减一,而您不可能无限次地递减一个自然数,对吧?

好的,所以在有限的时间内,与路径相关的数字序列将按字典顺序变得更小。

现在,你已经看到了这样的时刻:不存在无限递减的有界数有界序列链!哈哈。毕竟,每个这样的序列都可以看作是一个非常大的 2*Tnum 位长的整数,以 Tnum+Rnum+1 为基数,并且每次至少减 1,而且你不能无限次地递减一个自然数,对吧?

这意味着什么?这意味着在有限的时间后,我们的红色节点将不得不将其出边切换到其他边。但蓝色边的数量是有限的。因此,在有限的时间之后,我们的红色节点将不得不选择红色边缘,但这与不再出现红色边的假设相矛盾。

矛盾证明结束了。

但你可能会反驳说,事务访问的资源数量可以有界的假设是不公平的:毕竟数据库中的行数可以无限增长。而且您可能怀疑,将"无饥饿性"等同于"每个请求在先验有界的时间内被授予"并不是一个常见的定义。但您必须承认,行数是有限的,并且为了避免饥饿,请求必须在有限时间内被授予,所以让我们达成一个折中方案:你可以为每个新节点放入任意一对自然数,哪怕它们再大也没关系。你看,证明仍然成立,因为自然数的有界长度序列中并不存在无限长的严格递减链,哈哈哈!🙂

这一点可以通过归纳法来理解:显然,长度 <= 1的序列都满足条件。而如果我们只看每个序列的第一个元素,那么从某个有限的自然数开始,它必然是非递增。如果存在一个无限长的序列链,且每个序列的长度都不超过 N+1,那么我们可以根据它们的第一个元素进行分组。由于分组数是有限的,而序列总数是无限的,因此至少有一个分组中会包含一个无限长的严格递递减序列链,且这些序列都以同一个数字开头。如果我们去掉这个共同的首项,就会得到一个长度不超过 N 的无限长递减序列链,这与前提矛盾,从而证明了原命题。哈哈 🙂 另一种理解方式:这是一个非常无聊的倒计时,但随着最后一个数字不断变小,最终它必然要降到零,而前一个数字也必须随之减少,因此在有限时间内,倒数第二个数字也会变小,最终变为零,再前一个数字将不得不减少,依此类推。

但是,但是......你可能会反驳说------假设序列长度有限是不公平的,因为我们已经承认系统可以随着时间增长!没错,系统在任何给定时刻都是有限的,但并非有界。唉,这问题更棘手了:是否存在无限长的、由自然数有限序列构成的递减链?答案是有的:比如 <1>,<0,1>,<0,0,1>,<0,0,0,1>,...... 所以,如果你的服务器能处理任意数量的并发事务,那这个证明可能就不成立了------但世界上能用的套接字、内存和客户端数量终究是有限的。

好吧,理论和那些天文数字就先说到这里。实际上,死锁几乎在出现的同时就能被发现,因为大多数情况下情况相当简单:事务不会几乎饿死,循环也不会涉及几乎所有事务,选择边时也不会遭遇极端糟糕的运气等等。上面的理论证明只是为了让我们心里踏实一点,至少能感觉到即使在最坏的情况下,它最终也应该是能正常工作的。

这给我们带来了什么好处呢?好处是,我们可以不用存储一个庞大且混乱的图,而只需为每个事务存储一个指针,指向它当前等待的事务(或 nullptr)。并且每个节点最多只有一条出边的图非常容易遍历:只有一条前进的路,所以你可以在常数内存和线性时间内找到一个循环。但一旦你不需要处理如此复杂的数据结构,你还能做更多的事情!

第三个观察结果是,你无需"暂停世界"就能在稀疏图中查找循环。你看,如果存在死锁循环,那么它一定只涉及那些已经处于等待状态的事务,它们不会继续执行 🙂

事实证明,InnoDB 已经有一个数组,其中包含了所有当前正在等待的事务,因此检测循环就变得非常简单:只需遍历这个数组,记录这些事务等待的原因,然后运行一个简单的线性算法,在复制的数据中查找循环。由于算法是在副本上运行的,所以执行时无需"暂停世界"。如果你在副本中找到了循环,就可以保证该循环在真实稠密图中仍然存在,因为它无法自行解决(尽管几乎可以做到:别忘了超时机制和其他终止查询的方式)。你可能会觉得,至少在快照获取期间,我们还需要"暂停世界"。哈哈。不需要。

第四个观察结果是 :我们只需要确保在制作快照时,我们所遍历的事务不会从内存中被释放,因此从它们那里复制当前的等待原因是安全的------在我们遍历期间,它们仍然可能被授予资源(边被移除),或者改变它们等待的原因(边的终点发生变化)。我们只需要避免对指针本身的撕裂读取(使用原子操作),但除此之外,快照是否一致并不重要。再次强调,重要的是:如果 存在一个红色循环,那么它会一直保留在那里,直到我们注意到它。因此,快照中的重要部分将是一致的。是的,可能会出现一些误报:如果你将来自不同时间点的边的碎片拼接在一起,那么生成的"弗兰肯斯坦图"可能看起来像一个循环,而实际上从未存在过。但这可以通过一些额外的工作来避免,以跟踪 ABA 问题,并且仅在最终双重检查死锁循环候选者确实是一个死锁时才暂停整个世界(我们几乎没有误报,而且真正的死锁应该发生得足够稀少,因此为每个死锁"暂停整个世界"并不是什么大问题)。我们几乎没有误报的原因是,如果你真的仔细想想,即使这个循环是由来自不同时间点的边拼接而成的,由于事务使用两阶段锁,循环成为误报的唯一方式是,它已经因为其中一个事务被中止而得到解决(你可以通过从负责检测超时的同一个线程运行死锁检测来最大限度地降低这种可能性),或者你以某种方式将一个事务误认为是另一个事务(比如说,因为它重用了 ID、指针地址、槽位号或任何你用于标识的东西),所以你只需要在处理 ABA 错误时更加小心。

观测死锁

InnoDB 会跟踪一些与死锁检测相关的统计信息。您可以在 INFORMATION_SCHEMA.INNODB_METRICS 中查看它们:

  • lock_deadlocks -- 死锁发生的次数。

  • lock_timeouts -- 锁超时的次数。

  • lock_deadlock_false_positives -- 启发式算法在等待图中发现虚假候选死锁循环的次数。

  • lock_deadlock_rounds -- 为搜索死锁而扫描等待图的次数。

  • lock_threads_waiting -- 正在休眠等待锁的查询线程数量。

以上信息来源于 information_schema.innodb_metrics

|--------------------------------------------------------------------------------------------------------------------------------------------------------------------|
| SELECT name,comment FROM INFORMATION_SCHEMA.INNODB_METRICS WHERE name IN ('lock_deadlocks','lock_timeouts','lock_deadlock_false_positives','lock_deadlock_rounds') |

例如,您可以使用以下命令查看迄今为止发生了多少次死锁:

|--------------------------------------------------------------------------------------|
| SELECT `count` FROM INFORMATION_SCHEMA.INNODB_METRICS WHERE NAME="lock_deadlocks"; |

此外,您可以使用以下命令获取有关最新死锁的信息:

|----------------------------|
| SHOW ENGINE INNODB STATUS; |

并查看(可能会缺失)LATEST DETECTED DEADLOCK 部分。

例如,让我们在 SQL 世界中用 ABe 和 BAsil 来模拟这个示例。(实际上,我们将采用一种更合理、更细粒度的方法,请求访问单个"单元格"("Apples"或"Balance"),而不是整个"文件"("fileA"和"fileB"),但最终的死锁结构相同。我们必须这样做,因为 InnoDB 中的表级锁会干扰 MySQL 服务器自身的表级锁,这使得解释更加复杂。)

首先,让我们准备以下语句:

|-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
| CREATE DATABASE test; use test; CREATE TABLE fileA (name VARCHAR(10) PRIMARY KEY, value INT); CREATE TABLE fileB (name VARCHAR(10) PRIMARY KEY, value INT); INSERT INTO fileA (name,value) VALUES ("Apples",10); INSERT INTO fileB (name,value) VALUES ("Balance",0); |

然后,在ABe的客户端上:

|------------------------------------------------------------------------------------------------------------------------------|
| ABe> BEGIN; ABe> SELECT value FROM fileA WHERE name='Apples' FOR SHARE; +-------+ | value | +-------+ | 10 | +-------+ |

我们可以确认,正如预期,ABe 已获得访问"Apples"的权限:

|----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
| > SELECT ENGINE_TRANSACTION_ID as trx_id,OBJECT_NAME as `table`,INDEX_NAME,LOCK_DATA,LOCK_MODE,LOCK_STATUS FROM performance_schema.data_locks WHERE LOCK_TYPE='RECORD'; +-----------------+-------+------------+-----------+---------------+-------------+ | trx_id | table | INDEX_NAME | LOCK_DATA | LOCK_MODE | LOCK_STATUS | +-----------------+-------+------------+-----------+---------------+-------------+ | 284271835218160 | filea | PRIMARY | 'Apples' | S,REC_NOT_GAP | GRANTED | +-----------------+-------+------------+-----------+---------------+-------------+ |

performance_schema.data_lockss 表中的每一行记录了事务请求的某种访问权限。请注意,事务通过 ENGINE_TRANSACTION_ID_ID 进行标识,表通过 OBJECT_NAME_NAME 进行标识,实际请求的资源是特定索引中的某一行(因此如果表有多个索引,则 INDEX_NAME 信息很重要),而 LOCK_DATA 则用于标识该行。如前所述,InnoDB 使用了一套非常复杂的 LOCK_MODE(锁模式),允许事务指定其需要访问的是该行、该行之前的间隙(gap),还是两者的某种组合。由于 ABe 指定他 SELECT 该行 FOR SHARE,并直接通过主键定位该行,因此系统仅需以 S(共享)模式锁定该记录本身,而无需锁定其前面的间隙。

现在,让我们在另一个连接中启动 Basil 的操作:

|---------------------------------------------------------------------------------------------------------------------------------------|
| BAsil> BEGIN; BAsil> SELECT value FROM test.fileB WHERE name='Balance' FOR SHARE; +-------+ | value | +-------+ | 0 | +-------+ |

并确认他也获得了所请求的访问权限:

|----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
| > SELECT ENGINE_TRANSACTION_ID as trx_id,OBJECT_NAME as `table`,INDEX_NAME,LOCK_DATA,LOCK_MODE,LOCK_STATUS FROM performance_schema.data_locks WHERE LOCK_TYPE='RECORD'; +-----------------+-------+------------+-----------+---------------+-------------+ | trx_id | table | INDEX_NAME | LOCK_DATA | LOCK_MODE | LOCK_STATUS | +-----------------+-------+------------+-----------+---------------+-------------+ | 284271835218160 | filea | PRIMARY | 'Apples' | S,REC_NOT_GAP | GRANTED | | 284271835219208 | fileb | PRIMARY | 'Balance' | S,REC_NOT_GAP | GRANTED | |

现在,让 Basil 继续,并尝试将 Apples 修改为 0:

BAsil> UPDATE test.fileA SET value=0 WHERE name='Apples';

|------------------------------------------------------------|
| BAsil> UPDATE test.fileA SET value=0 WHERE name='Apples'; |

你会发现,该查询不会立即返回结果,而是进入等待状态。如果操作不够迅速导致等待时间过长,就会触发超时。你可以通过以下方式查看默认超时时间:

|---------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
| SELECT @@innodb_lock_wait_timeout; +----------------------------+ | @@innodb_lock_wait_timeout | +----------------------------+ | 50 | +----------------------------+ |

为了在尝试此类场景时无需匆忙行事,您可以将其全局设置为更大的数值,如下所示:

|----------------------------------------------|
| SET GLOBAL innodb_lock_wait_timeout=1000000; |

(由于此值会在进入睡眠【sleep】状态前进行检查,请提前使用上述注释)。

您可以通过以下方式确认 BAsil 正在等待表 filea 中记录"Apples"的 X(排他性)锁(但不是等待其前面的空白记录的锁):

|---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
| > SELECT ENGINE_TRANSACTION_ID as trx_id,OBJECT_NAME as `table`,INDEX_NAME,LOCK_DATA,LOCK_MODE,LOCK_STATUS FROM performance_schema.data_locks WHERE LOCK_TYPE='RECORD'; +-----------------+-------+------------+-----------+---------------+-------------+ | trx_id | table | INDEX_NAME | LOCK_DATA | LOCK_MODE | LOCK_STATUS | +-----------------+-------+------------+-----------+---------------+-------------+ | 3064 | fileb | PRIMARY | 'Balance' | S,REC_NOT_GAP | GRANTED | | 3064 | filea | PRIMARY | 'Apples' | X,REC_NOT_GAP | WAITING | | 284271835218160 | filea | PRIMARY | 'Apples' | S,REC_NOT_GAP | GRANTED | +-----------------+-------+------------+-----------+---------------+-------------+ |

这里有一个值得注意的细节:BAsil 的 `ENGINE_TRANSACTION_ID` 从那个极长的伪随机数 `284271835219208` 变成了看起来更"正常"、数值也更小的 `3064`。InnoDB 区分只读事务和读写事务。InnoDB 采取乐观策略,在事务执行更新操作之前,默认将其视为只读事务。这样做的目的是避免分配来自单调递增序列的事务 ID,因为同步该序列的过程开销较大。因此,除非确有必要,否则不会为事务分配真正的 ID。所以,一旦 BAsil 发出 `UPDATE` 语句,InnoDB 就会识别出这是一个读写事务,并为其分配一个真正的事务 ID。

现在,让我们回到 ABe,尝试继续操作,将 `Balance` 设置为 10:

|----------------------------------------------------------------------------------------------------------------------------------------------|
| ABe> UPDATE fileB SET value=10 WHERE name='Balance'; ERROR 1213 (40001): Deadlock found when trying to get lock; try restarting transaction |

这就是从"受害者"视角看到的死锁情形。这次 ABe 被选中作为受害者,但也完全可能是 BAsil。

而从"幸存者"的视角来看,情况仅仅表现为所请求的访问权限已获准,因此 BAsil 的查询结果如下:

|--------------------------------------|
| Query OK, 1 row affected (23.52 sec) |

(这 23 秒是我一边写博客、一边手动执行该脚本所花的时间 :D)

因此,BAsil 可以顺利完成操作:

|------------------------------------------------------|
| BAsil> COMMIT; Query OK, 0 rows affected (0.01 sec) |

数据库的最终状态与 ABe 回滚(rollback)且 BAsil 提交(commit)的执行历史相一致:

|---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
| > SELECT * FROM test.fileA; +--------+-------+ | name | value | +--------+-------+ | Apples | 0 | +--------+-------+ 1 row in set (0.00 sec) > SELECT * FROM test.fileB; +---------+-------+ | name | value | +---------+-------+ | Balance | 0 | +---------+-------+ 1 row in set (0.00 sec) |

那么ABe该怎么办呢?他应该重新尝试整个事务。通常情况下,处理死锁错误就应该这样做:不要惊慌,只需重试即可。

我们可以确认这次死锁已被正确计数:

|-------------------------------------------------------------------------------------------------------------------------------------------|
| > SELECT `count` FROM INFORMATION_SCHEMA.INNODB_METRICS WHERE NAME="lock_deadlocks"; +-------+ | count | +-------+ | 1 | +-------+ |

请查看 SHOW ENGINE INNODB STATUS 输出中的这一(最新)事件:

|----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
| ------------------------ LATEST DETECTED DEADLOCK ------------------------ 2020-07-27 14:21:59 0x2410 *** (1) TRANSACTION: TRANSACTION 3064, ACTIVE 154 sec starting index read mysql tables in use 1, locked 1 LOCK WAIT 4 lock struct(s), heap size 1200, 2 row lock(s) MySQL thread id 10, OS thread handle 22236, query id 30 localhost ::1 root updating UPDATE test.fileA SET value=0 WHERE name='Apples' *** (1) HOLDS THE LOCK(S): RECORD LOCKS space id 5 page no 4 n bits 72 index PRIMARY of table `test`.`fileb` trx id 3064 lock mode S locks rec but not gap Record lock, heap no 2 PHYSICAL RECORD: n_fields 4; compact format; info bits 0 0: len 7; hex 42616c616e6365; asc Balance;; 1: len 6; hex 000000000bf7; asc ;; 2: len 7; hex 810000009a0110; asc ;; 3: len 4; hex 80000000; asc ;; *** (1) WAITING FOR THIS LOCK TO BE GRANTED: RECORD LOCKS space id 4 page no 4 n bits 72 index PRIMARY of table `test`.`filea` trx id 3064 lock_mode X locks rec but not gap waiting Record lock, heap no 2 PHYSICAL RECORD: n_fields 4; compact format; info bits 0 0: len 6; hex 4170706c6573; asc Apples;; 1: len 6; hex 000000000bf5; asc ;; 2: len 7; hex 81000000990110; asc ;; 3: len 4; hex 8000000a; asc ;; *** (2) TRANSACTION: TRANSACTION 3065, ACTIVE 631 sec starting index read mysql tables in use 1, locked 1 LOCK WAIT 4 lock struct(s), heap size 1200, 2 row lock(s) MySQL thread id 9, OS thread handle 6128, query id 32 localhost ::1 root updating UPDATE fileB SET value=10 WHERE name='Balance' *** (2) HOLDS THE LOCK(S): RECORD LOCKS space id 4 page no 4 n bits 72 index PRIMARY of table `test`.`filea` trx id 3065 lock mode S locks rec but not gap Record lock, heap no 2 PHYSICAL RECORD: n_fields 4; compact format; info bits 0 0: len 6; hex 4170706c6573; asc Apples;; 1: len 6; hex 000000000bf5; asc ;; 2: len 7; hex 81000000990110; asc ;; 3: len 4; hex 8000000a; asc ;; *** (2) WAITING FOR THIS LOCK TO BE GRANTED: RECORD LOCKS space id 5 page no 4 n bits 72 index PRIMARY of table `test`.`fileb` trx id 3065 lock_mode X locks rec but not gap waiting Record lock, heap no 2 PHYSICAL RECORD: n_fields 4; compact format; info bits 0 0: len 7; hex 42616c616e6365; asc Balance;; 1: len 6; hex 000000000bf7; asc ;; 2: len 7; hex 810000009a0110; asc ;; 3: len 4; hex 80000000; asc ;; *** WE ROLL BACK TRANSACTION (2) |

这里有很多内容需要解释......首先,每个死锁都涉及一个循环中的若干事务,记为 (1),...(N)。这里我们有一个只涉及两个事务 (1) 和 (2) 的循环。*** 标记了重要的部分,这些部分:

  • 尝试描述事务 (1):
    • 事务本身的标识数据,
    • 它在死锁发生时持有的访问权限(循环中先前事务所需的访问权限),
    • 以及它正在等待获得的访问权限,
  • 然后对事务 (2) 进行相同的描述:
    • ......
  • <如果有更多事务,则以相同的方式描述它们>
  • 最后,宣布牺牲事务 (2) 的决定。

事务持有和请求的访问权限的描述方式非常底层,需要一定的实践经验才能正确解析。但更重要的是,需要理解锁的实现方式以及它如何与 InnoDB 的树形页(tree pages)关联的。InnoDB 将记录分组到页面中存储,每个页由 space_idpage_no 这对标识符唯一确定。单个页上可以有多条记录。事务通常会对一系列行请求相似的访问权限,这为压缩提供了机会:我们无需单独存储每个访问权限请求,而是将同一事务中同一页面上的所有同类请求分组,并使用位图(bitmap)来指明该页上具体涉及哪些记录。这也意味着锁系统无需知道表和记录的实际"名称",因此不会浪费内存来存储字符串和主键。(相应的代价是,要以人类可读的形式展示信息,必须查阅实际的数据库页面,才能将 space_idpage_no 解码为表和索引的名称,并将位图中的位置(即 heap_no)映射为实际的键值。有时,例如因缓冲池缓存未命中,这些信息不可用,因此输出中会缺失相关内容。另一个缺点是,每次 B 树页面重组都需要与锁系统协同更新映射关系。)

例如:

|------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
| *** (1) HOLDS THE LOCK(S): RECORD LOCKS space id 5 page no 4 n bits 72 index PRIMARY of table `test`.`fileb` trx id 3064 lock mode S locks rec but not gap Record lock, heap no 2 PHYSICAL RECORD: n_fields 4; compact format; info bits 0 0: len 7; hex 42616c616e6365; asc Balance;; 1: len 6; hex 000000000bf7; asc ;; 2: len 7; hex 810000009a0110; asc ;; 3: len 4; hex 80000000; asc ;; |

这意味着事务 (1) 持有下面的访问权限:

  • 以下列出了针对特定记录的权限,这些记录均位于 `space_id=5` 且 `page_no=4` 的页面内。该页面的位图(bitmap)包含 72 个比特位。该页面属于表 `test.fileb` 的主键(PRIMARY)索引。相关事务的 ID 为 3064。下文列出的所有权限均具有相同的"S"模式,并且这些权限是针对记录本身,而不是针对它们之前的间隙(gap):
    • 针对位于该页面的堆(heap)中位置编号为 2 的记录的访问权限(这意味着 72 比特长的位图中,从 0 开始编号的第 2 个比特位被置 1)。该记录包含 4 个字段,采用紧凑(compact)格式存储,其信息位【info bits】为 0。以下是该记录包含的字段(请注意,这些属于非常底层的细节,具体取决于索引类型和 InnoDB 的特定版本;其中某些列由 InnoDB 自身生成的等等。因此如果没有关于表结构、索引结构和源代码的额外知识,这些是无法解读的):
      • 第 0 个字段【field】,长度为 7 字节,包含字符串 "Balance"
      • 第 1 个字段【field】,长度为 6 字节,包含 x000000000bf7(即十进制的 3063,这是最后修改该行的事务的事务 ID,也就是在准备步骤期间执行 INSERT 时的那个事务)。
      • 第 2 个字段【field】,长度为 7 字节,包含 x810000009a0110(这是一个 roll_ptr,也称为"undo 指针",它应该指向该行的前一个版本,但由于这是插入后该行的初始版本,它只是将第 55 个比特位置为 1 来指示这一事实,并包含一些其他信息)。
      • 第 3 个字段,长度为 4 字节,包含 x80000000(在 SQL 使用的编码中,这表示正零的十六进制形式。换句话说,这就是值 0)。

特定记录的权限列在下方,所有这些记录都位于由 space_id=5page_no=4 标识的页内。位图共有 72 个比特位。该页属于表 test.filebPRIMARY 索引。事务 ID 为 3064。下面列出的所有权限都具有相同的模式 "S",并且这些权限是针对记录本身,而不是针对它们之前的间隙(gap):

  • 对该页上 heap 中位置编号为 2 的记录的访问权(这意味着 72 比特长的位图中,从 0 开始编号的第 2 个比特位被置 1)。该记录有 4 个字段,采用紧凑(compact)格式。其 info bits 等于 0。以下是该记录的字段(请注意,这是非常底层的信息,取决于索引类型和 InnoDB 的具体版本,其中某些列是 InnoDB 自身生成的等等,因此如果没有关于表结构、索引结构和源代码的额外知识,这些是无法解读的):

    • 第 0 个字段,长度为 7 字节,包含字符串 "Balance"

    • 第 1 个字段,长度为 6 字节,包含 x000000000bf7(即十进制的 3063,这是最后修改该行的事务的事务 ID,也就是在准备步骤期间执行 INSERT 的那个事务)。

    • 第 2 个字段,长度为 7 字节,包含 x810000009a0110(这是一个 roll_ptr,也称为"undo 指针",它应该指向该行的前一个版本,但由于这是插入后该行的初始版本,它只是将第 55 个比特位置为 1 来指示这一事实,并包含一些其他信息)。

    • 第 3 个字段,长度为 4 字节,包含 x80000000(在 SQL 使用的编码中,这表示正零的十六进制形式。换句话说,这就是值 0)。

    • <更多用户自定义列将在此处列出...>

  • <如果位图中有更多位被设置为1,则此处会列出更多记录...>

重要提示 :如果事务还拥有对其他页的访问权限,或者对同一页面但模式不同于 S, REC_NOT_GAP 的访问权限,则不会 在此处列出。此输出仅包含涉及死锁循环的那个锁对象的描述,但不包含 该事务持有的其他锁对象。如果多个锁都编码在同一个锁对象的位图中,您偶尔可能会看到更多锁被列出,但总体来说,此输出不会 让您了解该事务持有的所有锁,抱歉。您可以通过查看输出中 "LOCK WAIT 4 lock struct(s), heap size 1200, 2 row lock(s)" 这部分来判断是否缺少了某些信息------它表明内存中应该有 4 个锁对象,其中 2 个是记录锁(其余的 4-2=2 个是表锁)。

您可以按类似方式解析其他部分。

也许,对于我们人类来说,最重要的部分是描述事务本身,并提供它试图执行的查询。

*** (1) TRANSACTION: TRANSACTION 3064, ACTIVE 154 sec starting index read mysql tables in use 1, locked 1 LOCK WAIT 4 lock struct(s), heap size 1200, 2 row lock(s) MySQL thread id 10, OS thread handle 22236, query id 30 localhost ::1 root updating UPDATE test.fileA SET value=0 WHERE name='Apples'

|--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
| *** (1) TRANSACTION: TRANSACTION 3064, ACTIVE 154 sec starting index read mysql tables in use 1, locked 1 LOCK WAIT 4 lock struct(s), heap size 1200, 2 row lock(s) MySQL thread id 10, OS thread handle 22236, query id 30 localhost ::1 root updating UPDATE test.fileA SET value=0 WHERE name='Apples' |

同样,要正确解析和解读这些信息,可能需要查看源代码,但至少我们能知道该事务是在等待哪条查询完成。

`SHOW ENGINE INNODB STATUS` 仅显示最近检测到的死锁信息;因此,若要追踪所有死锁,可以启用 `innodb_print_all_deadlocks` 将其记录到错误日志中,但在操作前请先进行检查配置项:

|-------------------------------------------------------------------------------------|
| SELECT `count` FROM INFORMATION_SCHEMA.INNODB_METRICS WHERE NAME="lock_deadlocks" |

以便预估输出量,以免日志信息过多导致难以处理。

您还可以通过设置 innodb_deadlock_detect 变量来禁用死锁检测算法,这在旧实现中对某些客户曾有用处。我认为新的(8.0.18)实现不仅速度更快,更重要的是它运行在一个专用的后台线程中,不会阻塞事务线程,因此我看不到有什么理由禁用它。我建议您保持默认值(ON)。

本文讨论的是处理等待者之间相互等待而形成死锁的情况。但是,对于更典型的情况------即没有死锁,却需要以某种方式对等待者进行排队,并在锁可用时选择授予谁------我们该如何处理呢?如果您想知道我们使用的是先到先得、优先级队列还是某种随机方法,请阅读我们的下一篇文章《InnoDB Data Locking -- Part 4 "Scheduling"》。

感谢您使用 MySQL!