本地事务设计(4)-弱隔离级别之防止更新丢失
RC和快照隔离级别主要都是为解决 只读事务遇到并发写时可以看到什么(虽然中间也涉及脏写),还没触及另一种情况:两个写事务并发,而脏写只是写并发的特例。
写事务并发带来最着名的问题就是丢失更新,如图-1的两个并发计数器增量为例。
应用从DB读一些值,修改它并写回修改后的值,则可能导致丢失更新。若两事务同时执行,则其中一个的修改可能丢失,因为第二个写内容并未包括第一个事务的修改(有时会说后面的写入 狠揍(clobber) 了前面的写入)这种模式发生在各种不同场景:
- 增加计数器或更新账户余额(需要读取当前值,计算新值并写回更新后的值)
- 在复杂值中进行本地修改:例如,将元素添加到 JSON 文档中的一个列表(需要解析文档,进行更改并写回修改的文档)
- 两个用户同时编辑 wiki 页面,每个用户通过将整个页面内容发送到服务器来保存其更改,覆写数据库中当前的任何内容。
这是一个普遍的问题,所以已经开发了各种解决方案。
2.3.1 原子写
许多DB支持原子更新,避免了在应用程序代码中执行读取 - 修改 - 写入。用这些操作通常是最好的解决方案。如下指令在大多数关系DB中并发安全:
UPDATE counters SET value = value + 1 WHERE key = 'foo';
类似像:
- MongoDB文档DB提供了对 JSON 文档的一部分进行本地修改的原子操作
- Redis支持修改数据结构(如优先级队列)的原子操作
并不是所有的写操作都可以用原子操作的方式来表达,例如维基页面的更新涉及到任意文本编辑 [^viii],但是在可以使用原子操作的情况下,它们通常是最好的选择。
[^viii]: 将文本文档的编辑表示为原子的变化流是可能的,尽管相当复杂。请参阅 “自动冲突解决”。
实现方案
- 一般采用对读取对象加排它锁来实现,以便在更新完成之前没有其他事务可以读它。这种技术有时被称为游标稳定性(cursor stability)
- 另一个实现方案是强制所有的原子操作在单线程执行。
但ORM框架很容易导致执行不安全的读取 - 修改 - 写入,而不是使用数据库提供的原子操作。若你知道自己在做什么,或许这不会引发什么问题,但往往会埋下潜在Bug。
2.3.2 显式加锁
若DB不支持内置原子操作,防止丢失更新的另一个选择是让应用程序显式锁定待更新对象。然后应用程序执行读取 - 修改 - 写入,此时若其他事务尝试同时读取对象,则必须等待,直到第一个 读取 - 修改 - 写入 完成。
如多人游戏,其中几个玩家能同时移动同一个数字。只靠原子操作可能不够,因为应用程序还需确保玩家的移动符合规则,这可能涉及一些应用层逻辑,不可能将其剥离转移给DB层在查询时执行。此时,可使用锁来防止两名玩家同时移动相同棋子,如例-1:
例-1 显式锁定行,以防止丢失更新
BEGIN TRANSACTION;
SELECT * FROM figures
WHERE name = 'robot' AND game_id = 222
# 指示DB对返回的所有结果行要加锁。
FOR UPDATE;
-- 检查玩家的操作是否有效,然后更新先前 SELECT 返回棋子的位置
UPDATE figures SET position = 'c4' WHERE id = 1234;
COMMIT;
这有效,但要做对,需仔细考虑应用层逻辑。忘记在代码某处加锁很容易引入竞争条件。
2.3.3 自动检测更新丢失
原子操作和锁是通过强制 读取 - 修改 - 写入 串行执行来避免丢失更新。
另一种方法是允许它们并发,但若事务管理器检测到丢失更新,则中止当前事务,并强制它们回退到安全的 读取 - 修改 - 写入。
该方案的一个优点是DB能结合快照隔离高效执行检查。PostgreSQL的可重复读,Oracle的可串行化和 SQL Server 的快照隔离级别,都能自动检测到丢失更新,并中止违规的事务。但MySQL/InnoDB的可重复读并不会检测丢失更新。一些作者认为,DB必须防止丢失更新,才称得上是提供了快照隔离,所以在这种定义下,MySQL属于没有安全支持快照级别隔离。
丢失更新检测是个好功能,应用代码因此不依赖某些特殊的DB功能。你可能忘记使用锁或原子操作,但丢失更新的检测会自动生效,就不太容易出错。
2.3.4 CAS
不提供事务的DB有时支持CAS,可避免丢失更新:只有当前值从上次读取时一直未改变,才允许更新发生。若当前值与先前读取的值不匹配,则更新不起作用,就重试读取 - 修改 - 写入。
如为防止两个用户同时更新同一个 wiki,可尝试如下操作,只有当页面从上次读取之后没发生变化时,才会执行当前的更新:
-- 根据数据库的实现情况,这可能安全也可能不安全
UPDATE wiki_pages SET content = 'new content'
WHERE id = 1234 AND content = 'old content';
若内容已更改且不再与 “旧内容” 匹配,则更新失败,需应用层再次检查更新是否生效,必要时重试。若WHERE语句运行在DB的某个旧快照,即使另一个并发写入正在运行,条件可能仍为真,最终可能无法防止更新丢失。所以在使用前,应先仔细检查“比较-设置”操作的安全运行条件。
2.3.5 冲突解决和复制
支持多副本的数据库中,防止丢失更新还需考虑:由于多节点上存在数据副本,不同节点可能并发修改数据,需采取额外措施防止丢失更新。
加锁、CAS前提都要求只有一个最新的数据副本。但多主或无主复制的多副本DB,通常允许多个并发写,并异步复制到副本,所以会出现多个最新的数据副本。此时加锁或CAS将不再适用。
正如系列文章(5)中的【检测并发写入】一节所述,多副本DB通常允许并发写入创建多个冲突版本的值(互称为兄弟),并使用应用层代码或特殊数据结构来解决、合并这些多版本。
若操作可交换(顺序无关,在不同副本上以不同顺序执行时,仍得到相同结果),则原子操作在多副本情况下也能工作。如递增计数器或向集合添加元素都是典型的可交换操作。这是 Riak 2.0 新数据类型思想,当一个值被不同客户端同时更新时, Riak自动将更新合并在一起,避免发生更新丢失。
而最后写入胜利(LWW)的冲突解决方法则容易丢失更新,不幸的是,LWW目前是许多多副本DB的默认配置。
文章转载自公众号: JavaEdge