在操作数据库的时候,可能会由于并发问题而引起的数据的不一致性(数据冲突)。
MySQL事务隔离级别
一个事务的执行,本质上就是一条工作线程在执行,当出现多个事务同时执行时,这种情况则被称之为并发事务,所谓的并发事务也就是指多条线程并发执行。
多线程并发执行自然就会出问题,也就是脏写、脏读、不可重复读及幻读问题。而对于这些问题又可以通过调整事务的隔离级别来避免,那为什么调整事务的隔离级别后能避免这些问题产生呢?这是因为不同的隔离级别中,工作线程执行SQL语句时,用的锁粒度、类型不同。
并发事务会带来哪些问题?
-
1、脏读:事务A读取了事务B更新的数据,然后B回滚操作,那么A读取到的数据是脏数据
-
2、不可重复读:事务 A 多次读取同一数据,事务 B 在事务A多次读取的过程中,对数据作了更新并提交,导致事务A多次读取同一数据时,结果 不一致。
-
3、幻读:系统管理员A将数据库中所有学生的成绩从具体分数改为ABCDE等级,但是系统管理员B就在这个时候插入了一条具体分数的记录,当系统管理员A改结束后发现还有一条记录没有改过来,就好像发生了幻觉一样,这就叫幻读。
总结:不可重复读的和幻读很容易混淆,不可重复读侧重于修改,幻读侧重于新增或删除。解决不可重复读的问题只需锁住满足条件的行,解决幻读需要锁表
在博客中间部分mysql事务隔离级别例子讲得很详细
用例子说明各个隔离级别的情况
1、读未提交:
- (1)打开一个客户端A,并设置当前事务模式为read uncommitted(未提交读),查询表account的初始值
- (2)在客户端A的事务提交之前,打开另一个客户端B,更新表account400-50=350
- (3)这时,虽然客户端B的事务还没提交,但是客户端A就可以查询到B已经更新的数据
- (4)一旦客户端B的事务因为某种原因回滚,所有的操作都将会被撤销,那客户端A查询到的数据其实就是脏数据
2、读已提交
- (1)打开一个客户端A,并设置当前事务模式为read committed(不可重复读),查询表account的所有记录
- (2)在客户端A的事务提交之前,打开另一个客户端B,更新表account
- (3)这时,客户端B的事务还没提交,客户端A不能查询到B已经更新的数据,解决了脏读问题
- (4)客户端B的事务提交commit
- (5)客户端A执行与上一步相同的查询,结果 与上一步不一致,即产生了不可重复读的问题
3、可重复读
- (1)打开一个客户端A,并设置当前事务模式为repeatable read,查询表account的所有记录400
- (2)在客户端A的事务提交之前,打开另一个客户端B,更新表account400-50=350
并提交
- (3)在客户端A查询表account的所有记录,与步骤(1)查询结果400一致,没有出现不可重复读的问题
- (4)在客户端A,接着执行update balance = balance - 50 where id = 1,balance没有变成400-50=350,这里的balance值用的是步骤2中的350来算的,所以是300,数据的一致性倒是没有被破坏。可重复读的隔离级别下使用了MVCC机制,
select操作不会更新版本号,是快照读(历史版本);insert、update和delete会更新版本号,是当前读(当前版本)
。 - (5)重新打开客户端B,插入一条新数据后提交
- (6)在客户端A查询表account的所有记录,没有 查出 新增数据,所以没有出现幻读(??不是会幻读吗??)
4.串行化(serializable)
- (1)打开一个客户端A,并设置当前事务模式为serializable,查询表account的初始值:
- (2)打开一个客户端B,并设置当前事务模式为serializable,插入一条记录报错,表被锁了插入失败,mysql中事务隔离级别为serializable时会锁表,因此不会出现幻读的情况,这种隔离级别并发性极低,开发中很少会用到。
补充:
- 1、事务隔离级别为读提交时,写数据只会锁住相应的行
- 2、事务隔离级别为可重复读时,如果检索条件有索引(包括主键索引)的时候,默认加锁方式是next-key 锁;如果检索条件没有索引,更新数据时会锁住整张表。一个间隙被事务加了锁,其他事务是不能在这个间隙插入记录的,这样可以防止幻读。
- 3、事务隔离级别为串行化时,读写数据都会锁住整张表
- 4、隔离级别越高,越能保证数据的完整性和一致性,但是对并发性能的影响也越大。
MySQL的锁体系:
这篇讲得超详细——MySQL锁、加锁机制(超详细)
以锁粒度的维度划分
全局锁:锁定数据库中的所有表。加上全局锁之后,整个数据库只能允许读,不允许做任何写操作
表级锁:每次操作锁住整张表。主要分为三类
表锁(分为表共享读锁 read lock、表独占写锁 write lock)
元数据锁(meta data lock,MDL):基于表的元数据加锁,加锁后整张表不允许其他事务操作。这里的元数据可以简单理解为一张表的表结构
意向锁(分为意向共享锁、意向排他锁):这个是InnoDB中为了支持多粒度的锁,为了兼容行锁、表锁而设计的,使得表锁不用检查每行数据是否加锁,使用意向锁来减少表锁的检查
行级锁:每次操作锁住对应的行数据。主要分为三类
记录锁 / Record 锁:也就是行锁,一条记录和一行数据是同一个意思。防止其他事务对此行进行update和delete,在 RC、RR隔离级别下都支持
间隙锁 / Gap 锁:锁定索引记录间隙(不含该记录),确保索引记录间隙不变,防止其他事务在这个间隙进行insert,产生幻读。在RR隔离级别下都支持
临键锁 / Next-Key 锁:间隙锁的升级版,同时具备记录锁+间隙锁的功能,在RR隔离级别下支持
以互斥性的角度划分
共享锁 / S锁:不同事务之间不会相互排斥、可以同时获取的锁
排他锁 / X锁:不同事务之间会相互排斥、同时只能允许一个事务获取的锁
共享排他锁 / SX锁:MySQL5.7版本中新引入的锁,主要是解决SMO带来的问题
以操作类型的维度划分
读锁:查询数据时使用的锁
写锁:执行插入、删除、修改、DDL语句时使用的锁
以加锁方式的维度划分
显示锁:编写SQL语句时,手动指定加锁的粒度
隐式锁:执行SQL语句时,根据隔离级别自动为SQL操作加锁
以思想的维度划分
乐观锁:每次执行前认为自己会成功,因此先尝试执行,失败时再获取锁
悲观锁:每次执行前都认为自己无法成功,因此会先获取锁,然后再执行
放眼望下来,是不是看着还蛮多的,但总归说来说去其实就共享锁、排他锁两种,只是加的方式不同、加的地方不同,因此就演化出了这么多锁的称呼。
1.共享锁【S锁】
数据库自带的锁。
又称读锁,若事务T对数据对象A加上S锁,则事务T可以读A但不能修改A,其他事务只能再对A加S锁,而不能加X锁,直到T释放A上的S锁。这保证了其他事务可以读A,但在T释放A上的S锁之前不能对A做任何修改。
上共享锁的写法:lock in share mode
例如: select math from zje where math>60 lock in share mode;
2.排他锁【X锁】
数据库自带的锁。
又称写锁。若事务T对数据对象A加上X锁,事务T可以读A也可以修改A,其他事务不能再对A加任何锁,直到T释放A上的锁。
确保不会同时同一资源进行多重更新。
如果事务T对数据A加上排他锁后,则其他事务不能再对A加任任何类型的封锁。获准排他锁的事务既能读数据,又能修改数据。
上排它锁的写法:for update
例如:select math from zje where math >60 for update;
对比
- 共享锁就是允许多个线程同时获取一个锁,一个锁可以同时被多个线程拥有。
- 排它锁,也称作独占锁,一个锁在某一时刻只能被一个线程占有,其它线程必须等待锁被释放之后才可能获取到锁。
排它锁和共享锁实例
ReentrantLock就是一种排它锁。CountDownLatch是一种共享锁。这两类都是单纯的一类,即,要么是排它锁,要么是共享锁。 ReentrantReadWriteLock是同时包含排它锁和共享锁特性的一种锁,这里主要以ReentrantReadWriteLock为例来进行分析学习。我们使用ReentrantReadWriteLock的写锁时,使用的便是排它锁的特性;使用ReentrantReadWriteLock的读锁时,使用的便是共享锁的特性。
3.乐观锁
乐观锁不是数据库自带的,需要我们自己去实现。乐观锁是指操作数据库时(更新操作),想法很乐观,认为这次的操作不会导致冲突,在操作数据时,并不进行任何其他的特殊处理(也就是不加锁),而在进行更新后,再去判断是否有冲突。
使用乐观锁需修改数据库的事务隔离级别:
使用乐观锁的时候,如果一个事务修改了库存并提交了事务,那其他的事务应该可以读取到修改后的数据值,所以不能使用可重复读的隔离级别,应该修改为读取已提交(Read committed
)。
(1)版本号法实现乐观锁
在商品表中增加一个版本号字段,每次更新库存时,都会携带这个版本号。如果版本号没有发生变化,说明在此期间没有其他线程修改过数据,更新操作可以进行;如果版本号发生了变化,则说明有其他线程已经修改过数据,此时需要重新获取最新的数据并尝试再次更新。
"UPDATE goods SET stock = stock - 1, version = version + 1 WHERE id = #{id} AND stock > 0 AND version = #{version}
//当商品的库存大于0且版本号与传入的版本号相同时,将库存减1,并将版本号加1。
(2)CAS法实现乐观锁
CAS用库存量代替版本号,在执行操作时判断 where 用的库存量和在最初查询的时候,是否相等。
加乐观锁:先比较再更新,更新的时候判断**此时的库存是否是之前查询出的库存,**如果相同,表示没人修改,可以更新库存,否则表示别人抢过资源,不再执行库存更新。
CAS
CAS 的全称是 Compare And Swap(比较与交换) ,用于实现乐观锁,被广泛应用于各大框架中。CAS 的思想很简单,就是用一个预期值和要更新的变量值进行比较,两值相等才会进行更新。
CAS 是一个原子操作,底层依赖于一条 CPU 的原子指令。
原子操作 即最小不可拆分的操作,也就是说操作一旦开始,就不能被打断,直到操作完成。
- CAS的自旋:
自旋: 就是不停的判断比较,看能否将值交换
在CAS操作中,自旋的概念指的是当线程发现无法立即完成操作时,不会让出CPU时间片,而是继续循环尝试,直到成功为止。这种机制可以避免线程频繁地挂起和恢复,减少了线程切换的开销,提高了效率。自旋锁通常适用于锁被占用的时间较短的场景,因为长时间的自旋会导致CPU资源的浪费。
总的来说,CAS的自旋是通过不断循环尝试来实现的一种锁优化机制,它在多线程编程中用于保证操作的原子性和提高性能。
- CAS/乐观锁存在的问题(ABA):
CAS 三大问题: - (1)ABA问题。CAS需要在操作值的时候检查内存值是否发生变化,没有发生变化才会更新内存值。但是如果内存值原来是A,后来变成了B,然后又变成了A,那么CAS进行检查时会发现值没有发生变化,但是实际上是有变化的。ABA问题的解决思路就是在变量前面添加版本号,每次变量更新的时候都把版本号加一,这样变化过程就从A-B-A变成了1A-2B-3A。JDK从1.5开始提供了AtomicStampedReference类来解决ABA问题,原子更新带有版本号的引用类型。
- (2)循环时间长开销大。CAS操作如果长时间不成功,会导致其一直自旋,给CPU带来非常大的开销。
只能保证一个共享变量的原子操作。对一个共享变量执行操作时,CAS能够保证原子操作,但是对多个共享变量操作时,CAS是无法保证操作的原子性的。
乐观锁在高并发场景下的问题:
乐观锁在高并发场景下的问题,是严重的空自旋
自旋: 就是不停的判断比较,看能否将值交换
4.悲观锁
悲观锁就是在操作数据时,认为此操作会出现数据冲突,所以在进行每次操作时都要通过获取锁才能进行对相同数据的操作,这点跟java中的synchronized很相似,所以悲观锁需要耗费较多的时间。另外与乐观锁相对应的,悲观锁是由数据库自己实现了的,要用的时候,我们直接调用数据库的相关语句就可以了。
适用场景:
- 悲观锁适合写操作多的场景。
- 乐观锁适合读操作多的场景,不加锁可以提升读操作的性能。
当查询某条记录时,即让数据库为该记录加锁,锁住记录后别人无法操作,使用类似如下语法:
select for update,提前加上写锁
beginTranse(开启事务)
try{
query('select amount from s_store where goodID = 12345 for update');//加上写锁
if(库存 > 0){
//quantity为请求减掉的库存数量
query('update s_store set amount = amount - quantity where goodID = 12345');
}
}catch( Exception e ){
rollBack(回滚)
}
commit(提交事务)
5.行锁
行锁,由字面意思理解,就是给某一行加上锁,也就是一条记录加上锁。
6.表锁
对表操作的,所以自然锁住全表的表锁就不会出现死锁。但是表锁效率低。