目录
一、引言:为什么需要事务
事务是InnoDB存储引擎才支持的,本文的默认存储引擎就是InnoDB。
假设,有两个银行账户,账户A和账户B。账户A向账户B转100块钱。
第一步:从账户A的余额中减去100。
第二步:给账户B的余额加上100。
如果没有事务,可能会发生以下问题:
- 原子性问题:如果第一步执行成功,在第二步执行前,系统突发故障(比如数据库崩溃),那么账户A的钱已经扣了,但是账户B的钱没有增加,相当于这100块钱就消失了。事务可以保证,这两个步骤要么全部执行成功,要么全部执行失败。也就是说,如果第二步执行失败,那么将会进行回滚操作,账户A的钱不会减少。
- 一致性问题:假设账户A和账户B的余额总和在转账前是2000元,转账后应该还是2000元。如果没有事务,在第一步和第二步之间,其他操作可能看到不一致的状态(比如账户A已经扣款,账户B还未增加,总和是1900元)。事务通过隔离性来保证一致性,确保中间状态不会被其他操作看到。
- 隔离性问题:如果同时有另一个转账操作也在进行,比如从账户B转账50元到账户A。如果没有事务的隔离,两个转账操作可能会相互干扰,导致最终余额不正确。事务的隔离性可以防止这种并发问题。
- 持久性问题:一旦转账成功,账户A和账户B的余额变化应该是永久性的,即使系统故障也不会丢失。事务的持久性保证一旦提交,修改就会永久保存到数据库中。
所以,事务就是为了保证这一系列操作能够安全、可靠、一致地执行。
二、事务的基本概念
2.1 事务是什么
事务本质上就是包装了一组SQL语句,这组操作要么全部执行成功,要么不做。
只要这组SQL语句中有一句执行失败,那么将会进行回滚,前面已经成功执行的操作也会被撤销。
2.2 事务的ACID特性
原子性(Atomicity):事务中的操作要么全部执行成功,要么全部回滚,通过 undo log(回滚日志) 实现。

执行一个UPDATE操作
UPDATE accounts SET balance = balance - 100 WHERE id = 1;
在执行修改操作之前,会先将旧数据写入Undolog日志(回滚日志),其次才是对数据进行修改。如果事务执行成功,那么Undolog将被标记为可清理;如果执行失败,那么事务将会进行回滚,将数据恢复成旧值,就像update未曾发生过。
上图是Undolog日志,它是二进制文件,所以看不懂很正常。
顺便提个问题:为什么Undolog文件是二进制文件,而不是文本文件?
因为存储同一份数据,采用二进制存储往往要比直接进行文本储存所需要的空间更少,即文件大小更小。对于Undolog这样的文件,如果业务比较大,那么Undolog日志是要被频繁写入的,为了避免文件膨胀太快,节省一些磁盘空间,所以采用了二进制存储而不是直接进行文本存储。这么做的代价就是牺牲了文件的可读性。
一致性(Consistency):事务执行前后,数据库数据始终保持合法状态(如字段 “年龄” 不会因事务执行出现负数)。
一致性是结果,不是单一的机制。它是靠其他三个特性(原子性、隔离性、持久性)来共同保障的。
隔离性(Isolation):多个并发事务之间相互隔离,避免数据干扰。
隔离性是通过锁和MVCC来实现的,后面我们细说。
持久性(Durability):事务提交后,数据修改会永久保存,即使数据库崩溃也不会丢失,通过 redo log(重做日志) 实现(事务提交时先写 redo log,再刷盘,避免 “掉电丢失”)。
执行一个UPDATE操作
UPDATE accounts SET balance = balance - 100 WHERE id = 1;
还是以这个update操作为例。
开启事务---->对内存中的数据进行修改--->将所有修改写入redo log buffer--->将redo log buffer进行刷盘--->提交事务--->稍后,后台线程慢慢将被修改的数据页(脏页)刷到磁盘。
假设在对内存中的数据进行修改后,因为外星人袭击地球,导致服务器断电。下面我们来分析看会不会导致数据丢失。
因为内存是带电存储的,所以毫无疑问,内存中的所有数据将会丢失,包括我们刚进行修改的数据页。在服务器恢复供电以后,重启服务器。服务器就会根据redo log来恢复数据,但是由于redo log中也不存在这条修改记录,所以修改并为成功,还是之前的数据。而且,事务也为提交成功,所以也会进行回滚,总之,这条数据就不会被修改。
三、事务的隔离级别

以下将基于这张学生表进行演示。MySQL中,默认的事务隔离级别是可重复读。

3.1 读未提交(Read Uncommitted)
可以读到别人未提交的改动,而这个数据可能是无效的,它只是中间数据。

左边的是事务还未提交修改,但是右边的事务已经看到了。这就是读未提交隔离级别带来的问题——脏读。因为读到的这个数据不一定是有效的,所以这种情况就叫做脏读。
3.2 读已提交(Read Committed)
不能读到别人未提交的改动,只能读到别人已经提交的改动。

在读已提交隔离级别下,解决了读未提交隔离级别下的脏读问题。但是,仍然存在不可重复读问题。所谓的不可重复读,就是同一事务中,多次读取同一数据,结果不一样。

可以看到,右边的事务,前后两次查询张三的数学成绩,查到的结果是不一样的。
3.3 可重复读(Repeatable Read)
在同一事务中,多次读取同一数据,结果是一致的。

在可重复读隔离级别下,解决了不可重复读问题,但是,依然存在幻读——在同一事务中,同样的查询条件,第一次和第二次查询时,记录数不一样(因为其他事务插入或删除了满足条件的记录)。
3.4 串行化(Serializable)
所有事务,一个一个来,完全隔离,安全性最高,但是效率最低。
3.5 不同隔离级别总结
| 隔离级别 | 简称 | 说明 | 问题 |
|---|---|---|---|
| READ UNCOMMITTED | 读未提交 | 能看到其他事务未提交的修改 | 脏读、不可重复读、幻读 |
| READ COMMITTED | 读已提交 | 只能看到其他事务已提交的修改 | 不可重复读、幻读 |
| REPEATABLE READ | 可重复读 | 事务内多次读取同一数据,结果一致(MySQL 默认级别) | 幻读(InnoDB 已通过 MVCC 解决) |
| SERIALIZABLE | 串行化 | 最高隔离级别,事务串行执行,完全避免并发问题 | 性能极低(锁表) |
四、MVCC
4.1 什么是MVCC
MVCC(Multi-Version Concurrency Control,多版本并发控制)是InnoDB实现非锁定读的核心技术。它让读操作不阻塞写操作,写操作不阻塞读操作,从而大幅提升并发性能。
光看这句话,比较抽象,下面展开来说说。
要理解MVCC可以“让读操作不阻塞写操作,写操作不阻塞读操作”这句话,我们就要先从没有MVCC时,读写操作为什么会相互阻塞说起。
如果没有MVCC,那么读写操作就只能通过锁来实现并发控制,自然就会导致读写互斥的问题。关于锁的概念,这里可以先做个简单了解,后面再进一步理解。
读阻塞写:当事务A读取数据时,会加共享锁(S锁),此时事务B要修改该数据,需要加排他锁(X锁),但S锁和X锁互斥,事务B必须等待事务A释放S锁才能执行,即读阻塞了写。
写阻塞读:当事务B修改数据时,会加排他锁(X锁),此时事务A要读取该数据,需要加共享锁(S锁),但S锁和X锁互斥,事务A必须等待事务B释放X锁才能执行,即写阻塞了读。
在这种情况下,大多的线程都是在阻塞等待,所以导致并发效率低。至于MVCC如何实现“让读操作不阻塞写操作,写操作不阻塞读操作”,下面会详细分析。
4.2 MVCC的核心结构

1)隐藏字段
// 实际的存储结构(简化)
struct InnoDB_ROW {
DB_ROW_ID row_id; // 行ID
DB_TRX_ID db_trx_id; // 最近修改它的事务ID
DB_ROLL_PTR db_roll_ptr; // 回滚指针(指向UNDO LOG)
// ... 用户实际数据
// ... 所有索引列
};
2)Read View(读视图)
一个决定事务能看到什么的数据结构,当一个事务执行select时,会生成一个Read View,主要包含以下字段:
m_ids: 当前系统中活跃的事务ID集合(未提交的事务ID集合)
min_trx_id: 活跃的最小事务ID
max_trx_id: 下一个将要分配的事务ID(当前最大的事务ID + 1)
creator_trx_id: 创建这个Read View的事务ID
3)版本链

当一个事务要更新一行数据时,首先要要写Undo Log 日志,然后再修改数据,并且更新当前行的DB_ROLL_PTR(回滚指针),使其指向刚刚存入Undo Log的旧版本数据。这个旧版记录本身也包含指向上一版数据的回滚指针。因此,所有的历史版本数据被串联成一个链表,称为版本链。
4.3 MVCC如何实现读写互不阻塞
MVCC的可见性判断:
事务A发起一个select查询,数据库为事务A创建一个Read View,然后事务A开始遍历数据行。事务A检查这个数据行的DB_TRX_ID(最近修改它的事务ID)。
如果DB_TRX_ID < min_trx_id(当前活跃的最小事务ID),那么则说明在创建这个Read View之前,修改这行数据的事务已经提交了,所以当前的Read View可以看到这行数据。
如果DB_TRX_ID >= max_trx_id(下一个要分配的事务ID),那么则说明这个数据行的当前版本,是在当前Read View创建之后才开启的事务创建的,对当前的Read View 不可见。
如果 min_trx_id <= DB_TRX_ID < max_trx_id:
如果DB_TRX_ID在m_ids(活跃事务集合)中,说明该事务还未提交,该数据对当前的Read View不可见。
如果DB_TRX_ID不在m_ids集合中,说明该事务已经提交,该数据行对当前的Read View可见。
通过以上规则,就限制了事务只能看到它该看的数据。
MVCC实现读写互不阻塞:
MVCC的本质是:InnoDB为每个数据行维护多个版本的快照,读操作读取历史版本,写操作创建新版本,读写操作的目标版本不同,所以不存在锁的竞争,因而读写互不阻塞。下面举个例子:
事务A执行读操作(不加锁)时,不直接读取最新版的行数据,而是通过Read View从Undo Log中找到自己能看到的历史版本,读取这个历史快照。
此时,如果事务B要修改这行数据,那么它不会覆盖旧版本数据,而是创建一个新版本数据,并对新版本数据加一个排他锁(X锁)。
读取数据时,不需要加锁,而且读取的是历史数据,就算该数据被其他事务修改,也不影响读取。
4.4 不同隔离级别下的MVCC
1)读已提交
每次select查询时,都会创建新的Read View,所以在该隔离级别下总是能看到最新提交的数据。因为它只能读取到提交的数据,所以就解决了脏读的问题。
2)可重复读
在可重复读隔离级别下,只在第一次执行 SELECT 时生成一个 Read View,并在整个事务期间都使用这个视图。这就保证了在事务内看到的数据是一致的。所以MVCC可以解决不可重复读问题。
4.5 MVCC与幻读
-- 可重复读下,MVCC能避免"读幻读",但不能避免"写幻读"
-- 事务A
BEGIN;
SELECT * FROM users WHERE age > 20; -- 返回2条记录
-- 事务B插入并提交
INSERT INTO users(name, age) VALUES('Bob', 25);
COMMIT;
-- 事务A再次SELECT(快照读)
SELECT * FROM users WHERE age > 20; -- 还是2条(MVCC避免读幻读)
-- 但事务A尝试INSERT相同记录
INSERT INTO users(name, age) VALUES('Bob', 25);
-- 报错:Duplicate entry!这就是"写幻读"
-- MVCC的SELECT看不到,但唯一约束能检测到
在可重复读隔离级别下,MVCC+间隙锁可以解决幻读问题。间隙锁锁定查询范围的 “行 + 间隙”,阻止其他事务插入 / 删除符合条件的行,这样就确保了当前读到的结果集不变。
4.6 版本链清理
由于MVCC会创建很多旧版本数据,如果这些数据一直保留,磁盘和内存会无限增长。因此,数据库需要一个后台的垃圾回收机制。当系统中没有任何活跃的事务还需要某个旧版本数据时(即没有Read View依赖于它),这个旧版本数据就可以被安全地清除掉。Purge线程会定期的清理这些过期的Undo Log。
五、常见锁的分类
5.1 共享锁
也叫读锁(S锁),允许持有锁的事务读取数据,但禁止修改。
5.2 排他锁(独占锁)
也叫写锁(X锁),允许持有锁的事务读取和修改数据, 并禁止其他事务对该数据进行任何操作。
5.3 意向锁
提前声明事务对表中数据的锁定意图,即要加的是S锁还是X锁。
5.4 间隙锁
锁定索引记录间的数据,不包含索引记录本身,防止其他事务在间隙中插入新记录。解决幻读问题(避免事务再次查询时出现新插入的行)。
六、 死锁
6.1 什么是死锁
在MySQL中,死锁是指两个或多个事务相互持有对方所需要的锁,进而导致相互等待,如果没有外力介入,那么这个相互等待的状态将一直持续下去。这就是死锁。
6.2 死锁产生的条件
1)互斥条件:一把锁同一时间只能被一个事务持有。
2)持有并等待:一个事务在等待其他事务释放锁时,不会主动释放自己已经持有的锁。
3)不可剥夺:一个事务持有的锁,不能被其他事务剥夺,只能由自己主动释放。
4)循环等待:多个事务形成闭环等待链。
以上条件同时满足,则死锁发生。
6.3 死锁检测
InnoDB内置了死锁检测机制,当检测到死锁发生时,InnoDB会选择回滚代价最小的事务(修改行数最少),打破形成死锁的条件。本质上打破的是循环等待这个条件,当将其中一个事务回滚时,就切开了循环等待链。
6.4 死锁避免
1)通过设置系统变量innodb_lock_wait_timeout来控制锁的超时时间。
2)减少事务持有锁的时间,因为事务持有锁的时间越长,越容易和其他事务发生冲突。
3)统一加锁顺序,避免循环等待。
4)避免大事务,降低事务的粒度,这样可以避免一个事务持有很多个锁,容易与其他事务的锁产生冲突。
七、结语
如有错误,还请指出!
完~
3705

被折叠的 条评论
为什么被折叠?



