引子
本文讲述了 悲观锁 与 乐观锁 的概念与使用场景
为什么需要锁
因为 用户环境中,在同一时间可能会有多个用户更新相同的记录,这会产生冲突(简称为并发性问题),这种场景下就需要依赖特定的锁来解决这些并发冲突
典型的冲突有:
-
丢失更新:一个事务的更新覆盖了其它事务的更新结果,就是所谓的更新丢失。
例如:原始值为10,事务A把值从10改为5,事务B把值从5改为10,则事务A丢失了它原本的更新
-
脏读:当一个事务读取到其它完成一半(还未提交并异常回滚)的事务记录,就会发生脏读取。
例如:事务A修改某列的值,还没提交的时候,这时候事务B去获取该列的值,读到的是事务A修改后但是没提交的值,如果事务A因为异常回滚了,事务B读到的值就是脏数据
-
幻读:事务A 按照一定条件进行数据读取, 期间事务B 插入了相同搜索条件的新数据,事务A再次按照原先条件进行读取时,发现了事务B 新插入的数据
-
不可重复读:事务A 按一定条件搜索, 期间事务B 删除了符合条件的某一条数据,导致事务A 再次读取时数据少了一条
为了解决这些并发带来的问题。 我们需要引入并发控制机制。
并发控制机制
当程序中可能出现并发的情况时,就需要保证在并发情况下数据的准确性,以此确保当前用户和其他用一起操作时,所得到的结果和他单独操作时的结果是一样的。
高并发环境下 锁粒度 把控是一门重要的学问。选择一个好的锁,在保证数据安全的情况下,可以大大提升吞吐率,进而提升性能
如下所说的悲观锁与乐观锁就是一种并发控制手段
悲观锁
定义: 在做操作之前先上锁,再进行数据操作,等到操作完再释放让下一个操作
🥚解释
总是假设最坏的情况,每次去拿数据的时候都认为别人会修改,所以每次在拿数据的时候都会上锁,这样别人想拿这个数据就会阻塞直到它拿到锁(共享资源每次只给一个线程使用,其它线程阻塞,用完后再把资源转让给其它线程)。
传统的关系型数据库里边就用到了很多这种锁机制,比如 行锁,表锁,读锁,写锁 等。
🥚图解
假设有两个线程,线程A和线程B,这两个线程都要对数据库中的某一个字段进行修改
- 线程A,B同时对数据增加排他锁;
- 线程 A 先进行加锁,开始执行更新操作;线程B 被阻塞;
- 线程 A 完成更新操作,事务提升锁被释放; 线程 B 对数据增加排他锁;
- 线程 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,这两个线程都要对数据库中的某一个字段进行修改
- 线程A,B对字段修改之前会先查询该字段的 version (当前为 1)
- 线程A先进行了修改操作,线程B也进行了修改操作
- 线程A完成了修改,提交更新之前会先看数据库的版本和自己读取到的版本是否一致;一致的话,就会将数据版本号加1( version=2 ),数据被更新,数据库记录 version 更新为 2 。
- 线程 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;
乐观锁机制采取了更加宽松的加锁机制,相对悲观锁而言,乐观锁更倾向于开发运用
总结
悲观锁
- 完全保证数据的独占性和正确性
- 适合写入操作比较频繁的场景 乐观锁
- 机制采取了更加宽松的加锁机制
- 操作的性能比悲观锁快
- 适合读取操作比较频繁的场景