03.1.写入偏差与幻读

写入偏差与幻读

当不同的事务并发地尝试写入相同的对象时,会出现两种竞争条件,脏写与丢失更新。为了避免数据损坏,这些竞争条件需要被阻止;既可以由数据库自动执行,也可以通过锁和原子写操作这类手动安全措施来防止。但是,并发写入间可能发生的竞争条件还没有完。在本节中,我们将看到一些更微妙的冲突例子。

首先,想象一下这个例子:你正在为医院写一个医生轮班管理程序。医院通常会同时要求几位医生待命,但底线是至少有一位医生在待命。医生可以放弃他们的班次(例如,如果他们自己生病了),只要至少有一个同事在这一班中继续工作。现在想象一下,Alice 和 Bob 是两位值班医生。两人都感到不适,所以他们都决定请假。不幸的是,他们恰好在同一时间点击按钮下班。

写入偏差导致应用程序错误的示例

在两个事务中,应用首先检查是否有两个或以上的医生正在值班;如果是的话,它就假定一名医生可以安全地休班。由于数据库使用快照隔离,两次检查都返回 2,所以两个事务都进入下一个阶段。Alice 更新自己的记录休班了,而 Bob 也做了一样的事情。两个事务都成功提交了,现在没有医生值班了。违反了至少有一名医生在值班的要求。

写偏差的特征

这种异常称为写偏差,它既不是脏写,也不是丢失更新,因为这两个事务正在更新两个不同的对象(Alice 和 Bob 各自的待命记录)。在这里发生的冲突并不是那么明显,但是这显然是一个竞争条件:如果两个事务一个接一个地运行,那么第二个医生就不能歇班了。异常行为只有在事务并发进行时才有可能。

可以将写入偏差视为丢失更新问题的一般化。如果两个事务读取相同的对象,然后更新其中一些对象(不同的事务可能更新不同的对象),则可能发生写入偏差。在多个事务更新同一个对象的特殊情况下,就会发生脏写或丢失更新(取决于时机)。我们看到,有各种不同的方法来防止丢失的更新。随着写偏差,我们的选择更受限制:

由于涉及多个对象,单对象的原子操作不起作用。不幸的是,在一些快照隔离的实现中,自动检测丢失更新对此并没有帮助。在 PostgreSQL 的可重复读,MySQL/InnoDB 的可重复读,Oracle 可序列化或 SQL Server 的快照隔离级别中,都不会自动检测写入偏差。自动防止写入偏差需要真正的可序列化隔离。

某些数据库允许配置约束,然后由数据库强制执行(例如,唯一性,外键约束或特定值限制)。但是为了指定至少有一名医生必须在线,需要一个涉及多个对象的约束。大多数数据库没有内置对这种约束的支持,但是你可以使用触发器,或者物化视图来实现它们,这取决于不同的数据库。如果无法使用可序列化的隔离级别,则此情况下的次优选项可能是显式锁定事务所依赖的行。在例子中,你可以写下如下的代码:

BEGIN TRANSACTION;
SELECT * FROM doctors
  WHERE on_call = TRUE
  AND shift_id = 1234 FOR UPDATE;

UPDATE doctors
  SET on_call = FALSE
  WHERE name = 'Alice'
  AND shift_id = 1234;

COMMIT;

和以前一样,FOR UPDATE 告诉数据库锁定返回的所有行用于更新。

会议室预订系统

假设你想强制执行,同一时间不能同时在两个会议室预订。当有人想要预订时,首先检查是否存在相互冲突的预订(即预订时间范围重叠的同一房间),如果没有找到,则创建会议。

BEGIN TRANSACTION;

-- 检查所有现存的与12:00~13:00重叠的预定
SELECT COUNT(*) FROM bookings
WHERE room_id = 123 AND
	end_time > '2015-01-01 12:00' AND start_time < '2015-01-01 13:00';

-- 如果之前的查询返回0
INSERT INTO bookings(room_id, start_time, end_time, user_id)
  VALUES (123, '2015-01-01 12:00', '2015-01-01 13:00', 666);

COMMIT;

不幸的是,快照隔离并不能防止另一个用户同时插入冲突的会议。为了确保不会遇到调度冲突,你又需要可序列化的隔离级别了。

多人游戏

我们使用一个锁来防止丢失更新(也就是确保两个玩家不能同时移动同一个棋子)。但是锁定并不妨碍玩家将两个不同的棋子移动到棋盘上的相同位置,或者采取其他违反游戏规则的行为。按照您正在执行的规则类型,也许可以使用唯一约束,否则您很容易发生写入偏差。

抢注用户名

在每个用户拥有唯一用户名的网站上,两个用户可能会尝试同时创建具有相同用户名的帐户。可以在事务检查名称是否被抢占,如果没有则使用该名称创建账户。但是像在前面的例子中那样,在快照隔离下这是不安全的。幸运的是,唯一约束是一个简单的解决办法(第二个事务在提交时会因为违反用户名唯一约束而被中止)。

防止双重开支

允许用户花钱或积分的服务,需要检查用户的支付数额不超过其余额。可以通过在用户的帐户中插入一个试探性的消费项目来实现这一点,列出帐户中的所有项目,并检查总和是否为正值。有了写入偏差,可能会发生两个支出项目同时插入,一起导致余额变为负值,但这两个事务都不会注意到另一个。

导致写入偏差的幻读

所有这些例子都遵循类似的模式:

  • 一个SELECT查询找出符合条件的行,并检查是否符合一些要求。(例如:至少有两名医生在值班;不存在对该会议室同一时段的预定;棋盘上的位置没有被其他棋子占据;用户名还没有被抢注;账户里还有足够余额)

  • 按照第一个查询的结果,应用代码决定是否继续。(可能会继续操作,也可能中止并报错)

  • 如果应用决定继续操作,就执行写入(插入、更新或删除),并提交事务。这个写入的效果改变了步骤 2 中的先决条件。换句话说,如果在提交写入后,重复执行一次步骤 1 的 SELECT 查询,将会得到不同的结果。因为写入改变符合搜索条件的行集(现在少了一个医生值班,那时候的会议室现在已经被预订了,棋盘上的这个位置已经被占据了,用户名已经被抢注,账户余额不够了)。

这些步骤可能以不同的顺序发生。例如可以首先进行写入,然后进行 SELECT 查询,最后根据查询结果决定是放弃还是提交。

在医生值班的例子中,在步骤 3 中修改的行,是步骤 1 中返回的行之一,所以我们可以通过锁定步骤 1 中的行(SELECT FOR UPDATE)来使事务安全并避免写入偏差。但是其他四个例子是不同的:它们检查是否不存在某些满足条件的行,写入会添加一个匹配相同条件的行。如果步骤 1 中的查询没有返回任何行,则SELECT FOR UPDATE锁不了任何东西。

这种效应:一个事务中的写入改变另一个事务的搜索查询的结果,被称为幻读。快照隔离避免了只读查询中幻读,但是在像我们讨论的例子那样的读写事务中,幻影会导致特别棘手的写歪斜情况。

物化冲突

如果幻读的问题是没有对象可以加锁,也许可以人为地在数据库中引入一个锁对象?例如,在会议室预订的场景中,可以想象创建一个关于时间槽和房间的表。此表中的每一行对应于特定时间段(例如 15 分钟)的特定房间。可以提前插入房间和时间的所有可能组合行(例如接下来的六个月)。

现在,要创建预订的事务可以锁定(SELECT FOR UPDATE)表中与所需房间和时间段对应的行。在获得锁定之后,它可以检查重叠的预订并像以前一样插入新的预订。请注意,这个表并不是用来存储预订相关的信息——它完全就是一组锁,用于防止同时修改同一房间和时间范围内的预订。

这种方法被称为物化冲突(materializing conflicts),因为它将幻读变为数据库中一组具体行上的锁冲突。不幸的是,弄清楚如何物化冲突可能很难,也很容易出错,而让并发控制机制泄漏到应用数据模型是很丑陋的做法。出于这些原因,如果没有其他办法可以实现,物化冲突应被视为最后的手段。在大多数情况下。可序列化(Serializable)的隔离级别是更可取的。

上一页
下一页