乐观锁与悲观锁

时间:2022-07-23
本文章向大家介绍乐观锁与悲观锁,主要内容包括其使用实例、应用技巧、基本知识点总结和需要注意事项,具有一定的参考价值,需要的朋友可以参考一下。

在如今分布式、高并发、各种负载纵横天下的时代,支持高访问量成为检验一个系统合不合格的重要标准,然而我们除了在运算过程中要求系统更加效率外,在最终的数据存储过程中也希望其能够准确。

并发修改同一记录时为避免更新丢失,要么在应用层加锁,要么在缓存加锁,要么在数据库层使用乐观锁,使用 version 作为更新依据。

悲观锁:

正如其名,它指对数据被外界(可能是本机的其他事务,也可能是来自其它服务器的事务处理)的修改持保守态度。在整个数据处理过程中,将数据处于锁定状态。悲观锁大多数情况下依靠数据库的锁机制实现,以保证操作最大程度的独占性。如果加锁的时间过长,其他用户长时间无法访问,影响程序的并发访问性,同时这样对数据库性能开销影响也很大,特别是长事务而言,这样的开销往往无法承受。

乐观锁:

分为三个阶段:数据读取、写入校验、数据写入。

假设数据一般情况下不会造成冲突,只有在数据进行提交更新时,才会正式对数据的冲突与否进行检测,如果发现冲突了,则返回错误信息,让用户决定如何去做。fail-fast机制。

小结:

乐观锁和悲观锁之间选择的标准是冲突的频率、严重性。如果冲突较少或者冲突的后果不是很严重,通常情况下会选择乐观锁,容易实现且吞吐性高,能得到更好的并发性。如果冲突的结果对用户来说是非常严重的,可以使用悲观锁,适当牺牲一些性能。

针对如何解决多线程并发产生的脏数据问题,本文简单列举一些常见案例及应对措施。

案例一:

本地起10个线程,分别执行10次,对数据库的一条记录的sum字段(初始值为0)+1操作,中间的业务逻辑我们忽略掉,如何保证执行完毕后sum的值为100?

表结构:

字段名

字段类型

可空

字段描述

使用备注

ID

BIGINT(20)

N

主键ID

无业务含义

SUM

NUMBER(20)

N

金额

初始值为0

解决措施:

利用数据库自身的事务来解决问题,update 表 set sum=sum+#increment# where id=#id#,适用于一些只更新数量、金额的场景。尽量不要采用在后台计算一个最终的sum值,然后通过 update 表 set sum=#sum# where id=#id#,因为此时在读与写的时间间隔里,很有可能其它的线程已经读过或操作过

案例二:

买家操作一笔订单,执行确认收货,假如同一笔订单打开了两个窗口,开始时在一个窗口确认成功,后来在另一个窗口又点了一次,此时应如何解决?

解决措施:

在执行“买家确认收货”操作时,我们通常会首先查出这笔订单,判断当前操作用户是否有执行权限,同时判断当前订单的状态是否是“等待买家确认收货”,。。。,如果满足这些前置条件,才允许后面的业务操作,更新数据库。当然,存在另一种可能,如果是通过自动化脚本操作呢?两次操作几乎同时执行,也就是说,两次的前置校验都能顺利通过(因此那时,数据库记录还没来的及更新),此时一个好的解决方案,操作时增加前置条件,比如确认收货的前置条件是“等待买家确认收货”,如果此时订单的状态变成了成功就无法操作。update 订单表 set status="交易成功" where id=#orderId# and status="等待买家确认收货" 这样,第二次操作sq条件不满足,也就避免执行两次买家确认收货操作。

案例三:

增加前置条件是一个不错的解决方案,但是,不是每个业务都有前置条件,或者说前置条件不明确、无规则,此时应如何解决?

字段名

字段类型

可空

字段描述

使用备注

ID

BIGINT(20)

N

主键ID

无业务含义

SUM

NUMBER(20)

N

金额

初始值为0

attribute_cc

INT(11)

N

用于为attribute加锁

解决措施:

可以借助乐观锁,比较并交换(CAS),在数据库表增加一个冗余字段,每次操作都会自动+1。执行业务时,首先会从数据库读取该字段信息,更新业务数据时,会自动比较attribute_cc的值是否有变化,如果有变化,表示刚才读的信息已变化过,需要重新操作。

特别注意:

attribute_cc是针对整条记录设置的行锁,如果数据库表有很多类似于features这样的json复合字段,我们将锁的粒度范围进一步缩小,每一个features配一个features_cc,features_cc的作用就是features的乐观锁版本的控制,可以很好规避使用attribute_cc与整个字段冲突的尴尬。

案例四:

商品表items表中有一个字段status,status=1表示商品未被下单,status=2 表示该商品已经被下单,那么我们对每个商品下单前必须保证此商品的status=1。假设有一件商品,其id 为1000。

常规思路:

  • 先查询商品状态 select status from items where id=1000
  • 生成订单
  • 修改商品状态 update items set status=2 wehre id=1000

在高并发环境下,在操作第三步update时,很有可能其它人已经先一步把商品的status修改为2

悲观锁思路:从查询出items信息时就把当前的数据锁定,直到我们修改完毕后再解锁。使用悲观锁,需要关闭mysql数据库的自动提交属性,因为mysql默认使用autocommit模式,当你执行一个更新操作后,mysql会立刻将结果提交。

步骤:

  • set autocommit=0
  • 开始事务。begin、begin work、start transaction(三者选一就可以)
  • 查询出商品信息 select status from items where id=1000 for update;
  • 生成订单 insert into orders(id,item_id) values(null,1000)
  • 修改商品状态 update items set status=2 wehre id=1000
  • 提交事务 commit

我们使用select ... for update的方式,通过数据库实现了悲观锁。id=1000那条记录被我们锁定了,其它事务必须等本次事务提交后才能执行。这样我们就可以保证当前的数据不会被其它事务修改。

注:用select ... for update 同一条记录时会等待其它事务结束后才执行,一般select...不受影响。比如当我们执行select status from items where id=1000 for update后,另外的事务也执行了select status from items where id=1000 for update 则第二个事务会一直等待第一个事务的提交,此时第二个查询处于阻塞的状态,但如果第二个事务中执行select status from items where id=1000,则能正常查询数据,不受第一个事务的影响。

mysql innoDB默认使用行锁,需要明确指定主键,否则mysql将会执行表锁(将整个表锁住)。除了主键外,使用索引也会影响数据库的锁定级别。

案例五:

商品减库存时,如果在秒杀等高并发的场景下,如果采用version作为乐观锁,虽然每次只有一个事务能更新成功,但业务感知上会有大量的操作失败。解决方案可以采用库存数做为乐观锁

update item
set quantity=quantity - #sub_quantity#
where item_id=#id#
	  and quantity - #sub_quantity# > 0

注意:如果每次访问冲突概率小于 20%,推荐使用乐观锁,否则使用悲观锁。乐观锁的重试次数不得小于 3 次。