背景
在工作中,经常遇到会用到数据库本地事务的情况,然而在使用过程中,经常不知道该怎么去用这个事务,用什么样的隔离级别。我这几天查阅了许多资料,整理了ACID事务的原理,数据库底层实现,还有Spring对事务管理的底层实现,本文讲逐一介绍这些内容。跟着我从0到一彻底的一次性学会本地事务。
本地事务
本地事务(Local Transaction)是指仅操作单一事务资源的、不需要全局事务管理器进行协调的事务。
ARIES(Algorithms for Recovery and Isolation Exploiting Semantics)理论,翻译过来是基于语义的恢复与隔离算法,是现代数据库的基础理论,现代的主流关系数据库基本都在事务实现上深受ARIES影响。
ACID
事务具备四大特性:A(Atomicity)C(Consistensy)I(Isolation)D(Durability),分别是原子性,一致性,隔离性和持久性。本质上来说,ACD三者相互协作,实现了隔离性。
原子性与持久性
原子性与持久性是事务里密切相关的两个属性。 原子性保证了事务的多个操作要么都生效要么都不生效,不会存在中间状态。 持久性保证了一旦事务生效,就不会再因为任何原因而导致其修改的内容被撤销或丢失。
实现原子性和持久性的最大困难是,写入磁盘的操作不是原子的,在持久化过程中,可能会遇到崩溃(Crash),有如下的两种崩溃的情形:
-
未提交事务,写入后崩溃
-
已提交事务,写入前崩溃 为了保证原子性和持久性,必须要进行崩溃恢复(Crash Recovery/Failure Recovery/Trasaction Recovery),为了实现崩溃恢复,有两种实现方式:
-
提交日志(Commit Logging) 把修改数据这个操作所需的全部信息,包括修改什么数据、数据物理上位于哪个内存页和磁盘块中、从什么值改成什么值,等等,以日志的形式------即仅进行顺序追加的文件写入的形式(这是最高效的写入方式)先记录到磁盘中。只有在日志记录全部都安全落盘 ,数据库在日志中看到代表事务成功提交的"提交记录"(Commit Record )后,才会根据日志上的信息对真正的数据进行修改,修改完成后,再在日志中加入一条"结束记录"(End Record)表示事务已完成持久化。MySQL采用这种机制。
-
影子分页(Shadow Paging) 将数据复制一份副本,保留原数据,修改副本数据。当事务成功提交,所有数据的修改都成功持久化之后,最后一步是去修改数据的引用指针,将引用从原数据改为新复制出来修改后的副本,最后的"修改指针"这个操作将被认为是原子操作,现代磁盘的写操作可以认为在硬件上保证了不会出现"改了半个值"的现象。SQLLite则采用这种机制。
-
预写日志(Write Ahead Logging, WAL) Commit Logging 存在一个巨大的先天缺陷:所有对数据的真实修改都必须发生在事务提交以后,即日志写入了Commit Record之后。在此之前,即使磁盘I/O有足够空闲、即使某个事务修改的数据量非常庞大,占用了大量的内存缓冲区,无论有何种理由,都决不允许在事务提交之前就修改磁盘上的数据。然而WAL允许在事务提交之前,提前写入变动的数据,相对于提交日志的方法,提升了数据库性能。
FORCE与STEAL
- FORCE:当事务提交后,要求变动数据必须同时完成写入则称为FORCE,如果不强制变动数据必须同时完成写入则称为NO-FORCE
- STEAL:在事务提交前,允许变动数据提前写入则称为STEAL,不允许则称为NO-STEAL
回滚日志(Undo Log)
Undo Log用来实现NO-FORCE和STEAL,它注明修改了哪个位置的数据、从什么值改成什么值,以便在事务回滚或者崩溃恢复时根据Undo Log对提前写入的数据变动进行擦除。
重做日志(Redo Log)
Redo Log用于崩溃恢复时重演数据变动
WAL崩溃恢复过程
- 分析阶段 从最后一次的Checkpoint开始扫描日志,找到没有End Record 的事务,组成待恢复的事务集合。
- 重做阶段 找出所有包含Commit Record的日志,将这些日志修改的数据写入磁盘,写入完成后在日志中增加一条End Record,然后移除出待恢复事务集合
- 回滚阶段 剩下的就是需要会滚的事务集合了。根据Undo Log,将已经提前写入磁盘的信息重新改写回去
隔离性
隔离性保证了各个事务各自的读、写的数据相互独立,不会彼此影响。隔离性与并发密切相关。数据库隔离性的由锁机制和MVCC共同实现。
InnoDB锁与事务模型
在我的平时开发中,用的最多的就是MySQL,MySQL的ACID事务模型由InnoDB存储引擎来实现,这里主要讨论InnoDB存储引擎的锁和事务模型
InnoDB锁模型
InnoDB中的锁很多,列举如下:
- 读锁和写锁/共享锁和互斥锁(Shared and Exlusive Locks)
- 意向锁(Intention Locks)
- 记录锁(Record Locks)
- 间隙锁(Gap Locks)
- 临键锁(Next-Key Locks)
- 插入意向锁(Insert Intention Locks)
- 自增锁(AUTO-INC Locks)
- 空间索引的谓词锁(Predicate Locks for Spatial Indexes)
读写锁
InnoDB有两个标准的行锁,读锁和写锁。
- 读锁S-Lock(共享锁):多个事务可以对同一个数据添加多个读锁,数据被加上读锁后就不能再被加上写锁。对于持有读锁的事务,如果该数据只有它自己一个事务加了读锁,允许直接将其升级为写锁,然后写入数据。读锁用于读取行(select)
- 写锁X-Lock(互斥锁):如果数据有加写锁,就只有持有写锁的事务才能对数据进行写入操作,数据加持着写锁时,其他事务不能写入数据,也不能施加读锁。写锁用于更新和删除行(update、delete)
意向锁
InnoDB支持多粒度锁机制MGL(multiple granularity locking),即允许同时存在行锁和表级锁。意向锁表明事务稍后将对表中的行加哪种类型的锁,意向锁不会阻塞 其他事务加锁(除非 有事务对整个表进行加锁如LOCK TABLES ... WRITE,那么这个事务会被阻塞)。有两种意向锁,意向读锁和意向写锁。
- 意向读锁(Intetion Shared Lock, IS):表明事务意图对表中某些行加共享锁(S-Lock)
- 意向写锁(Intetion Exclusive Lock, IX):表明事务意图对表中某些行加互斥锁(X-Lock)
sql
select ... for share 会设置一个IS锁
select ... for update 会设置一个IX锁
意向锁协议工作如下:
- 在事务对表中的某行加共享锁(S Lock)之前,必须先对表加一个 IS 锁或更强的锁。
- 在事务对表中的某行加排他锁(X Lock)之前,必须先对表加一个 IX 锁。
表级锁的兼容性
锁类型 | X | IX | S | IS |
---|---|---|---|---|
X | 冲突 | 冲突 | 冲突 | 冲突 |
IX | 冲突 | 兼容 | 冲突 | 兼容 |
S | 冲突 | 冲突 | 兼容 | 兼容 |
IS | 冲突 | 兼容 | 兼容 | 兼容 |
- 如果请求的锁与现有锁兼容 ,则锁会被授予给请求锁的事务。
- 如果请求的锁与现有锁冲突 ,则事务需要等待直到导致冲突的锁被释放。
- 如果锁请求与现有锁冲突 ,并且由于可能导致死锁 而无法授予,则会返回错误。
记录锁(行锁)
记录锁用于锁定满足条件的索引记录 。如果表没有聚簇索引(Clustered Index),会自动创建一个隐藏的聚簇索引。 select c1 from t where c1=10 for update
这句SQL锁定了t.c1=10这一行记录,阻止其他的事务更新或者删除索引记录
间隙锁
间隙锁是对索引记录前后的间隙 的锁定。间隙范围可能是0个或多个索引值。间隙锁只会锁定没有索引或者具有非唯一索引的索引记录的间隙。间隙锁也有两种类型,间隙读锁和间隙写锁,二者可以共存不会冲突。间隙锁的唯一目的就是防止其他事务在间隙中插入数据。 select c1 from t where c1 between 10 and 20 for update
间隙锁是对性能和并发的一种权衡,主要用于某些低级别的事务隔离级别
间隙:是索引记录之间的空白区域,基于索引值的顺序定义。例如记录有10,11,15,17,间隙就有(10,11),(11,15),(15,17)几个间隙
临键锁
临键锁是记录锁和间隙锁的结合,他锁定了索引记录和该记录的间隙。主要用于在REPEATABLE READ隔离级别下,InnoDB使用临键锁进行搜索和索引扫描,防止幻读。
幻读:一个事务中,相同的前后两次查询,返回了不同的数据行集,因为其它事务在这个两次查询过程之间插入了新的数据 例如数据有10,11,15,17,则临键锁为(-infinity,10],(10,11],(11,15],(15,17],(17,+infinity)
插入意向锁
插入意向锁是一种间隙锁。插入记录时,先获取插入意向锁,然后再获取写锁 。多个事务在同一个间隙 中插入数据,只要插入间隙的位置不同则不会阻塞。
自增锁
自增锁是一种特殊的表级锁。一个事务在插入一个具有AUTO_INCREMENT(通常为主键列)的列的行时,需要获取自增锁,确保主键的连续性。
MySQL的innodb_autoinc_lock_mode参数可以控制自增锁定的算法
- 0:传统模式,使用传统AUTO-INC锁机制,最低的并发
- 1:连续模式,轻量级的锁机制结合AUTO-INC锁,更高的并发
- 2:交错模式,完全使用轻量级锁机制,并发最高,不保证连续,但保证唯一性
空间索引的谓词锁
谓词锁用于锁定满足查询条件的空间数据范围,确保事务在读取空间数据时不会被其他事务修改
多版本并发控制 (Multi-Version Concurrency Control,MVCC)
MVCC是一种读取优化策略(针对一致性读取),它是针对"一个事务读+一个事务写"进行读取时的无锁优化方案。MVCC针对于REPEATABLE READ和READ COMMITTED两种隔离级别。
MVCC底层原理
MVCC的基本思路是对数据库的任何修改都不会直接覆盖之前的数据,而是产生一个新版副本与老版本共存,以此达到读取时可以完全不加锁的目的。"版本"理解为数据库中每一行记录都存在两个看不见的字段:CREATE_VERSION 和 DELETE_VERSION ,这两个字段记录的值都是数据库事务ID(TRX ID),它是严格递增的。
- 插入数据时:CREATE_VERSION记录插入数据的事务ID,DELETE_VERSION为空。
- 删除数据时:DELETE_VERSION记录删除数据的事务ID,CREATE_VERSION为空。
- 修改数据时:将修改数据视为"删除旧数据,插入新数据"的组合
一致性非锁定读取(普通SELECT)
- 一致性读取是InnoDB提供的一种读取机制,使用MVCC实现。一致性读取会为查询提供一个快照,查询只能看到在该时间点之前已提交的事务所做的更改,而不会看到之后或未提交的事务所做的更改。
- 避免了脏读和不可重复读
- 不会对读取的数据加锁,提高了并发性能
- 一致性读取不适用于:DROP TABLE/ALTER TABLE这些操作会改变表结构或删除表
在REPEATABLE READ级别下,一致性读取只对于事务中的普通SELECT语句有效,不一定对DML语句有效。 DML语句(UPDATE,DELETE)并不受一致性快照的限制。如果当前事务尝试更新或删除某些行,而这些行是其他事务提交的,它们仍然会被当前事务修改,即使当前事务的SELECT还看不到这些数据。
sql
-- 事务 A
START TRANSACTION;
SELECT * FROM users WHERE id = 1;
-- 事务 B
START TRANSACTION;
INSERT INTO users (id, name) VALUES (2, 'Charlie');
COMMIT;
-- 事务 A
DELETE FROM users WHERE id = 2;
尽管事务A之前还看不到 id = 2
这条记录,但 DELETE
仍然可以执行成功,这是因为DML语句是基于最新的数据库状态,而不是事务的一致性快照。
如果想要每个一致性读取都看到最新的快照数据,有两种做法:
- 使用READ COMMITTED隔离级别
- 使用锁定读(在REPEATABLE READ级别下),SELECT FOR SHARE
一致性非锁定读异常情况
在同一个事务中,SELECT可以看到本事务的更新,但可能看不到其他事务的并发更新,导致查询结果出现逻辑上不一致的状态。本质上是:并发的事务内部的查询看到的版本是不一致的,部分是自己更新后的数据,部分是事务开始时的快照数据。
举例说明
初始数据如下:
id | value |
---|---|
1 | 100 |
2 | 200 |
事务A:
sql
START TRANSACTION;
UPDATE t SET value = 150 WHERE id = 1;
SELECT * FROM t;
事务B:
ini
START TRANSACTION;
UPDATE t SET value = 250 WHERE id = 2;
COMMIT;
结束后的数据如下:
id | value |
---|---|
1 | 150 |
2 | 200 |
异常解决办法
- 直接使用SERIALIZABLE
- 使用SELECT FOR UPDATE
一致性读取的行为受到隔离级别影响
隔离等级 | 一致性读取行为 |
---|---|
REPEATABLE READ | 事务中的所有一致性读取都基于事务第一次读取的快照 |
READ COMMITTED | 每次一致性读取都会基于最新的已提交数据生成新快照 |
REPEATABLE READ + START TRANSACTION WITH CONSISTENT SNAPSHOT | 事务开始时立即生成一个快照 |
InnoDB事务模型
InnoDB事务模型将多版本并发控制(MVCC)与传统的两阶段锁(2PL)结合,实现了高并发和事务隔离性。
SQL语句类型
非锁定语句:非锁定读(普通SELECT语句) 锁定语句:锁定读(SELECT FOR UPDATE,SELECT FOR SHARE),UPDATE,DELETE
事务隔离级别
隔离级别是用来平衡性能与一致性的一个设置指标。InnoDB提供了ISO SQL-92中提供的所有四种级别,分别是SERIALIZABLE(序列化),REPEATABLE READ(可重复读),READ COMMITTED(读提交),READ UNCOMMITTED(读未提交),INNODB默认隔离等级是REPEATABLE READ
不同隔离等级的应用场景如下:
- 可重复读:用于关键数据的操作,严格保证ACID的场景
- 读提交:用于一致性要求较低场景,减少锁的开销,如批量报告和数据分析
- 读未提交:不用任何锁机制,一致性最差
- 可串行化:使用临键锁和表锁,用于完全隔离的场景,如XA事务和并发问题和死锁排查
REPEATABLE READ(默认隔离级别)
-
使用MVCC+锁机制实现
-
非锁定读,读取事务第一次快照
-
写操作,使用临键锁,避免幻读
-
非锁定读,直接加记录锁 同一个事务中所有的SELECT都是同一个一致性快照。如果事务执行期间,其它的事务修改了数据,当前事务不会看到这些修改。
-
对于非锁定读(普通SELECT)
- 第一次执行SELECT,创建一个一致性快照
- 后续的SELECT都会读取这个快照的数据
- 保证事务中所有的SELECT读取到的数据一致性
-
对于锁定读(SELECT FOR SHARE/SELECT FOR UPDATE)和UPDATE、DELETE
- 不使用一致性快照,而是读取最新的数据
- 扫描并对数据加锁,防止其他事务修改这些行
- 加什么锁取决于查询的条件
REPEATABLE READ的锁定机制
- 唯一索引的精确查找:对扫描到的记录,使用记录锁
- 范围查询:对扫描过的索引范围,使用间隙锁或临键锁
例如:如果一个表没有索引必须通过全表扫描,所有记录都会被访问,那么对于锁定语句需要对全表进行加锁。
为什么REPEATABLE READ事务不要混用锁定和非锁定语句
非锁定SELECT读取的是事务开始的快照,锁定语句读取的是最新的数据,而且会加锁。这样可能会导致事务内部数据的不一致
sql
START TRANSACTION;
SELECT * FROM orders WHERE id = 10; -- 读取的是事务开始时的快照
UPDATE orders SET status = 'shipped' WHERE id = 10; -- 作用于最新数据
实现严格的可见性一致性,有下面几种方式
sql
-- 使用 `SERIALIZABLE` 级别
SET TRANSACTION ISOLATION LEVEL SERIALIZABLE;
sql
-- 使用 `SELECT ... FOR UPDATE` 或 `SELECT ... FOR SHARE`
START TRANSACTION;
SELECT * FROM orders WHERE id = 10 FOR UPDATE; -- 确保获取的是最新数据并加锁
UPDATE orders SET status = 'shipped' WHERE id = 10;
COMMIT;
sql
-- 在 `UPDATE` 之前额外执行一次 `SELECT ... FOR UPDATE`
START TRANSACTION;
SELECT * FROM orders WHERE id = 10 FOR UPDATE;
UPDATE orders SET status = 'shipped' WHERE id = 10;
COMMIT;
READ COMMITTED
-
使用MVCC+锁机制实现
-
这个级别下,禁用了间隙锁,可能会出现脏读
-
非锁定读,读取最新的快照
-
只支持基于行的binlog
-
写操作,使用记录锁
-
锁定读,加记录锁
-
对于非锁定读取
- 每次读取都会创建并读取自己的快照,而不是复用事务第一次生成的快照。
-
对于锁定读取和锁定语句
- 仅锁定索引记录,不会锁定间隙(不会加任何间隙锁),允许插入新的记录(导致幻读)
-
对于DELETE和UPDATE语句,InnoDB只会锁定实际被更新和删除的行,对于不匹配WHERE条件的记录,WHERE条件执行完成后会立即释放他们的记录锁
-
对于UPDATE语句,如果某一行已经被锁定,那么使用"半一致性读"。
半一致性读取(semi-consistent read)
是InnoDB在READ COMMITTED级别下,对UPDATE语句的一种优化机制。减少死锁概率,优化锁持有时间,提高并发性能。
当执行UPDATE,遇到已经被锁定 的行,InnoDB返回最近已提交的版本给MySQL,MySQL判断是否满足UPDATE的WHERE条件。如果条件符合,再次读取该行数据,并尝试上锁,否则直接跳过。
READ UNCOMMITTED
- 在这个隔离级别下,不使用MVCC机制
- 事务的读操作(非锁定读),直接从数据页读取最新的原始数据
- 事务中读操作不加锁,写操作会加记录锁
- 锁定读,加记录锁
SERIALIZABLE
- 完全只依赖于锁机制实现,不使用MVCC
- 改隔离级别下,会多查询的整个结果集加锁
- 事务的所有读操作(非锁定读)加间隙锁和记录锁(读锁),写操作加互斥锁
- 锁定读,加记录锁
锁定读(Locking Reads)
普通的SELECT是非锁定一致性读。如果在一个事务内查询数据,然后根据这些数据执行INSERT或者UPDATE,非锁定读就不是那么的安全(其它事务可能已经修改了这些数据,一致性被破坏),就需要锁定读。有两种锁定读:
- SELECT ... FOR SHARE(共享锁):适用于读后再插入的场景
- SELECT ... FOR UPDATE(互斥锁):适用于读后更新的场景
锁定读的并发策略
在锁定读语句中,有两种并发选项可以加入
- NOWAIT:立即失败,不等待锁释放
- SKIP LOCKED:直接跳过被锁的行,继续查询未被锁的数据
sql
-- 会立即失败
SELECT * FROM t WHERE i = 2 FOR UPDATE NOWAIT;
-- 跳过已被锁住的行,返回剩余数据
SELECT * FROM t FOR UPDATE SKIP LOCKED;
事务隔离性可能导致的几个并发问题
- 脏读:事务执行过程中,一个事务读取到了另一个事务未提交的数据
- 幻读:事务执行过程中,两个完全相同的范围查询得到了不同的结果集
- 不可重复读:事务执行过程中,对同一行数据的两次查询得到了不同的结果
- 丢失更新:事务执行过程中,两个事务同时更新一行数据,导致一个事务更新丢失
不同隔离级别下问题对比
隔离级别 | 脏读 | 不可重复读 | 幻读 | 丢失更新 |
---|---|---|---|---|
读未提交 | ❌ 可能 | ❌ 可能 | ❌ 可能 | ❌ 可能 |
读提交 | ✅ 避免 | ❌ 可能 | ❌ 可能 | ❌ 可能 |
可重复读 | ✅ 避免 | ✅ 避免 | ❌ 可能(InnoDB通过MVCC和间隙锁解决) | ❌ 可能 |
可串行化 | ✅ 避免 | ✅ 避免 | ✅ 避免 | ✅ 避免 |
脏读解决策略
方案 | 具体方式 |
---|---|
提升隔离级别 | 使用 READ COMMITTED 及以上 |
悲观锁(行级锁) | SELECT ... FOR UPDATE |
不可重复读解决策略:
方案 | 具体方式 |
---|---|
提升隔离级别 | 使用 REPEATABLE READ及以上 |
悲观锁(行级锁) | SELECT ... FOR UPDATE |
幻读解决策略:
方案 | 具体方式 |
---|---|
提升隔离级别 | 使用SERIALIZABLE |
临键锁 | InnoDB 在 REPEATABLE READ 级别自动加间隙锁 |
悲观锁(行级锁) | SELECT ... FOR UPDATE + 表锁 |
乐观锁(CAS) | version 字段更新前检查 |
丢失更新解决策略:
方案 | 具体方式 |
---|---|
悲观锁(行级锁) | SELECT ... FOR UPDATE |
乐观锁(CAS 机制) | version 字段更新前检查 |
事务队列 | 让事务串行执行 |
总结
最近有个朋友问了我一个数据库事务相关的问题,由此我花了2天时间,把前段时间学习的有关数据库事务相关的内容整理输出了一下,算是对之前学习内容的回顾复习。数据库事务这块的内容很多很杂乱,面试很爱问,工作也很经常遇到。如果不多花时间,从原理上深刻理解数据库ACID事务的底层原理,去阅读自己工作中接触到的数据库的参考手册(Oracle,MySQL,PostgreSQL),当写代码遇到技术上的难点的时候,即使似问ai,也会是一头雾水。后续如果有机会了解到Oracle和PostgreSQL,再来补充一下关于他们的事务模型相关的底层原理。
下一篇我将介绍,Spring如何进行实现事务管理。
参考文献
- 【1】凤凰架构
- 【2】MySQL8.4参考手册
- 【3】MySQL技术内幕:InnoDB存储引擎