前言
并发控制: 事务和锁的存在都是为了更好的解决并发访问造成的数据不一致性的的问题
一、乐观锁与悲观锁
乐观锁和悲观锁都是为了解决并发控制问题;
乐观锁可以认为是一种在最后提交的时候检测冲突的手段;
而悲观锁则是一种避免冲突的手段。
1.1 乐观锁
-
什么是乐观锁
是应用系统层面和数据的业务逻辑层次上的,利用程序处理并发,它假定当某一个用户去读取某一个数据时,其他的用户不会来访问修改这个数据,但在最后进行事务的提交的时候会进行版本的检查,以判断在该用户的操作过程中,没有其他用户修改了这个数据。开销比较小 -
实现方式
乐观锁的实现大部分都是基于版本控制实现, 此外,还可通过时间戳方式,通过提前读取,事后对比方式实现 。如mysql里添加版本version字段来控制UPDATE price_version SET front = #{front,jdbcType=DECIMAL}, version= version + 1 WHERE id = #{id,jdbcType=INTEGER}
-
乐观锁的优势和劣势
- 优势:如果数据库记录始终处于悲观锁加锁状态,面对几百上千个并发,要不断的加锁减锁,且用户等待的时间会非常的长, 乐观锁机制避免了长事务中的数据库加锁解锁开销,大大提升了大并发量下的系统整体性能表现,所以如果系统的并发非常大=,悲观锁定会带来非常大的性能问题,建议就要选择乐观锁定的方法, 而如果并发量不大,完全可用悲观锁定的方法。乐观锁也适合于读较多的场景。
- 劣势: 乐观锁也存在问题,只能在提交数据时才发现业务事务将要失败,如果系统冲突非常多,且一旦冲突就要因为重新计算提交而造成较大的代价,乐观锁也会带来很大的问题,在某些情况下,发现失败太迟的代价会非常的大。且乐观锁也无法解决脏读的问题
1.2 悲观锁
-
什么是悲观锁?
完全依赖数据库锁的机制实现,在数据库中可用Repeatable Read的隔离级别(可重复读)实现悲观锁。它认为当某一用户读取某一数据时,其他用户也会对该数据进行访问,所以在读取时就对数据加锁, 在该用户读取数据的期间,其他任何用户都不能来修改该数据,但其他用户可读取该数据, 只有当自己读取完毕才释放锁。 -
悲观锁的优势和劣势
劣势:开销较大,且加锁时间较长,对于并发的访问性支持不好。
优势 : 能避免冲突的发生,
1.3 如何实现乐观锁/悲观锁
以hibernate为例,可通过为记录添加版本或时间戳字段来实现乐观锁,一旦发现出现冲突了,修改失败就要通过事务进行回滚。可用session.Lock()锁定对象来实现悲观锁(本质是执行SELECT * FROM t FOR UPDATE语句)
1.4 乐观锁和悲观锁选择标准
乐观锁和悲观所各有优缺点,在乐观锁和悲观锁之间进行选择的标准是:
- 发生冲突的频率与严重性。
- 如果冲突很少,或者冲突的后果不会很严重,那么通常情况下应该选择乐观锁,因为它能得到更好的并发性,而且更容易实现。
- 如果冲突太多或者冲突的结果对于用户来说痛苦的,那么就需要使用悲观策略,它能避免冲突的发生。 如果要求能够支持高并发,那么乐观锁。
其实使用乐观锁 高并发==高冲突, 看看你怎么衡量了。
但现在大多数源代码开发者更倾向于使用乐观锁策略
二、共享锁和排它锁
共享锁和排它锁是具体的锁,是数据库机制上的锁。
2.1 共享锁(读锁)
共享锁(读锁) 在同一个时间段内,多个用户可读取同一个资源,读取过程中数据不发生任何变化。读锁间是相互不阻塞的, 多个用户可同时读,但不允许有人修改, 任何事务都不允许获得数据上的排它锁,直到数据上释放掉所有的共享锁
2.2 排它锁(写锁)
排它锁(写锁) 在任何时候只能有一个用户写入资源,当进行写锁时会阻塞其他读锁或写锁操作,只能由这一个用户来写,其他用户既不能读也不能写。
三、加锁的粒度
加锁会有粒度问题,从粒度上从大到小可以划分为
3.1 表锁
开销较小,一旦有用户访问这个表就会加锁,其他用户就不能对这个表操作了,应用程序的访问请求遇到锁等待的可能性比较高。
3.2 页锁
MySQL中较独特的一种锁定级别,锁定颗粒度介于行级锁定与表级锁间,所以获取锁定所需要的资源开销,及所能提供的并发处理能力也同样是介于上面二者间。另外,页级锁定和行级锁定一样,会发生死锁。
3.3 行锁
开销较大,能具体锁定到表中的某行数据,能更好的支持并发处理, 会发生死锁
四、事务
4.1 事务
4.1.1 用于保证数据库的一致性
所谓数据一致性,就是当多个用户试图同时访问一个数据库,它们的事务同时使用相同的数据时,可能会发生以下四种情况:
- 丢失更新、
- 脏读、
- 不可重复读
- 幻读
4.1.2 用于保证数据库的完整性
所谓数据完整性,数据库中的数据是从外界输入的,而数据的输入由于种种原因,会发生输入无效或错误信息。保证输入的数据符合规定,
数据完整性分为四类:
- 实体完整性(Entity Integrity)、
- 域完整性(Domain Integrity)、
- 参照完整性(Referential Integrity)、
- 用户定义的完整性(User-definedIntegrity)。
数据库采用多种方法来保证数据完整性,包括外键、约束、规则和触发器。
4.2 事务的ACID特性
- 原子性Automicity,一个事务内的所有操作,要么全做,要么全不做
- 一致性Consistency,数据库从一个一致性状态转到另一个一致性状态
- 独立性(隔离性)isolation, 一个事务在执行期间,对于其他事务来说是不可见的
- 持久性(Durability): 事务一旦成功提交,则就会永久性的对数据库进行了修改
4.3 隔离级别
在SQL 中定义了四种隔离级别
4.3.1 READ UNCOMMITED(未提交读)
READ UNCOMMITED(未提交读) 事务间的数据是相互可见的
4.3.2 READ COMMITED(提交读)
READ COMMITED(提交读) 大多数数据库的默认隔离级别, 保证了不可能脏读,但不能保证可重复读, 在这个级别里,数据的加锁实现是读取都是不加锁的,但数据的写入、修改和删除是要加锁的。
4.3.3 REPEATABLE READ (可重复读)
REPEATABLE READ (可重复读) 解决了不可重复读问题,保证在同一个事务之中,多次读取相同的记录的值的结果是一致的。 但无法解决幻读。这个阶段的事务隔离性,在mysql中是通过基于乐观锁原理的多版本控制实现的。
4.3.4 SERIALIZABLE (可串行化读)
SERIALIZABLE (可串行化读) 最高的隔离级别,解决了幻读 ,它在读取的每行数据上都加锁, 有能导致超时和锁争用的问题。
它的加锁实现是读取时加共享锁,修改删除更新时加排他锁,读写互斥,但并发能力差。
隔离级别 | 脏读 | 不可重复读 | 幻读 |
---|---|---|---|
未提交读(Read uncommitted) | 可能 | 可能 | 可能 |
已提交读(Read committed) | 不可能 | 可能 | 可能 |
可重复读(Repeatable read) | 不可能 | 不可能 | 可能 |
可串行化(Serializable) | 不可能 | 不可能 | 不可能 |
Mysql 默认的隔离级别是可重复读 。
丢失更新: 当两个或多个事务同时对某一数据进行更新时,事务B的更新可能覆盖掉事务A的更新,导致更新丢失
解决方案:
- 悲观锁方式: 加锁,建议最后一步更新数据时加上排它锁,不要在一开始就加锁。执行到了最后一步更新,首先做加锁的查询确认数据有无改变,如没有被改变,则进行数据更新,否则失败。 一定要是做加锁的查询确认,因为如果不加锁,有可能在做确认时数据又发生了改变。
- 乐观锁的方式:使用版本控制实现
级别高低是:脏读 < 不可重复读 < 幻读。所以,设了最高级别的SERIALIZABLE_READ就不用在设置REPEATABLE_READ和READ_COMMITTED了
4.4 不同隔离级别带来的并发问题
4.4.1 脏读:
事务可读取未提交的数据,如: 事务A对某一个数据data=1000 进行了修改: data = 2000, 但是还没有提交; 事务B读取data 得到了结果data = 2000, 由于某种原因事务A撤销了刚才的操作,数据data = 1000 然后提交 这时事务B读取到的2000就是脏数据。正确的数据应该还是 1000
解决方法 : 把数据库的事务隔离级别调整到READ_COMMITTED , 但存在事务A与B都读取了data,A还未完成事务,B此时修改了数据data,并提交, A又读取了data,发现data不一致了,出现了不可重复读。
4.4.2 不可重复读
在同一个事务之中,多次读取相同的记录的值的结果是不一样的,针对的是数据的修改和删除。
事务A 读取data = 1000, 事务还未完成; 事务B 修改了data = 2000, 修改完毕事务提交; 事务A 再次读取data, 发现data = 2000 了,与之前的读取不一致的
解决办法; 把数据库的事务隔离级别调整到 REPEATABLE READ , 读取时候不允许其他事务修改该数据,不管数据在事务过程中读取多少次,数据都是一致的,避免了不可重复读问题
4.4.3 幻读
幻读: 当某个事务在读取某个范围内的记录的时候,另外一个事务在这个范围内增加了一行,当前一个事务再次读取该范围的数据的时候就会发生幻行,. 针对的是数据的插入insert
解决方案 : 采用的是范围锁 RangeS RangeS_S模式,锁定检索范围为只读 或者 把数据库的事务隔离级别调整到SERIALIZABLE_READ, MySQL中InnoDB 和 XtraDB 利用(多版本并发控制)解决了幻读问题,
五、加锁协议
5.1 一次封锁协议
因为有大量的并发访问,为了预防死锁,一般应用中推荐使用一次封锁法,就是在方法的开始阶段,已经预先知道会用到哪些数据,然后全部锁住,在方法运行之后,再全部解锁。这种方式可以有效的避免循环死锁,但在数据库中却不适用,因为在事务开始阶段,数据库并不知道会用到哪些数据。
5.2 两段锁协议
将事务分成两个阶段,加锁阶段和解锁阶段(所以叫两段锁)
- 加锁阶段:在该阶段可以进行加锁操作。在对任何数据进行读操作之前要申请并获得S锁(共享锁,其它事务可以继续加共享锁,但不能加排它锁),在进行写操作之前要申请并获得X锁(排它锁(只有当前数据无共享锁,无排它锁之后才能获得),其它事务不能再获得任何锁)。加锁不成功,则事务进入等待状态,直到加锁成功才继续执行。
- 解锁阶段:当事务释放了一个封锁以后,事务进入解锁阶段,在该阶段只能进行解锁操作不能再进行加锁操作。
事务提交时(commit) 和事务回滚时(rollback)会自动的同时释放该事务所加的insert、update、delete对应的锁。
这种方式虽然无法避免死锁,但是两段锁协议可以保证事务的并发调度是串行化(串行化很重要,尤其是在数据恢复和备份的时候)的。
六、死锁
**指两个事务或者多个事务在同一资源上相互占用,并请求对方所占用的资源,从而造成恶性循环的现象。 **
6.1 出现死锁的原因:
- 系统资源不足
- 进程运行推进的顺序不当
- 资源分配不当
产生死锁的四个必要条件 - 互斥条件: 一个资源只能被一个进程使用
- 请求和保持条件:进行获得一定资源,又对其他资源发起了请求,但是其他资源被其他线程占用,请求阻塞,但是也不会释放自己占用的资源。
- 不可剥夺条件: 指进程所获得的资源,不可能被其他进程剥夺,只能自己释放
- 环路等待条件: 进程发生死锁,必然存在着进程-资源之间的环形链
处理死锁的方法: 预防,避免,检查,解除死锁
数据库也会发生死锁的现象,数据库系统实现了各种死锁检测和死锁超时机制来解除死锁,锁监视器进行死锁检测,MySQL的InnoDB处理死锁的方式是 将持有最少行级排它锁的事务进行回滚,相对比较简单的死锁回滚办法
6.2 如何避免死锁?
避免死锁的核心思想是:
系统对进程发出每一个资源申请进行动态检查,并根据检查结果决定是否分配资源,如果分配后系统可能发生死锁,则不予分配,否则予以分配.这是一种保证系统不进入不安全或者死锁状态的动态策略。 什么是不安全的状态?系统能按某种进程推进顺序( P1, P2, …, Pn),为每个进程Pi分配其所需资源,直至满足每个进程对资源的最大需求,使每个进程都可顺序地完成。此时称 P1, P2, …, Pn 为安全序列。如果系统无法找到一个安全序列,则称系统处于不安全状态。
其实第一和第二是预防死锁的方式,分别对应着的是破坏循环等待条件,和破坏不可剥夺条件。
- 第一: 加锁顺序: 对所有的资源加上序号,确保所有的线程都是按照相同的顺序获得锁,那么死锁就不会发生,比如有资源 A, B,规定所有的线程只能按照A–B的方式获取资源, 这样就不会发生 线程1持有A,请求B,线程2持有B请求A的死锁情况发生了
- 第二: 获取锁的时候加一个超时时间,这也就意味着在尝试获取锁的过程中若超过了这个时限该线程则放弃对该锁请求,同时放弃掉自己已经成功获得的所有资源的锁,然后等待一段随机的时间再重试。这段随机的等待时间让其它线程有机会尝试获取相同的这些锁,并且让该应用在没有获得锁的时候可以继续运行。
- 第三:死锁的提前检测, 很出名的就是银行家算法。 每当一个线程获得了锁,会存储在线程和锁相关的数据结构中(map、graph等等)将其记下。除此之外,每当有线程请求锁,也需要记录在这个数据结构中,当一个线程请求锁失败时,这个线程可以遍历锁的关系图看看是否有死锁发生。
银行家算法:
思想: 当进程首次申请资源时,要测试该进程对资源的最大需求量,如果系统现存的资源可以满足它的最大需求量则按当前的申请量分配资源,否则就推迟分配。当进程在执行中继续申请资源时,先测试该进程已占用的资源数与本次申请的资源数之和是否超过了该进程对资源的最大需求量。若超过则拒绝分配资源,若没有超过则再测试系统现存的资源能否满足该进程尚需的最大资源量,若能满足则按当前的申请量分配资源,否则也要推迟分配
6.3 如何预防死锁?
主要是通过设置某些外部条件去破坏死锁产生的四个必要条件中的一个或者几个。
- 破坏互斥条件,一般不采用,因为资源的互斥性这个特性有时候是我们所需要的;
- 破坏请求和保持条件:可以一次性为一个进程或者线程分配它所需要的全部资源,这样在后面就不会发起请求资源的情况,但是这样资源的效率利用率很低;
- 破坏不可剥夺条件: 当一个已保持了某些不可剥夺资源的进程,请求新的资源而得不到满足时,它必须释放已经保持的所有资源,待以后需要时再重新申请,但是释放已获得的资源可能造成前一阶段工作的失效,反复地申请和释放资源会增加系统开销,降低系统吞吐量;
- 破坏循环等待条件: 可釆用顺序资源分配法。首先给系统中的资源编号,规定每个进程,必须按编号递增的顺序请求资源,同类资源一次申请完。也就是说,只要进程提出申请分配资源Ri,则该进程在以后的资源申请中,只能申请编号大于Ri的资源。
但是这样的话,编号必须相对稳定,这就限制了新类型设备的增加;尽管在为资源编号时已考虑到大多数作业实际使用这些资源的顺序,但也经常会发生作业使甩资源的顺序与系统规定顺序不同的情况,造成资源的浪费;此外,这种按规定次序申请资源的方法,也必然会给用户的编程带来麻烦
七、InnoDB 中事务隔离性的实现:
READ COMMITED 和 REPEATABLE READ 的隔离性实现:MVCC
7.1 MVCC的实现
MVCC(多版本控制系统)的实现(目的: 实现更好的并发,可以使得大部分的读操作不用加锁, 但是insert,delete,update是需要加锁的):
MVCC 只在 READ COMMITED 和 REPEATABLE READ 这两个事务隔离性级别中使用。这是因为MVCC 和其他两个不兼容,READ UNCOMMITED 总是读取最新的行,不关事务, 而Seriablizable则会对每一个读都加共享锁。
7.2 InnoDB中的MVCC
在InnoDB中,会在每行数据后添加两个额外的隐藏的值来实现MVCC,这两个值一个记录这行数据何时被创建,另外一个记录这行数据何时过期(即何时被删除)。 在实际操作中,存储的并不是时间,而是系统的版本号,每开启一个新事务,系统的版本号就会递增。
通过MVCC,虽然每行记录都需要额外的存储空间,更多的行检查工作以及一些额外的维护工作,但可以减少锁的使用,大多数读操作都不用加锁,读数据操作很简单,性能很好,并且也能保证只会读取到符合标准的行,也只锁住必要行。
select (不加锁): 满足两个条件的结果才会被返回:
- 创建版本号<= 当前事务版本号,小于意味着在该事务之前没有其他事务对其进行修改,等于意味着事务自身对其进行了修改;
- 删除版本号 > 当前事务版本号 意味着删除操作是在当前事务之后进行的,或者删除版本未定义,意味着这一行只是进行了插入,还没有删除过。
INSERT ; 为新插入的每一行保存当前事务的版本号作为创建版本号
DELETE ; 为删除的行保存当前事务的版本号为删除版本号
UPDATE; 为修改的每一行保存当前事务的版本号作为创建版本号
7.3 “快照读”与“当前读”区别
MySQL中的读,和事务隔离级别中的读,是不一样的, 在REPEATABLE READ 级别中,通过MVCC机制,虽然让数据变得可重复读,但我们读到的数据可能是历史数据,是不及时的数据(存储在缓存等地方的数据),不是数据库当前的数据!这在一些对于数据的时效特别敏感的业务中,就很可能出问题。
- 快照读 (snapshot read):读取历史数据(缓存数据)的方式。
- 当前读 (current read):读取数据库当前版本数据的方式。
在MVCC中:
-
快照读就是select ,是不加锁的, 在REPEATABLE READ 和READ COMMITED 级别中 select语句不加锁。
select * from table ….; -
当前读:插入/更新/删除操作,属于当前读,处理的都是当前的数据,需要加锁。
select * from table where ? lock in share mode; select * from table where ? for update; insert; update ; delete;
事务的隔离级别实际上都是定义了当前读的级别,MySQL为了减少锁处理(包括等待其它锁)的时间,提升并发能力,引入了快照读的概念,使得select不用加锁。而update、insert这些“当前读”,就需要另外的模块来解决了。(这是因为update、insert的时候肯定要读取数据库中的值来与当前事务要写入的值进行对比,看看在该事务所处理的数据在这一段时间内有没有被其他事务所操作(就是先读取数据库中数据的版本号与当前的版本号做检查))
为了解决当前读中的幻读问题,MySQL事务使用了Next-Key锁。Next-Key锁是行锁和GAP(间隙锁)的合并 GAP(间隙锁)就是在两个数据行之间进行加锁,防止插入操作
行锁防止别的事务修改或删除,解决了数据不可重复读的问题,GAP锁防止别的事务新增,行锁和GAP锁结合形成的的Next-Key锁共同解决了RR级别在读数据时的幻读问题
7.4 InnoDB 中 Serializable 的隔离性实现
Serializable级别使用的是悲观锁的理论, 读加共享锁,写加排他锁,读写互斥, 在Serializable这个级别,select语句还是会加锁的。
https://blog.csdn.net/woshiluoye9/article/details/68954515