前言
数据库的锁机制是并发控制的重要手段,是保证数据正确性的最后的一道屏障。而锁的种类和范围,对并发控制的程度和效率又不大一样。本篇文章将围绕乐观锁和悲观锁,结合业务场景对这两个锁的实现进行说明。
场景
假设我们有张商品库存表如下:
1 | CREATE TABLE `item_stock` ( |
其中商品库存表中有一个字段 remain_stock_num
表示当前该商品的库存量。假设有一个 iphone13 ,其 id 为 100 ,remain_stock_num=10
。现在我们按照一般的库存扣减方法进行操作,程序流程如下:
- step1: 查出商品剩余库存量
select remain_stock_num from item_stock where id = 100; - step2: 还有剩余库存,则录单
insert into order(id,item_id) values(null,100); - step3: 扣减商品库存
update item_stock set remain_stock_num = #{num} where id = 100;
上述流程咋一看好像没有问题,在没有并发的情况下确实没有问题。那我们看存在并发的情况,操作序列如下:
序列 | 线程 A | 线程 B |
---|---|---|
1 | step1 查询还有 10 个 | step1 查询还有 10 个 |
2 | step2 生成订单 | |
3 | step2 生成订单 | |
4 | step3 扣减库存 9 个 | |
5 | step3 扣减库存 2 个 |
可以看到,上述流程会造成超卖问题。那如何解决上述问题呢,解决方式还是很多的,下面我们分别看如何使用悲观锁和乐观锁解决。
乐观锁 & 悲观锁
乐观锁和悲观锁是锁的一种思想,并不局限于应用在数据库中。因此,不要把乐观锁和悲观锁狭隘地理解为数据库中的概念,更不要把它们和数据库中提供的锁机制混为一谈。
悲观锁
悲观锁是指在数据处理过程中,使数据资源处于锁定状态,数据库中使用锁机制实现。
悲观并发控制主要用于数据竞争激烈的场景,以及发生并发冲突时使用锁保护数据的成本要低于回滚事务的成本的环境中。
MySQL中的悲观锁
对任意记录进行修改前,先尝试为该记录加上排他锁(exclusive locking) 。如果加锁失败,说明该记录正在被修改,那么是选择等待还是抛出异常由业务方决定。
要使用悲观锁,需要关闭 MySQL 的自动提交属性,因为 MySQL 默认使用 auto commit
模式,也就是说,当执行一个更新操作后,MySQL 会立即提交。
1 | set autocommit=0 |
我们回到场景中,看使用悲观锁如何解决上述问题。在上面的场景中,商品从查询出来到最后的库存扣减,是没有对这个库存进行持续锁定的,仅仅在扣减的时候会进行锁定,查询的时候使用的是快照读。而使用悲观锁的原理就是,在查询出商品的过程就把当前的数据锁定,直到扣减完库存再解锁。
这样一来,整个过程中因为数据被锁定了,就不会有其他请求来同时进行修改了。需要说明的是,使用悲观锁的前提是需要将要执行的 SQL 语句放在一个事务中,否则达不到锁定数据的目的。大致过程如下:
- setp0: 开启事务
begin; - step1: 查出商品剩余库存量
select remain_stock_num from item_stock where id = 100 for update; - step2: 还有剩余库存,则录单
insert into order(id,item_id) values(null,100); - step3: 扣减商品库存
update item_stock set remain_stock_num = remain_stock_num - #{num} where id = 100; - step4: 提交事务
commit;
需要说明的是,上述流程中,使用了 select...for update
的方式实现了悲观锁。此时,商品库存 id=100 的那条记录被锁定了,其它的事务要访问这个数据就必须等本次事务提交之后才能执行,这样就保证了并发修改的问题。
此外,对于 MySQL 的 InnoDB 引擎来说,它支持行锁,而锁是基于索引的,如果一条语句用不到索引(全表扫描),那么在加锁的时候会把整个表锁住(在默认的 RR 隔离级别下)。
优缺点
悲观锁其实是采用先加锁后访问的保守策略,为数据处理的安全提供了保证。
悲观锁的加锁时间可能会过长,这会导致其它事务无法访问,无疑影响了程序的并发访问性;此外,这样对数据库性能开销影响也很大,特别是对长事务而言,同时还会增加生产死锁的概率。
乐观锁
乐观锁相对悲观锁而言,它认为数据一般情况下不会造成冲突,因此只会在数据进行提交的时候才会进行冲突与否的判断,如果发现冲突了,则返回冲突的信息,让业务方决定如何去做,一般业务方会选择进行重试,这也是乐观锁的由来。
MySQL中的乐观锁
实现乐观锁一般有以下两种方式:
- 使用版本号实现乐观锁:数据版本号或时间戳;
- 使用条件限制实现乐观锁:缩小乐观锁的范围,适用于只更新时做数据安全校验;
注意:
- 使用乐观锁进行更新操作,最好用主键或者唯一索引来更新,这样是行锁,否则更新会锁表;
- 乐观锁不依赖数据库的锁机制,但更新数据库数据是一个写操作,会触发数据库锁机制。
版本号实现乐观锁
使用数据版本记录机制实现是乐观锁最常用的一种实现方式。一般是通过为数据表增加一个数字类型的 version
字段来实现。当读取数据时,将 version 的值一同读出,数据每更新一次就对 version 值加 1。当提交更新的时候,判断数据库表对应记录的当前版本信息与第一次取出来的 version 值,如果数据库表当前版本号与第一次取出来的 version 值相等,则予以更新,否则认为是过期数据。流程如下:
从上图可以看出,如果更新操作顺序执行,则数据的 version 一次递增,不会产生冲突。如果出现不同业务的事务操作同一版本的数据,那么先提交的会成功,后提交的会失败。注意,如果并发很大,会导致大量失败的情况。
使用时间戳也是类似的,就不再介绍。
需要说明的是,这种方式的乐观锁虽然更通用,但是也不是适用于所有的乐观锁场景,这个时候可以考虑条件限制实现的乐观锁。
条件限制实现乐观锁
使用条件限制实现乐观锁,适用于更新时做数据安全校验,适合库存模型等,性能更高。
使用条件限制实现乐观锁还有一个重要原因,就是缩小锁范围。比如商品库存扣减时,特别是在秒杀等场景下,如果采用版本号实现的乐观锁,因为对于同一个版本的数据每次只有一个事务能更新成功,业务感知上就是大量操作失败。
缩小锁范围是指:采用条件限制,只会在不满足条件才会失败,这是一个范围概念,而版本号是等值的概念,后者失败率更高,并且这是可以避免的。
乐观锁解决并发
我们回到场景中,看使用乐观锁如何解决上述问题。在上面的场景中,虽然扣减库存的时候会进行锁定,但是没有解决操作上的原子问题,也就是要修改的数据必须是查询到的数据。而使用乐观锁的原理就是,在查询出商品的时候会将数据的 version 也一同查出,然后在提交修改的时候在数据库层面进行 version 的对比,如果数据当前版本号与取出来的 version 值相等,则予以更新,否则认为是过期数据,不予更新。
使用版本号的乐观锁的大致过程如下:
- step1: 查出商品剩余库存量
select remain_stock_num from item_stock where id = 100; - step2: 还有剩余库存,则录单
insert into order(id,item_id) values(null,100); - step3: 扣减商品库存
update item_stock set remain_stock_num = remain_stock_num - #{num},version = version + 1 where id = 100 and version = #{version};
这种乐观锁实现的并发控制虽然可以解决问题,但是锁的范围太大了,即业务感知上会有大量的操作失败。根本原因是,大量的库存查询确实存在,但是扣减的时候只有一个会成功。那如何尽可能地保证成功呢?可以采用条件限制实现的乐观锁方式,它的锁粒度更小,最大程度的提高并发能力。大概过程如下:
- step1: 查出商品剩余库存量
select remain_stock_num from item_stock where id = 100; - step2: 还有剩余库存,则录单
insert into order(id,item_id) values(null,100); - step3: 扣减商品库存
update item_stock set remain_stock_num = remain_stock_num - #{num} where id = 100 and remain_stock_num >= #{num};
通过 remain_stock_num >= #{num}
条件,既实现了数据安全校验,又提高了并发性能。
小结
悲观锁和乐观锁各有优缺点,乐观锁适用于读多写少的情况下,即冲突真的很少发生的时候,这样可以省去不少的开销,加大系统的整个吞吐量。但如果是多写的情况,一般会经常产生冲突,这就会导致业务方会不断的进行 retry,这样反倒是降低了性能,所以一般多写的场景下用悲观锁就比较合适。
不过随着互联网架构的演进,对并发和性能要求越来越高,悲观锁已经越来越少的被应用到生产环境中了。