使用锁管理复合关系文档的并发访问 - 适用于 MS SQL 和其他服务器






4.33/5 (3投票s)
本文对在MS SQL Server、MySql、PostgreSql和Oracle中,使用显式锁管理数据库文档(数据分布在多个表中)的并发访问进行了比较分析,并提供了相关模式。结果表明,解决方案并非总是显而易见或简单的。
源代码:
https://github.com/rivantsov/LockDemo
引言
关系数据库中的数据通常逻辑上组织成文档(发票、采购订单),其部分数据存储在多个表中,通过一对多或多对多关系连接。这些文档通常具有某些内部一致性要求(订单总额应与所有订单项目的总和匹配)。虽然我们可以通过正确编程单个CRUD操作来确保一致性,但挑战在于管理并发编辑和加载文档的尝试,以防止死锁、内部约束破坏或在编辑过程中加载快照等问题。
一个内部一致性要求的例子是,发票总额应始终与订单行(项目)的总和匹配。当有多个并发进程向发票添加项目时,可能会出现总额错误的情况——最后一个完成的进程会将其设置为它认为应该是什么,而不知道还有其他并发进程刚刚添加了项目。在另一个进程正在编辑文档的过程中尝试读取文档,可能会导致快照损坏,发票总额与行总和不匹配。
解决方案是使用数据库中的显式锁序列化编辑和读取——以确保没有两个进程同时编辑同一文档,并且读取操作必须等待任何编辑的完成。数据库引擎设置的自动、隐式锁是不够的——引擎不知道操作的总范围,也不知道哪些记录应该提前锁定。现在,这应该不是一个大问题,对吗?
文档锁定——这样一个常见而重要的任务,应该很容易实现!这只是常识……
但情况并非如此。并非总是如此。对于“其他”服务器——MySql、PostgreSql、Oracle——这实际上是相当简单的。对于MS SQL Server,这却是一个真正的挑战。我经历了这段经历,想分享我的发现。
本文不仅包含有关正确锁定的发现和建议,还包含一个演示/测试应用程序,用于验证解决方案,并提供了一个尝试和测试不同方法的平台。
文档锁定被证明是少数几种情况之一,其中解决方案的现场测试不足以验证。您需要一个独立的应用程序,其中包含一些“不切实际”的、强化工作负载,以验证您是否做对了。当涉及到锁时,最初的评估——“我尝试过,它似乎有效”——可能具有相当大的误导性(我实际上就发生过)。需要一个专门编写的应用程序——30个线程对5个文档进行操作——才能揭示真相:它并不完全奏效。但是我们总是太忙,没有时间编写一个单独的应用程序来测试解决方案,因为没有明显的理由怀疑事情并非如此简单。
因此,随附的演示应用程序就是我们通常没有时间编写的这个测试应用程序。玩转它,在不同模式下尝试它,带锁或不带锁;尝试更改隔离级别、线程数、修改SQL等——看看什么实际有效,什么失败。很有可能,您可能会发现一些(更简单?)的替代方案有效(比我下面建议的更简单);然后只需让我和其他人知道。
背景
数据库服务器使用锁来管理对数据的并发访问。首先,有自动锁——这些锁由数据库引擎自动设置,主要是为了防止两个或多个进程同时编辑同一记录时可能发生的数据损坏。这些自动锁在保护数据完整性的同时,有时也会产生不良影响——死锁。
服务器向应用程序公开锁定功能,让它们显式建立锁,以避免死锁或在编辑过程中读取部分更改的文档等不良情况。
数据库中的锁具有作用域(生命周期),它就是其所在的事务。当事务提交或中止时,所有锁都会释放。那么,当没有事务时,锁会怎么样呢?始终存在事务。简单地说,当您在没有事务的情况下执行单个SQL语句时,数据库会将其封装到一个隐式、自动的事务中。因此,在没有显式事务的独立语句中建立的锁会在语句完成后立即释放——因为其包含的隐式事务会在语句完成后自动提交。
让我们再次列出我们的目标。
- 防止在更新文档时数据库引擎自动锁定的死锁。
- 维护文档完整性。——标题中的总计应与详细信息行的总和匹配。
- 加载文档的一致快照,其中不包含加载时正在进行的所有未完成的更改。
- 额外要求——并发读取不应相互阻塞,以提高吞吐量。只有并发更新才应是排他的。
为了实现前两个目标(更新保持一致性且无死锁),一个显而易见的解决方案是在事务开始时对文档设置排他更新锁。数据库引擎并不真正了解文档,它只了解并能锁定表中的行。要锁定文档,我们需要显式锁定“根”——文档头,即始终存在并作为“主/头”部分的数据行——例如对应表中的PurchaseOrderHeader或Invoice Header行。诀窍是在事务开始时锁定头,即使我们不打算更新头本身,而只修改子项目/行。
为了实现一致的干净读取,我们在文档头设置共享读取锁。该锁应该是“共享”的,这样并发读取就不会相互阻塞。就像更新一样,锁应该在事务开始时设置。事务?——你可能会问,——用于读取?是的,我们必须在显式事务中执行读取(所有SELECT语句)——因为锁的生命周期是其包含的事务,这是确保在我们加载文档时不会发生并发更改的唯一方法。
数据库服务器(其中一些)支持一种特殊的并发更新管理方式:行版本控制。基本上,当并发更新(和读取)正在进行时,服务器会保留同一行的多个版本,并使用它们为每个进程提供“一致”的数据视图。在这种情况下,如果我们在事务中读取数据,我们看到的版本始终是事务开始时的数据。因此,对于这些服务器(Postgres、Oracle,以及现在具有快照隔离的MS SQL),我们不需要设置任何读取锁,只需在事务中执行SELECT即可。
任何读写操作的逻辑顺序如下:
- 开始事务
- 使用适当的锁加载文档头
- 执行所有其他读取和/或更新操作
- 提交事务
请注意,在读取或更新两种情况下,我们都从加载(SELECT)头部开始,只是使用了不同的锁。对于更新操作,这可能看起来是一些额外的不必要加载;但通常情况下,至少在Web应用程序中,它通常就是这样工作的。当服务器从客户端接收到更新命令时,它会从加载文档开始,将更改应用于“对象”,然后将文档提交到数据库。所以这个SELECT并不完全离谱。
现在我们可以转向实际的实现,但首先我们将回顾随附的演示应用程序及其功能。
使用代码
文章的示例代码在github仓库中:https://github.com/rivantsov/LockDemo
该应用程序可以对任何服务器(MS SQL Server、PostgreSQL、MySql、Oracle)执行锁定测试。您必须在某处安装目标服务器。从仓库下载zip文件,在Visual Studio 2015中打开解决方案,并按照ReadMe.md文件中的说明操作。基本上,您需要选择服务器,调整app.config中该服务器的连接字符串,运行DDL脚本创建数据库表,最后运行应用程序。
尝试在没有锁的情况下运行它,看看区别(在app.config文件中将UseLocks=false)
以下部分解释了应用程序的一些内部原理——数据模型、不同服务器的锁等。之后我们将详细介绍每个服务器的特性以及如何正确锁定。
演示应用程序和示例文档模型
演示应用程序使用简化的复合文档数据模型,包含主/详细信息部分:
文档由DocHeader表中的标题和链接到该标题的多个DocDetail记录组成。标题的主键是DocName列。DocDetail行由Name标识,主键是复合键:(DocName, Name)。文档只是一个具有名称-值对字典的命名事物。标题有一个Total列——它应该等于所有相关DocDetail行中值的总和——这是一个内部“一致性”约束。
注意:请不要关注这种“设计”,这与我在现实世界中的做法并非一致。相反,它是一个最简化的例子,说明了本文的重点——在关系数据库中处理多部分文档。这只是一个演示模型!
演示应用程序最初创建了5个文档,名称为“D0”..“D4”,每个文档包含5个名为“V0”..“V4”的子DocDetail行。这些文档最初是一致的(有效、正确)——所有Value和Total值都为零。我们预先创建了所有文档,并且在测试的主要部分不插入或删除任何内容,只更新现有记录。涉及随机插入/删除的情况可能会更有趣,但为了简单起见,我们仅限于更新——相信我,它会揭示所有不好的东西。
创建文档后,应用程序开始主运行。它启动30个线程,每个线程重复40次以下操作:
随机选择“读取”或“更新”两种操作之一。
- 对于更新操作:启动事务;随机选择文档名称和3个随机值名称(从V0..V4中)。将这3个值更新为1..10范围内的随机数;加载所有详细信息行,计算总和并更新标题的Total值;提交。
- 对于读取操作:选择随机文档名称,加载文档标题,加载所有详细信息行。计算详细信息Value列的总和,与标题中的Total进行比较。如果存在不匹配——这是一个一致性检查错误,增加错误计数。
以下代码片段是运行操作的方法,它在30个独立线程中的一个线程上执行:
private void RunRandomOps(object objRepo) {
var repo = (DocRepository)objRepo;
var rand = new Random();
DocHeader doc;
int total;
for(int i = 0; i < Program.RepeatCount; i++) {
if(i % 5 == 0)
Console.Write("."); //show progress
Thread.Yield();
var op = rand.Next(2);
var docName = "D" + rand.Next(5);
try {
switch(op) {
case 0: //update several random detail rows
repo.Open(forUpdate: true);
if(Program.UseLocks)
doc = repo.DocHeaderLoad(docName);
repo.DocDetailUpdate(docName, "V" + rand.Next(5), rand.Next(10));
repo.DocDetailUpdate(docName, "V" + rand.Next(5), rand.Next(10));
repo.DocDetailUpdate(docName, "V" + rand.Next(5), rand.Next(10));
// Recalc total and update header
total = repo.DocDetailsLoadAll(docName).Sum(d => d.Value);
repo.DocHeaderUpdate(docName, total);
repo.Commit();
break;
case 1: //load doc, verify total
repo.Open(forUpdate: false);
doc = repo.DocHeaderLoad(docName);
total = repo.DocDetailsLoadAll(docName).Sum(d => d.Value);
if(total != doc.Total) {
Interlocked.Increment(ref InconsistentReadCount);
var msg = "\r\n--- Inconsistent read; doc.Total: " + doc.Total + ", sum(det): " + total;
Console.WriteLine(msg);
repo.Log(msg);
}
repo.Commit();
break;
}//switch
} catch(Exception ex) {
//database error, most often deadlock
Interlocked.Increment(ref DbErrorCount);
Console.WriteLine("\r\n--- DB error: " + ex.Message + " (log file: " + repo.LogFile + ")");
repo.Log("Db error ----------------------------------- \r\n" + ex.ToString() + "\r\n");
repo.Rollback();
}
}//for i
}
执行过程中散布着对 Thread.Yield() 的调用,您在上面的代码中看到了一个,还有更多。这使得线程切换更加频繁,以“强化”测试。线程计数和重复计数可以在 app.config 文件中更改。
app.config文件还包含“UseLocks”设置:如果为false,测试将不带任何锁运行,您将看到多个错误:更新操作导致死锁,而读取操作导致一致性检查失败——docHeader.Total与docDetail行的Sum不匹配。如果UseLocks设置为true,您将看不到错误——应用程序正在为选定的服务器类型使用适当的锁定策略,并且一切正常。
应用程序将所有 SQL 和错误写入 bin/debug 文件夹中的日志文件,每个线程一个单独的文件,名为“sqllog_N.log”,其中 N 是线程号——所以如果您对详细的 SQL 和错误堆栈跟踪感兴趣,请在那里查看。 以下是日志文件中 SQL 的示例,其中包含文档加载和 MySql 的更新:
BeginTransaction
SELECT "DocName", "Total" FROM lck."DocHeader" WHERE "DocName"='D2' LOCK IN SHARE MODE
SELECT "DocName", "Name", "Value"
FROM lck."DocDetail" WHERE "DocName"='D2'
Commit
BeginTransaction
SELECT "DocName", "Total" FROM lck."DocHeader" WHERE "DocName"='D2' FOR UPDATE
UPDATE lck."DocDetail"
SET "Value" = 6 WHERE "DocName" = 'D2' AND "Name" = 'V0'
UPDATE lck."DocDetail"
SET "Value" = 0 WHERE "DocName" = 'D2' AND "Name" = 'V2'
UPDATE lck."DocDetail"
SET "Value" = 1 WHERE "DocName" = 'D2' AND "Name" = 'V2'
SELECT "DocName", "Name", "Value"
FROM lck."DocDetail" WHERE "DocName"='D2'
UPDATE lck."DocHeader" SET "Total" = 7 WHERE "DocName" = 'D2'
Commit
关于多服务器实现的一些说明。该应用程序通过一个名为DocRepository的类运行SQL语句——它构建SQL并执行它。对于每种支持的服务器类型,都有一个子类,它稍微调整基类对目标服务器的行为。主要的调整在类构造函数中:我们修改了用于以读写锁加载头的SQL模板——每个服务器都有自己的提示/子句。此外,MS SQL和Oracle需要特定的事务隔离级别。
各种服务器中锁的程序化控制
现在我们进入本文的核心主题——在各种数据库服务器中进行适当的并发管理的具体模式。如果我们使用ADO.NET,我们有以下控制点来管理服务器上SQL语句的执行:
- 事务隔离级别——当我们使用connection.BeginTransaction方法打开事务时,我们可以提供所需的隔离级别作为参数。如果未提供任何值,则级别为未指定。
- 提示或特殊的SQL关键字/子句,添加到SQL语句中,指定所需的锁。
- 数据库范围的设置,必须正确设置才能使锁定按预期工作。事实证明,只有MS SQL Server需要这些选项,其他服务器开箱即用,使用默认设置。
以下小节列出了每个服务器的确切设置并提供了示例。
MySql
读写事务隔离级别:未指定
SQL 子句
读取: LOCK IN SHARE MODE
写入:FOR UPDATE
示例:
SELECT "DocName", "Total" FROM lck."DocHeader" WHERE "DocName"='N1' LOCK IN SHARE MODE
SELECT "DocName", "Total" FROM lck."DocHeader" WHERE "DocName"='N1' FOR UPDATE
注意:请记住,要开始更新文档,我们需要锁定标题——即使我们不打算更新标题,只更新详细信息行。我们通过使用“更新”锁选择标题来实现。
PostgreSql
读写事务隔离级别:未指定
SQL 子句
读取: FOR SHARE
写入:FOR UPDATE
示例:
SELECT "DocName", "Total" FROM lck."DocHeader" WHERE "DocName"='N1' FOR SHARE
SELECT "DocName", "Total" FROM lck."DocHeader" WHERE "DocName"='N1' FOR UPDATE
Oracle
事务隔离级别
读取:Serializable
写入:ReadCommitted
SQL 子句
读取: (无)
写入:FOR UPDATE
示例:
SELECT DocName, Total FROM DocHeader WHERE DocName='N1' FOR UPDATE
注:Oracle使用数据行版本控制,因此使用Serializable隔离级别的一致性读取不需要在SQL中提供任何额外的提示。
MS SQL Server
数据库范围设置
ALLOW_SNAPSHOT_ISOLATION = ON
READ_COMMITTED_SNAPSHOT = ON
事务隔离级别
读取:Snapshot
写入:ReadCommitted
SQL 提示:
写入:WITH(UpdLock)
示例:
SELECT "DocName", "Total" FROM lck."DocHeader" WITH(UpdLock) WHERE "DocName"='N1'
MS SQL Server是一个非常特殊的案例——它需要一些数据库级别的设置。您需要为数据库启用快照隔离级别。粗略地说,快照模式意味着“启用行版本控制模式”:当更新正在进行时,服务器会保留数据行的几个版本,每个更新进程一个版本,加上原始版本。每个进程都会看到自己的“当前”版本。这种方法与PostgreSql和Oracle默认的做法非常相似。使用快照隔离,您不需要为读取操作提供任何子句/提示——只需为写入操作提供UpdLock提示。
吐槽:MS SQL Server的棘手案例
乍一看,MS SQL Server的情况与其他服务器并没有太大区别——只是多了两个数据库范围的设置,没什么大不了的。问题不在于模式本身,而在于很难弄清楚。上面针对MS SQL的解决方案在任何MSDN文章、服务器文档页面,甚至第三方帖子中都无法轻易找到——至少我没有找到,而且我花了很多时间寻找。你找到的是关于隔离级别、内部锁和SQL服务器内部复杂细节的无尽解释——这让你头疼欲裂,但并不能真正让你接近解决方案。我确实通读了许多这些文章,但仍然无法弄清楚如何让我的小案例奏效。这对我来说真的很震惊。
首先,我自己在几种服务器类型方面的经验是,MS SQL 总是最对开发者友好的——通常是“其他”服务器需要额外的工作。其次,这里的案例并不是一些非常特殊的极端和一生难遇的场景——它是一个非常基本、根本的案例,如果您的应用程序处理重要的财务或法律数据,就必须实现。因此,考虑到 MS SQL 和 .NET 平台在商业应用程序领域的流行度,它应该在成千上万的网站上应用。但令人惊讶的是——没有现成的建议。
我在微软官方网站的 T-SQL 论坛上描述了我的问题。两位专家(MVP)很快回应,我们开始共同解决。令人惊讶的是,他们也没有立即提出解决方案。经过多次尝试/失败,分析事务日志,两天多的时间才最终弄清楚。我绝不是在质疑帮助我的 MVP 们的专业知识,不!他们确实是专家,但他们花了不止一次尝试才找到解决方案。
这真令人不安。我认为有成千上万的网站运行在MS SQL Server上并处理关键信息,他们的所有者——我相信都是经验丰富的开发人员——可能认为他们正在正确处理并发。但对他们中的许多人来说,情况可能并非如此。
需要一个专门编写的测试应用程序,投入大量时间和精力,才能测试和确认(?!),或者发现并发处理不正确。
对于MS SQL世界,除了写这篇文章之外,还有什么解决方案呢?微软的一个方法是最终(!)实现(标准?)SQL语句的共享/读取/写入锁,就像PostgreSql或MySql那样(而不是MS SQL特有的表提示!),使实现变得简单直接,开箱即用,就像“其他”服务器一样(顺便说一句,它们在许多情况下更符合SQL标准)。如何处理必须提前启用的快照隔离设置?如果代码尝试使用“FOR UPDATE”锁定子句而未启用此选项,则抛出错误,清楚地解释使用此功能所需的额外步骤。认为它已经存在,只是通过提示实现并已在文档中解释的论点——不,事实并非如此——我自己的经验恰恰相反。
另一个令我不安的观点是,你所有关于 ADO.NET 和 Entity Framework 并发性的谷歌搜索,最终都只会找到关于乐观并发的文章(参见下面的章节),让你产生一种印象,即并发管理除了这些之外就没什么可谈的了!真的吗?...
恕我直言,微软应该对此采取一些措施。
数据库锁和 ORM
对象关系映射 (ORM) 框架通常用于从中间层组件访问数据库。所有与数据库工作的真实应用程序都使用 ORM,即使它是自建的轻量级 ORM。
至于并发支持,现在应该很清楚,任何真正的ORM,作为数据库的桥梁,都必须支持文档级锁定。据我所知,今天唯一直接支持这些锁的ORM是
是的,我是这个ORM的开发者——你猜对了,它确实支持多种服务器:MS SQL Server、Postgres、MySql和SQLite;Oracle正在开发中。VITA不仅仅是一个ORM——它是一个应用程序框架,它做了更多的事情,比如与Web API堆栈集成、预构建的功能模块(如登录、各种日志记录、高级Web客户端等)。
这实际上解释了这篇文章的由来。一段时间前,并发支持工作项终于排到了我的待办事项列表的首位,我开始研究这个主题。我写了单元测试,类似于本文的代码(30个线程对5个文档进行操作),很快就让它在Postgres和MySql上工作了。但对于MS SQL服务器——麻烦就开始了。相当令人震惊的是,在与这3个服务器合作的所有这些年里,MS SQL一直是其中最简单的。这次不一样了。剩下的故事你已经知道了。最后,在VITA中实现了所有这些锁定之后,我决定分享这些发现,并编写了一个不依赖VITA进行不同服务器数据访问的测试应用程序,只是为了清楚地说明,没有依赖项,没有底层晦涩的框架代码。
乐观并发——它与此有何关联?
乐观并发是管理多个用户并发访问服务器文档的常用模式。如果您使用它,您可能会想——它与本文讨论的问题有什么关系?如果我使用乐观并发——这是否意味着我一切都好,或者我仍然需要所有这些锁定机制?答案是肯定的,您仍然需要它;乐观并发适用于不同类型的“并发”,即使您正确地实现了乐观并发,您仍然可能遇到死锁和不一致的读取。
让我们快速回顾一下什么是乐观并发模式。每个文档都有一个时间戳(或行版本),它唯一标识文档的版本;每次更新时,时间戳都会改变。当用户打算修改文档时,它会从服务器加载文档,服务器会连同文档一起发送时间戳/版本值。当用户完成更改并提交新版本时,服务器会检查数据库中文档的当前时间戳是否与接收到的更改版本相同。如果不同,则意味着在用户在客户端更改文档期间,其他人已经更新了文档。在大多数情况下,这会导致发送回用户一个错误:“抱歉,文档已更新”。服务器拒绝更新,因为它使用已过时版本的值进行更新可能不安全。应用程序建议刷新文档,再次查看,进行更改并重新提交。
应该清楚的是,乐观并发是为了处理并发用户尝试更新他们可能在几秒甚至几分钟前加载的文档的情况。因此,冲突的时间跨度是秒/分钟。对于本文主要讨论的并发性,时间跨度是毫秒,适用于在服务器上执行的不同并发进程。另一点:如果您的文档由单个记录组成,您仍然有理由使用乐观并发,而不是我们本文中描述的锁。因此,范围和范围差异应该很清楚,即使使用乐观模式,您仍然需要锁。
让我们用一个假设的例子来说明。在网站上实现乐观并发(但没有锁)的情况下,假设两个用户同时开始编辑同一文档。完成修改后,假设他们同时点击了“提交”按钮。两个POST请求在同一时刻到达一个或两个独立的Web服务器,并开始执行两个更新进程。两个进程都执行并发检查(比较数据库中的版本与从客户端收到的版本),两个检查都成功——中间没有完成的更新。然后两个进程都尝试进行实际更新,您就会遇到所有麻烦——死锁、文档无效状态等。再想象一下第三个用户在更新正在进行时加载文档——它很可能会得到一个正在更新中的文档,可能是一个内部不一致的快照。
而令我困扰的是,所有解释乐观并发实现(尤其是关于它在 Entity Framework 中的支持)的文章——所有这些文章最终都让你觉得,这基本上就是你需要了解的关于应用程序中并发处理的全部内容。听起来很熟悉吗?
双击的故事
我想分享一个有趣的故事,它表明即使在单用户场景中,锁也可能很有用。我承认,我一直认为只有当您有多个用户最终可以编辑同一文档时,才需要锁。事实证明,即使是单个用户,在某些情况下,锁也是正确的解决方案。
我们有一个 Web 应用程序,有时我们会要求用户参与调查——一组问题。我们将答案记录在一个简单的表中:(UserId, QuestionKey, Answer)。(UserId, QuestionKey) 对上有一个唯一键——每个用户每个问题只有一个答案。用户可以滚动回到之前的问题并更改他们的答案,因此保存答案可能是一个插入操作(如果记录不存在),或者是一个更新操作(如果记录存在)。
因此,我们开始测试应用程序原型(没有锁,当时还没有),突然服务器上出现了奇怪的错误。提交偶尔会失败,显示“唯一索引冲突”错误。Web 调用日志清楚地显示,在每种情况下都有两次相同的提交,来自同一个客户端/机器,相隔几毫秒。用户偶尔会双击!(在 iPad 等触控设备上,触摸通常会导致双击甚至多次点击)。其余的很清楚:双击后,两个请求几乎同时到达 Web 服务器,都尝试查找现有答案,没有找到,然后都尝试插入新的名称/值。第一个成功,而第二个导致唯一约束冲突。
显而易见的修复方法是:在按钮点击后立即禁用它(而不是仅仅等待显示新问题)。我们尝试了,错误变得不那么频繁,但仍然偶尔发生。原因是UI由客户端框架控制,启用/禁用等操作是通过间接操作按钮样式来完成的——通过某些元素/属性与数据(Angular!)的绑定。所以,传播禁用状态并实际禁用按钮需要一些时间,双击偶尔会通过(这是我的猜测)。
我们没有继续在客户端解决问题,而是选择了在服务器端进行修复——通过对“文档”使用服务器端锁。我们将调查答案视为一个复合文档,并要求在开始添加答案之前锁定标题。双重提交在服务器上序列化,因此第一个提交进行插入,而第二个提交对同一记录进行无效更新。问题解决了。
总的来说,任何客户端行为,无论多么糟糕,即使是恶意黑客编写的脚本,都不应该导致服务器崩溃或失败,产生服务器端错误,句号。这通过在服务器上使用文档锁来解决。
关注点
一篇关于MS SQL Server中一些额外锁和可能由外键引起的意外的有趣博客文章:
http://www.sqlnotes.info/2012/06/26/locking-behavior-fks/。结果表明,即使两个进程更新不相交的记录集,也可能发生死锁!通过此处描述的锁定,我相信我们已经涵盖了这种情况,因为我们总是锁定标题,即使我们只计划修改子记录。
历史
2016/08/24 - 第一个版本