乐观锁悲观锁的使用背景
为了在高并发的情况下,也可以保证数据的准确性,也就是并发控制。
并发控制:确保当前用户和其他用户一起操作时,所得到的结果和他单独操作时的结果是一样的,目的就是保证一个用户的工作不会对另一个用户的工作产生不合理的影响。
实现并发控制的主要手段分为乐观并发控制和悲观并发控制两种。乐观锁比较适用于读多写少的情况(多读场景),悲观锁比较适用于写多读少的情况(多写场景)。
1.悲观锁
当要对数据库中的一条数据进行修改的时候,为了避免同时被其他人修改,最好的办法就是直接对该数据进行加锁以防止并发。这种借助数据库锁机制,在修改数据之前先锁定,再修改的方式被称之为悲观并发控制【Pessimistic Concurrency Control,缩写“PCC”,又名“悲观锁”】。
之所以叫做悲观锁,是因为这是一种对数据的修改持有悲观态度的并发控制方式。总是假设最坏的情况,每次读取数据的时候都默认其他线程会更改数据,因此需要进行加锁操作,当其他线程想要访问数据时,都需要阻塞挂起。
悲观锁的实现:
- 传统的关系型数据库使用这种锁机制,比如行锁、表锁、读锁、写锁等,都是在操作之前先上锁。
- Java 里面的同步 [synchronized] 关键字的实现。
悲观锁的种类:
主要分为共享锁和排他锁
共享锁:又称为读锁,简称 S 锁。顾名思义,共享锁就是多个事务对于同一数据可以共享一把锁,都能访问到数据,但是只能读不能修改。
排他锁【exclusive locks】又称为写锁,简称 X 锁。顾名思义,排他锁就是不能与其他锁并存,如果一个事务获取了一个数据行的排他锁,其他事务就不能再获取该行的其他锁,包括共享锁和排他锁。获取排他锁的事务可以对数据行读取和修改。
悲观锁的说明:
悲观并发控制实际上就是,先取锁,再访问的保守策略,为数据安全提供了保证,但是效率方面,这会让数据库产生额外的开销,还有可能产生死锁。另外也会降低并行性,因为一旦某数据被锁定,就必须要等待该事务处理完再处理。
2.乐观锁
乐观锁是相对悲观锁而言的,乐观锁假设数据一般情况不会造成冲突,所以在数据进行提交更新的时候,才会正式对数据的冲突与否进行检测,如果冲突,则返回给用户异常信息,让用户决定如何去做。乐观锁适用于读多写少的场景,这样可以提高程序的吞吐量。
乐观锁的实现:
1.CAS 实现:Java 中java.util.concurrent.atomic包下面的原子变量使用了乐观锁的一种 CAS 实现方式。
2.版本号控制:一般是在数据表中加上一个数据版本号 version 字段,表示数据被修改的次数。当数据被修改时,version 值会 +1。当线程 A 要更新数据时,在读取数据的同时也会读取 version 值,在提交更新时,若刚才读取到的 version 值与当前数据库中的 version 值相等时才更新,否则重试更新操作,直到更新成功。
乐观锁的说明:
乐观并发控制相信事务之间的数据竞争(data race)的概率是比较小的,因此尽可能直接做下去,直到提交的时候才去锁定,所以不会产生任何锁和死锁。
3.具体实现方式
悲观锁实现方式
1.数据库实现方式
悲观锁的实现,往往依靠数据库提供的锁机制。在数据库中,悲观锁的流程如下:
- 在对记录进行修改前,先尝试为该记录加上排他锁(exclusive locks)。
- 如果加锁失败,说明该记录正在被修改,那么当前查询可能要等待或者抛出异常。具体响应方式由开发者根据实际需要决定。
- 如果成功加锁,那么就可以对记录做修改,事务完成后就会解锁了。
- 期间如果有其他对该记录做修改或加排他锁的操作,都会等待解锁或直接抛出异常。
实例:
mysql innodb
![](https://img.haomeiwen.com/i15726274/fb982ec4052b7bcf.png)
使用 [select…for update] 锁数据,需要注意锁的级别,MySQL InnoDB 默认行级锁。行级锁都是基于索引的,如果一条 SQL 语句用不到索引是不会使用行级锁的,会使用表级锁把整张表锁住,这点需要注意。
PS:一定记得set autocommit=0,来关闭mysql的数据库自动提交属性。否则将会自动提交更新。
2.通过代码来实现
例如同步块和同步代码synchronized
乐观锁实现方式
主要就是两个步骤:冲突检测和数据更新。比较典型的就是 CAS (Compare and Swap)。
当多个线程尝试使用 CAS 同时更新同一个变量时,只有其中一个线程能更新变量的值,而其它线程都失败,失败的线程并不会被挂起,而是被告知这次竞争中失败,并可以再次尝试。比如前面的扣减库存问题,通过乐观锁可以实现如下:
![](https://img.haomeiwen.com/i15726274/fe88d56503906c7c.png)
在更新之前,先查询一下库存表中当前库存数(quantity),然后在做 update 的时候,以库存数作为一个修改条件。当提交更新的时候,判断数据库表对应记录的当前库存数与第一次取出来的库存数进行比对,如果数据库表当前库存数与第一次取出来的库存数相等,则予以更新,否则认为是过期数据。
但是在这个过程容易产生ABA问题,
比如说线程一从数据库中取出库存数 3,这时候线程二也从数据库中取出库存数 3,并且线程二进行了一些操作变成了 2。
然后线程二又将库存数变成 3,这时候线程一进行 CAS 操作发现数据库中仍然是 3,然后线程一操作成功。
尽管线程一的 CAS 操作成功,但是不代表这个过程就是没有问题的。
一个比较好的解决办法,就是通过一个单独的可以顺序递增的 version 字段。优化如下:
![](https://img.haomeiwen.com/i15726274/8cec81700a7ce6c7.png)
乐观锁每次在执行数据修改操作时,都会带上一个版本号,一旦版本号和数据的版本号一致就可以执行修改操作并对版本号执行 +1 操作,否则就执行失败。因为每次操作的版本号都会随之增加,所以不会出现 ABA 问题。除了 version 以外,还可以使用时间戳,因为时间戳天然具有顺序递增性。
以上 SQL 其实还是有一定的问题的,就是一旦遇上[高并发]的时候,就只有一个线程可以修改成功,那么就会存在大量的失败。对于像淘宝这样的电商网站,高并发是常有的事,总让用户感知到失败显然是不合理的。所以,还是要想办法减少乐观锁的粒度。一个比较好的建议,就是减小乐观锁力度,最大程度的提升吞吐率,提高并发能力!如下:
![](https://img.haomeiwen.com/i15726274/05c03ba6458453ce.png)
4.CAS 性能优化
从流程图可以看出来,大量的线程同时并发修改一个 值,可能有很多线程会不停的自旋,进入一个无限重复的循环中。这些线程不停地获取值,然后发起 CAS 操作,但是发现这个值被别人改过了,于是再次进入下一个循环,获取值,发起 CAS 操作又失败了,再次进入下一个循环。在大量线程高并发更新 AtomicInteger 的时候,这种问题可能会比较明显,导致大量线程空循环,自旋转,性能和效率都不是特别好。那么如何优化呢?
[Java8] 有一个新的类,LongAdder,它就是尝试使用分段 CAS 以及自动分段迁移的方式来大幅度提升多线程高并发执行 CAS 操作的性能,这个类具体是如何优化性能的呢?属于热点分离,具体实现参考:https://www.jianshu.com/p/d2ac26ca6525
5.如何选择
在乐观锁与悲观锁的选择上面,主要看下两者的区别以及适用场景就可以了。
1️⃣响应效率:如果需要非常高的响应速度,建议采用乐观锁方案,成功就执行,不成功就失败,不需要等待其他并发去释放锁。乐观锁并未真正加锁,效率高。一旦锁的粒度掌握不好,更新失败的概率就会比较高,容易发生业务失败。
2️⃣冲突频率:如果冲突频率非常高,建议采用悲观锁,保证成功率。冲突频率大,选择乐观锁会需要多次重试才能成功,代价比较大。
3️⃣重试代价:如果重试代价大,建议采用悲观锁。悲观锁依赖数据库锁,效率低。更新失败的概率比较低。
4️⃣乐观锁如果有人在你之前更新了,你的更新应当是被拒绝的,可以让用户从新操作。悲观锁则会等待前一个更新完成。这也是区别。
ACID
事务有四种基本特性,叫ACID,它们分别是:
Atomicity-原子性,Consistency-一致性,Isolation-隔离性,Durability-持久性。
1、原子性(Atomicity):事务开始后所有操作,要么全部做完,要么全部不做,不可能停滞在中间环节。事务执行过程中出错,会回滚到事务开始前的状态,所有的操作就像没有发生一样。也就是说事务是一个不可分割的整体,就像化学中学过的原子,是物质构成的基本单位。
2、一致性(Consistency):事务开始前和结束后,数据库的完整性约束没有被破坏 。比如A向B转账,不可能A扣了钱,B却没收到。
3、隔离性(Isolation):同一时间,只允许一个事务请求同一数据,不同的事务之间彼此没有任何干扰。比如A正在从一张银行卡中取钱,在A取钱的过程结束前,B不能向这张卡转账。
4、持久性(Durability):事务完成后,事务对数据库的所有更新将被保存到数据库,不能回滚。
未作好并发控制可能造成的结果:
脏读,脏写,不可重复读,幻读
脏写
当两个事务同时尝试去更新某一条数据的时候:
A更新,B随后更新并提交,结果A回滚,会将B的记录冲掉。
脏读
A更新数据,B看到了,然后A回滚了,B就相当于看到了本不应该看到的记录,也可以说是本不存在的数据。
参考路径:https://www.jianshu.com/p/d2ac26ca6525
一点小体会:
1.悲观锁会在读取数据的时候对数据进行加锁,当其他线程想要访问数据时,都会被阻塞挂起。
2.悲观锁分为共享锁和排他锁,for update是排他锁的用法,在排他锁生效的时候,只允许当前线程修改删除数据,其他线程都不可对数据操作包括读取。lock in share mode是共享锁,会阻塞其他事务修改表数据但是不会阻塞读表。
3.FOR UPDATE仅适用于InnoDB,且必须在事务处理模块(BEGIN/COMMIT)中才能生效。
网友评论