并发控制 - 乐观/悲观锁

在互联网高速发展的今天,网络流量所带来的效益愈发明显,但是高流量所带来一个必然的联系就是高并发,而现代系统对于并发的处理有很多种方式,譬如多线程、异步调用、核心功能加锁、消息队列等,这篇文章主要就谈论一下处理高并发的两种思路,乐观锁(Optimistic Locking)和悲观锁(Pessimistic Concurrency Control)

并发问题

为了应对并发,开发者提出了事务的概念,以完成原子性的操作。但是在事务进行的过程中,同样也会产生很多问题,譬如脏读,不可重复读,幻读等,当然也就有不同的事务隔离级别去解决对应的问题。

脏读

脏读,指线程A在通过事务修改对象O的状态但未提交时,线程B获取到了对象O未被修改时的状态,这时候线程B读取到的数据就是脏数据,而根据脏数据所进行的操作,无法保证其的正确性。

举一个简单的栗子: 用户A在某电商平台下单了一件商品,根据后台的业务逻辑,对应工作者线程将会开启一个事务,扣除所对应的库存数量,但同一时间,用户B也下单可同样的物品,在用户A线程提交库存扣除事务之前获取到了库存数量,之后执行了下单操作,可想而知用户B所对应的工作者线程执行的一系列操作都是不正确的。

不可重复读

不可重复读,指线程A在同一个事务中,两次完全相同的数据读取操作,获取到了不一样的数据,原因可能是在线程A执行事务的过程中,线程B对统一数据提交了事务,导致两次获取数据不一样。

幻读

幻读,和不可重复读类似但其中又有所区别,同一个事务内多次查询返回的结果集不一样(比如增加了或者减少了行记录)。

悲观锁

悲观锁,又叫悲观并发控制(PCC),该锁类似Java的显式加锁,之所以叫悲观锁,是因为对数据修改持有悲观态度的并发控制,一般认为数据被并发修改的几率比较大,需要加锁才能保证修改时的数据一致性。

悲观锁实例: 线程A、B并发修改数据O -> 线程A获取到锁,进行修改数据状态操作 -> 线程A释放锁,线程B获得锁,进行数据状态修改操作 -> 线程B释放锁 -> 修改操作完成

由于对数据进行了显式加锁,除了会产生额外的开销以外,还会降低处理效率,增加死锁几率。所以在目前信息系统种,很少会再使用悲观锁。

悲观锁实现

悲观锁大多的实现方式都是基于数据库的,即:

  • 在执行操作之前先给数据加排他锁,若加锁失败视业务要求进行等待或者抛出异常
  • 加锁成功后执行数据更新修改操作,事务提交后会自动释放锁
// 开启事务
BEGIN;
// FOR UPDATE 加锁
SELECT amount FROM goods WHERE id = 233 FOR UPDATE;
// 进行数据操作
UPDATE goods SET amount = 15 WHERE id = 233;
// 提交释放锁
COMMIT;
复制代码

通过FOR UPDATE建立一个排他锁,在事务提交前,无法对操作数据进行操作以保证数据的一致性。

TipMysql在Sql有用到索引的表数据时,会使用行级锁,其它时间会用表级锁。

乐观锁

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

乐观锁实例: 线程A、线程B并发读取数据并进行操作 -> 线程A完成操作,进行数据版本检测,若无冲突,则提交事务 -> 线程B完成操作,进行版本检测,检测有冲突,不提交并返回异常

乐观并发控制相信事务之间的数据竞争(data race)的概率是比较小的,因此尽可能直接做下去,直到提交的时候才去锁定

乐观锁实现

乐观锁的主要实现方式为冲突检测,CAS(Compare and swap),CAS是项乐观锁技术,当多个线程尝试使用CAS同时更新同一个变量时,只有其中一个线程能更新变量的值,而其它线程都失败,失败的线程并不会被挂起,而是被告知这次竞争中失败,并可以再次尝试。

简单乐观锁实现:

// 得知amount = 4
SELECT amount FROM goods WHERE id = 233;
UPDATE goods SET amount = 2 WHERE id = 233 AND amount = 4;
复制代码

这样就完成了不加锁的数据更新,若数据一致,则更新,数据不一致,则为过期数据,可以重新发起请求。但这样解决会引发ABA问题。

ABA问题

ABA问题指线程A读取到数据amount数值为4,开始进行相关操作,线程B在同时修改了amount为2,又将amount修改为4,此时A线程操作执行完成,判断amount是否等于4,等于则更新。这时候就产生了ABA问题,虽然线程A完成了数据更新,但不能保证过程的正确性。

解决ABA问题

为了确认数据版本和第一次获取时一致,可以增加Version字段,在更新数据时同步更新Version字段状态,这样可以保证能知晓数据版本是否一致的问题。

// amount = 4, version = 1
SELECT amount, version FROM goods WHERE id = 233;
UPDATE goods SET amount = 3, version = 2 WHERE id = 233 AND version = 1;
复制代码

虽然增加version的操作可以保证数据版本的一致,但是这样就会造成大量的数据修改失败,这样同样会降低大量的处理效率。

故而产生了以下解决方案:

// amount = 4
SELECT amount FROM goods WHERE id = 233;
UPDATE goods SET amount = amount - 1 WHERE id = 233 AND amount > 0;
复制代码

即解决了Version字段表入侵,又解决了大量修改失败的问题。

总结

乐观锁其实并不是真正的锁,只是一种并发控制思路,所以效率相较于普通的锁来说更高,但是限制粒度不掌握好,就会导致大量的业务失败。 而悲观锁虽然能保证数据的一致性,但是效率过低,不建议使用。

ps:个人博客地址是shawJie.me,不定期会发布一些自己所经历的,所学习的,所了解的,欢迎来坐坐。

猜你喜欢

转载自juejin.im/post/5d56ee866fb9a06b2005623a