丢失更新问题

206 阅读5分钟

前言

什么是丢失更新?

譬如两个事务对同一行记录进行更新,事务A先进行提交,随后事务B提交,事务B的结果覆盖了事务A的提交结果,导致事务A的更新 丢失了。

丢失更新

数据库层面的丢失更新

一个事务的更新操作被另一个事务的更新操作所覆盖,从而导致数据的不一致,比如:

  1. 事务 A 将行记录 r 更新为 v1,但是事务 A 并未提交
  2. 同时,事务 B 将行记录 r 更新为 v2,事务 B 未提交
  3. 事务 A 提交
  4. 事务 B 提交

可以看到,事务 B 的提交覆盖了 事务A 的提交。可事实真是这样吗?

不是。

在 MySQL InnoDB 的实现中,任何隔离级别下都不会导致数据库理论意义上的丢失更新问题

原因是,即使 读未提交的隔离级别下,对于行的 DML 操作,需要对行 或 其他粗粒度级别的对象加锁。

因此,在上述第二步过程中,事务 B 并不能对记录 r 进行更新操作,事务 B 会被阻塞,直到事务A 提交后才能进行操作。

当然,也有例外

那就是先读后写的场景下,也可能会导致丢失更新问题,比如:

-- 事务 T1
START TRANSACTION;
-- 读取余额
SELECT balance FROM accounts WHERE account_id = 1; 
-- 此时 balance 为 1000
SET @new_balance = balance + 100; 
-- 假设事务 T2 在此期间完成了更新操作

-- 加行锁并更新
UPDATE accounts SET balance = @new_balance WHERE account_id = 1; 
COMMIT;

-- 事务 T2
START TRANSACTION;
-- 读取余额
SELECT balance FROM accounts WHERE account_id = 1; 
-- 此时 balance 为 1000
SET @new_balance = balance - 50; 
-- 加行锁并更新
UPDATE accounts SET balance = @new_balance WHERE account_id = 1; 
COMMIT;

两个事务都先读取了余额为 1000 元。虽然在执行 UPDATE 语句时会加行锁,但由于它们都是基于旧的余额值进行计算和更新,最终后执行的更新操作会覆盖前一个的更新结果,导致其中一个更新丢失。

其实,这类操作更像是应用层面的业务逻辑导致的丢失更新问题。

应用层面的丢失更新

应用层面倒是极易产生丢失更新问题。笔者在实践开发过程中,曾经遇到过这样的幽灵操作,常常令人头疼。

比如:

  1. 线程 T1 查询一行数据 r 存入本地内存
  2. 线程 T2 查询一行数据 r 存入本地内存
  3. 线程 T1 对行记录 r 进行修改,行记录版本 r1
  4. 线程 T2 对行记录 r 进行修改,行记录版本 r2
  5. 线程 T1 提交事务
  6. 线程 T2 提交事务

最终落库的行记录版本是 r2,而 T1 线程提交的行记录 r1 被覆盖丢失了。

本质来讲,导致丢失更新原因都是 并发+ 先读后写 导致

这其实是非常恐怖事情,正常的操作被莫名其妙的覆盖丢失了。如果涉及银行等金融账户问题,展现的问题将更加严重。

解决丢失更新问题

使用时读取行记录

在应用开发中,比如定时任务等场景会批量查询一批数据,然后,一条条进行顺序处理;因为一批数据处理完,一般需要较多的时间,而时间一长,极易与应用系统其他线程产生并发,从而导致丢失更新问题。

如何有效避免呢?

对于这类场景,处理方式很简单,批量查询时可以先只查询对应的记录id,然后依次处理记录 r时,再通过 id 查询对应数据记录,也就是真正处理时再查询对应的记录

这是一种不严格的丢失更新解决方案,但在某些场景下能极大避免丢失更新问题,使用简单且没有更多额外的开发成本。

正因为非严格解决丢失更新解决方案,使用时你需要评估你的业务场景。不过很多业务场景都是非严格场景,笔者也在实际开发中多次使用,能够有效避免丢失更新问题,屡试不爽。

读操作加锁(悲观锁)

使用 SELECT ... FOR UPDATE:在读取数据时就加排他锁,防止其他事务在当前事务更新之前修改数据:

-- 事务 T1
START TRANSACTION;
SELECT balance FROM accounts WHERE account_id = 1 FOR UPDATE;
SET @new_balance = balance + 100;
UPDATE accounts SET balance = @new_balance WHERE account_id = 1;
COMMIT;

当事务 T1 加锁成功后,其他事务需要排队等待事务 T1 释放后才能进行。

版本号机制(乐观锁)

另一个更为常用的是版本号机制。

简单来说,在数据表新增版本号字段,对于每一行数据的更新,都要将版本号作为更新条件代入,只有满足条件的版本号才能正常更新。

如此一来, 就可以避免其他线程更新的数据被当前线程提交覆盖,因为你一旦发现提交更新失败就回滚或者采取其他有效补救措施,从而彻底避免线程(事务)间的相会覆盖影响。

小结

丢失更新问题是应用层面开发时最容易犯的错误之一,也是最不容易发现的一个错误,也正是这种现象只是随机的、零星出现的,才导致问题难以排除,甚至导致一些严重的后果。