你知道悲观锁与乐观锁怎么用吗?

329 阅读7分钟

引子

本文讲述了 悲观锁乐观锁 的概念与使用场景

为什么需要锁

因为 用户环境中,在同一时间可能会有多个用户更新相同的记录,这会产生冲突(简称为并发性问题),这种场景下就需要依赖特定的锁来解决这些并发冲突

典型的冲突有:

  1. 丢失更新:一个事务的更新覆盖了其它事务的更新结果,就是所谓的更新丢失。

    例如:原始值为10,事务A把值从10改为5,事务B把值从5改为10,则事务A丢失了它原本的更新

  2. 脏读:当一个事务读取到其它完成一半(还未提交并异常回滚)的事务记录,就会发生脏读取。

    例如:事务A修改某列的值,还没提交的时候,这时候事务B去获取该列的值,读到的是事务A修改后但是没提交的值,如果事务A因为异常回滚了,事务B读到的值就是脏数据

  3. 幻读:事务A 按照一定条件进行数据读取, 期间事务B 插入了相同搜索条件的新数据,事务A再次按照原先条件进行读取时,发现了事务B 新插入的数据

  4. 不可重复读:事务A 按一定条件搜索, 期间事务B 删除了符合条件的某一条数据,导致事务A 再次读取时数据少了一条

为了解决这些并发带来的问题。 我们需要引入并发控制机制。

并发控制机制

当程序中可能出现并发的情况时,就需要保证在并发情况下数据的准确性,以此确保当前用户和其他用一起操作时,所得到的结果和他单独操作时的结果是一样的。

高并发环境下 锁粒度 把控是一门重要的学问。选择一个好的锁,在保证数据安全的情况下,可以大大提升吞吐率,进而提升性能

如下所说的悲观锁与乐观锁就是一种并发控制手段

悲观锁

定义: 在做操作之前先上锁,再进行数据操作,等到操作完再释放让下一个操作

🥚解释

总是假设最坏的情况,每次去拿数据的时候都认为别人会修改,所以每次在拿数据的时候都会上锁,这样别人想拿这个数据就会阻塞直到它拿到锁(共享资源每次只给一个线程使用,其它线程阻塞,用完后再把资源转让给其它线程)。

传统的关系型数据库里边就用到了很多这种锁机制,比如 行锁表锁读锁写锁 等。

🥚图解

假设有两个线程,线程A和线程B,这两个线程都要对数据库中的某一个字段进行修改

  1. 线程A,B同时对数据增加排他锁;
  2. 线程 A 先进行加锁,开始执行更新操作;线程B 被阻塞;
  3. 线程 A 完成更新操作,事务提升锁被释放; 线程 B 对数据增加排他锁;
  4. 线程 B 完成更新操作,事务提升锁被释放;

🥚特点

  • 完全保证数据的独占性和正确性
  • 加锁释放锁的过程会造成消耗,所以性能不高;

🥚使用场景

如果出现大量的读取操作,每次读取的时候都会进行加锁,这样会增加锁的开销,降低了系统的吞吐量。

因此 悲观锁适合写入操作比较频繁的场景

🥚实现方式

前提: 使用悲观锁,必须关闭 MySQL数据库的自动提交属性set autocommit=0。因为 MySQL 默认使用 autocommit 模式,当执行一个更新操作后,MySQL 会立刻将结果进行提交。

用一个电商下单减库存的过程说明悲观锁的使用,如下所示:

 # -- 开始事务
 begin;
 # 先通过 for update (行级锁)的方式进行加锁
 select stock from tb_sku where id=1 for update;
 # 然后再进行修改
 SKU.objects.select_for_update().get(id=1)
 # -- 提交事务
 commit;

悲观锁类似于我们在多线程资源竞争时添加的互斥锁,容易出现死锁现象,采用不多

乐观锁

定义: 操作数据时不会对操作的数据进行加锁(这使得多个任务可以并行的对数据进行操作),只有到数据提交的时候才通过一种机制来验证数据是否存在冲突

🥚解释

总是假设最好的情况,假设数据一般情况下不会造成冲突,所以在数据进行提交更新的时候,才会正式对数据的冲突与否进行检测,如果发现冲突了,则返回给用户错误的信息,让用户决定如何去做。

可以使用版本号机制和CAS算法实现。

数据库提供的类似于write_condition机制,其实都是提供的乐观锁。

🥚图解

假设有两个线程,线程A和线程B,这两个线程都要对数据库中的某一个字段进行修改

  1. 线程A,B对字段修改之前会先查询该字段的 version (当前为 1)
  2. 线程A先进行了修改操作,线程B也进行了修改操作
  3. 线程A完成了修改,提交更新之前会先看数据库的版本和自己读取到的版本是否一致;一致的话,就会将数据版本号加1( version=2 ),数据被更新,数据库记录 version 更新为 2 。
  4. 线程 B 完成了操作,提交更新之前会先看数据库的版本和自己读取到的版本是否一致;但此时比对数据库记录版本时发现,线程B 提交的数据版本号为 2 ,而自己读取到的版本号为1 ,不满足 “ 当前最后更新的version与自己第一次读取的版本号相等 “ 的乐观锁策略,因此,线程 B 的提交被驳回。

🥚特点

  • 省掉了对数据加锁和解锁的过程,在一定程度上提高操作的性能
  • 在并发非常高的情况下,会有大量的请求冲突导致大部分操作无功而返从而浪费资源
  • 在高并发的场景,乐观锁的性能却反而不如悲观锁

🥚使用场景

如果出现大量的写入操作,数据发生冲突的可能性就会增大,为了保证数据的一致性,应用层需要不断的重新获取数据,这样会增加大量的查询操作,降低了系统的吞吐量。

因此 乐观锁适合读取操作比较频繁的场景

🥚实现方式

前提: 乐观锁不需要借助数据库的锁机制,主要使用 冲突检测数据更新(典型的 CAS )。

用一个电商下单减库存的过程说明乐观锁的使用,如下所示:

 # -- 开始事务
 begin;
 # 先判断是否相同
 update tb_sku set stock=2 where id=1 and stock=7;
 # 若相同则修改,否则不执行更新
 SKU.objects.filter(id=1, stock=7).update(stock=2)
 # -- 提交事务
 commit;

乐观锁机制采取了更加宽松的加锁机制,相对悲观锁而言,乐观锁更倾向于开发运用

总结

悲观锁

  • 完全保证数据的独占性和正确性
  • 适合写入操作比较频繁的场景 乐观锁
  • 机制采取了更加宽松的加锁机制
  • 操作的性能比悲观锁快
  • 适合读取操作比较频繁的场景