一、事务基础概念
1.1 什么是事务?
定义:事务是一组逻辑上相关的DML(数据操作语言)语句组成的操作序列,这些语句构成一个完整的业务逻辑单元。
核心特性 :事务中的操作要么全部成功,要么全部失败,保证数据的完整性和一致性。
1.2 事务的应用场景
| 场景 | 问题 | 事务解决方案 |
|---|---|---|
| 购票系统 | 超卖问题 | 原子性保证一张票只卖一次 |
| 银行转账 | 扣款成功但收款失败 | 原子性保证要么全成功要么全回滚 |
| 库存管理 | 并发修改导致数据错误 | 隔离性保证事务间互不干扰 |
1.3 事务的ACID特性
事务ACID特性 原子性
Atomicity 一致性
Consistency 隔离性
Isolation 持久性
Durability 全部成功或全部失败 异常时自动回滚 数据库完整性 逻辑正确性 并发事务隔离 四种隔离级别 提交后永久生效 故障恢复保障
A、原子性(Atomicity)
事务的一组DML语句,所有的操作,要么全部执行成功,要么全部失败,如果一部分失败了,就需要把执行成功的语句,全部回滚到开始的状态。
B、持久性(Durable)
一个事物完成后,对数据库的修改是永久的,不会因为数据库故障而丢失。
C、隔离性(Isolation)
数据库允许多个事务并发的同时执行,但是将这些事务隔离开了,这些事务互不影响,不同的事务看到的是不同的数据库。
事务的隔离性有不同的隔离级别:读未提交、读提交、可重复读、串行化
D、一致性(Consistency)
一个事物在开始前和完成后,数据库的完整性没有被破坏,也就是说一个事物在完成之前,就能够预测到完成后的状态。
注意:实际上,MySQL没有采用特殊的策略来保证一致性,而是通过原子性、持久性、隔离性三大特性,就保证了事务的一致性。
1.4 在MySQL中事务怎么体现呢?
因为会随时存在多个客户端访问MySQL,所以MySQL中必然同时存在多个事务,
也就是说MySQL一定要对事务进行管理,老样子,先描述、再组织,
MySQL中一定将事务描述成了一个结构体,用某种数据结构管理起来了,
用户提交一个事物,MySQL用这些SQL语句构造一个事物对象,放到特定的数据结构中进行管理,按照特定的机制执行。
1.5 为什么要有事务呢?
事务是为了简化应用层编码难度的,
如果数据库没有事务这个功能,那么就需要我们开发人员在应用层做好同步与互斥,保证线程安全,
编码难度显著提升,而数据库有了事务这个特性之后,我们就不用考虑数据库内部数据的线程安全的问题了,
直接交给数据库来处理这些线程安全问题即可。
二、MySQL事务操作
2.1 事务支持与存储引擎
| 存储引擎 | 事务支持 | 特点 |
|---|---|---|
| InnoDB | ✅ 支持 | 默认引擎,支持ACID、行级锁、外键 |
| MyISAM | ❌ 不支持 | 表级锁,性能高但不支持事务 |
sql
-- 查看存储引擎
SHOW ENGINES;
-- 查看表的存储引擎
SHOW CREATE TABLE table_name;
2.2 事务的提交方式
MySQL中事务的提交方式有两种:自动提交和手动提交。
sql
-- 查看自动提交设置
SHOW VARIABLES LIKE 'autocommit';
-- 返回:autocommit = ON(默认开启)
-- 关闭自动提交(手动控制事务)
SET autocommit = 0;
-- 开启自动提交
SET autocommit = 1;
2.3 事务基本操作
sql
-- 1. 开启事务(两种方式)
BEGIN; -- 推荐
START TRANSACTION;
-- 2. 创建保存点
SAVEPOINT sp1;
-- 3. 执行SQL操作
UPDATE accounts SET balance = balance - 100 WHERE id = 1;
UPDATE accounts SET balance = balance + 100 WHERE id = 2;
-- 4. 回滚到保存点
ROLLBACK TO sp1;
-- 5. 回滚整个事务
ROLLBACK;
-- 6. 提交事务
COMMIT;
2.4 事务异常处理机制
异常类型 是 否 客户端崩溃 网络断开 MySQL服务异常 SQL执行错误 开始事务 执行SQL操作 是否发生异常? 自动回滚所有操作 手动提交COMMIT 数据恢复到事务前状态 数据永久生效
重要规则:
A、当我们手动begin或者start transaction启动一个事物后,必须要手动commit提交事务,与autocommit无关,否则事务不会被持久化,出现异常就会被回滚
B、事务可以手动回滚,同时,当发生异常的时候,MySQL会自动回滚所有没有自动提交的事务
C、对于InnoDB来说,单条SQL语句默认就是一个事物(默认autocommit = 1),select有特殊情况,MySQL有MVCC机制
三、事务隔离级别
3.1 隔离级别概述
隔离级别解决的问题 :在并发事务环境下,如何平衡数据一致性 与系统性能的矛盾。
MySQL作为一个服务器,肯定是会同时被多个客户端访问的,
会同时执行多个事务,那么不同的事务之间肯定是会有可能互相影响的,比如多个事务访问同一张表、同一行数据,
MySQL的事务为了避免不同的事务之间互相影响,于是将不同的事务隔离开,
让每一个事务看到的数据库都是不同时间的数据库。
MySQL中,允许事务受到不同程度的干扰,这些不同的程度就叫隔离级别。
3.2 四种隔离级别对比
读未提交
Read Uncommitted 读提交
Read Committed 可重复读
Repeatable Read 串行化
Serializable
| 隔离级别 | 脏读 | 不可重复读 | 幻读 | 性能 | 适用场景 |
|---|---|---|---|---|---|
| 读未提交 | ❌ 存在 | ❌ 存在 | ❌ 存在 | ⭐⭐⭐⭐⭐ | 几乎不用 |
| 读提交 | ✅ 解决 | ❌ 存在 | ❌ 存在 | ⭐⭐⭐⭐ | Oracle默认 |
| 可重复读 | ✅ 解决 | ✅ 解决 | ✅ 解决* | ⭐⭐⭐ | MySQL默认 |
| 串行化 | ✅ 解决 | ✅ 解决 | ✅ 解决 | ⭐ | 金融交易 |
*注:MySQL在RR级别通过Next-Key锁解决了幻读问题
3.2.1 读未提交
读未提交级别下,不同的事务是并发执行的,没有任何限制,导致不同的事务,在提交之前,就可以被其他的事务看到执行结果。
这种隔离级别实际上没有任何隔离性,不同的事务之间是会相互干扰、相互影响的。
在读未提交的情况下,不同的事务之间可以在提交之前,就可以看到执行结果,
也就造成了脏读、不可重复读、幻读等问题,实际生产环境,是不可能使用读未提交这种低隔离级别的。
3.2.2 读提交
读提交是大多数数据库的默认隔离级别(MySQL不是)。
读提交隔离级别,不同的事务只能看到其他事务commit提交之后的的结果,commit提交之前,看不到其他事务的执行结果。
读提交隔离级别,解决了脏读的问题,但是依旧面临不可重复读、幻读的问题。
也就是说一个事物,如果有多个select语句select同一条记录,可能得到不同的结果,比如,有两个执行的很快的事务对该条记录修改了,并且commit提交了。
3.2.3 可重复读
可重复读是MySQL的默认隔离级别。
可重复读这种隔离级别比较奇怪,两个事务,先来的事务执行完了,并且commit完了之后,依然看不到先来的事务的执行结果,必须要事务本身执行完了自己的SQL语句,并且把自己的事务也给提交了,才能看得到先来的事务的执行结果。
这样就保证了,同一个事物中的多个select,得到的是同一个结果。
当然,我事务都执行完了,肯定已经不需要再去看你的执行结果了,所以是给我后面的事务来看这个先来的事务的执行结果的。
可重复读,看名字就知道解决了脏读、不可重复读的问题,依然还面临着幻读的问题。(但是,mysql在RR隔离级别通过Next-Key锁解决了幻读问题)
3.2.4 串行化
串行化是最高的隔离级别。
看名字,串行化肯定是要求不同的事务按照串行的顺序执行,给每一个读的数据行上面都加上了共享锁,先来的事务执行完了,提交了,后面的事务才能开始执行。
这样子,毫无疑问最安全,解决了脏读、不可重复读、幻读等问题,但是串行化,就意味着效率太低了。
3.3 问题现象解析
3.3.1 脏读(Dirty Read)
脏读就类似于,考试的时候,有草稿纸和答题卡,正常来说,应该交答题卡,老师看不到我们的草稿纸,也就是说看不到我们的中间没有用的数据,而脏读,就是读到了这些没有用的中间数据。
sql
-- 事务A
BEGIN;
UPDATE users SET balance = 500 WHERE id = 1; -- 未提交
-- 事务B(读未提交级别)
SELECT balance FROM users WHERE id = 1; -- 读到500(脏数据)
结果:事务B读到了事务A未提交的中间数据
3.3.2 不可重复读(Non-Repeatable Read)
两次对同一行进行读取,由于两次读取之间间隔的时间较久,在这之间有其他事务对这一行的数据进行了处理,并提交了,就造成两次读取的结果不同。
sql
-- 事务A
BEGIN;
SELECT balance FROM users WHERE id = 1; -- 返回100
-- 事务B
UPDATE users SET balance = 200 WHERE id = 1;
COMMIT;
-- 事务A再次查询
SELECT balance FROM users WHERE id = 1; -- 返回200(结果变了!)
结果:同一事务内两次读取同一数据结果不一致
3.3.3 幻读(Phantom Read)
幻读,也算是一种特殊的不可重复读,在两次查询中间,进行了insert插入操作,由于插入的这一行在原来的表中是没有的,无法加行锁进行限制,
就会导致两次查询的到的结果不一样,就像出现幻觉一行,这就是幻读。
sql
-- 事务A
BEGIN;
SELECT COUNT(*) FROM users WHERE age > 30; -- 返回10
-- 事务B
INSERT INTO users(name, age) VALUES('张三', 35);
COMMIT;
-- 事务A再次查询
SELECT COUNT(*) FROM users WHERE age > 30; -- 返回11(多了一行!)
结果:同一事务内两次查询范围数据,行数发生变化
3.4 隔离级别设置与查看
sql
-- 查看当前隔离级别
SELECT @@global.tx_isolation; -- 全局隔离级别
SELECT @@session.tx_isolation; -- 会话隔离级别
SELECT @@tx_isolation; -- 当前隔离级别(会话别名)
-- 设置隔离级别
-- 全局设置(重启后生效)
SET GLOBAL TRANSACTION ISOLATION LEVEL READ COMMITTED;
-- 会话设置(当前连接有效)
SET SESSION TRANSACTION ISOLATION LEVEL REPEATABLE READ;
-- 仅对下一个事务生效
SET TRANSACTION ISOLATION LEVEL SERIALIZABLE;
四、MVCC多版本并发控制
4.1 MVCC概述
MVCC(Multi-Version Concurrency Control):多版本并发控制,解决数据库读写冲突的无锁并发控制机制。
解决的问题:
- 读写冲突:读操作阻塞写操作,或写操作阻塞读操作
- 并发性能:提高数据库并发处理能力
数据库中的并发场景有三种:
读读:不存在任何问题,不需要并发控制
读写:有线程安全问题,可能造成事物隔离性的问题,比如脏读、不可重复读、幻读等问题
写写:有线程安全问题,可能存在更新丢失的问题,比如第一类更新丢失、第二类更新丢失
4.2 隐藏字段与版本链
当我们在建表的时候,MySQL会在我们建表的基础上,增加四个隐藏的字段。
A、DB_TRX_ID :6字节,表示最近修改的事务的ID,用来记录创建这条记录/最后一次修改该记录的事务ID
B、DB_ROLL_PTR :7字节,回滚指针,指向这条记录的上一个版本/历史版本,历史版本一般都在undo log中
C、DB_ROW_ID :6字节,行ID,也就是索引中提到的隐藏主键,没有指定主键,就用DB_ROW_ID作为主键
D、flag :表示该数据有没有被删除,删除的时候,并不是直接去进行IO,而是先标记成无效,被删除了,等被删除的数据多了,一起IO
MySQL表的隐藏字段:
sql
-- 实际表结构(隐藏字段)
CREATE TABLE users (
id INT PRIMARY KEY,
name VARCHAR(50),
-- 以下为隐藏字段 --
DB_TRX_ID BIGINT, -- 最近修改的事务ID(6字节)
DB_ROLL_PTR BIGINT, -- 回滚指针(7字节)
DB_ROW_ID BIGINT, -- 行ID(隐藏主键,6字节)
DELETED_FLAG BOOLEAN -- 删除标志
);
undo log版本链
undo log //undo日志
undo log可以理解成MySQL在内存中开辟的一块缓冲区,用来存放MySQL的日志,可以用来事务回滚。
4.3 MVCC版本链构建过程
首先向空表插入了一条数据,插入数据嘛,先加锁,然后把insert的这条数据复制,放到undo log这个缓冲区中,由于是第一条数据,没有历史版本,
回滚指针就是null,记录下事务id,给予行号,插入完毕后,释放锁。

事务10想要修改这条数据,先加锁,然后将当前数据复制一份,放到undo log中,最新的数据的回滚指针,就指向了undo log中的历史版本,
然后修改最新数据的name,然后记录事务id,操作完毕后,释放锁。

事务11又要修改这条数据,依旧是先加锁,然后复制最新数据,放到undo log中,将最新数据的回滚指针指向undo log中的历史版本,
然后修改age = 38,记录下事务id,然后释放锁。

由于回滚指针指向历史版本,一个事物内对同一条记录的操作,形成了一条版本链,依照版本链就可以很容易得回滚。
直接找到历史版本,覆盖当前版本即可。
undo log中的每一个历史版本,我们称之为一个快照。
ps:不同操作的快照是不一样的。
1.insert的快照是 记录下事务id,表id,主键内容,回滚的时候,直接delete * from tableid where primary key = ;//执行相反的操作
2.update的快照是直接复制上一个版本,把上一个版本直接放进去,回滚的时候,拿快照覆盖当前数据
3.delete的快照,由于在事务提交前,不会真实删除数据,而是把flag 置为删除标志,然后就把这个快照放到undo log,所以回滚的时候,只需要将flag标志置为未删除即可。
4.4 当前读和快照读
写操作一定是操作最新的数据,我们称之为当前读,写写之间都是操作最新数据一定要加锁,
快照读就是读取历史的数据。
读操作根据不同的隔离级别,操作的可能是快照读,也可能是当前读,
4.5 如何实现隔离?
如何实现隔离? --- 快照隔离/版本隔离
在进行读写的时候,只需要让不同事物看到的是不同时期的快照,就可以实现非常好的隔离了。
在RU级别,读操作是当前读,但是没有加锁,所以有线程安全的问题,
RC和RR级别,读操作是快照读,和写操作不冲突,可以读写并发,效率高,而且没有锁冲突,
serializable级别,读操作是当前读,但是加了共享锁,读写操作不能够并发,需要竞争锁,线程安全。
4.6 Read View机制
RC和RR在读的时候都是快照读(历史版本),为啥看到的却是不同的结果?
read view读视图是事务进行快照读的时候产生的读视图,
在事务执行快照读的一瞬间,数据库就会创建一个读视图,记录并维护系统当前活跃事务的ID。
通过read view作为比较标准,就可以做可见性判断,决定哪些事务能看见当前快照。
Read View结构:
cpp
class ReadView {
// 省略...
private:
trx_id_t m_low_limit_id//高水位,大于等于这个ID的事务均不可见
trx_id_t m_up_limit_id;//低水位:小于这个ID的事务均可见
trx_id_t m_creator_trx_id;//创建该 Read View 的事务ID
ids_t m_ids;//创建视图时的活跃事务id列表
/** 配合purge,标识该视图不需要小于m_low_limit_no的UNDO LOG,
* 如果其他视图也不需要,则可以删除小于m_low_limit_no的UNDO LOG*/
trx_id_t m_low_limit_no;
/** 标记视图是否被关闭*/
bool m_closed;
// 省略...
};
m_ids; //一张列表,用来维护Read View生成时刻,系统正活跃的事务ID
up_limit_id; //记录m_ids列表中事务ID最小的ID(没有写错)
low_limit_id; //ReadView生成时刻系统尚未分配的下一个事务ID,也就是目前已出现过的事务ID的最大值+1(也没有写错)
creator_trx_id; //创建该ReadView的事务ID
4.7 可见性判断算法
A、如果快照的事务ID == 创建读视图的事务ID ,说明就是当前事物,肯定能看到
B、如果快照的事务ID < 低水位ID,说明这个事务在创建读视图之前早就提交了,肯定能看到
C、如果快照的事务ID > 高水位ID,说明这个事务在创建读视图的时候还没有开始,必然看不到(即使未来这个事务可能比读视图的事务先提交)
D、如果快照的事务ID 在高水位和低水位之间,此时有两种可能,
(1)快照的事务ID ∈ m_ids(活跃的事务ID),说明这个事务和读视图的事务正在并发执行,没有提交,看不到快照
(2)快照的事务ID ∉ m_ids(活跃的事务ID),说明这个事务早就在创建读视图前已经提交了,肯定能看到。


4.8 RR vs RC隔离级别的核心区别
RR隔离级别和RC隔离级别的区别并不是网上的这种说法:
RC隔离级别,事务A提交了之后,并发的事务B就能看到事务A的结果,
RR隔离级别,事务A提交了之后,并发的事务B看不到事务A的结果,事务B也提交后才能看到事务A的结果。
这种理解是错误的,比如下面的这个场景?
| 时序 | 事务A操作 | 事务A描述 | 事务B描述 | 事务B操作 |
|---|---|---|---|---|
| T1 | begin; |
开启事务A | 开启事务B | begin; |
| T2 | select * from user; |
快照读 ,查询到 age=18 |
- | - |
| T3 | update user set age=28 where id=1; |
更新 age=28 |
- | - |
| T4 | commit; |
提交事务A | - | - |
| T5 | - | - | 快照读 ,查询到 age=28 |
select * from user; |
| T6 | - | - | 当前读 ,查询到 age=28 |
select * from user lock in share mode; |
我们发现,实际上在事务B提交之前,就能够看到事务A的执行结果了。
具体是为啥呢?看下面的分析。
RC隔离级别,每次进行快照读,都会创建一个新的ReadView,所以其他并发事务一提交,就能够看到执行结果;
RR隔离级别,只有第一次进行快照读的时候,才会创建一个ReadView,同一个事务,后续都是这一个ReadView,看到的是同一个版本的快照,因此,RR隔离级别实现了可重复读。
读提交 Read Committed 可重复读 Repeatable Read 第一次快照读 事务开始 创建ReadView1 第二次快照读 创建新的ReadView2 可能看到新提交的数据 第一次快照读 事务开始 创建ReadView 后续快照读 复用同一个ReadView 看到相同版本
核心区别表:
| 特性 | RR(可重复读) | RC(读提交) |
|---|---|---|
| ReadView创建时机 | 事务第一次快照读时创建 | 每次快照读都创建新的 |
| ReadView复用 | 整个事务复用同一个 | 每次创建新的 |
| 可见性变化 | 事务内看到的数据版本不变 | 可能看到其他事务新提交的数据 |
| 解决不可重复读 | 是 | 否 |
五、总结与关键要点
5.1 核心概念回顾
| 概念 | 要点 | 实现机制 |
|---|---|---|
| 原子性 | 全成功或全失败 | Undo Log回滚 |
| 持久性 | 提交后永久生效 | Redo Log重做 |
| 隔离性 | 并发事务互不干扰 | 锁 + MVCC |
| 一致性 | 数据完整性 | 前三个特性保证 |
5.2 MVCC核心机制总结
- 版本链:通过隐藏字段构建数据历史版本
- Undo Log:存储历史版本,支持回滚
- Read View:决定事务能看到哪个版本
- 可见性算法:基于事务ID和活跃事务列表判断
5.3 重要结论
- MySQL默认RR级别:在大多数场景下提供了良好的平衡
- MVCC优势:读写不冲突,提高并发性能
- RR vs RC核心区别:ReadView的创建和复用策略
- 事务设计原则:尽量短小,尽快提交,避免长事务