前言
本篇文章主要讲解两块内容:Mysql中的锁和ACID原理,这2个部分是面试的时候被问的蛮多的看完本篇文章之后相信你对Mysql事务会有更深层次的理解,如果文章对你有所帮助请记得好评
一.Mysql中的锁
1.锁的分类
在Mysql中锁也分为很多种,按照不同的维度有不同的分法:
- 从性能上分为(或者按照加锁和不加锁):乐观锁和悲观锁
- 按照对数据的读写操作分为:读锁和写锁(都属于悲观锁)
- 按照锁的范围粒度分为:表锁、页锁、行锁
下面我们一一理解这些说的含义和用途。
2.乐观锁和悲观锁
悲观锁和乐观锁是按照加锁机制进行分类的,是一种设计理念,并不是具体的某把锁,乐观锁和悲观锁不仅适用于数据库也适用于Java应用中或其他中间件比如:ElasticSearch也用到乐观锁。
悲观锁
悲观锁的设计理念是悲观的,认为总是有线程并发问题导致数据不安全,所以在整个数据处理过程中,将数据处于锁定状态,数据处理完成后释放锁(一般是事务提交释放锁)
。悲观锁的实现,往往依靠数据库提供的锁机制。通常是在 select语句后面增加 for update
来锁定数据。其实对于synchronized,Lock等锁也是悲观锁的一种。下面是在Java中的用法,以修改Account为案例
1. 查询User,通过 select id,amount from Account where id = 1 for update 加锁
2. 在业务代码中修改amount的值
3. 执行update,完成事务提交了
上面的代码在第一步的时候id =1 的这行数据就会被上锁,因为我们增加了for update
,直到第三步事务提交完成才会释放锁,也就意味着如果有2个线程同时执行上面的代码,只有1个线程能执行,第二个线程会阻塞在第一步进行等锁。等第一个线程释放锁后,第二个线程才能继续执行。这样就保证了2个线程安全执行。 这个效果就和使用synchronized锁代码块类似。
乐观锁
乐观锁是相对悲观锁而言的,乐观锁假设数据一般情况下不会造成冲突,在数据处理的过程中会不加锁,在数据进行提交更新的时候(update更新到数据库的时候),才会正式对数据的冲突与否进行检测,如果发现冲突了就进行重试或者抛出异常等
。在数据库中通使用version版本号,或者时间戳,或者UUID来实现乐观锁,AtomicInteger等原子类,ElasticSearch也是使用乐观锁来控制并发修改。
下面我们举例来说明,现有2个线程修改同一个账户,都对账户金额+100,只是时间点一前一后,如图:
上图出现了明显的问题,A线程基于100+100,把数据库修改为200,B线程比A线程晚一步,但是也是基于100+100,又把数据库修改为200,正确的结果应该是300,但是实际上是200,这就是事务并发修改出现的丢失更新
问题,B的更新把A的更新给覆盖掉了。
解决这个问题我们可以使用悲观锁,在查询账户的SQL后面增加for update
,这样的话A线程的事务没提交之前,B事务都会阻塞在 查询账户等锁,等A线程提交事务,数据库修改为200后,B现成再查下就是以200为基础+100,结果是正确的。 但是这种场景最好的解决方案是乐观锁,性能更优。
乐观锁通常就是通过一个:Version 版本号来判断是否产生事务并发修改,我们来给account表增加一个version字段,每次查询的时候需要把 version查询出俩,每次update的时候需要判断version版本号是否一致,如下
当我们在执行update的时候需要加上条件 version = #{version}
来比较版本号,如果:B线程从 查询 到 修改 这之间,A线程修改了该条数据,那么数据库版本号会被+1,B线程在执行update的时候version会出现不匹配的情况。那么这个时候我们直到可以能出现了并发修改问题了,我们可以通过接受到update的返回值,如果发现是 0 ,就抛出异常,或者进行重试等操作,这样就可以避免覆盖更新了。
悲观锁是在业务一开始就加锁,业务处理完成之后,释放锁,拿Mysql来说当执行加了 for update 的查询语句时就加锁,业务处理完成,事务提交就释放锁。而乐观锁是不加锁,只是最后做数据同步的时候,判断该条数据时候被别的线程修改过,来决定要不要执行当前业操作。 所以一个是加锁,一个是不加锁的,乐观锁的性能是更高的除此之外还可以通过UUID,时间戳等方式来实现乐观锁。
3.读锁和写锁
在Mysql中按数据的读写操作分为:共享锁(读锁)和排它锁(写锁),共享锁意味着多个事务可以获取到同一把锁,通常是作用于读操作。而排它锁是相互排斥的,只可能有一个事务可以获取排它锁,通常是发生在写操作上。
读锁
读锁也叫共享锁(share Lock):又称(S锁),允许多个事务同时获得该锁,也就是允许多个事务同时读一条数据。当一个事务为数据加上读锁之后,其他事务只能对该数据加读锁,而不能对数据加写锁,直到所有的读锁释放之后其他事务才能对其进行加持写锁
。共享锁的特性主要是为了支持[并发]的读取数据,读取数据的时候不支持修改,避免出现重复读的问题。在select语句末尾加上lock in share mode关键字即可加共享锁
。
写锁
写锁也叫排他锁(exclusive Lock)又称(X锁),当一个事务为数据加上写锁时,其他请求将不能再为数据加任何锁,直到该锁释放之后,其他事务才能对数据进行加锁
。排他锁的目的是在数据修改时候,不允许其他人同时修改
,也不允许其他人读取,避免了出现脏数据和脏读的问题
。 Mysql在执行写操作时默认会加上排它锁,或者我们可以在select语句末尾加上for update
关键字来实现排它锁。
对于读锁和写锁的兼容性你只需要记住:只要有写锁出现,一定是相互排斥的,需要等锁。都是读锁则相互兼容
锁 | 读锁 | 写锁 |
---|---|---|
读锁 | Y | N |
写锁 | N | N |
意向锁
意向锁(Intention Lock):又称I锁,针对表锁,主要是为了提高加表锁的效率,是mysql数据库自己加的。当有事务给表的数据行加了共享锁或排他锁,同时会给表设置一个标识,代表已经有行锁了
,其他事务要想对表加表锁时,就不必逐行判断有没有行锁可能跟表锁冲突了,直接读这个标识就可以确定自己该不该加表锁。特别是表中的记录很多时,逐行判断加表锁的方式效率很低。而这个标识就是意向锁。
意向锁主要分为:
- 意向共享锁,IS锁,对整个表加共享锁之前,需要先获取到意向共享锁。
- 意向排他锁,IX锁,对整个表加排他锁之前,需要先获取到意向排他锁。
4.表锁页锁行锁
表锁
表锁(table lock)是指上锁的时候锁住的是整个表
,当下一个事务访问该表的时候,必须等前一个事务释放了锁才能进行对表进行访问;特点:粒度大,加锁简单,容易冲突;一般用在整表数据迁移的场景,业务中一般不会锁表,加锁和释放锁如下
LOCK TABLES table_name [READ | WRITE];
-- 在这里执行查询操作
UNLOCK TABLES;
页锁
页锁就是在页的粒度上进行锁定,页级锁是MysQL中锁定粒度介于行级锁和表级锁中间的一种锁
.表级锁速度快,但冲突多,行级冲突少,但速度慢。所以取了折衷的页级
,一次锁定相邻的一组记录
。特点:开销和加锁时间界于表锁和行锁之间,会出现死锁;锁定粒度界于表锁和行锁之间,并发度一般。只有BDB存储引擎支持页锁
行锁
行锁是指上锁的时候锁住的是表的某一行或多行记录
,其他事务访问同一张表时,只有被锁住的记录不能访问,其他的记录可正常访问,特点:粒度小,加锁比表锁麻烦,不容易冲突,相比表锁支持的并发要高
InnoDB的行锁实际上是针对索引加的锁(在索引对应的索引项上做标记),不是针对整个行记录加的锁。并且该索引不能失效,否则会从行锁升级为表锁比如:select * from t_user name name = zs for update
,如果name字段没有索引,那么在可重复读(RR)的隔离级别下Mysql会把整个表锁住。因为行锁扫描的是索引,在RR隔离级别下为了防止当前事务扫描过的索引被其他事务修改(会出现不可重复读问题),或者间隙被其他事务插入数据(会出现幻读问题),所以Mysql会把扫描过的索引记录和间隙都上锁。
间隙锁
是属于行锁的一种
,间隙锁是在事务加锁后其锁住的是表记录的某一个区间
,当表的相邻ID之间出现空隙则会形成一个区间,遵循左开右闭原则。间隙锁只会出现在REPEATABLE_READ(重复读)的事务级别中。间隙锁本质上是用于阻止其他事务在该间隙内插入新记录,而自身事务是允许在该间隙内插入数据的,事务并发会产生幻读问题,在RR隔离级别中,Mysql通过间隙锁来解决幻读问题
。下面是一个间隙锁的案例:假如数据库有数据ID为: 1,2,3,8,10 ; 那么 3-8,8-10 ,10-正无穷 三个间隙,当我们执行
select * from 表 where id = 5 for update
那么 3-8这个区间就会被锁住,其他事务是无法插入向3-8这个范围插入数据的。其他区间也是一样的。
也就是说,只要在间隙范围内锁了一条不存在的记录会锁住整个间隙范围,不锁边界记录,这样就能防止其它Session在这个间隙范围内插入数据,就解决了可重复读隔离级别的幻读问题。
临键锁(Next-key Locks)
(Next-Key lock):也属于行锁的一种
,并且它是INNODB的行锁默认算法,总结来说它就是记录锁和间隙锁的组合
,临键锁会把查询出来的记录锁住
,同时也会把该范围查询内的所有间隙空间也会锁住,再之它会把相邻的下一个区间也会锁住。如果你使用select … in share mode或者select … for update语句,那么InnoDB会使用临键锁,因而可以防止幻读。
临键锁 :锁住的是前面间隙+行 ,比如:select * from table where id >= 2 for update ,那么除了会把2-5之间加上间隙锁,还会多锁住一行2。
MyISAM在执行查询语句SELECT前,会自动给涉及的所有表加读锁,在执行update、insert、delete操作会自动给涉及的表加写锁
。
InnoDB在执行查询语句SELECT时(非串行隔离级别),不会加锁。但是update、insert、delete操作会加行锁。
另外,读锁会阻塞写,但是不会阻塞读。而写锁则会把读和写都阻塞,Innodb存储引擎由于实现了行级锁定,虽然在锁定机制的实现方面所带来的性能损耗可能比表级锁定会要更高一下,但是在整体并发处理能力方面要远远优于MYISAM的表级锁定的。当系统并发量高的时候,Innodb的整体性能和MYISAM相比就会有比较明显的优势了
6.锁超时
另外:当某个线程操作一个条被别的线程加了锁的数据时,等待时间过长就会出现:Lock wait timeout exceeded
异常,默认情况下:InnoDB的锁获取时间是50s,可以通过SHOW VARIABLES LIKE 'innodb_lock_wait_timeout';
命令来查看超时时间,遇到这种问题的决绝思路是:
- 分析并解决死锁:死锁是另一种导致锁等待超时的原因,检查系统日志并找出导致死锁的查询,然后修改它们以避免死锁。另外可以通过
show PROCESSLIST
显示进程列表来分析SQL锁住的原因 - 优化查询:检查导致锁等待超时的查询语句,并优化它们以减少执行时间。
- 增加超时时间:如果查询优化不能解决问题,可以考虑增加innodb_lock_wait_timeout的值。这可以通过在MySQL配置文件中设置或者在会话级别动态设置。
- 硬件资源:如果查询优化和超时时间增加都无法解决问题,可能需要考虑增加服务器的硬件资源,如CPU或内存,以提高数据库的并发处理能力。
二.事务的四大特性
1.什么是事务
事务: 指作为单个逻辑工作单元(Service方法)执行的一系列操作(数据库操作。),要么完全地执行,要么完全地不执行.事务可以看做是一组任务,通常对应了一个业务方法,这些任务要么全部成功,要么全部失败。在实际的业务开发中我们通常在Service层通过@Transcational注解来指定事务。我们要知道的是Spring只是帮我们管理事务,它对事务的操作也是调用数据库的事务指令来完成的。
2.事务的四大特性
事务具有原子性(Atomicity)、一致性(Consistency)、隔离性(Isolation)、持久性(Durability)四个特性,简称 ACID,缺一不可。
原子性 Atomicity :
把一组操作(一个事务)看成一个整体,这一组操作要么全部成功,或者全部失败,不可分割。比如一个service方法中对数据库做了2次写操作,那么这2个操作需要都成功,一旦某一个操作失败就必须要回顾。
Consistency 一致性
一致性要求任何写到数据库的数据都必须满足于预先定义的规则(数据不能出错),简单来说就是在任何时间点都不能出现违反一致性要求的状态。比如:A账户扣减500,B账户增加500,如果A口扣减成功,B并未增加成功,那么数据就会出问题,和我们预先定义的规则不一致。
Durability 持久性
持久性的关键在于一旦“完成提交”(committed),那么数据就不会丢失,即使Mysql重启或者宕机数据依然不会丢失,因为数据已经被持久化到磁盘。
Isolation 隔离性
多个事务之间是相互隔离的,如果多个事务同时执行产生了影响就可能会出现各种并发问题,比如:脏读,幻读等,针对于不同的并发问题可以通过不同的事务隔离级别来解决。
3. 原子性实现原理
Mysql的原子性是利用Innodb的undo log来实现,
undo log名为回滚日志,是实现原子性的关键,当事务回滚时能够撤销所有已经成功执行的sql语句,他需要记录你要回滚的相应日志信息。 例如
-
当你delete一条数据的时候,就需要记录这条数据的信息,回滚的时候,insert这条旧数据
-
当你update一条数据的时候,就需要记录之前的旧值,回滚的时候,根据旧值执行update操作
-
当年insert一条数据的时候,就需要这条记录的主键,回滚的时候,根据主键执行delete操作
undo log记录了这些回滚需要的信息,当事务执行失败或调用了rollback,导致事务需要回滚,便可以利用undo log中的信息将数据回滚到修改之前的样子
。
4.持久性的实现原理
Mysql通过Redo Log实现持久性
,Redo Log记录的是新数据的备份。在事务提交前,先将Redo Log持久化,当系统崩溃时,虽然数据没有持久化,但是Redo Log已经持久化
。系统可以根据Redo Log的内容,将所有数据恢复到最新的状态。至于数据会异步方式刷盘到Mysql磁盘文件。
采用redo log的好处?
redo log进行刷盘比对数据页刷盘效率高,具体表现如下
-
redo log体积小,毕竟只记录了哪一页修改了啥,因此
体积小,刷盘快
。 -
mysql的数据页在磁盘上是随机的,而
Redo log是一直往末尾进行追加,属于顺序IO。效率显然比随机IO来的快
。
redo log它由两部分组成,内存中的 redo log buffer,磁盘上的 redo log file
1.redo log file 由一组文件组成,当写满了会循环覆盖较旧的日志,这意味着不能无限依赖 redo log,更早的数据恢复需要 binlog
2.buffer 和 file 两部分组成意味着,写入了文件才真正安全,同步策略由参数innodb_flush_log_at_trx_commit 控制
- 0 - 每隔 1s 将日志 write and flush 到磁盘
- 1 - 每次事务提交将日志 write and flush(默认值)
- 2 - 每次事务提交将日志 write,每隔 1s flush 到磁盘,意味着 write 意味着写入操作系统缓存,如果 MySQL 挂了,而操作系统没挂,那么数据不会丢失
关于UndoLog 和 RedoLog 请看另一篇文章《探秘MySQL底层架构:设计与实现流 程一览》
三.事务的并发问题
1.事务并发和隔离级别
在实际项目中,事务并发问题难以避免,而事务并发会产生各种各样的问题,如:脏读,幻读,不可重复读,丢失更新
等
- 脏读:事务B读到了事务A未提交的数据,事务A回滚,事务B读到的数据就是脏数据
- 不可重复读:在同一个事务中对同一个数据读多次读到的结果不一致,通常是因为两次读之间其他事务修改了该数据
- 幻读 :事务B以相同的条件读多次数据读到的条数不一致,通常是因为在多次读之间别的事务insert了数据导致多次读到相同条件的数据条数不一样
- 丢失更新 :事务并发写的情况B事务的update覆盖了A事务的update,这叫覆盖丢失更新,还有一种是数据回滚丢失更新。
MyQL 标准定义了四种隔离级别,MySQL 全都支持。这四种隔离级别分别是:
-
读未提交(READ UNCOMMITTED) : 简称 RU ,可以读到别的事务未提交的数据
-
读提交 (READ COMMITTED) :简称RC ,可以读到别的事务提交了的数据,不能读到未提交的数据
-
可重复读 (REPEATABLE READ):简称 RR ,同一个事务中多次读取同一个数据结果应该是一致的。
-
串行化 (SERIALIZABLE):加锁机制让事务串行执行,绝对安全,事务相互不会有任何影响
这四种隔离级别约束了事务与事务之间的影响程度,不同的隔离级别解决了不同的事务并发问题。在不同的业务场景中我们需要做选择,Mysql 默认的事务隔离级别是RR可重复读
。
2.脏读
脏读就是读到了脏数据,有可能是因为某事务读到了其他事务未提交的数据
,未提交意味着这些数据可能会回滚
,也就是可能最终不会存到数据库中,也就是不存在的数据。读到了不一定最终存在的数据,这就是脏读。如果事务的隔离级别是 set global transaction isolation level read uncommitted
RU读未提交就会产生脏读,下面用一个图理解脏读
解释:读未提交就是可以读到别的事务中未提交的事务,事务B查询select * from user
把事务A未提交的数据 age = 10
查询出来了,一旦事务A回滚,那么age = 10
就是脏数据,在数据库中根本不存在。
要解决脏读需要把事务隔离级别提升为 set global transaction isolation level read committed;
RC 读提交,这意味着事务B只能读到事务A已经提交的数据,不能读到未提交的数据,但是RC这种隔离级别会产生 不可重复读或幻读的问题
3.不可重复读
可重复读指的是在一个事务内,最开始读到的数据和事务结束前的任意时刻读到的同一批数据都是一致的,那不可重复读就是一个事务多次读取到的数据是不一致的
,通常是因为事务B在做同一个数据多次读取之前,别的事务修改了该数据导致的,RU,RC 隔离级别下都有可能产生不可重复读问题。
上图中事务B就产生了不可重复读,在两次 select * from user
之间事务A修改了数据,导致在事务B中,2次读到的数据不一致,可以通过设置隔离级别为 set global transaction isolation level repeatable read;
RR来解决不可重复读问题。这也是Mysql默认的事务隔离策略,但是该事务隔离级别(RR)没办法解决幻读问题
重要
然而:在Mysql-InnoDB的RR事务隔离级别下其实是不会产生幻读的,在之前讲锁的部分我们说过,因为Mysql通过间隙锁解决了幻读问题
。它通过锁住当前查询数据的间隙,不允许别的事务往这个间隙中insert数据,那自然就能解决幻读问题了。
4.幻读
幻读和不可重复读有点类似,前者是读到了别的事务修改的数据,后置是读到了别的事务插入的数据。比如:事务A 按照一定条件进行数据读取, 期间事务B 插入了相同搜索条件的新数据,事务A再次按照原先条件进行读取时,在事务A中发现了事务B 新插入的数据 称为幻读
, 即:事务A两次读取相同条件的数据读到的条数不一样
下面理解一下幻读
在事务A第二次执行select * from user
的时候,把事务B insert的数据也读取出来了,这就是幻读。2次以相同的条件读取一批数据读到的数量不一致,要解决幻读可以把事务隔离级别设置为:串行化 (SERIALIZABLE),这种隔离级别是最安全的,它通过加锁让多个事务以串行的方式执行,然而性能也是最差的,一般不用。
5.丢失更新
脏读,幻读,不可重复读都是发生在一个事务读,另外一个事务写的情况下,而丢失更新发生在两个事务写的情况下。丢失更新分为一类丢失更新(回滚丢失更新),二类丢失更新(覆盖丢失更新),上面在讲解乐观锁的时候已经讲解过覆盖丢失更新(二类丢失更新)问题,解决方案:悲观锁,乐观锁
都可以。
丢失更新还有一个种情况,回滚丢失更新(一类丢失更新),我们以甲乙两人同时操作同一账户里的钱来举这个例子:
时刻 | 甲 | 乙 |
---|---|---|
t1 | 查询到账户余额为100元 | |
t2 | 查询到账户余额为100元 | |
t3 | 花费10块钱买了个棒棒糖 | |
t4 | 提交,账户余额为90元 | 花费20块买了个铁锤 |
t5 | 后悔了,取消订单,事务回滚,账户余额为100元 |
以上的例子很容易解释清楚第一类丢失更新,也就是 A事务回滚时,把已经提交的B事务的更新数据覆盖了。但是这种丢失更新已经被数据库消灭掉了, 我们在使用的过程中是不会遇到了。通过设置隔离级别可以防止 Repeatable Read
6.总结
下面我们用一个表格来总结一下事务并发问题和隔离级别的关系
隔离级别 | 脏读 | 不可重复读 | 幻读 |
---|---|---|---|
读未提交(Read uncommitted) | 可能 | 可能 | 可能 |
读提交(Read committed) | 不可能 | 可能 | 可能 |
可重复读(Repeatable reads) | 不可能 | 不可能 | 可能 |
串行化(Serializable) | 不可能 | 不可能 | 不可能 |
对于读未提交而言什么安全措施都没做,可能会出现各种问题,一般不用,对于串行化是直接加锁让事务串行执行,性能比较低不建议用,多数情况下我们使用Mysql默认的隔离级别即可。
四.事务隔离级别MVCC
1.什么是MVCC
事务隔离级别利用-MVCC机制实现,MVCC指的是多版本并发控制
”(Multi Version Concurrency Control)。大致含义是为一个行记录数据生成多个版本的快照数据,这些快照数据在undo log中
。 如果一个事务读取的行正在做DELELE或者UPDATE操作,读取操作不会等行上的锁释放,而是读取该行的快照版本
。这样做的优势是可以读写分离,减少锁的冲突,大大提高了数据库的并发能力。
2.MVCC快照版本链
undo log是一种逻辑日志,当一个事务对记录做了变更操作就会产生undo log
,也就是说undo log记录了记录变更的逻辑过程。当一个事务要更新一行记录时,会把当前记录当做历史快照保存下来,多个历史快照会用两个隐藏字段trx_id和roll_pointer串起来(关于隐藏字段,这里不用考虑隐式主键id:DB_ROW_ID),形成一个历史版本链
。可以用于MVCC和事务回滚。比如多个事务对id为1的数据做了更新,会形成如下图这种历史版本链:
那么这个版本链是如何执行的,假如我们有一条ID为1的数据,现在多个事务对其做修改操作,那么每次修改都会:基于一个版本的undo log日志生成一行新的undo log 日志,把值修改掉,然后当前版本的undo log 的 roll_pointer(回滚指针)指向了上一个版本的undo log 日志形成链条,如果需要回滚就通过这个roll_pointer找到上一个版本的undo log 日志进行回滚。
3.RR和RC的区别
对于读已提交 RC 和 可重复读RR 在MVCC版本链中查找版本日志是不一样的,读已提交每次在读的时候会去版本链中找有:事务提交标志的那一条日志也就是对应数据库最新的那条日志
,因为事务已经提交,这样的话读已提交每次都可以读到提交事务后的最新数据,
而对于可重复读而言,每次读的都是当前事务对应的那条版本日志
,即使后续有其他事务生成了新的版本日志,RR也只会读当前事务对应的版本日志,这样的话在同一个日志中多次读到的都是相同的数据。
文章到这里就结束了,建议收藏,随便给个好评。