关于乐观锁与悲观锁的实际应用

2018-11-283012
黄隆鹏
Java后台

数据库必备知识,关于乐观锁与悲观锁的实际应用

开门见山,先聊一聊我实际遇到的业务问题:

​ 在项目中有一个竞猜下注的功能,它的赔率是根据A队和B队两边的下注总金额来计算的。于是当有用户下注某一边时,两边的赔率都会进行相应的变化。

​ 反应到数据库里就是(简化版本),一个人下注,会更改数据库盘口表的几个字段:A队赔率,A队下注金额、B队赔率,B队下注金额 等等。

​ 如果使用默认事务方式,就加个@Transactional 注解,会导致更新丢失的问题。(何为丢失更新:就是一个事务的更新覆盖了其它事务的更新结果。举个例子,A读到的数据为下注金额1000,对他进行计算,这时B读到的数据也是1000。A再把计算完的1200写进数据库。最后B把计算完的1100写进数据库。最终表里的下注金额就只有1100,发生了丢失更新)。如果真有高并发的情况,每秒钟几十上百个人下注的话,就必须解决此问题。

​ 默认事务无法解决,当然就得寻求解决方案。这里可以采用乐观锁或悲观锁的方式。

一、悲观锁解决方案

重点:每次读数据都加行锁(也称写锁、X锁),修改完后事务结束才释放。

注意:mysql使用InonDB引擎时,默认增删改时都会加行锁。读不加行锁。

  • 实现如下(select 语句最后面加 for update 即可):
# 第一步 查的时候加行锁 (注意:InnoDB只有通过索引条件检索数据才使用行级锁,否则,InnoDB将使用表锁, 也就是说,InnoDB的行锁是基于索引的!)
SELECT * FROM table_name WHERE xxx FOR UPDATE;
# 第二步 逻辑处理完后更新数据
UPDATE xxx...
@Query(value = "SELECT * FROM guessing_handicap WHERE handicap_id = ?1 FOR UPDATE", nativeQuery = true)
GuessingHandicap getBet(Integer id);

实现起来十分简单,概念的理解放在后面写。

二、乐观锁解决方案

乐观锁的实现一般会使用版本号机制或CAS算法

  • 版本号机制:在数据表中加上一个数据版本号version字段,表示数据被修改的次数,当数据被修改时,version值会加1。在读取数据的同时也会读取version值,在提交更新时,若刚才读取到的version值为当前数据库中的version值相等时才更新,否则重试更新操作,直到更新成功。
int count = 0; // 计数重复次数,暂定10次
while (count < 10) {
    count++;

    // 先读取数据,保存版本号
    GuessingHandicap handicap = guessingHandicapDao.getBet(id);
    Integer version = handicap.getVersion();
    // 进行数据的处理
    // ...

    // 将处理完的结果写回数据库
    Integer rows = guessingHandicapDao.updateHandicap(...);
    if (rows == 0) {
        continue;
    }
    // ...
}
throw new ValidationException("下注失败");
  • CAS算法:即compare and swap(比较与交换),是一种有名的无锁算法。

CAS概念略复杂,举个简单的实现方式:还是盘口表,我读数据的时候读到了该条记录的下注金额,赔率,将其数据暂时保存。处理完逻辑写回去时,可用 update xxx set odds = 新赔率 where odds = 原来赔率

- CAS算法也有缺点,最明显且容易理解的就是,会导致 ABA 问题。

- 如果一个变量V初次读取的时候是A值,并且在准备赋值的时候检查到它仍然是A值,
那我们就能说明它的值没有被其他线程修改过了吗?很明显是不能的,因为在这段时间它的值可能被改为其他值,
然后又改回A,那CAS操作就会误认为它从来没有被修改过。这个问题被称为CAS操作的 "ABA"问题。

所以乐观锁建议使用版本号机制。就加个字段,简单轻松。

两种锁的使用场景

​ 从上面对两种锁的介绍,我们知道两种锁各有优缺点,不可认为一种好于另一种,像乐观锁适用于写比较少的情况下(多读场景),即冲突真的很少发生的时候,这样可以省去了锁的开销,加大了系统的整个吞吐量。但如果是多写的情况,一般会经常产生冲突,这就会导致上层应用会不断的进行retry,这样反倒是降低了性能,所以一般多写的场景下用悲观锁就比较合适。

​ 记住结论,即:乐观锁适用于写比较少的情况(多读场景);悲观锁适用于多写场景。

三、何谓悲观锁与乐观锁(概念的理解)

​ 乐观锁对应于生活中乐观的人总是想着事情往好的方向发展,悲观锁对应于生活中悲观的人总是想着事情往坏的方向发展。这两种人各有优缺点,不能不以场景而定说一种人好于另外一种人。

悲观锁

​ 总是假设最坏的情况,每次去拿数据的时候都认为别人会修改,所以每次在拿数据的时候都会上锁,这样别人想拿这个数据就会阻塞直到它拿到锁(共享资源每次只给一个线程使用,其它线程阻塞,用完后再把资源转让给其它线程)。传统的关系型数据库里边就用到了很多这种锁机制,比如行锁,表锁等,读锁,写锁等,都是在做操作之前先上锁。Java中synchronized和ReentrantLock等独占锁就是悲观锁思想的实现。

乐观锁

​ 总是假设最好的情况,每次去拿数据的时候都认为别人不会修改,所以不会上锁,但是在更新的时候会判断一下在此期间别人有没有去更新这个数据,可以使用版本号机制和CAS算法实现。乐观锁适用于多读的应用类型,这样可以提高吞吐量,像数据库提供的类似于write_condition机制,其实都是提供的乐观锁。在Java中java.util.concurrent.atomic包下面的原子变量类就是使用了乐观锁的一种实现方式CAS实现的。

另外补充一些易混淆概念

  • 从锁的粒度,我们可以将数据库的锁分成两大类: 表锁行锁

  • 表锁又分为表读锁和表写锁,

  • 行锁又分为共享锁排他锁,而共享锁、排他所又有其他别名,其实只是叫法的不同而已

    • 共享锁--读锁--S锁
    • 排它锁--写锁--X锁
  • 为了允许行锁和表锁共存,实现多粒度锁机制,InnoDB还有两种内部使用的意向锁(Intention Locks),这两种意向锁都是表锁

    • 意向共享锁(IS):事务打算给数据行加行共享锁,事务在给一个数据行加共享锁前必须先取得该表的IS锁。
    • 意向排他锁(IX):事务打算给数据行加行排他锁,事务在给一个数据行加排他锁前必须先取得该表的IX锁。
    • 认真梳理一遍,概念还是挺清晰的,而且意向锁也是数据库隐式帮我们做了,不需要我们关心!

四、 对于只读事务的理解

网上的各种资料里众说纷纭: ​ “只读事务”并不是一个强制选项,它只是一个“暗示”,提示数据库驱动程序和数据库系统,这个事务并不包含更改数据的操作,那么JDBC驱动程序和数据库就有可能根据这种情况对该事务进行一些特定的优化,比方说不安排相应的数据库锁,以减轻事务对数据库的压力,毕竟事务也是要消耗数据库的资源的。 因此,“只读事务”仅仅是一个性能优化的推荐配置而已,并非强制你要这样做不可。

@Transactional(readOnly = true)
只读事务的注意点:
  • 只读事务内,不能增加、修改、删除内容,否则报Cannot execute statement in a READ ONLY transaction。
  • 只读事务内,只能读取到执行时间点前的内容,期间修改的内容不能读取到。
  • 只读事务作为ORM框架优化执行的一个暗号,比如放弃加锁,或者flush never。
  • 只读事务也有缺点,使用了事务,会动态生成代理类,增加开销。
应用场景总结:
  1. 如果一次执行单条查询语句,则没有必要启用事务支持,数据库默认支持SQL执行期间的读一致性;
  2. 如果一次执行多条查询语句,例如统计查询,报表查询,在这种场景下,多条查询SQL必须保证整体的读一致性,否则,在前条SQL查询之后,后条SQL查询之前,数据被其他用户改变,则该次整体的统计查询将会出现读数据不一致的状态,此时,应该启用事务支持。
  3. 若要将代码写的精致,可按照前两点来添加只读事务,若嫌麻烦,全部加上注解也行 (捂脸.jpg)
分享
点赞1
打赏
上一篇:Docker常用命令笔记(一)
下一篇:Promise对象的初步探索