MySQL事务深度解析:从ACID到MVCC的实现原理

一、事务基础概念

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核心机制总结

  1. 版本链:通过隐藏字段构建数据历史版本
  2. Undo Log:存储历史版本,支持回滚
  3. Read View:决定事务能看到哪个版本
  4. 可见性算法:基于事务ID和活跃事务列表判断

5.3 重要结论

  1. MySQL默认RR级别:在大多数场景下提供了良好的平衡
  2. MVCC优势:读写不冲突,提高并发性能
  3. RR vs RC核心区别:ReadView的创建和复用策略
  4. 事务设计原则:尽量短小,尽快提交,避免长事务
相关推荐
狮子也疯狂1 小时前
【天翼AI-星辰智能体平台】| 基于Excel表实现智能问数助手智能体开发实战
人工智能·oracle·excel
DechinPhy2 小时前
使用Python免费合并PDF文件
开发语言·数据库·python·mysql·pdf
共享家95272 小时前
MySQL-基础查询(下)
android·mysql
苹果醋32 小时前
JAVA设计模式之策略模式
java·运维·spring boot·mysql·nginx
杨了个杨89822 小时前
PostgreSQL 完全备份与还原
数据库·postgresql
爱吃KFC的大肥羊2 小时前
Redis持久化详解(一):RDB快照机制深度解析
数据库·redis·缓存
黎明破晓.2 小时前
Redis
数据库·redis·缓存
Dovis(誓平步青云)2 小时前
《MySQL从入门:基础安装与数据库核心概念全解析》
数据库·mysql
Web极客码2 小时前
如何选择最适合的内容管理系统(CMS)?
java·数据库·算法