https://tech.meituan.com/2014/08/20/innodb-lock.html
基本认识
事务(Transaction)在数据库中一般是指包含了一组原子性操作的步骤组合,这些操作要么都成功,要么都失败,事务一般包含了ACID四大特性,即原子性(Atomicity)、一致性(Consistency)、隔离性(Isolation)、持久性(Durability)。这也是数据库区别于一般文件系统的重要特性之一。
事务ACID的概念
原子性(Atomicity):原子性是指一个事务中的操作,要么全部成功,要么全部失败,如果失败,就回滚到事务开始前的状态。
一致性(Consistency):一致性是指事务必须使数据库从一个一致性状态变换到另一个一致性状态,也就是说一个事务执行之前和执行之后都必须处于一致性状态。那转账举栗子,A账户和B账户之间相互转账,无论如何操作,A、B账户的总金额都必须是不变的。
隔离性(Isolation):隔离性是当多个用户 并发的 访问数据库时,如果操作同一张表,数据库则为每一个用户都开启一个事务,且事务之间互不干扰,也就是说事务之间的并发是隔离的。再举个栗子,现有两个并发的事务T1和T2,T1要么在T2开始前执行,要么在T2结束后执行,如果T1先执行,那T2就在T1结束后在执行。关于数据的隔离性级别,将在后文讲到。
持久性(Durability):持久性就是指如果事务一旦被提交,数据库中数据的改变就是永久性的,即使断电或者宕机的情况下,也不会丢失提交的事务操作。
事务分类
https://www.cnblogs.com/starsray/p/16456569.html
事务按照类别可以划分为以下几种类型:
扁平事务(Flat Transactions)
事务类型中最为简单的一种,也是使用最频繁的一种,通常伴随着BEGIN…COMMIT/ROLLBACK语句,所有操作都是原子性的。
带有保存点的扁平事务(Flat Transactions with Savepoints)
相比于扁平事务,带有保存点的扁平事务再事务内部维护了一个可以递增的保存点,在事务进行回滚时可以指定回滚到某个保存点,如果需要回滚到最初状态,需要再次执行ROLLBACK语句。
说明:执行过程类似于START TRANSACTION…SAVEPOINT a…ROLLBACK TO SAVEPOINT a…ROLLBACK /COMMIT
链事务(Chained Transactions)
链事务可以看作为带有保存点的扁平事务的一种变种。链事务将带有保存点的扁平事务中一个较长的操作链路转化为较短链路的事务链,这样一定程度保障了数据丢失的可能性。链事务同带有保存点的扁平事务类型相比,前者只能回滚到最近一个操作事务内的保存点,而后者可以回滚到任意保存点,并且链事务在COMMIT后就释放了当前事务所拥有的锁。
嵌套事务(Nested Transactions)
嵌套事务是一个多层次架构的事务树,由顶层事务控制子事务,子事务既可以是嵌套事务也可以是扁平事务,且叶子节点只能是扁平事务,每个子事务从根到叶节点的距离可以是不同的。
子事务既可以提交也可以回滚。但是它的提交操作并不马上生效,除非其父事务已经提交。因此可以推论出,任何子事物都在顶层事务提交后才真正的提交。树中的任意一个事务的回滚会引起它的所有子事务一同回滚,故子事务仅保留A、C、I特性,不具有D的特性。
分布式事务(Distributed Transactions)
分布式事务通常指在分布式环境中运行的扁平事务,一般出现在跨数据库实例的访问链路中。分布式事务同样要求满足ACID特性。单依靠数据库特性并不一定可以完美解决,一般需要引入第三方工具进行中间协调,如Seata。
MySQL中对于InnoDB存储引擎来说,其支持扁平事务、带有保存点的事务、链事务、分布式事务。对于嵌套事务,其并不原生支持,但用户仍可以通过带有保存点的事务来模拟串行的嵌套事务。
四种隔离级别
SQL标准定义了4类隔离级别,包括了一些具体规则,用来限定事务内外的哪些改变是可见的,哪些是不可见的。低级别的隔离级一般支持更高的并发处理,并拥有更低的系统开销。
mysql的4种事务隔离级别,如下所示:
1、未提交读(Read Uncommitted):
允许脏读,也就是可能读取到其他会话中未提交事务修改的数据
这种事务隔离级别下,select语句不加锁,也不是快照读。
--原数据
--id name
--1 lisi
--事务1
START TRANSACTION;
updata t_table set name = 'wangwu' where id = 1; --此时事务2查询id = 1
ROLLBACK;
--事务2
select * from t_table where id = 1; --查询到 id = 1, name = 'wangwu'
2、提交读(不可重复读)(Read Committed):
--原数据
--id name
--1 lisi
--事务1
select * from t_table where id = 1; -- 查询到 id = 1, name = list, 事务2在此时提交
select * from t_table where id = 1; -- 查询到 id = 1, name = wangwu
--事务2
start transaction;
update t_table set name = 'wangwu' where id = 1;
COMMIT;
能读取到已经提交的数据。Oracle等多数数据库默认都是该级别 (不重复读)
该级别下是通过快照读来防止读脏的。因为在该级别下的快照读总是能读到最新的行数据快照,当然,必须是已提交事务写入的,所以可能产生不可重复读。
而且如果是不上锁的select,可能产生不可重复读。
会产生幻读, 每次select都生成一个快照读。
3、可重复读(Repeated Read)(解决大部分幻读):
可重复读。在同一个事务内的查询都是事务开始时刻一致的,InnoDB默认级别。在SQL标准中,该隔离级别消除了不可重复读,但是还存在幻象读,但是innoDB解决了幻读
在该级别下
- 通过快照读以及锁定区间来实现避免产生幻读和不可重复读;
- 某个事务首次read记录的时间为T,未来不会读取到T时间之后已提交事务写入的记录,以保证连续相同的read读到相同的结果集,这可以防止不可重复读;
- RR下是通过间隙锁,临键锁来解决幻影读问题;
快照读没有完全解决幻读?
是的,MySQL没有完全解决快照读下的幻读问题。
可以做这个实验:
1.当前DB已有id 5, 10, 15三条数据。
2.事务A查询id < 10的数据,可以查出一行记录id = 5
3.事务B插入id = 6的数据
4.事务A再查询id < 10的数据,可以查出一行记录id = 5,查不出id = 6的数据(读场景,解决了幻读)
5.事务A可以更新/删除id = 6的数据,不能插入id = 6的数据(写场景,幻读不彻底)
这个很好理解,MySQL虽然通过MVCC的版本号来解决了读场景下的幻读,但对于上面第5步那种写场景的情况,其实是无能为力的,因为MVCC毕竟是无锁实现。
首先,没有彻底解决幻读。各自解决了一半
- RR隔离级别,如果事务中都是快照读,或者全都是当前读,都不会产生幻读。只有当前读和快照读混用,才会产生幻读。
- MVCC保证快照读不会幻读
- next-key lock保证当前读不会产生幻读
案例
-
在 RR 隔离级别下,插入新数据不会受到锁的限制,因此在事务中进行插入操作可能导致幻读。
-
如果使用非标准的语句或查询方式,例如使用不稳定的函数或不加锁的查询语句,也可能导致幻读。
-
如果在高并发环境下,有多个事务同时读取相同的数据行,而其中一个事务在读取之后修改或删除了该行数据,此时其他事务再次读取该行数据时可能会出现幻读。
MySQL innodb 在 RR 隔离下一样会出现幻读,next-key lock 和 MVCC 只解决了部分幻读的场景。
4、串行读(Serializable):完全串行化的读,每次读都需要获得表级共享锁,读写相互都会阻塞
数据库事物隔离级别解决的问题
隔离性1-4 由低到高, 而并发性相反
- 脏读、不可重复读、幻读:
也许有很多读者会对上述隔离级别中提及到的 脏读、不可重复读、幻读 的理解有点吃力,我在这里尝试使用通俗的方式来解释这三种语义:
脏读:一个事务读取了另一个事务未提交的数据。
-
不可重复读:事务A首先读取了一条数据,然后执行逻辑的时候,事务B将这条数据改变了,然后事务A再次读取的时候,发现数据不匹配了,就是所谓的不可重复读了。
也就是说,当前事务先进行了一次数据读取,然后再次读取到的数据是别的事务修改成功的数据,导致两次读取到的数据不匹配,也就照应了不可重复读的语义。
幻读:一个事务读取到了别的事务插入的数据。
行锁防止别的事务修改或删除,GAP锁防止别的事务新增,行锁和GAP锁结合形成的的Next-Key锁共同解决了RR级别在写数据时的一部分幻读问题。
https://opensource.actionsky.com/20210818-mysql/
可重复读是如何避免幻读的?
MySQL InnoDB 引擎的默认隔离级别虽然是「可重复读」,但是它很大程度上避免幻读现象解决的方案有两种:
针对快照读(普通 select 语句)
- 快照读情况下
在可重复读隔离级别下是通过MVCC来避免幻读的,具体的实现方式在事务开启后的第一条select语句生成一张Read View(数据库系统当前的一个快照),之后的每一次快照读都会读取这个Read View。
即在第②时刻生成一张Read View,所以在第⑤时刻时读取到数据和第②时刻相同,避免了幻读。
当前读(select … for update 等语句)
-
当前读情况下
当前读:像select lock in share mode(共享锁), select for update ; update, insert ,delete这些操作都是一种当前读,读取的是记录的最新版本。
在当前读情况下是通过next-key lock来避免幻读的,即加锁阻塞其他事务的当前读。如果有其他事务在 next-key lock 锁范围内插入了一条记录,那么这个插入语句就会被阻塞,无法成功插入,所以就很好了避免幻读问题。事务A在第②时刻执行了select for update当前读,会对id=1和2加记录锁,以及(2,+∞)这个区间加间隙锁,两个都是排它锁,会阻塞其他事务的当前读,所以在第③时刻事务B更新时阻塞了,从而避免了当前读情况下的幻读。
不可重复读和幻读的区别
很多人容易搞混不可重复读和幻读,确实这两者有些相似。但不可重复读重点在于update和delete,而幻读的重点在于insert。
如果使用锁机制来实现这两种隔离级别,在可重复读中,该sql第一次读取到数据后,就将这些数据加锁,其它事务无法修改这些数据,就可以实现可重复读了。但这种方法却无法锁住insert的数据,所以当事务A先前读取了数据,或者修改了全部数据,事务B还是可以insert数据提交,这时事务A就会发现莫名其妙多了一条之前没有的数据,这就是幻读,不能通过行锁来避免。需要Serializable隔离级别 ,读用读锁,写用写锁,读锁和写锁互斥,这么做可以有效的避免幻读、不可重复读、脏读等问题,但会极大的降低数据库的并发能力。
所以说不可重复读和幻读最大的区别,就在于如何通过锁机制来解决他们产生的问题。
上文说的,是使用悲观锁机制来处理这两种问题,但是MySQL、ORACLE、PostgreSQL等成熟的数据库,出于性能考虑,都是使用了以乐观锁为理论基础的MVCC(多版本并发控制)来避免这两种问题。
RC、RR区别
RC 与 RR 在锁方面的区别
- RC 隔离级别,通过 where 条件过滤之后,不符合条件的记录上的行锁,会释放掉(虽然这里破坏了“两阶段加锁原则”);但是RR隔离级别,即使不符合where条件的记录,也不会释放行锁和gap lock;所以从锁方面来看,RC的并发应该要好于RR;另外 insert into t select … from s where 语句在s表上的锁也是不一样的:
- RR 支持 gap lock(next-key lock),而RC则没有gap lock。因为MySQL的RR需要gap lock来解决幻读问题。而RC隔离级别则是允许存在不可重复读和幻读的。所以RC的并发一般要好于RR;
RR 和 RC 在并发性上的差异,核心在于 锁定范围(扫描过程中是否释放不满足条件的记录的锁),而非锁类型本身。
RC 隔离级别:
执行锁定读(如 SELECT … FOR UPDATE)时,仅锁定最终满足条件的记录。
如果某条记录被扫描但不满足 WHERE 条件,会立即释放锁。
结果:锁定的记录数较少,并发性更高。
RR 隔离级别:
执行锁定读时,即使记录不满足 WHERE 条件,也会保留锁(通过 Gap Lock 或 Next-Key Lock 防止幻读)。
结果:锁定的记录数更多(包括不满足条件的记录和间隙),并发性更低。
RC 与 RR 在复制方面的区别
RC 隔离级别不支持 statement 格式的bin log,因为该格式的复制,会导致主从数据的不一致;只能使用 mixed 或者 row 格式的bin log; 这也是为什么MySQL默认使用RR隔离级别的原因。复制时,我们最好使用:binlog_format=row
SQL标准的RR并不要求避免幻象读,而InnoDB通过gap锁来避免幻读,从而实现SQL的可串行化,保证Binlog的一致性。
RC 与 RR 在一致性读方面的区别
RC隔离级别时,事务中的每一条select语句会读取到他自己执行时已经提交了的记录,也就是每一条select都有自己的一致性读ReadView;
而RR隔离级别时,事务中的一致性读的ReadView是以第一条select语句的运行时,作为本事务的一致性读snapshot的建立时间点的。只能读取该时间点之前已经提交的数据。
锁
数据库的锁,在不同的事务隔离级别下,是采用了不同的机制的。在 MySQL 中,有三种类型的锁,分别是
- Record Lock: 记录锁,锁的是索引记录
- Gap Lock: Gap Lock是间隙锁,锁的是索引记录之间的间隙
- Next-Key Lock: Record Lock和Gap Lock的组合,同时锁索引记录和间隙。他的范围是左开右闭的
悲观锁和乐观锁
悲观锁
正如其名,它指的是对数据被外界(包括本系统当前的其他事务,以及来自外部系统的事务处理)修改持保守态度,因此,在整个数据处理过程中,将数据处于锁定状态。悲观锁的实现,往往依靠数据库提供的锁机制(也只有数据库层提供的锁机制才能真正保证数据访问的排他性,否则,即使在本系统中实现了加锁机制,也无法保证外部系统不会修改数据)。
在悲观锁的情况下,为了保证事务的隔离性,就需要一致性锁定读。读取数据时给加锁,其它事务无法修改这些数据。修改删除数据时也要加锁,其它事务无法读取这些数据。
乐观锁
相对悲观锁而言,乐观锁机制采取了更加宽松的加锁机制。悲观锁大多数情况下依靠数据库的锁机制实现,以保证操作最大程度的独占性。但随之而来的就是数据库性能的大量开销,特别是对长事务而言,这样的开销往往无法承受。
而乐观锁机制在一定程度上解决了这个问题。乐观锁,大多是基于数据版本( Version )记录机制实现。何谓数据版本?即为数据增加一个版本标识,在基于数据库表的版本解决方案中,一般是通过为数据库表增加一个 “version” 字段来实现。读取出数据时,将此版本号一同读出,之后更新时,对此版本号加一。此时,将提交数据的版本数据与数据库表对应记录的当前版本信息进行比对,如果提交的数据版本号大于数据库表当前版本号,则予以更新,否则认为是过期数据。
要说明的是,MVCC的实现没有固定的规范,每个数据库都会有不同的实现方式,这里讨论的是InnoDB的MVCC。
Next-Key锁
Next-Key锁是行锁和GAP(间隙锁)的合并,行锁上文已经介绍了,接下来说下GAP间隙锁。
意向锁
死锁
并发事务交叉更新不同记录(经典死锁)
-- 表结构
CREATE TABLE accounts (
id INT PRIMARY KEY,
balance INT
);
INSERT INTO accounts VALUES (1, 100), (2, 200);
-- 事务1(更新 id=1 → id=2)
BEGIN;
UPDATE accounts SET balance = balance - 50 WHERE id = 1;
-- 此处不提交,等待事务2执行
-- 事务2(更新 id=2 → id=1)
BEGIN;
UPDATE accounts SET balance = balance + 50 WHERE id = 2;
UPDATE accounts SET balance = balance + 50 WHERE id = 1; -- 阻塞,等待事务1释放 id=1 的锁
-- 事务1此时执行:
UPDATE accounts SET balance = balance - 50 WHERE id = 2; -- 等待事务2释放 id=2 的锁
-- 死锁发生!
死锁原因
-
事务1持有 id=1 的锁,请求 id=2 的锁;事务2持有 id=2 的锁,请求 id=1 的锁,形成循环等待。
-
InnoDB检测到后会自动回滚权重较小的事务(undo日志量少的)。
解决方案:
-
统一更新顺序:所有事务按固定顺序(如先id小的后id大的)操作资源。
-
减小事务粒度:拆分大事务为多个小事务提交。
间隙锁(Gap Lock)冲突(RR隔离级别特有)
-- 表结构:users(id PK, age INDEX), 数据:id=1(age=20), id=3(age=30)
-- 事务1(RR隔离级别)
BEGIN;
SELECT * FROM users WHERE age = 25 FOR UPDATE; -- 获取(20,30)间隙锁
-- 事务2
BEGIN;
INSERT INTO users VALUES (2, 25); -- 尝试插入到被锁定的间隙,阻塞
-- 若此时事务1也执行INSERT,则死锁,如 INSERT INTO users VALUES (4, 25)
死锁原因:
-
事务1持有(20,30)的间隙锁,事务2插入时被阻塞。
-
若事务1随后尝试插入相同间隙的记录(如INSERT INTO users VALUES (4, 25)),InnoDB会判定死锁。
解决方案:
-
改用RC隔离级别:避免间隙锁(但可能引发幻读)。
-
使用唯一索引:减少间隙锁范围。
唯一键冲突引发的死锁
-- 表结构:users(id PK, username UNIQUE)
-- 事务1
BEGIN;
INSERT INTO users VALUES (1, 'Alice'); -- 成功,持有'Alice'的唯一键锁
-- 事务2
BEGIN;
INSERT INTO users VALUES (2, 'Bob'); -- 成功,持有'Bob'的唯一键锁
INSERT INTO users VALUES (3, 'Alice'); -- 等待事务1的唯一键锁
-- 事务1此时执行:
INSERT INTO users VALUES (4, 'Bob'); -- 等待事务2的唯一键锁
-- 死锁形成!
死锁原因:
-
两个事务先各自成功插入一条记录,随后互相等待对方持有的唯一键锁。
-
与交叉更新不同,这种死锁涉及隐式锁转换(插入意向锁升级为排他锁)。
解决方案:
-
批量插入:用单条INSERT INTO users VALUES (1,‘Alice’), (2,‘Bob’)语句。
-
重试机制:捕获死锁异常后自动重试事务。
插入意向锁 导致死锁
首先在测试库里建表,并准备相关的原数据。
1、使用的mysql版本:线上5.7.21,测试8.0.32
2、配置的隔离级别:REPEATABLE-READ
创建个checkout_detail表,分别插入三条数据。
CREATE TABLE `checkout_detail` (
`id` bigint(20) NOT NULL COMMENT '主键id',
`recycle_order_id` bigint(20) NOT NULL COMMENT '回收单ID',
`confirm_recycle_time` datetime NOT NULL COMMENT '确认回收时间',
`contrast_type` int(4) NOT NULL COMMENT '对比类型:1:售前、2:后验、3:售后',
`remark` varchar(255) DEFAULT '' COMMENT '备注',
PRIMARY KEY (`id`),
UNIQUE KEY `uniq_idx_recycle_order_id_contrast_type` (`recycle_order_id`,`contrast_type`) USING BTREE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='后验详情表';
INSERT INTO checkout_detail ( id, recycle_order_id, confirm_recycle_time, contrast_type, remark )
VALUES
( 1, 1, '2024-07-15 19:56:01', 1, "回收单1" );#模拟线上数据
INSERT INTO ch eckout_detail ( id, recycle_order_id, confirm_recycle_time, contrast_type, remark );
VALUES
( 2, 10, '2024-07-15 19:56:01', 2, "回收单10" );#模拟线上数据
INSERT INTO checkout_detail ( id, recycle_order_id, confirm_recycle_time, contrast_type, remark );
VALUES
( 3, 20, '2024-07-15 19:56:01', 3, "回收单20" ); #模拟线上数据
死锁排查
上面执行第3步会锁等待,执行第4步会死锁。
执行如下SQL
SHOW ENGINE INNODB STATUS;
它是MySQL 中一个非常有用的命令,它用于显示 InnoDB 存储引擎的当前状态信息。这个命令对于诊断 InnoDB 存储引擎的问题、监控性能以及理解内部操作非常有帮助。
输出的内容非常多,我们只关注锁信息就行,找到LATEST DETECTED DEADLOCK 最近一次死锁信息如下:
总结
-
两个事务即使生成的间隙锁的范围是一样的,也不会发生冲突,因为间隙锁目的是为了防止其他事务插入数据,因此间隙锁与间隙锁之间是相互兼容的。
-
在执行插入语句时,如果插入的记录在其他事务持有间隙锁范围内,插入语句就会被阻塞,因为插入语句在碰到间隙锁时,会生成一个插入意向锁,然后插入意向锁和间隙锁之间是互斥的关系。
避免死锁的原则:
- 建立合适的索引,减小锁的粒度
- 选择合适的事务隔离级别
- 大事务拆成小事务,一个事务中的锁尽量少
六.事务是如何实现?
前面讲的重做日志,回滚日志以及锁技术就是实现事务的基础。
原子性 undo log
- 事务的原子性是通过 undo log 来实现的,(undo log 保存了事物发生之前的数据的一个版本,同时提供多版本并发控制下的读(mvcc 非锁定读))
为了做到同时成功或者失败,当系统发生错误或者执行rollback操作时需要根据undo log 进行回滚。
持久性 redo log
- 事务的持久性性是通过 redo log 来实现的,redolog是在事务开始与提交前就产生了
- MySQL的redo log是在事务提交前写入,并且在提交事务时进行刷盘操作。
这是因为redo log的目的是为了保证数据的持久性,即在数据库崩溃或意外关机等异常情况下,可以通过redo log来恢复数据库中未持久化的修改。因此,为了保证持久性,redo log必须先写入到磁盘中,等待确认后才能提交事务。在确认之前,redo log中的数据可以被视为“暂存”,尚未完全融合到数据库中。
既然redo log也需要存储,也涉及磁盘IO为啥还用它?
(1)redo log 的存储是顺序存储,而缓存同步是随机操作。
(2)缓存同步是以数据页为单位的,每次传输的数据大小大于redo log。
隔离性(读写锁+mvcc)
- 事务的隔离性是通过 (读写锁+MVCC)来实现的
一致性
而事务的终极大 boss 一致性是通过原子性,持久性,隔离性来实现的。
原子性,持久性,隔离性折腾半天的目的也是为了保障数据的一致性!
总之,ACID只是个概念,事务最终目的是要保障数据的可靠性,一致性。
MVCC
MVCC概念
MVCC(Multi-Version Concurrency Control)即多版本并发控制。MVCC 是一种并发控制的方法,一般在数据库管理系统中,实现对数据库的并发访问。
MVCC使得大部分支持行锁的事务引擎,不再单纯的使用行锁来进行数据库的并发控制,取而代之的是把数据库的行锁与行的多个版本结合起来,只需要很小的开销,就可以实现非锁定读,从而大大提高数据库系统的并发性能。
在并发读写数据库时,可以做到在读操作时不用阻塞写操作,写操作也不用阻塞读操作,提高了数据库并发读写的性能
同时还可以解决脏读,幻读,不可重复读等事务隔离问题,但不能解决更新丢失问题
MVCC基本特征
-
每行数据都存在一个版本,每次数据更新时都更新该版本。
-
修改时Copy出当前版本随意修改,各个事务之间无干扰。
-
保存时比较版本号,如果成功(commit),则覆盖原记录;失败则放弃copy(rollback)。
补充
1.MVCC手段只适用于Msyql隔离级别中的读已提交(Read committed)和可重复读(Repeatable Read);
2.Read uncimmitted由于存在脏读,即能读到未提交事务的数据行,所以不适用MVCC; 原因是MVCC的创建版本和删除版本只要在事务提交后才会产生。
3.串行化由于是会对所涉及到的表加锁,并非行锁,自然也就不存在行的版本控制问题;
4.通过以上总结,可知,MVCC主要作用于事务性的,有行锁控制的数据库模型。
MVCC 实现原理
MVCC 的目的就是多版本并发控制,在数据库中的实现,就是为了解决读写冲突,它的实现原理主要是依赖记录中的 3个隐式字段,undo日志 ,Read View(读视图) 来实现的。
3个隐式字段
每开启一个新事务,事务的版本号就会递增
undo log 数据链表记录
Undo log 的用途
(1)保证事务进行rollback时的原子性和一致性,当事务进行回滚的时候可以用undo log的数据进行恢复。
(2)用于MVCC快照读的数据,在MVCC多版本控制中,通过读取undo log的历史版本数据可以实现不同事务版本号都拥有自己独立的快照数据版本。
不同事务或者相同事务的对同一记录的修改,会导致该记录的undo log成为一条记录版本线性表,既链表,undo log 的链首就是最新的旧记录,链尾就是最早的旧记录.
Read View(读视图)
Read View是如何保证可见性判断的呢?我们先看看Read view 的几个重要属性
- m_ids:当前系统中那些活跃(未提交)的读写事务ID, 它数据结构为一个List。
- min_limit_id:表示在生成Read View时,当前系统中活跃的读写事务中最小的事务id,即m_ids中的最小值。
- max_limit_id:表示生成Read View时,系统中应该分配给下一个事务的id值。
- creator_trx_id: 创建当前Read View的事务ID
Read view 匹配条件规则如下:
- 1.如果数据事务ID trx_id < min_limit_id,表明生成该版本的事务在生成Read View前,已经提交(因为事务ID是递增的),所以该版本可以被当前事务访问。
- 2.如果trx_id>= max_limit_id,表明生成该版本的事务在生成ReadView后才生成,所以该版本不可以被当前事务访问。
- 3.如果 min_limit_id =<trx_id< max_limit_id,需要分3种情况讨论
- (1).如果m_ids包含trx_id,则代表Read View生成时刻,这个事务还未提交,但是如果数据的trx_id等于creator_trx_id的话,表明数据是自己生成的,因此是可见的。
- (2)如果m_ids包含trx_id,并且trx_id不等于creator_trx_id,则Read View生成时,事务未提交,并且不是自己生产的,所以当前事务也是看不见的;
- (3).如果m_ids不包含trx_id,则说明你这个事务在Read View生成之前就已经提交了,修改的结果,当前事务是能看见的。
准确的说,MVCC多版本并发控制指的是 “维持一个数据的多个版本,使得读写操作没有冲突” 这么一个概念。仅仅是一个理想概念
-
在RC隔离级别下,是每个快照读都会生成井获取最新的Read View
-
而在RR隔离级别下,则是同一个事务中的第一个快照读才会创建Read View,之后的快照读获取的都是同一个Read View.
MVCC 在可重读Repeatable reads事务隔离级别下:
- SELECT时,读取创建版本号<=当前事务版本号, 删除版本号为空或>当前事务版本号。
- INSERT时,保存当前事务版本号为行的创建版本号
- DELETE时,保存当前事务版本号为行的删除版本号
- UPDATE时,插入一条新纪录,保存当前事务版本号为行创建版本号,同时保存当前事务版本号到原来删除的
通过MVCC,虽然每行记录都需要额外的存储空间,更多的行检查工作以及一些额外的维护工作,但可以减少锁的使用,大多数读操作都不用加锁,读数据操作很简单,性能很好,并且也能保证只会读取到符合标准的行,也只锁住必要行。
4.1 查询一条记录,基于MVCC,是怎样的流程
- 获取事务自己的版本号,即事务ID
- 获取Read View
- 查询得到的数据,然后Read View中的事务版本号进行比较。
- 如果不符合Read View的可见性规则, 即就需要Undo log中历史快照;
- 最后返回符合规则的数据
InnoDB 实现MVCC,是通过Read View+ Undo Log 实现的,Undo Log 保存了历史快照,Read View可见性规则帮助判断当前版本的数据是否可见。
当前读
当前读的实现方式:next-key锁(行记录锁+Gap间隙锁)
select...lock in share mode (共享读锁)
select...for update
update , delete , insert
当前读, 读取的是最新版本, 并且对读取的记录加锁, 阻塞其他事务同时改动相同记录,避免出现安全问题。
例如,假设要update一条记录,但是另一个事务已经delete这条数据并且commit了,如果不加锁就会产生冲突。所以update的时候肯定要是当前读,得到最新的信息并且锁定相应的记录。
快照读
像不加锁的select * from 操作就是快照读,即不加锁的非阻塞读,不涉及其他锁之间的冲突;
快照读的前提是隔离级别不是串行级别,串行级别下的快照读会退化成当前读;之所以出现快照读的情况,是基于提高并发性能的考虑,快照读的实现是基于多版本并发控制,即MVCC,可以认为MVCC是行锁的一个变种,但它在很多情况下,避免了加锁操作,降低了开销;
既然是基于多版本,即快照读可能读到的并不一定是数据的最新版本,而有可能是之前的历史版本。
由于当前读是通过LBCC基于锁的方式来进行并发控制,是悲观锁的实现,同时也会因为锁的原因,造成锁冲突的概率变大,也会导致性能的下降,因此基于提高并发性能的考虑,引入了快照读。
快照读顾名思义即读取的是数据的快照版本,快照读的实现是基于MVCC多版本并发控制,它在很多情况下,避免了加锁操作,降低了性能开销。
快照读的实现方式:undolog和多版本并发控制MVCC
对于快照读,InnoDB 使用 MVCC 解决幻读,
单纯的select操作,不包括上述 select … lock in share mode, select … for update。
-
Read Committed隔离级别:每次select都生成一个快照读。
-
Read Repeatable隔离级别:开启事务后第一个select语句才是快照读的地方,而不是一开启事务就快照读。
说白了MVCC就是为了实现读(select)-写冲突不加锁,而这个读指的就是快照读, 而非当前读,当前读实际上是一种加锁的操作,是悲观锁的实现。
RC、RR innodb快照读有什么区别
RR级别没有完全解决幻读
mysql> START TRANSACTION; -- 1. 开启事务1
Query OK, 0 rows affected (0.00 sec)
mysql> select * from user_info; -- 2. 在事务1中查询用户信息
+----+-------+--------+---------------+
| id | name | gender | email |
+----+-------+--------+---------------+
| 1 | Curry | 男 | curry@163.com |
| 2 | Wade | 男 | wade@163.com |
| 3 | James | 男 | james@163.com |
+----+-------+--------+---------------+
3 rows in set (0.00 sec)
mysql> update user_info set email = REPLACE(email, '@163.com', '@gmail.com'); -- 6. 在事务1中将所有用户的邮箱信息的后缀更换为@gmail.com
Query OK, 4 rows affected (0.00 sec)
Rows matched: 4 Changed: 4 Warnings: 0
mysql> select * from user_info; -- 7. 在事务1中再次查询用户信息
+----+-------+--------+-----------------+
| id | name | gender | email |
+----+-------+--------+-------------- --+
| 1 | Curry | 男 | curry@gmail.com |
| 2 | Wade | 男 | wade@gmail.com |
| 3 | James | 男 | james@gmail.com |
| 4 | White | 男 | white@gmail.com |
+----+-------+--------+-----------------+
4 rows in set (0.00 sec)
mysql> commit; -- 8. 提交事务1
Query OK, 0 rows affected (0.00 sec)
mysql> START TRANSACTION; -- 3.开启事务2
Query OK, 0 rows affected (0.00 sec)
mysql> INSERT INTO `user_info` (`id`, `name`, `gender`, `email`) VALUES (4, 'White', '男', 'white@163.com'); -- 4.在事务2中插入一条新数据
Query OK, 1 row affected (0.00 sec)
mysql> commit; -- 5.提交事务2
Query OK, 0 rows affected (0.00 sec)
mysql> select * from user_info;
+----+-------+--------+---------------+
| id | name | gender | email |
+----+-------+--------+---------------+
| 1 | Curry | 男 | curry@163.com |
| 2 | Wade | 男 | wade@163.com |
| 3 | James | 男 | james@163.com |
| 4 | White | 男 | white@163.com |
+----+-------+--------+---------------+
4 rows in set (0.00 sec)
事务1在进行更新之后再次查询读取到了事务2新插入到数据,出现了幻读。
产生幻读的场景
1.初始三条测试数据
2.开启事务1
3.在事务1中查询用户信息
4.开启事务2
5.在事务2中插入一条新数据
6.提交事务2
7.在事务1中将所有用户的邮箱信息更换为@gmail.com
8.在事务1中再次查询用户信息
9.提交事务1
1.3 执行步骤2:在事务1中查询用户信息
因为是开启事务后的首次查询,所以此时会生成一张Read Veaw读视图,此时trx_list,up_limit_id,low_limit_id的值分别为:
trx_list:因为是测试验证,无其他并发事务参与,所以活跃事务列表中只有当前的事务id[2335];
up_limit_id:活跃事务列表中最小的事务id,即当前事务id:2335;
low_limit_id:下一个未开始的事务id,即当前事务id+1为:2336;
此时查询数据会使用当前生成的Read View并依据可见性算法来进行查询,因为数据库中数据的事务id均小于up_limit_id所以对当前事务均是可见的,所以三条初始数据会全部被查询出来。
可见性算法
注: 可见性算法
首先比较 DB_TRX_ID < up_limit_id , 如果小于,则当前事务能看到 DB_TRX_ID
所在的记录,如果大于等于进入下一个判断 接下来判断 DB_TRX_ID >= low_limit_id , 如果大于等于则代表
DB_TRX_ID 所在的记录在 Read View 生成后才出现的,那对当前事务肯定不可见,如果小于则进入下一个判断 判断
DB_TRX_ID 是否在活跃事务之中,trx_list.contains (DB_TRX_ID),如果在,则代表Read View
生成时刻,这个事务仍处于活跃中,还没有commit,如果DB_TRX_ID=creator_trx_id,则说明是当前事务自己产生的数据,是可见的,如果不等于,则为其他事务修改的数据,当前事务也是看不见的;如果不在活跃事务之中,则说明,你这个事务在Read
View生成之前就已经commit了,修改的结果,当前事务是能够看见的。
undolog情况如下:
当前读可以通过锁机制完全避免幻读问题,快照读如果中间对其他事务已提交的插入或更新的数据进行了更新,则会出现幻读的问题。
如何进行避免呢?
- 采用串行化的隔离级别(不建议);
- 开发时注意考虑这种产生幻读的场景,尽量通过调整代码逻辑规避幻读问题的发生(建议);
- 若不能通过调整代码逻辑规避,可以考虑采用当前读的方式避免(建议);