04.防止丢失更新
防止丢失更新
到目前为止已经讨论的读已提交和快照隔离级别,主要保证了只读事务在并发写入时可以看到什么。却忽略了两个事务并发写入的问题,我们只讨论了脏写,一种特定类型的写-写冲突是可能出现的。并发的写入事务之间还有其他几种有趣的冲突。其中最着名的是丢失更新(lost update)问题,如下图所示,两个并发计数器增量会有冲突。
如果应用从数据库中读取一些值,修改它并写回修改的值(读取-修改-写入序列),则可能会发生丢失更新的问题。如果两个事务同时执行,则其中一个的修改可能会丢失,因为第二个写入的内容并没有包括第一个事务的修改,这种模式发生在各种不同的情况下:
- 增加计数器或更新账户余额(需要读取当前值,计算新值并写回更新后的值)
- 在复杂值中进行本地修改:例如,将元素添加到 JSON 文档中的一个列表(需要解析文档,进行更改并写回修改的文档)
- 两个用户同时编辑 wiki 页面,每个用户通过将整个页面内容发送到服务器来保存其更改,覆写数据库中当前的任何内容。
这是一个普遍的问题,所以已经开发了各种解决方案。
原子写
许多数据库提供了原子更新操作,从而消除了在应用程序代码中执行读取-修改-写入序列的需要。如果你的代码可以用这些操作来表达,那这通常是最好的解决方案。例如,下面的指令在大多数关系数据库中是并发安全的:
$ UPDATE counters SET value = value + 1 WHERE key = 'foo';
类似地,像 MongoDB 这样的文档数据库提供了对 JSON 文档的一部分进行本地修改的原子操作,Redis 提供了修改数据结构(如优先级队列)的原子操作。并不是所有的写操作都可以用原子操作的方式来表达,例如维基页面的更新涉及到任意文本编辑,但是在可以使用原子操作的情况下,它们通常是最好的选择。
原子操作通常通过在读取对象时,获取其上的排它锁来实现。以便更新完成之前没有其他事务可以读取它。这种技术有时被称为游标稳定性(cursor stability)。另一个选择是简单地强制所有的原子操作在单一线程上执行。
不幸的是,ORM 框架很容易意外地执行不安全的读取-修改-写入序列,而不是使用数据库提供的原子操作。如果你知道自己在做什么那当然不是问题,但它经常产生那种很难测出来的微妙 Bug。
显式锁定
如果数据库的内置原子操作没有提供必要的功能,防止丢失更新的另一个选择是让应用程序显式地锁定将要更新的对象。然后应用程序可以执行读取-修改-写入序列,如果任何其他事务尝试同时读取同一个对象,则强制等待,直到第一个读取-修改-写入序列完成。
例如,考虑一个多人游戏,其中几个玩家可以同时移动相同的棋子。在这种情况下,一个原子操作可能是不够的,因为应用程序还需要确保玩家的移动符合游戏规则,这可能涉及到一些不能合理地用数据库查询实现的逻辑。但你可以使用锁来防止两名玩家同时移动相同的棋子:
BEGIN TRANSACTION;
SELECT * FROM figures
WHERE name = 'robot' AND game_id = 222
FOR UPDATE;
-- 检查玩家的操作是否有效,然后更新先前SELECT返回棋子的位置。
UPDATE figures SET position = 'c4' WHERE id = 1234;
COMMIT;
FOR UPDATE 子句告诉数据库应该对该查询返回的所有行加锁;这是有效的,但要做对,你需要仔细考虑应用逻辑。忘记在代码某处加锁很容易引入竞争条件。
自动检测丢失的更新
原子操作和锁是通过强制读取-修改-写入序列按顺序发生,来防止丢失更新的方法。另一种方法是允许它们并行执行,如果事务管理器检测到丢失更新,则中止事务并强制它们重试其读取-修改-写入序列。
这种方法的一个优点是,数据库可以结合快照隔离高效地执行此检查。事实上,PostgreSQL 的可重复读,Oracle 的可串行化和 SQL Server 的快照隔离级别,都会自动检测到丢失更新,并中止惹麻烦的事务。但是,MySQL/InnoDB 的可重复读并不会检测丢失更新。一些人认为,数据库必须能防止丢失更新才称得上是提供了快照隔离,所以在这个定义下,MySQL 下不提供快照隔离。
丢失更新检测是一个很好的功能,因为它不需要应用代码使用任何特殊的数据库功能,你可能会忘记使用锁或原子操作,从而引入错误;但丢失更新的检测是自动发生的,因此不太容易出错。
比较并设置(CAS)
在不提供事务的数据库中,有时会发现一种原子操作:比较并设置(CAS, Compare And Set)。此操作的目的是为了避免丢失更新:只有当前值从上次读取时一直未改变,才允许更新发生。如果当前值与先前读取的值不匹配,则更新不起作用,且必须重试读取-修改-写入序列。
例如,为了防止两个用户同时更新同一个 wiki 页面,可以尝试类似这样的方式,只有当用户开始编辑页面内容时,才会发生更新:
-- 根据数据库的实现情况,这可能也可能不安全
UPDATE wiki_pages SET content = '新内容'
WHERE id = 1234 AND content = '旧内容';
如果内容已经更改并且不再与“旧内容”相匹配,则此更新将不起作用,因此您需要检查更新是否生效,必要时重试。但是,如果数据库允许 WHERE 子句从旧快照中读取,则此语句可能无法防止丢失更新,因为即使发生了另一个并发写入,WHERE 条件也可能为真。在依赖数据库的 CAS 操作前要检查其是否安全。
冲突解决和复制
在复制数据库中,防止丢失的更新需要考虑另一个维度:由于在多个节点上存在数据副本,并且在不同节点上的数据可能被并发地修改,因此需要采取一些额外的步骤来防止丢失更新。锁和 CAS 操作假定有一个最新的数据副本。但是多主或无主复制的数据库通常允许多个写入并发执行,并异步复制到副本上,因此无法保证有一份数据的最新副本。所以基于锁或 CAS 操作的技术不适用于这种情况。
相反,这种复制数据库中的一种常见方法是允许并发写入创建多个冲突版本的值(也称为兄弟),并使用应用代码或特殊数据结构在事实发生之后解决和合并这些版本。原子操作可以在复制的上下文中很好地工作,尤其当它们具有可交换性时(即,可以在不同的副本上以不同的顺序应用它们,且仍然可以得到相同的结果)。例如,递增计数器或向集合添加元素是可交换的操作。这是 Riak 2.0 数据类型背后的思想,它可以防止复制副本丢失更新。当不同的客户端同时更新一个值时,Riak 自动将更新合并在一起,以免丢失更新。