MySQL 读后写并发更新问题

Posted by icoding168 on 2020-03-09 17:39:56

分类: MySQL  

有以下一个场景,假如有多个消费者同时在网上商城购买了商品,商品的库存需要重新计算,我们如何写代码去保证商品的库存数量是正确的。

一般我们都是先查询出库存数,再减掉购买数。这种做法在只处理一个请求的时候,确实没有问题。一旦有多个请求发生,那就会出现数据覆盖的情况。例如两个并发请求都在极短的时间差距内查询到库存数,然后先后执行了减掉库存的操作,那么就会出现第一个执行了减掉库存的请求实际上跟没有执行过一样。这就是一种读后写并发更新问题,解决方案有以下几种。

合并 SQL 语句

把减库存跟读库存的操作合并在同一条 SQL 语句中,例如:

update product set quantity=quantity-1 where product_id=123;

使用事务 + 行锁

开启事务后,MySQL 的 Innodb 存储引擎在读取行数据时可以加两种锁:共享锁和排他锁。

如果事务 A 先获得了共享锁,那么事务 B 之后仍然可以读取加了共享锁的行数据,但必须等事务 A commit 或者 roll back 之后才可以更新或者删除加了共享锁的行数据。

如果事务 A 先获得了某行的排他锁,那么事务 B 就必须等待事务 A commit 或者 roll back 之后才可以访问行数据。

针对这种减库存的应用场景,排他锁才是我们能用的锁。因为共享锁虽然能让第二个事务读取数据,但读到的数据依然还是旧的数据,最终还是会出现数据覆盖的情况。

共享锁是在 select 语句末尾加上 lock in share mode,例如:

select * from product where product_id=123 lock in share mode;

排他锁是在 select 语句末尾加上 for update,例如:

select * from product where product_id=123 for update;

注意,update,delete,insert 都会自动给涉及到的数据加上排他锁

使用乐观锁

上面说的第二种方案,排他锁是一种悲观锁,一般来说悲观锁的性能会差一些,还有一种方案是乐观锁。

乐观锁不需要开启数据库的事务,具体做法是 select 语句保持不变,但在 update 语句中加入对库存数、版本号的判断,例如:

update product set quantity=新的库存数, version_no=递增的版本号 where product_id=123 and quantity=旧的库存数 and version_no=旧的版本号

根据 update 语句的返回值来判断是否成功,如果返回值是 0 就说明存在并发更新问题,需要进行重试。

加入版本号的判断是为了解决 ABA 问题。

选择哪一种方案

  • select … lock in share mode 锁住更改,不锁查询
  • select … for update 锁住更改和查询
  • update table set column=#{value }没有读取操作 有丢失更新的问题,可使用额外的version字段或者其他列,hibernate乐观锁原理
  • update table set column=column + #{value} 当前读,最新的column无丢失更新的问题

如果对读取数据的速度要求较高,或者读取数据的场景多于写入数据的场景,那么应该选择乐观锁。

如果对写入数据的速度要求较高,或者写入数据的场景多于读取数据的场景,那么应该选择悲观锁。

至于第一种方案,合并 SQL 语句,对于业务逻辑比较简单的场景而言这种方案没什么问题,但是在业务逻辑比较复杂的情况下,例如涉及到多个表、多个字段的更新时,SQL 语句会变得复杂而难以维护。

注意:MVCC在 Innodb 中只负责解决读写冲突,把普通 select 语句变成快照读。写冲突仍然是靠锁来解决的。因此要解决、丢失更新,要用 select…for update 主动加写锁。当然 MVCC 不是说完全不能解决丢失更新,比如 Postgresql 的 serializable 隔离级别下,遇到写写冲突直接向客户端返回异常,保证只有一个事务可以更新成功。