介绍更新丢失

更新丢失可能发生在这样一个操作场景中:应用程序从数据库读取某些值,根据应用逻辑做出修改,然后写回新值 (read-midify-write过程)。当有两个事务在同样的数据对象上执行类似操作时,由于隔离性,第二个写操作并不包括第一个事务修改后的值,最终会导致第一个事务的修改值可能会丢失。
更新丢失还可能在其他不同的场景下发生,例如:

  • 递增计数器,或更新账户余额(需要读取当前值,计算新值井写回更新后的值)。
  • 对某复杂对象的一部分内容执行修改,例如对JSON文档中一个列表添加新元素(需要读取并解析文档,执行更改井写回修改后的文档)。
  • 两个用户同时编辑wiki页面,且每个用户都尝试将整个页面发送到服务器,覆盖数据库中现有内容以使更改生效 。

写事务并发冲突是一个普遍问题,目前有多种可行的解决方案。

原子更新操作

许多数据库提供了原子更新操作,以避免在应用层代码完成“读-修改-写回”操作序列,如果支持的话,通常这就是最好的解决方案。
例如,以下指令在多数关系数据库中都是并发安全的:

  1. update counters set value = value + 1 where key = 'foo';

类似地,像 MongoDB 这样的文档数据库支持对 JSON 文档的某部分进行本地修改的原子操作,Redis 也提供了对特定数据结构(如优先级队列)修改的原子操作。
然而,并非所有的应用更新操作都可以以原子操作的方式来表达,例如维基页面的更新涉及各种文本编辑。
无论如何,如果原子操作可行,那么它就是推荐的最佳方式。


原子操作通常采用对读取对象加独占锁的方式来实现,这样在更新被提交之前其他事务不可以读取它。这种技术有时被称为游标稳定性。另一种实现方式是强制所有的原子操作都在单线程上执行。
Redis 的原子写操作

不过,基于对象关系映射( ORM )框架可以很容易地就产生出“读-修改-写回”这样的应用层代码,导致无法使用数据库所提供的原子操作。假如你清楚知道自己在做什么,或许这并不会引发什么问题,但往往这种情况会埋下很多难以发现的潜在错误。

显式加锁

如果数据库不支持内置原子操作,另一种防止更新丢失的方法是由应用程序显式锁定待更新的对象。
然后,应用程序可以执行“读-修改-写回”这样的操作序列;此时如果有其他事务尝试同时读取对象,则必须等待当前正在执行的操作序列全部完成。
首先该方法是可行的,但要做到这一点,需要仔细考虑清楚应用层的逻辑。很多代码会忘记在必要的地方加锁,结果很容易引入竞争冲突。
SQL 语句在 MySQL 中的执行过程

自动检测更新丢失

原子操作和锁都是通过强制“读-修改-写回”操作序列串行执行来防止丢失更新。
另一种思路则是先让他们并发执行,但如果事务管理器检测到了更新丢失风险,则会中止当前事务,并强制回退到安全的“读-修改-写回”方式。
的确,PostgreSQL 的可重复读, Oracle 的可串行化以及 SQL Server 的快照级别隔离等,都可以自动检测何时发生了更新丢失,然后会中止违规的那个事务。但是, MySQL 中 InnoDB 存储引擎的可重复读却并不支持检测更新丢失。
有一些观点认为,数据库必须防止更新丢失,要不然就不能宣称符合快照级别隔离,如果基于这样的定义,那么MySQL 就属于没有完全支持快照级别隔离。
更新丢失检测是一个非常赞的功能,因为这个功能不需要应用层代码使用某些特殊的数据库功能。开发者可能会不小心忘记使用锁或原子操作,但更新丢失检测会自动生效,有效地避免这类错误。

比较并设置 CAS

在不提供事务支持的数据库中,有时你会发现它们支持“比较并设置”操作。使用该操作可以避免更新丢失,即只有在上次读取的数据没有发生变化时才允许更新;如果已经发生了变化 , 则回退到“读-修改-写回”方式。
例如,为了防止两个用户同时更新同一个 wiki 页面,可以尝试下面的操作,这样只有当页面从上次读取之后没发生变化时,才会执行当前的更新 :

  1. -- 根据数据库的实现情况,这可能安全也可能不安全
  2. UPDATE wiki_pages SET content = '新内容'
  3. WHERE id = 1234 AND content = '旧内容';

如果内容已经有了变化且值与“旧内容”不匹配,则更新将失败,需要应用层再次检查并在必要时进行重试。
需要注意,如果 where 语句是运行在数据库的某个旧的快照上,即使另一个并发写入正在运行,where 条件可能仍然为真,最终可能无法防止更新丢失问题。所以在使用之前,应该首先仔细检查“比较-设置”操作的安全运行条件。
CAS

冲突解决与复制

对于支持多副本的数据库,防止更新丢失还需要考虑另一个维度 :由于多节点上的数据副本,不同的节点可能会并发修改数据,因此必须采取一些额外的措施来防止更新丢失。
加锁和原子修改都有个前提即只有一个最新的数据副本。然而,对于多主节点或者无主节点的多副本数据库,由于支持多个井发写 ,且通常以异步方式来同步更新,所以会出现多个最新的数据副本。 此时加锁和比较并设置将不再适用。
正如【《数据密集型应用系统设计》一书的】第5章“检测并发写”所描述的,多副本数据库通常支持多个井发写,然后保留多个冲突版本(互称为兄弟),之后由应用层逻辑或依靠特定的数据结构来解决、合并多版本。
如果操作可交换(顺序无关,在不同的副本上以不同的顺序执行时仍然得到相同的结果), 则原子操作在多副本情况下也可以工作。例如,计数器递增或向集合中添加元素等都是典型的可交换操作。这也是 Riak 2.0新数据类型的设计思路,当一个值被不同的客户端同时更新时, Riak 自动将更新合并在一起,避免发生更新丢失。
而“最后写入获胜( LWW )”冲突解决方案则容易丢失更新。不幸的是,目前 LWW 是许多多副本数据库的默认配置。

Git 的冲突解决就是由程序员自己来解决并合并多个版本。

参考资料

弱隔离级别