1. 背景
MySQL默认存储引擎是InnoDB,代替了之前的MyISAM存储引擎。
InnoDB与MyISAM相比,InnoDB支持事务,支持多种锁机制,有行锁和表锁,行锁支持事务。MyISAM只支持表锁,且不支持事务。
2. 事务隔离级别
2.1. 事务及其ACID属性
事务是由一组SQL语句组成的逻辑处理单元。
事务具有以下4个属性,简称为ACID。
-
原子性(Atomicity) :事务是一个不可分割的工作单位,事务中的操作要么都成功,要么都失败; -
一致性(Consistency) :事务前后,数据库的完整性约束没有被破坏; 如A给B转账,不论转账的事务操作是否成功,其两者的存款总额不变。 -
隔离性(Isolation) :多个线程并发访问数据库时,数据库为每一个线程开启的事务,不能被其他事务的操作数据所干扰,多个并发事务之间要相互隔离; -
持久性(Durability) :一个事务一旦被提交,它对数据库中数据的改变就是永久性的,即使数据库发生故障也不应该对其有任何影响。
2.2. 并发事务带来的问题
在事务的并发操作中,也就是多个事务同时对同一组数据进行操作时,可能会出现以下问题。
-
更新丢失(Lost Update) 当两个或多个事务在并发下同时进行更新,后一个事务的更新覆盖了前一个事务更新的情况,丢失更新是数据没有保证一致性导致的。 -
脏读(Dirty Reads) 事务A读取到了事务B已经修改但尚未提交的数据,还在这个数据基础上做更新作。此时,如果B事务回滚,A读取的数据无效,不符合一致性要求。 脏读要避免,因为会发生客诉,比如,你给小编赞赏 1 分钱,整个事务需要两个步骤: ①给小编账号加一分钱,这时小编看到了,觉得很欣慰; ②你的账号减一分钱; 但是,若该事务未提交成功,最终所有操作都会回滚,小编看到的一分钱也只是镜花水月。 -
不可重读(Non-Repeatable Reads) 事务A读取到了事务B已经提交的修改数据,事务A相同查询操作前后读取的数据不一样,不符合隔离性。 接着上一个例子,假设你真给小编打赏了一分钱,小编乐得屁颠屁颠地去准备提现,一查,发现真多了一分钱。 在这同时,在我还没有提现成功之前,小编的老婆已经提前将这一分钱支走了,小编此时再次查账,发现一分钱也没了。 脏读和不可重复读有点懵逼? 二者的区别是,脏读是某一事务读取了另外一个事务未提交的数据,不可重复读是读取了其他事务提交的数据。 其实,有些情况下,不可重复读不是问题,比如,小编提现期间,一分钱被老婆支走了,这不是问题! 而脏读,是可以通过设置隔离级别避免的。 -
幻读(Phantom Reads) 幻读大致上有两种情况。 幻读情况1: 同样的查询语句,前后两次读取,发现数据量的个数发生了改变。可重复读和序列化隔离级别可以解决这个问题。 幻读情况2: 第二种情况中还可以分为两种。 2.1:有A、B两个事务,事务A按某个条件查询数据。事务B在事务A查询之后,插入了一条符合事务A查询条件的数据。在事务B做完操作后,事务A也想插入事务B刚才插入的数据,但却发现插入不成功,第一次没有读到的数据,但却插入不成功,这种情况即为幻读; 2.2:有A、B两个事务,事务A按某个条件查询数据。事务B在事务A查询之后,删除了一条之前事务A查询到的数据。在事务B完成操作后,事务A也想把B刚才删除的数据删除掉,但发现影响的行数是0,明明查到了,但删不掉,这种情况也是幻读。 并且在Rr的隔离级别下,不仅影响行数为0,再查的时候,数据还依然存在,造成这种现象的原因是Rr级别使用了快照读(下文会详细说明)。Rr隔离级别可以在一定程度上避免这种情况,序列化可完全避免。
总结:这三种操作其实不会有什么根本的影响,不会对数据产生什么影响,只是会影响前端展示,进而引起客诉,所以要最大程度避免,尤其是脏读。
2.3. 数据库事务隔离级别
脏读、不可重复读、幻读其实都是数据库读一致性问题,必须由数据库提供的事务隔离机制来解决(由数据库锁机制解决的),更新丢失问题可通过业务层面锁机制解决。以期保证事务的ACID原则。 数据库的事务隔离越严格,并发副作用越小,但付出的代价也就越大。 因为事务隔离实质上就是使事务在一定程度上“串行化”进行,这显然与“并发”是矛盾的。
同时,不同的应用对读一致性和事务隔离程度的要求也是不同的。
InnoDB默认的隔离级别为REPEATABLE READ(可重复读)。
查看当前数据库的事务隔离级别:
show variables like 'transaction_isolation';
select @@transaction_isolation
设置事务隔离级别: 跟会话有关,一个会话中设置隔离级别,只跟当前会话有关,其他会话依然是默认的隔离级别。
set transaction_isolation='REPEATABLE-READ';
3. 锁机制
表锁示例未做说明情况下基于此表:
CREATE TABLE `sys_user` (
`userId` bigint(20) NOT NULL AUTO_INCREMENT,
`userName` varchar(50) CHARACTER SET utf8 COLLATE utf8_general_ci NOT NULL COMMENT '用户名',
`password` varchar(100) CHARACTER SET utf8 COLLATE utf8_general_ci DEFAULT NULL COMMENT '登录密码',
`superPassword` varchar(100) CHARACTER SET utf8 COLLATE utf8_general_ci DEFAULT NULL COMMENT '超级密码',
`nickName` varchar(100) CHARACTER SET utf8 COLLATE utf8_general_ci DEFAULT NULL COMMENT '用户昵称',
`email` varchar(32) CHARACTER SET utf8 COLLATE utf8_general_ci DEFAULT '0' COMMENT '可跑额度',
`mobile` varchar(12) CHARACTER SET utf8 COLLATE utf8_general_ci DEFAULT NULL COMMENT '费率',
`status` tinyint(4) DEFAULT '1' COMMENT '状态 0:禁用, 1:正常',
`isSuperAdmin` tinyint(4) DEFAULT '0' COMMENT '是否超级管理员 0:否, 1:是',
`createUserId` bigint(20) DEFAULT NULL COMMENT '创建者ID',
`lastPasswordResetTime` datetime DEFAULT NULL COMMENT '最后一次重置密码时间',
`createTime` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
`updateTime` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
PRIMARY KEY (`userId`) USING BTREE
) ENGINE=InnoDB AUTO_INCREMENT=2000 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci ROW_FORMAT=DYNAMIC;
行锁示例未做说明情况下基于此表:
CREATE TABLE `pay_order` (
`PayOrderId` varchar(30) CHARACTER SET utf8 COLLATE utf8_general_ci NOT NULL COMMENT '支付订单号',
`MchOrderNo` varchar(30) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NOT NULL COMMENT '商户订单号',
`PayType` int(11) DEFAULT NULL COMMENT '支付类型 1:微信 2:支付宝',
`PassageId` int(11) DEFAULT NULL COMMENT '通道ID',
`PassageName` varchar(255) CHARACTER SET utf8 COLLATE utf8_general_ci DEFAULT NULL COMMENT '通道名称',
`Amount` bigint(20) NOT NULL COMMENT '订单支付金额,单位分',
`ReallyPrice` bigint(20) NOT NULL COMMENT '实际支付价格,单位分',
`Status` tinyint(6) NOT NULL DEFAULT '0' COMMENT '订单状态 -1:订单过期 0:等待支付 1:支付成功 2:支付完成但通知失败 3处理完成',
`ExpireTime` datetime DEFAULT NULL COMMENT '订单失效时间',
`PaySuccTime` datetime DEFAULT NULL COMMENT '订单支付成功时间',
`CreateTime` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
`UpdateTime` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
PRIMARY KEY (`PayOrderId`) USING BTREE,
KEY `idx_passageId` (`PassageId`) USING BTREE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci ROW_FORMAT=DYNAMIC;
3.1. 定义
锁是计算机协调多个进程或线程并发访问同一资源的机制。
锁也是用来实现数据库事物隔离级别的重要机制。
3.2. 分类
3.2.1. 性能上划分(悲观乐观)
-
乐观锁: 每次去拿数据的时候都认为别人不会修改,所以不会上锁。但是在更新的时候,会判断一下在此期间别人有没有更新这个数据,可以使用版本号机制和CAS算法实现。 乐观锁适用于多读的应用类型,这样可以提高吞吐量,像数据库提供的类似于write_condition机制,其实就是乐观锁实现的。 在Java中java.util.concurrent.atomic包下面的原子变量类就是使用了乐观锁的一种实现方式CAS实现的。 -
悲观锁: 每次去拿数据的时候都认为别人会修改,所以每次都会上锁。这样其他人想拿这个数据,就会阻塞直到它拿到锁(共享资源每次只给一个线程使用,其它线程阻塞,用完后,再把资源转让给其它线程)。 一般多写的场景下用悲观锁比较合适。 传统的关系型数据库就用到了很多这种锁机制,比如行锁,表锁等。读锁,写锁等都是在做操作之前先上锁。 Java中synchronized和ReentrantLock等独占锁就是悲观锁思想的实现。
3.2.2. 从对数据操作的粒度划分(表锁行锁)
从操作粒度划分,无非就是表级锁和行级锁。
3.2.3. 从对数据库操作类型划分(共享排他)
读写锁都属于悲观锁。一直很不理解悲观锁会阻塞其他进程获取锁,那自然就是排他锁了,那为什么写锁既是悲观锁又是共享锁,原来是从事务层面划分的,多个事务获取同一数据可以共享一把锁,所以排他和共享要从场景去分析。重点理解锁的概念,而不是死记硬背。 重点还是悲观和乐观,二者的分歧主要来源于是否允许并发,前者不允许并发,因此同一时间只有一个人可以操作,重量级锁,影响性能,后者正好是与其相反的场景,但又要应对可能出现的冲突,所以利用了cas和版本号。
-
读锁(共享锁):Shared Locks(S锁),多个事务获取同一数据可以共享一把锁,都能访问到数据,但是只能读不能修改。普通查询不会加任何锁,手动加共享锁使用select … lock in share mode语句。 -
写锁(排它锁):Exclusive Locks(X锁),一个事务获取了一行数据的排他锁,其他事务就不能再获取该行的其他锁,包括共享锁和排他锁。 获取排他锁的事务可以对数据进行读取和修改,其他事务会阻塞读写操作,直到获取锁的事务释放排他锁。 在InnoDB中,update、delete、insert都会自动给涉及到的数据行加上排他锁。select查询语句可使用select …for update加排他锁,事务结束或者rollback/commit会释放锁。
3.3. 表锁
每次操作锁住整张表,特点如下:
-
开销小,加锁快; -
不会出现死锁; -
锁定粒度大,发生锁冲突的概率最高,并发度最低;
在对某个表执行增删查改语句时,InnoDB存储引擎是不会为这个表添加表级别的 S锁或者X锁的,如果想加表级锁需要手动添加。
在对某个表执行删改语句时,其他事务对这个表执行语句就会发生阻塞。
这个过程是通过使用元数据锁(英文名: Metadata Locks,简称MDL)来实现的,并不是使用表级别的S锁和X锁
3.3.1. 手动增加表锁
SQL语句:lock table 表名称 read(write),表名称2 read(write);
-
加读锁 当前session(会话1)添加读锁。 LOCK TABLE sys_user READ;
当前session会话和其他session会话都可以读该表。 当前session中插入或者更新锁定的表都会报错,其他session插入或更新则会等待。 -
加写锁 当前session(会话1)添加写锁。 LOCK TABLE sys_user WRITE;
当前session对该表的增删改查都没有问题,其他session对该表的所有操作被阻塞。
3.3.2. 查看表上加过的锁
show open tables;
3.3.3. 删除表锁
3.4. 行锁
每次操作锁住一行数据,特点如下:
-
开销大,加锁慢; -
会出现死锁; -
锁定粒度最小,发生锁冲突的概率最低,并发度最高。
InnoDB存储引擎既支持行级锁,也支持表级锁,默认情况下是采用行级锁。
行锁是通过给索引上的索引项加锁来实现的,不是针对记录加的锁。
意味着,只有通过索引条件检索数据,InnoDB才使用行级锁,否则,InnoDB将使用表锁。
行级锁都是基于索引的,如果一条SQL语句用不到索引是不会使用行级锁的,会使用表级锁。
示例一:可证,通过索引update使用到的是行锁。
示例二:可证,不使用索引时会进行表锁 因为主键PayOrderId这里发生了隐式转换,索引失效,行锁也就失效转为使用表锁。
3.4.1. 行级锁分类
从加锁范围可以将行级锁分为三种:
-
Record Lock:对表中的记录(索引项)加锁,叫做记录锁,简称行锁; -
Gap Lock:对索引前后所在记录的间隙上锁,不对索引记录本身上锁; -
Next-key Lock:上面两种锁的组合,锁定一个范围,锁定记录本身及其前后的间隙。InnoDB默认加锁方式是next-key 锁。
这三种锁都是排它锁,也就是说行级锁都是排他锁。
幻读的问题是因为新增或者更新操作,这时如果进行范围查询的时候(加锁查询),会出现不一致的问题。
这时使用不同的行锁已经没有办法满足要求,需要对一定范围内的数据进行加锁。在可重复读隔离级别下,数据库是通过行锁和间隙锁共同组成的next-key lock来解决幻读问题的。
间隙锁的目的是为了防止幻读,主要通过两个点实现:
-
防止间隙内有新数据被插入; -
防止已存在的数据,更新成间隙内的数据。
Innodb自动使用间隙锁的条件:
注意:这里的普通索引不包括主键索引和唯一索引,因为在这两个索引下能精确查找出结果,所以会使用Record Lock直接锁定具体的行(范围查询除外)。
Gap Lock可以同时存在,不同的事务可以同时获取相同的Gap Lock,并不会互相冲突。Gap Lock也是可以显示的被禁止的,只要将事务的隔离级别降低到READ COMMITTED。
加锁规则: 包含了两个“原则”、两个“优化”和一个“bug”。
-
加锁的基本单位是next-key lock,next-key lock是前开后闭区间; -
查找过程中访问到对象才会加锁(如更新间隙中不存在的数据,不会加锁); -
索引上的等值查询,给唯一索引加锁的时候,next-key lock退化为行锁; -
索引上的等值查询,向右遍历时且最后一个值不满足等值条件的时候,next-key lock退化为间隙锁; -
唯一索引上的范围查询会访问到不满足条件的第一个值为止。
简而言之:
如果精确查询,条件全部命中,则不会使用Gap锁,只会加行锁。 范围查询以及where条件部分命中或者全部不命中时,则会加Gap锁。
假设有2,4,5,6四条数据。 锁定记录4,那么锁的组成是这样的: 对区间如(2,4),(4,5)加间隙锁,同时4的记录加记录锁。
行级锁的缺点: 由于针对的是单行记录(实际是对索引项加锁),如果表内的数据量特别大或某些情况下需要对全表扫描是哪些行加了锁,就需要请求大量的锁资源,所以速度慢,内存消耗大。
行锁和表锁并无优劣,都是应对不同的场景,还是那句话,技术无贵贱。
3.4.2. 加锁解锁机制
数据库遵循的是两阶段锁协议,将事务分成两个阶段,加锁阶段和解锁阶段。
在事务执行过程中,随时都可以执行加锁操作,但是只有在事务执行COMMIT或者ROLLBACK的时候才会释放锁,并且所有的锁是在同一时刻被释放。
3.4.3. mysql 事务和锁关系
MySQL 默认开启事务自动提交模式,即除非显式的开启事务(BEGIN 或 START TRANSACTION),否则每条SOL语句都会被当做一个单独的事务自动执行。
锁是实现事务ACID属性的一种机制(不同隔离级别,加了不同的锁),开启事务就会自动加锁,锁的生命周期在事务的作用范围内。
在数据库操作中,为了有效保证并发读取数据的正确性,提出了事务隔离级别。
数据库事务为了维护ACID,尤其是一致性和隔离性,一般使用加锁这种方式。
数据库是个高并发的应用,同一时间会有大量的并发访问,如果加锁过度,会极大的降低并发处理能力。所以对于加锁的处理,可以说是数据库对于事务处理的精髓所在。
读操作可以分为两类:
-
快照读 (snapshot read) 读取的是记录的历史版本,在一个没有结束的事务中,快照读每次读取的都和在本次事务中第一次读到的信息一致。快照读不加锁,依赖MVCC进行事务隔离,可解决脏读、不可重复读和部分幻读。 -
当前读 (current read) 读取的是记录的最新版本,并且,当前读返回的记录,都会加上读锁,保证其他事务不会再并发修改这条记录。当前读依赖Next-Key锁进行事务隔离。 Next-Key锁 (行级锁) = S锁/X锁 (record lock) + 间隙锁(gap lock)。
执行select的时候,默认是不加锁的(快照读),在隔离级别为Serializable中不成立。
如果想要对某个行数据加锁,通过显示加锁实现。
-- 共享锁
select * from table where id = 10 lock in share mode
-- 排它锁
select * from table where id = 10 for update
当执行update,insert,delete的时候,默认是加排它锁的。
接下来,介绍四种事务隔离级别是如何实现的,能解决哪些并发问题,以及使用了哪些锁来实现这四种隔离级别。
3.4.3.1. 隔离级别-读未提交(Ru)
实现机制: 事务在读数据的时候加读锁(当前读),读完即释放共享锁。 事务在修改数据的时候加共享锁,提交后释放(解决了修改时,数据被删除或修改的情况)。
开启两个事务,事务A、B。
分别设置当前事务模式为read uncommitted(未提交读):
3.4.3.1.1. 脏读情况:可能发生
-
事务A第一查询,Amount为1000。 -
事务B执行更新操作,将Amount减100,且未提交事务。 -
回到事务A,再次执行查询,发现读取到了事务B中已经更新的数据,脏读就发生了(Ru修改操作是加的读锁,因此事务A可以读。但是此时要是执行写操作,会发现处于阻塞状态,因为事务B未提交,读锁未释放)。 -
一旦事务B因为某种原因回滚,所有的操作都将会被撤销,那么事务A查询到的数据其实就是脏数据: -
事务A执行更新语句update pay_order SET Amount = Amount - 100 WHERE PayOrderId = 1。 Amount没有变成800,居然是900,那是因为数据库执行该更新操作,会先查询再更新,所以数据是正确的。 但在应用程序中,我们会用查询到的900减去100,最后等于800。 因此,要想解决这个问题可以采用后面的隔离级别。
3.4.3.1.2. 不可重复读情况:可能发生
3.4.3.1.3. 幻读情况:可能发生
3.4.3.2. 隔离级别-读已提交(Rc)
实现机制:
事务在读数据的时候采用的是快照读,不加锁,依赖MVCC进行事务隔离,事务在begin之后,执行每条select语句时,快照会被重置,即会重新创建一个快照(read view)。
在同一个事务中,select只能看到快照创建前已经提交的修改和该事务本身做的修改。
事务在修改某数据时会加写锁,直到事务结束再释放。
这样的机制保证了Rc隔离级别不会发生脏读,只有提交过的事务,才能被其他事务看见。
开启两个事务,事务A、B。
分别设置当前事务模式为read uncommitted(未提交读):
3.4.3.2.1. 脏读情况:不可能发生
3.4.3.2.2. 不可重复读情况:可能发生
Rc隔离级别下的不可重复读的情况和Ru是类似的,只是事务B在修改数据的时候,需要提交事务(Rc执行每条select语句时,快照会被重置,这个时候事务B修改已提交,所以事务A能读取到,导致不可重复度)。
3.4.3.2.3. 幻读情况:可能发生
Rc隔离级别下的幻读的情况和Ru是类似的,只是事务B在插入数据的时候,需要提交。
3.4.3.3. 隔离级别-可重复读(Rr)
实现机制:
事务在读数据的时候采用的是快照读,不加锁,依赖MVCC进行事务隔离。
事务在begin之后,执行第一条select时, 才会创建一个快照(read view),将当前系统中活跃的其他事务记录起来,并且创建事务之后都是使用这个快照,不会重新创建,直到事务结束。
在同一个事务中,select只能看到快照创建前已经提交的修改和该事务本身做的修改。
事务在修改数据的时候加写锁,并且采用了间隙锁,但触发间隙锁的前提是查询条件列不可以是唯一索引和主键。在触发间隙锁后,会锁住一定范围内的数据,防止在这范围内插入数据,这个机制可以在一定程度上降低发生幻读情况的可能。
开启两个事务,事务A、B。
分别设置当前事务模式为repeatable read(可重复读):
3.4.3.3.1. 脏读情况:不会发生
Rr隔离级别下的脏读的情况和Rc一样。
3.4.3.3.2. 不可重复读情况:不会发生
第一、二步操作,验证为可重复读。 第三步操作:事务A在第二步查询Amount结果为1000,执行更新操作,Amount减100,再次查询结果变为800。也就是这里更新操作初始值用了事务B提交更新的900,数据一致性没有破坏。
按常理来讲,扣减操作值应该为900。那么,为什么这里不会按快照读的值来进行更新扣减操作呢?
因为MVCC机制中,select操作不会更新版本号,是快照读(历史版本)。 insert、update、delete会更新版本号,是当前读(当前版本)。
3.4.3.3.3. 幻读情况:可能发生
会发生幻读情况2,由于Rr采用了快照读,所以不会读到插入的数据,不会发生幻读情况1。
a、幻读情况1:事务B新增数据,不会出现幻读。
INSERT INTO `vone`.`pay_order` VALUES ('66', '234567', '2', 17, '哇呀呀', '2000', '200', '0', NULL, NULL, NULL, NULL, NULL, '', NULL, NULL, '2022-03-17 19:55:35', '2022-03-18 14:50:45');
b、幻读情况2.1:事务A由于不知事务B插入过这条数据,所以也想插入这条数据。事务A会因为主键插入重复的约束而插入失败。并且再次全部读数据的时候,还不能查出这条数据,出现了幻读。
INSERT INTO `vone`.`pay_order` VALUES ('66', '234567', '2', 17, '哇呀呀', '2000', '200', '0', NULL, NULL, NULL, NULL, NULL, '', NULL, NULL, '2022-03-17 19:55:35', '2022-03-18 14:50:45');
c、幻读情况2.2:事务A由于不知事务B删除过这条数据,所以也想删除这条数据。事务A删除语句执行后,会发现影响的行数为0,并且再次全部读数据的时候,还发现这条数据没有被删除,出现了幻读。
d、间隙锁在某些情况下可以解决幻读问题,前面已阐述,不再说明。
3.4.3.4. 隔离级别-可串行化(S)
实现机制:
事务在读取数据时,对整个表加读锁,提交或回滚事务后释放
事务在修改数据时,对整个表加写锁,提交或回滚事务后释放
这是最高的隔离级别,可以解决脏读,不可重复读和幻读,但同时效率也是最差的一个。它解决这些由于事务并发带了的问题的方法就是把这些操作变成串行操作,一旦不符合条件,就会被阻塞,所以效率特别差。
3.4.4. 行锁分析
通过检查InnoDB_row_lock状态变量来分析系统上的行锁的争夺情况 对各个状态量的说明如下:
-
Innodb_row_lock_current_waits: 当前正在等待锁定的数量; -
Innodb_row_lock_time: 从系统启动到现在锁定总时间长度; -
Innodb_row_lock_time_avg: 每次等待所花平均时间; -
Innodb_row_lock_time_max:从系统启动到现在等待最长的一次所花时间; -
Innodb_row_lock_waits:系统启动后到现在总共等待的次数;
当等待次数很高,而且每次等待时长也不小的时候,我们就需要分析系统中为什么会有如此多的等待,然后根据分析结果着手制定优化计划。
3.4.5. 死锁
指两个或两个以上的事务在执行过程中,因争夺资源而造成的一种互相等待的现象。若无外力作用,它们都将无法推进下去,此时称系统产生了死锁。
3.4.5.1. 死锁解决方案
-
超时 解决死锁问题最简单的一种方法。当两个事务互相等待时,当一个等待时间超过设置的阈值时,其中一个事务进行回滚,另一个等待的事务就能继续进行。 在InnoDB存储引擎中,参数innodb_lock_wait_timeout用来设置超时的时间。 超时机制很简单,仅通过超时后对事务进行回滚的方式来处理。 系统会选择重启undo log量最小(权重最小)的事务。 除了超时机制,当前数据库还普遍采用wait-for graph(等待图)的方式来进行死锁检测。 -- 查看
SHOW GLOBAL VARIABLES LIKE 'innodb_lock_wait_timeout';
-- 设置
SET GLOBAL innodb_lock_wait_timeout = 120;
-
wait-for graph 一种主动的死锁检测方式。InnoDB存储引擎采用的此方式。 通过锁的信息链、事务等待链可以构造出一张图,而在这个图中若存在回路,就代表存在死锁,资源间相互发生等待。 如图所示: 图中节点代表事务,箭头指向代表事务A等待事务B所占用的资源,也就是事务之间在等待相同的资源,而事务A发生在事务B的后面。
3.4.5.2. 等待图自动检测详解
当前事务和锁的状态,如下图所示: 在事务等待列表中有3个事务,故在等待图中有3个节点。
事务A中先拿到了row2的读锁,然后再想获取row1的写锁。
事务B中先拿到了row1的读锁,然后再想获取row2的写锁。
事务C中想获取row2的写锁。
wait-for graph结构图如下所示: 事务A和事务B之间存在环路,所以检测存在死锁。
wait-for graph是一种较为主动的死锁检测机制,在每个事务请求锁,且发生等待时都会判断是否存在回路。若存在,则存在死锁。
通常来说 InnoDB 存储引擎选择回滚 undo log量最小的事务。
wait-for graph 的死锁检测通常采用深度优先的算法实现。
在InnoDB1.2版本之前,都是采用递归方式实现。而从1.2 版本开始,对 wait-for graph的死锁检测进行了优化,将递归用非递归方式进行了实现,进一步提高了InnoDB的性能。
3.4.5.3. 死锁产生的情况
a、不同表相同记录行冲突
事务A和事务B同时操作两张表,出现循环等待锁情况。A等B释放资源,B等待A释放资源。
事务A:
update user set name = 'wakaka' where id = 1;
update order set amount = amount+100 where id = 1;
事务B:
update order set amount = amount+100 where id = 1;
update user set name = 'wakaka' where id = 1;
b、相同表相同记录行冲突
事务A:
update user set name = 'wakaka' where id = 1;
update user set name = 'wahaha' where id = 2;
事务B:
update user set name = 'wahaha' where id = 2;
update user set name = 'wakaka' where id = 1;
c、不同索引锁冲突 事务A执行时,除了在辅助索引上加锁外,还会在主键索引上加锁,在主键索引上加锁的顺序是(1,4,2,3,5)。
而事务B执行时,只在主键索引上加锁,加锁顺序是(1,2,3,4,5),这样就可能造成死锁。
事务A
update user set name = 'wakaka' where create_time >= '2022-03-26 17:00:00';
事务B
update user set name = '666' where id >= 1
d、gap锁冲突 InnoDB在可重复读事务隔离级别下,也可能出现死锁。
表数据,id主键,name普通索引
('1', 'a');
('3', 'c');
('5', 'd');
('7', 'e');
事务A
--获取间隙锁,锁定范围(a,d)
UPDATE user SET name = 'wakaka' where name = 'c';
--增加操作,处在事务B的间隙锁锁定范围中
INSERT INTO user VALUES('9', 'f');
事务B
--获取间隙锁,锁定范围(d,无穷大)
UPDATE user SET name = 'waxixi' where name = 'e';
--增加操作,处在事务A的间隙锁锁定范围中
INSERT INTO user VALUES('2', 'b');
3.4.5.4. 死锁定位
查看近期死锁日志信息:show engine innodb status;
大多数情况mysql可以自动检测死锁并回滚产生死锁的那个事务,但是有些情况没法自动检测死锁。
MySQL在检测到死锁以后,重启事务的依据是什么? 依据是选择重启undo log量最小(权重最小)的事务。
事务权重小的标准:
-
事务加的锁最少; -
事务写的日志最少; -
事务开启的时间最晚。
3.4.5.5. 如何尽可能避免死锁
-
以固定的顺序访问表和行 -
大事务拆成小事务 -
在同一个事务中,尽可能做到一次锁定所需的所有资源,减少死锁概率 -
降低隔离级别 如果业务允许,可将隔离级别从RR调整为RC,避免因为gap锁造成的死锁。 -
为表添加合理的索引 如果不走索引,将会为表的每一行记录添加上锁,死锁的概率将大大提升。
3.4.6. 优化建议
3.5. 意向锁
为了支持在不同粒度上进行加锁操作(允许行锁和表锁共存,实现多粒度锁机制),InnoDB引入两种存储引擎内部使用的意向锁(Intention Locks),这两种意向锁都是表锁(MyISAM不存在意向锁)。
意向锁是由InnoDB存储引擎自己维护的,用户无法手动操作意向锁。在为数据行加读写锁之前,InnoDB会先获取该数据行所在表的对应意向锁。
意向锁产生的主要目的是为了处理行锁和表锁之间的冲突,允许事务在行级上的锁和表级上的锁同时存在。用于表明某个事务正在某一行上持有了锁,或者准备去持有锁。
-
为什么说意向锁不会与行级的读写锁互斥? 意向锁本来就是为了修饰行锁的。修改某行数据的这个事务,其中的意向锁和行锁是一一对应的。即事务之间是相互隔离的,那么意向锁和行锁作为一个整体的之间也是相互隔离的,自然就谈不上兼容与否。 我们知道,一个表中允许多个行级X锁同时存在,只要不是针对相同的数据行,那么对应的就有多个IX锁同时存在。如果意向锁和行锁不兼容,那么也就不可能存在多个行级锁的情况。 -
为什么没有意向锁的话,表锁和行锁不能共存? 假设没有意向锁,行锁和表锁能共存。 事务A锁住表中的某行记录,事务B锁住整个表。那么问题来了,既然事务A锁住了某行记录,那么其他事务就不可能修改这一行数据。而事务B锁住整个表就能修改表中的任意某行数据,这样就形成了冲突。 有了意向锁,事务A获取了X锁,也就获取了该表的IX锁,当事务B想获取表X锁时,是不兼容的,不能加锁成功,只能阻塞等待。 所以,没有意向锁的话,行锁与表锁是无法共存的。 -
为什么意向锁是表级锁? 意向锁的目的是为了表锁和行锁共存。 加表级别锁时,需先判断表记录是否存在行锁,才能判断是否能够加表锁成功。 如果意向锁是行锁,则需要遍历每一行数据去确认。 如果意向锁是表锁,则只需要判断一次,即可知道有没有数据行被锁定。 IS锁和IX锁避免了判断表中行有没有加锁时,对每一行的遍历。直接查看表有没有意向锁,就可以知道表中有没有行锁。
表锁、行锁、意向锁相关总结如下:
1. 表级读锁,不会阻塞各个线程对同一表的读请求,但会阻塞对同一表的写请求。只有当读锁释放后,各个线程才可以执行写操作; 2. 表级写锁,不会阻塞当前线程读写操作,但会阻塞其他线程对同一表的读和写操作。只有当写锁释放后,其它线程才会执行读写操作; 3. MyISAM在执行增删查改语句前,会自动给涉及的所有表加表锁; 4. InnoDB想加表锁需要手动添加; 5. MyISAM不存在意向锁,只存在于InnoDB中; 6. 意向锁只是用来修饰行锁的,即添加表锁,不会自动添加对应的意向锁; 7. 意向锁产生的主要目的是为了处理行锁和表锁之间的冲突,允许行级锁和表级锁同时存在; 8. 表锁偏向于读操作。
4. MVCC
4.1. 定义
MVCC,全称Multi-Version Concurrency Control ,即多版本并发控制。
MVCC 是一种并发控制的方法,一般在数据库管理系统中,实现对数据库的并发访问,在编程语言中实现事务内存。
MVCC 在 MySQL InnoDB 中的实现主要是为了提高数据库并发性能,处理读写冲突而不用加锁,非阻塞并发读。
在MySQL中,MVCC只在读已提交(Read Committed)和可重复读(Repeatable Read)两个事务隔离级别下有效。
4.2. 读操作分类
-
当前读(current read) 读取的是记录最新版本。当前读返回的记录,都会加上读锁,保证其他事务不会再并发修改这条记录。UPDATE、DELETE、INSERT、SELECT … LOCK IN SHARE MODE、SELECT … FOR UPDATE都是当前读。 当前读依赖Next-Key锁进行事务隔离。 Next-Key锁 (行级锁) = S锁/X锁 (record lock) + 间隙锁(gap lock)。 -
快照读(snapshot read) 读取的是记录的历史版本,在一个没有结束的事务中,快照读每次读取的都和在本次事务中第一次读到的信息一致。快照读不加锁,依赖MVCC进行事务隔离,可解决脏读、不可重复读和部分幻读。 MVCC在很多情况下,避免了加锁操作,降低了开销。既然是基于多版本,快照读可能读到的并不一定是数据的最新版本,而有可能是之前的历史版本。
MVCC模型在MySQL中的具体实现原理主要是依赖记录中的隐藏字段,undo log ,Read View 来实现的。
4.3. 隐藏字段
MySQL中,每行记录除了自定义的字段外,会默认在表后面添加几个隐藏字段。
-
DB_ROW_ID(行ID,6字节) MySQL的B+树索引要求每个表必须要有一个主键。如果没有设置的话,会自动选择第一个不包含NULL的唯一索引列作为主键。如果还是找不到,就会在这个DB_ROW_ID上自动生成一个唯一值,以此来当作主键(该列和MVCC的关系不大)。 -
DB_TRX_ID(事务ID,6字节) 记录当前事务最后一次做INSERT或UPDATE语句操作时的事务ID。如果是DELETE语句的话,也会在版本链上将最新的数据插入一份,然后将事务ID赋值为当前进行删除操作的事务ID。同时会在该条记录的信息头(record header)里面的deleted_flag位置标记为true,以此来表示当前记录已经被删除。 -
DB_ROLL_PTR(回滚指针,7字节) 通过它可以将不同的版本串联起来,形成版本链,相当于链表的next指针,用于配合 undo日志,指向这条记录的上一个版本(存储于rollback segment里)。 -
deleted_flag(删除标识) 标识记录被更新或删除,并不代表真的删除。
4.4. undo log
以撤销操作为目的,返回指定某个状态的操作。
指事务开始之后,在操作任何数据之前,首先将需操作的数据备份到一个地方(undo log) 。
undo Log是为了实现事务的原子性而出现的产物。
总结:undo log是用来回滚数据的保障,未提交事务原子性的保障。
事务处理过程中,如果出现了错误或者用户执行了ROLLBACK语句,MySQL可以利用undo Log中的备份将数据恢复到事务开始之前的状态。
与redo log的区别? redo log以恢复操作为目的,重现操作。
指事务中操作的任何数据,会将最新的数据备份到一个地方(redo log) 。
redo log一般是在事务提交的时候,以顺序IO的方式写入磁盘。
具体的落盘策略可以进行配置。
redo log实现了事务的持久性
防止在发生故障的时间点,尚有脏页未写入磁盘,在重启mysql服务的时候,根据redo log进行重做,从而保证事务中尚未写入磁盘的数据的持久性。
一旦事务成功提交且数据持久化落盘之后,此时redo log中的对应事务数据记录就失去了意义,所以redo log的写入是日志文件循环写入的。
总结:redo log是用来恢复数据的保障,已提交事务持久化的保障。
4.4.1. undo日志分类
Purge线程
为了实现InnoDB的MVCC机制,更新或者删除操作都只是标识一下旧记录的deleted_bit,并不真正将旧记录删除。
为了节省磁盘空间,InnoDB有专门的purge线程来清理deleted_bit为true的记录。purge线程自己也维护了一个read view,如果某个记录的deleted_bit为true,并且DB_TRX_ID相对于purge线程的read view可见,那么这条记录一定是可以被安全清除的。
4.4.2. undo日志执行流程
全局数据处理流程图如下(后面的流程可对照此图进一步加深理解):
-
有个事务1往user表中插入一条记录行,三个隐藏字段–隐式主键是1,事务 ID为1,回滚指针为null。insert新记录时产生insert undo log,事务提交后立即清理,因此undo log这里不做展示。 如下图: -
又来了一个事务2,事务ID为2,将该记录的name修改为wakaka。 流程如下:
-
最后来了一个事务3,事务ID为3。修改同一记录行,将该记录的name修改为fushuang。
由上可知,不同事务或者相同事务对同一记录行的修改,会使该记录行的undo log形成一条版本链,undo log的链首就是最新的旧记录,链尾是最早的旧记录。
4.5. Read View
Read View主要用来做可见性判断的。用来判断当前事务能够看到哪个版本的数据。既可能是当前最新的数据,也有可能是该行记录undo log里面的某个版本的数据。
ReadView一致性视图主要由两部分组成:
事务100和200是当前未提交的事务,而事务300是当前已经提交的最大事务ID。
在事务中执行第一个select语句的时候,innodb会创建一个读视图,读视图中会保存系统当前不应该被本事务看到的其他活跃事务id列表(所有未提交事务的ID数组)。
当这个事务中要快照读取某行记录的时候,innodb会将版本链中该记录行的从上往下的DB_TRX_ID(事务ID)与该读视图中的一些变量(ReadView组成的两部分)进行比较,判断是否满足可见性条件(版本事务ID是已提交的事务就是可见的,也就是要查找的数据)。
读已提交和可重复读两个事务隔离级别生成ReadView的策略不一样:
4.6. 快照读流程
假设当前事务要快照读取某一行记录,该行记录的DB_TRX_ID(最新修改该行的事务ID)为trx_id,ReadView中未提交事务数组中的最小事务ID为min_id,ReadView中已经创建提交的最大事务ID为max_id。需要跟ReadView做对比,才能获取到快照结果。
版本链比对规则:
首先从版本链中拿出最上面第一个版本的事务ID(trx_id)开始逐个往下进行比对。
-
如果落在绿色区间(trx_id < min_id) 表示这个版本比min_id还小,说明这个版本在SELECT之前就已经提交事务了,所以这个数据是可见的; -
如果落在红色区间(trx_id > max_id) 表示这个版本是由将来启动的事务来生成的,当前还未开始,那么是不可见的; -
如果落在黄色区间(min_id <= trx_id <= max_id) 包含两种情况: a、如果这个版本的事务ID在ReadView的未提交事务数组中,表示这个版本是由还未提交的事务生成的,那么就是不可见的,当前自己的事务是可见的; b、如果这个版本的事务ID不在ReadView的未提交事务数组中,表示这个版本是已经提交了的事务生成的,那么是可见的。
对于删除的情况可以认为是更新的特殊情况,会将版本链上最新的数据复制一份。然后将副本数据的trx_id修改成删除操作的事务ID,同时在该条记录的头信息(record header)里的deleted_flag位标记为true,来表示当前记录已经删除。在查询时按照上面的规则查到对应的记录,如果deleted_flag位标记为true,意味着记录已被删除,那么也不会返回数据,而是继续寻找下一个。
如果在上述的判断中发现当前版本是不可见的,那么就继续从版本链中通过回滚指针拿取下一个版本来进行上述的判断,直到找到满足特定条件的DB_TRX_ID , 那么这个DB_TRX_ID所在的旧记录就是当前事务能看见的最新老版本。
如果当前事务执行rollback回滚的话,会把版本链中属于该事务的所有版本都删除掉。
例子演示:
从左往右分别是4个事务,从上到下是时刻点。 事务ID是执行一条更新操作(增删改)的语句后才会生成,并不是开启事务的时候就会生成。最后一个事务中可以看到就是执行了一些SELECT语句而已,所以它们并没有事务ID。
第1时刻点:4个事务分别开启了事务,这个时候还没有生成事务ID。
第2时刻点:事务1执行了一条UPDATE语句,生成了事务ID为100。
第3时刻点:事务2执行了一条UPDATE语句,生成了事务ID为200。
第4时刻点:事务3执行了一条UPDATE语句,生成了事务ID为300。
第5时刻点:事务3执行了commit操作。
第6时刻点:事务4执行了一条SELECT语句,想要查询一下当前id为1的数据(如上所说,该事务没有生成事务ID)。
可知,此时readview:[100,200],300 版本链信息如下:
因为第5时刻点,事务300已经提交了,所以ReadView的未提交事务数组中不包含它。
此时根据上面所说的比对规则,拿版本链中的第一个版本的事务ID为300进行比对。发现落在黄色区间,而且事务300也没有在ReadView的未提交事务数组中,所以是可见的。即此时在第6时刻点,事务4所查找到的结果是app。
第7时刻点:事务1执行了一条UPDATE语句。
第8时刻点:事务4执行了一条SELECT语句,想要查询一下当前id为1的数据。
可知,此时readview:[100,200],300 版本链信息如下:
因为当前在可重复读的事务隔离级别下,所以此时的ReadView沿用了在第6时刻点生成的ReadView(如果是在读取已提交的事务级别下,此时就会重新生成一份ReadView了)。然后根据上面所说的比对规则,拿版本链中的第一个版本的事务ID为100进行比对,发现是落在黄色区间,而且事务1是在ReadView的未提交事务数组中,所以是不可见的。
此时通过回滚指针从undo日志中获取下一个版本,事务ID为300进行比对。发现是落在黄色区间,但是事务300没有在ReadView的未提交事务数组中,所以是可见的。
即此时在第8时刻点,事务4所查找到的结果仍然是app。
第9时刻点:事务1提交事务了,同时事务2执行了一条UPDATE语句。
第10时刻点:事务4执行了一条SELECT语句,想要查询一下当前id为1的数据。
可知,此时readview:[100,200],300 版本链信息如下:
跟第8时刻点一样,在可重复读的事务隔离级别下,ReadView沿用了在第6时刻点生成的ReadView([100,200],300)。
此时如果新增新事务(篇幅有限,只做文字说明),执行事务内的第一条SELECT语句,会重新生成在当前情况下的ReadView([200],300)。
然后根据上面所说的比对规则,拿版本链中的第一个版本的事务ID为200进行比对,发现落在黄色区间,而且事务200是在ReadView的未提交事务数组中,所以是不可见的。
此时通过回滚指针拿取下一个版本,事务ID为100进行比对,发现落在黄色区间内,同时在ReadView的未提交数组中,所以依然是不可见的。
此时再拿取下一个版本:事务ID为300进行比对,发现落在黄色区间,但是事务300没有在ReadView的未提交事务数组中,所以是可见的。
即此时在第10时刻点,事务4所查找到的结果仍然是app。
4.7. MVCC优点
多版本并发控制(MVCC)是一种用来解决读-写冲突的无锁并发控制,也就是为事务分配单向增长的时间戳,为每个修改保存一个版本,版本与事务时间戳关联,读操作只读该事务开始前的数据库的快照。
所以 MVCC 可以为数据库解决以下问题:
在并发读写数据库时,可以做到在读操作时不用阻塞写操作,写操作也不用阻塞读操作,提高了数据库并发读写的性能。
同时还可以解决脏读,幻读,不可重复读等事务隔离问题,但不能解决更新丢失问题。
在数据库中,因为有了 MVCC,所以可以形成两个组合:
这种组合的方式就可以最大程度的提高数据库并发性能,并解决读写冲突,和写写冲突导致的问题。
事务实现总结:
原子性,持久性,隔离性的目的就是为了保障数据的一致性!
总之,ACID只是个概念,事务最终目的是要保障数据的可靠性,一致性。
|