【航母特辑】06_乐观锁和悲观锁的业务场景以及使用案例,这篇很全
文章目录
-
- 1 引言
- 2 MVCC
-
- 2.1 MVCC可以解决什么问题
- 2.2 什么是快照读
- 2.3 什么是当前读
- 2.4 当前读,快照读和MVCC的关系
- 2.5 什么是共享锁、排他锁
- 2.6 MVCC小结
-
- (1)数据库并发场景
- (2) MVCC带来的好处
- (3) MVCC组合解决写写冲突
- 3 乐观锁
-
- 3.1乐观锁使用场景示例
-
- (1)版本号机制
- (2)CAS算法
- 4 悲观锁
-
- 4.1悲观锁使用场景示例
-
- 4.1.1 Sql语句加`for update`
-
- (1) 不加 for update事务用表格展示如下
- (2) 加 for update事务用表格展示如下
- 5 乐观锁悲观锁比较
-
- 5.1 各自适用的场景
- 5.2 乐观锁加锁吗?
- 5.3 CAS与Synchronized的使用场景
1 引言
本文记录乐观锁悲观锁的使用场景以及案例,由MVCC引出乐观锁和悲观锁和一些共享锁、排他锁、当前读、快照读的概念,全面分析乐观锁和悲观锁。
2 MVCC
MVCC的英文全称是 Multiversion Concurrency Control,中文意思是多版本并发控制技术。原理是,通过数据行的多个版本管理来实现数据库的并发控制,简单来说就是保存数据的历史版本。可以通过版本号决定数据是否显示出来。读取数据的时候不需要加锁可以保证事务的隔离效果。
多版本控制,指的是一种高并发的技术,最早的数据库中,只有读读之间可以并发,读写,写读,写写都要阻塞。引入多版本之后,只有写写之间相互阻塞,其他操作都可以并行,这样大幅度提高了InnoDB的并发度。在内部实现中,InnoDB是在undolog中实现的,通过undolog可以找回数据的历史版本,找回的数据历史版本可以提供给用户读(有些读请求只能看到比较老的数据版本),也可以在回滚的时候覆盖数据页上的数据。在InnoDB内部中,会记录一个全局的活跃读写事务数组,其主要用来判断事务的可见性。
MVCC在MySQL InnoDB中的实现主要是为了提高数据库并发性能,用更好的方式去处理读-写冲突,做到即使有读写冲突时,也能做到不加锁,非阻塞并发读。
2.1 MVCC可以解决什么问题
- 读写之间阻塞的问题 ,通过MVCC可以让读写互相不阻塞,读不互相阻塞,写不阻塞读。只有写写之间堵塞,读读,读写,写读不堵塞,这样可以提升数据并发处理能力
- 降低了死锁的概率 ,这个是因为MVCC采用了乐观锁的方式,读取数据时,不需要加锁,写操作,只需要锁定必要的行。
- 解决了一致性读的问题 ,当我们朝向某个数据库在时间点的快照时,只能看到这个时间点之前事务提交更新的结果,不能看到时间点之后事务提交的更新结果。
2.2 什么是快照读
快照读,读取的是快照数据,不加锁的简单Select都属于快照读。
select * from product where ...
快照读的隔离级别是不串行级别,串行级别下的快照度会退化成当前读,之所以出现快照读的情况是基于提高并发性能的考虑,快照读的实现是基于多版本并发控制,即MVCC,可以认为MVCC是行锁的一个变种,但它在很多情况下,避免了加锁的操作,降低了开销;既然是多版本,即快照读可能读到的并不一定是数据的最新版本,而有可能是之前的版本
用一句话概括就是,MVCC就是为了实现读写冲突不加锁,而这个读就是快照读,当前读实际上是一种加锁的操作,是悲观锁的实现
2.3 什么是当前读
当前读就是读的是最新数据,而不是历史的数据。加锁的Select ,或者对数据进行增删改都会进行当前读。
select * from product LOCK IN SHARE MODE(共享锁);SELECT * from product FOR UPDATE(排他锁);insert into product values...(排他锁)delete from product where...(排他锁)update product set... (排他锁)
为什么叫当前读? 就是它读取的是记录的最新版本,读取时还要保证其他并发事务不能修改当前记录,会对读取的记录进行加锁
2.4 当前读,快照读和MVCC的关系
准确的说,MVCC多版本并发控制指的是 维持一个数据的多个版本,使得读写操作没有冲突
,这么一个概念,仅仅是一个理想概念。
而在MySQL中,实现这么一个MVCC的理想概念,我们就需要MySQL提供具体的功能去实现它,而快照读就是MySQL为我们实现MVCC理想模型中的一个具体非阻塞读功能
。而相对而言,当前读就是悲观锁的具体功能实现
。
2.5 什么是共享锁、排他锁
MySql锁分为共享锁和排他锁,也叫读锁和写锁。
- 读锁是共享的,可以通过lock share mode 实现,这时候只能读不能写。
- 写锁是排他的,他会阻塞其他的写锁和读锁。从颗粒度来区分,可以分为表锁和行锁两种。
- 表锁会锁定整张表并且阻塞其他用户对该表的所有读写操作,比如alter修改表结构的时候会锁表
- 行锁又可以分为乐观锁和悲观锁,悲观锁可以通过for update实现,乐观锁则通过版本号/CAS实现实现。
彭于晏,现在先停一下!此时再去看下当前读的sql示例是不是能够很好的理解共享锁和排他锁了呢?
2.6 MVCC小结
(1)数据库并发场景
读-读
:不存在任何问题,也不需要并发控制读-写
:有线程安全问题,可能会造成事务隔离性问题,可能遇到脏读,幻读,不可重复读写-写
:有线程安全问题,可能会存在更新丢失问题。比如第一类更新丢失,第二类更新丢失
第一类更新丢失示例:事务A撤销时,把已经提交的事务B更新的数据覆盖了;第二类更新丢失示例:事务A覆盖事务B已经提交的数据,造成事务B的操作丢失
这种组合的方式就可以最大程度的提高数据库并发性能,并解决读写冲突,和写写冲突导致的问题
(2) MVCC带来的好处
多版本并发控制(MVCC)是一种用来解决读-写冲突
的无锁并发控制,也就是为事务分配单向增长的时间戳,为每个修改保存一个版本,版本与事务时间戳关联,读操作只读该事务开始前的数据库的快照,所以MVCC可以为数据库解决以下问题
- 在并发读写数据库时,可以做到在读操作时不用阻塞写操作,写操作也不用阻塞读操作,提高了数据库并发读写的性能
- 同时还可以解决脏读,幻读,不可重复读等事务隔离问题,但不能解决更新丢失问题
(3) MVCC组合解决写写冲突
总之,MVCC就是因为大牛们,不满意只让数据库采用悲观锁这样性能不佳的形式去解决读-写冲突问题,而提出的解决方案,所以在数据库中,因为有了MVCC,所以我们可以形成两个组合:
MVCC + 悲观锁
MVCC解决读写冲突,悲观锁解决写写冲突MVCC + 乐观锁
MVCC解决读写冲突,乐观锁解决写写冲突
3 乐观锁
乐观锁在操作数据时非常乐观,认为别人不会同时修改数据。因此乐观锁不会上锁,只是在执行更新
的时候判断一下在此期间别人是否修改了数据:如果别人修改了数据则放弃操作,否则执行操作。
乐观锁用于读多写少
的情况,即很少发生冲突
的场景,这样可以省去锁的开销,增加系统的吞吐量。
乐观锁的适用场景有很多,典型的比如某成本系统,柜员要对一笔金额做修改,为了保证数据的准确性和实效性,使用悲观锁锁住某个数据后,再遇到其他需要修改数据的操作,那么此操作就无法完成金额的修改,对产品来说是灾难性的一刻,使用乐观锁的版本号机制能够解决这个问题
3.1乐观锁使用场景示例
(1)版本号机制
一般是在数据库表加上一个版本号字段,表示数据被修改的次数,当数据库被修改时,version值会加1,当线程A要更新数据数值时,在读取数据的同时也会读取version值,在提交更新时,若刚才读到的version值与当前数据库中的version值相等时才更新,否则重试更新操作,直到更新成功。
举个栗子🌰
(1) 数据库表设计 task,表内有三个字段,id、value、version
(2) 实现乐观锁
- 假设task表有一条数据,1 1 1
- 读取task表获取version的值为oldVersion 1
- 每次更新task表中的value字段时,为了防止发生冲突,采取
MVCC+乐观锁
, sql示例:
update task set value = newValue,version = oldVersion + 1 where version = oldVersion;
代码使用案例
/ * 基于乐观锁的更新操作 * @param editFinance 编辑的账户对象 * @param queryLockNo 上次查询的乐观锁版本号 * @return */@Overridepublic int updateForLockNo(BzFinanceEntity editFinance, int queryLockNo) { editFinance.setLockNo(queryLockNo + 1); //修改乐观锁版本 BzFinanceEntityExample example = new BzFinanceEntityExample();BzFinanceEntityExample.Criteria criteria = example.createCriteria();criteria.andIdFinanceEqualTo(editFinance.getIdFinance());criteria.andLockNoEqualTo(queryLockNo); //基于乐观锁,修改查询版本的数据//根据Example条件更新实体BzFinanceEntity包含的不是null的属性值int mark = this.baseEntityDao.updateByExampleSelective(editFinance, example); return mark;}
再举个栗子🌰
某成本系统中有一个数据表,表中有两个字段分别是 金额 和 version,金额的属性是能够实时变化,而 version 表示的是金额每次发生变化的版本
,一般的策略是,当金额发生改变时,version 采用递增的策略每次都在上一个版本号的基础上 + 1。
在了解了基本情况和基本信息之后,我们来看一下这个过程:公司收到回款后,需要把这笔钱放在金库中,假如金库中存有100 元钱
开启事务一:当男柜员执行回款写入操作前,他会先查看(读)一下金库中还有多少钱,此时读到金库中有 100 元,可以执行写操作,并把数据库中的钱更新为 120 元,提交事务,金库中的钱由 100 -> 120,version的版本号由 0 -> 1。
开启事务二:女柜员收到给员工发工资的请求后,需要先执行读请求,查看金库中的钱还有多少,此时的版本号是多少,然后从金库中取出员工的工资进行发放,提交事务,成功后版本 + 1,此时版本由 1 -> 2。
上面两种情况是最乐观的情况,上面的两个事务都是顺序执行的,也就是事务一和事务二互不干扰,那么事务要并行执行会如何呢?
事务一开启,男柜员先执行读操作,取出金额和版本号,执行写操作
beginupdate 表 set 金额 = 120,version = version + 1 where 金额 = 100 and version = 0
此时金额改为 120,版本号为1,事务还没有提交
事务二开启,女柜员先执行读操作,取出金额和版本号,执行写操作
beginupdate 表 set 金额 = 50,version = version + 1 where 金额 = 100 and version = 0
此时金额改为 50,版本号变为 1,事务未提交
现在提交事务一,金额改为 120,版本变为1,提交事务。理想情况下应该变为 金额 = 50,版本号 = 2,但是实际上事务二 的更新是建立在金额为 100 和 版本号为 0 的基础上的,所以事务二不会提交成功,应该重新读取金额和版本号,再次进行写操作。
这样,就避免了女柜员 用基于 version=0 的旧数据修改的结果覆盖男操作员操作结果的可能。
(2)CAS算法
先来了解一下什么是CAS,CAS的ABA问题,原子类的底层,乐观锁示例。这是以前撰写的文章点击跳转,这里就不再详细论述。
4 悲观锁
悲观锁在操作数据时比较悲观,认为别人会同时修改数据。因此操作数据时直接把数据锁住,直到操作完成后才会释放锁
;上锁期间其他人不能修改数据.
4.1悲观锁使用场景示例
一般来说,悲观锁不仅会对写操作加锁
还会对读操作加锁
,一个典型的悲观锁调用:
select * from account where userName = "lisheng" for update
在这个事务提交之前,其他事务都不会对这条数据进行操作,起到了独占和排他的作用。
4.1.1 Sql语句加for update
有这么一个业务场景,查询订单的状态,比如订单的状态为待收货、已收货、待签收、已签收、已完成、待售后、待申诉
现在需要实现用定时器扫描订单的已签收的更新时间,若该更新时间是七天前,则自动更新为已完成。拿当前时间与更新时间比较即可
select * from order where updatetime >= #{currentime}update order set status = "已完成" where status = "已签收" and orderNo in (orderNoList) for update
(实际业务场景,只需查询需要的字段,现作为示例,统一用 * 演示)
事务一
需要完成自动更新订单状态为“已完成”任务,事务二
,用户在订单失效节点发起售后,希望该笔“已签收”订单变为“待售后”
若不加锁,可能会产生幻读,事务一可能读取已签收的订单后,将订单更新为“已完成”,事务一还未提交。事务二将该笔订单更改为待售后,事务一提交后,又将订单更改为已完成。导致事务二被覆盖。
(1) 不加 for update事务用表格展示如下
时间 | 事务一(已完成) | 事务二(待售后) |
---|---|---|
1s | 开始事务 | |
2s | 开始事务 | |
3s | 查询所有订单状态为已签收的数据 | |
4s | 查询该笔订单单条数据 | |
5s | 将该笔订单提交申请售后( 此时订单状态为待售后) | |
6s | 定时器更新订单状态为已完成 | |
7s | 提交事务 | |
8s | 提交事务 |
(2) 加 for update事务用表格展示如下
时间 | 事务一(已完成) | 事务二(待售后) |
---|---|---|
1s | 开始事务 | |
2s | 开始事务 | |
3s | 查询所有订单状态为已签收的数据 ,此时查询出来的数据(已经被锁定 ) | |
4s | (等待)查询该笔订单单条数据 | |
5s | 定时器更新订单状态为已完成 | |
6s | 提交事务 | |
7s | 将该笔订单提交申请售后( 此时订单状态为待售后) | |
8s | 提交事务 |
5 乐观锁悲观锁比较
5.1 各自适用的场景
乐观锁和悲观锁并没有优劣之分,它们有各自适合的场景
- 功能限制
与悲观锁相比,乐观锁的使用场景受到了更多的限制,无论是CAS还是版本号机制
例如,CAS只能保证单个变量操作的原子性,当涉及到多个变量时,CAS是无能为力的,而Synchronized则可以通过对整个代码块加锁来处理。
再比如版本号机制,如果query时是针对表1,而update的时候是针对表2,也很难通过简单的版本号来实现乐观锁。
- 竞争激烈程度
当竞争不激烈(出现并发冲突的概率小时),乐观锁更有优势,因为悲观锁会锁住代码块或数据,其他线程无法同时访问,影响并发,而且加锁和释放锁都需要消耗额外的资源。
当竞争激烈(出现并发冲突概率大时,悲观锁更有优势,因为乐观锁在执行更新时频繁失败,需要不断重试,浪费CPU资源)
5.2 乐观锁加锁吗?
- 乐观锁本身是不加锁的,只是在更新时判断一下数据是否被其他线程更新了,AtomicInteger原子类就是一个例子。
- 有时候乐观锁可能与加锁操作合作,例如update时的
排他锁
,但这只是乐观锁与加锁操作合作的例子,不能改变“乐观锁本身不加锁”
这一事实。
5.3 CAS与Synchronized的使用场景
简单的来说 CAS 适用于写比较少
的情况下(多读场景,冲突一般较少),synchronized 适用于写比较多
的情况下(多写场景,冲突一般较多)
对于资源竞争较少(线程冲突较轻)的情况
,使用 synchronized 同步锁进行线程阻塞和唤醒切换以及用户态内核态间的切换操作额外浪费消耗 cpu 资源;而 CAS 基于硬件实现,不需要进入内核,不需要切换线程,操作自旋几率较少,因此可以获得更高的性能。
对于资源竞争严重(线程冲突严重)的情况
,CAS 自旋的概率会比较大,从而浪费更多的 CPU 资源,效率低于 synchronized。
Java并发编程这个领域中 synchronized 关键字一直都是元老级的角色,很久之前很多人都会称它为 “重量级锁” 。但是,在JavaSE 1.6之后进行了主要包括为了减少获得锁和释放锁带来的性能消耗而引入的 偏向锁 和 轻量级锁 以及其它各种优化之后变得在某些情况下并不是那么重了。synchronized 的底层实现主要依靠 Lock-Free 的队列,基本思路是 自旋后阻塞,竞争切换后继续竞争锁,稍微牺牲了公平性,但获得了高吞吐量。在线程冲突较少的情况下,可以获得和 CAS 类似的性能;而线程冲突严重的情况下,性能远高于CAS。