一次性彻底搞懂本地事务

310 阅读20分钟

背景

在工作中,经常遇到会用到数据库本地事务的情况,然而在使用过程中,经常不知道该怎么去用这个事务,用什么样的隔离级别。我这几天查阅了许多资料,整理了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),有如下的两种崩溃的情形:

  1. 未提交事务,写入后崩溃

  2. 已提交事务,写入前崩溃 为了保证原子性和持久性,必须要进行崩溃恢复(Crash Recovery/Failure Recovery/Trasaction Recovery),为了实现崩溃恢复,有两种实现方式:

  3. 提交日志(Commit Logging) 把修改数据这个操作所需的全部信息,包括修改什么数据、数据物理上位于哪个内存页和磁盘块中、从什么值改成什么值,等等,以日志的形式——即仅进行顺序追加的文件写入的形式(这是最高效的写入方式)先记录到磁盘中。只有在日志记录全部都安全落盘,数据库在日志中看到代表事务成功提交的“提交记录”(Commit Record)后,才会根据日志上的信息对真正的数据进行修改,修改完成后,再在日志中加入一条“结束记录”(End Record)表示事务已完成持久化。MySQL采用这种机制。

  4. 影子分页(Shadow Paging) 将数据复制一份副本,保留原数据,修改副本数据。当事务成功提交,所有数据的修改都成功持久化之后,最后一步是去修改数据的引用指针,将引用从原数据改为新复制出来修改后的副本,最后的“修改指针”这个操作将被认为是原子操作,现代磁盘的写操作可以认为在硬件上保证了不会出现“改了半个值”的现象。SQLLite则采用这种机制。

  5. 预写日志(Write Ahead Logging, WAL) Commit Logging 存在一个巨大的先天缺陷:所有对数据的真实修改都必须发生在事务提交以后,即日志写入了Commit Record之后。在此之前,即使磁盘I/O有足够空闲、即使某个事务修改的数据量非常庞大,占用了大量的内存缓冲区,无论有何种理由,都决不允许在事务提交之前就修改磁盘上的数据。然而WAL允许在事务提交之前,提前写入变动的数据,相对于提交日志的方法,提升了数据库性能。

FORCE与STEAL

  • FORCE:当事务提交后,要求变动数据必须同时完成写入则称为FORCE,如果不强制变动数据必须同时完成写入则称为NO-FORCE
  • STEAL:在事务提交前,允许变动数据提前写入则称为STEAL,不允许则称为NO-STEAL

image.png

回滚日志(Undo Log)

Undo Log用来实现NO-FORCE和STEAL,它注明修改了哪个位置的数据、从什么值改成什么值,以便在事务回滚或者崩溃恢复时根据Undo Log对提前写入的数据变动进行擦除。

重做日志(Redo Log)

Redo Log用于崩溃恢复时重演数据变动

WAL崩溃恢复过程

  1. 分析阶段 从最后一次的Checkpoint开始扫描日志,找到没有End Record的事务,组成待恢复的事务集合
  2. 重做阶段 找出所有包含Commit Record的日志,将这些日志修改的数据写入磁盘,写入完成后在日志中增加一条End Record,然后移除出待恢复事务集合
  3. 回滚阶段 剩下的就是需要会滚的事务集合了。根据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)

image.png

读写锁

InnoDB有两个标准的行锁,读锁和写锁。

  1. 读锁S-Lock(共享锁):多个事务可以对同一个数据添加多个读锁,数据被加上读锁后就不能再被加上写锁。对于持有读锁的事务,如果该数据只有它自己一个事务加了读锁,允许直接将其升级为写锁,然后写入数据。读锁用于读取行(select)
  2. 写锁X-Lock(互斥锁):如果数据有加写锁,就只有持有写锁的事务才能对数据进行写入操作,数据加持着写锁时,其他事务不能写入数据,也不能施加读锁。写锁用于更新和删除行(update、delete)

意向锁

InnoDB支持多粒度锁机制MGL(multiple granularity locking),即允许同时存在行锁和表级锁。意向锁表明事务稍后将对表中的行加哪种类型的锁,意向锁不会阻塞其他事务加锁(除非有事务对整个表进行加锁如LOCK TABLES ... WRITE,那么这个事务会被阻塞)。有两种意向锁,意向读锁和意向写锁。

  1. 意向读锁(Intetion Shared Lock, IS):表明事务意图对表中某些行加共享锁(S-Lock)
  2. 意向写锁(Intetion Exclusive Lock, IX):表明事务意图对表中某些行加互斥锁(X-Lock)
select ... for share 会设置一个ISselect ... for update 会设置一个IX锁

意向锁协议工作如下:

  1. 在事务对表中的某行加共享锁(S Lock)之前,必须先对表加一个 IS 锁或更强的锁。
  2. 在事务对表中的某行加排他锁(X Lock)之前,必须先对表加一个 IX 锁。

表级锁的兼容性

锁类型XIXSIS
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还看不到这些数据。

-- 事务 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语句是基于最新的数据库状态,而不是事务的一致性快照。

如果想要每个一致性读取都看到最新的快照数据,有两种做法:

  1. 使用READ COMMITTED隔离级别
  2. 使用锁定读(在REPEATABLE READ级别下),SELECT FOR SHARE

一致性非锁定读异常情况

在同一个事务中,SELECT可以看到本事务的更新,但可能看不到其他事务的并发更新,导致查询结果出现逻辑上不一致的状态。本质上是:并发的事务内部的查询看到的版本是不一致的,部分是自己更新后的数据,部分是事务开始时的快照数据。

举例说明

初始数据如下:

idvalue
1100
2200

事务A:

START TRANSACTION;
UPDATE t SET value = 150 WHERE id = 1;
SELECT * FROM t;

事务B:

START TRANSACTION;
UPDATE t SET value = 250 WHERE id = 2;
COMMIT;

结束后的数据如下:

idvalue
1150
2200
异常解决办法
  1. 直接使用SERIALIZABLE
  2. 使用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

不同隔离等级的应用场景如下:

  1. 可重复读:用于关键数据的操作,严格保证ACID的场景
  2. 读提交:用于一致性要求较低场景,减少锁的开销,如批量报告和数据分析
  3. 读未提交:不用任何锁机制,一致性最差
  4. 可串行化:使用临键锁和表锁,用于完全隔离的场景,如XA事务和并发问题和死锁排查

REPEATABLE READ(默认隔离级别)

  • 使用MVCC+锁机制实现

  • 非锁定读,读取事务第一次快照

  • 写操作,使用临键锁,避免幻读

  • 非锁定读,直接加记录锁 同一个事务中所有的SELECT都是同一个一致性快照。如果事务执行期间,其它的事务修改了数据,当前事务不会看到这些修改。

  • 对于非锁定读(普通SELECT)

    1. 第一次执行SELECT,创建一个一致性快照
    2. 后续的SELECT都会读取这个快照的数据
    3. 保证事务中所有的SELECT读取到的数据一致性
  • 对于锁定读(SELECT FOR SHARE/SELECT FOR UPDATE)和UPDATE、DELETE

    1. 不使用一致性快照,而是读取最新的数据
    2. 扫描并对数据加锁,防止其他事务修改这些行
    3. 加什么锁取决于查询的条件
REPEATABLE READ的锁定机制
  • 唯一索引的精确查找:对扫描到的记录,使用记录锁
  • 范围查询:对扫描过的索引范围,使用间隙锁或临键锁

例如:如果一个表没有索引必须通过全表扫描,所有记录都会被访问,那么对于锁定语句需要对全表进行加锁。

为什么REPEATABLE READ事务不要混用锁定和非锁定语句

非锁定SELECT读取的是事务开始的快照,锁定语句读取的是最新的数据,而且会加锁。这样可能会导致事务内部数据的不一致

START TRANSACTION;
SELECT * FROM orders WHERE id = 10;  -- 读取的是事务开始时的快照
UPDATE orders SET status = 'shipped' WHERE id = 10;  -- 作用于最新数据

实现严格的可见性一致性,有下面几种方式
-- 使用 `SERIALIZABLE` 级别
SET TRANSACTION ISOLATION LEVEL SERIALIZABLE;
-- 使用 `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;

-- 在 `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

  • 写操作,使用记录锁

  • 锁定读,加记录锁

  • 对于非锁定读取

    1. 每次读取都会创建并读取自己的快照,而不是复用事务第一次生成的快照。
  • 对于锁定读取和锁定语句

    1. 仅锁定索引记录,不会锁定间隙(不会加任何间隙锁),允许插入新的记录(导致幻读)
  • 对于DELETE和UPDATE语句,InnoDB只会锁定实际被更新和删除的行,对于不匹配WHERE条件的记录,WHERE条件执行完成后会立即释放他们的记录锁

  • 对于UPDATE语句,如果某一行已经被锁定,那么使用“半一致性读”。

半一致性读取(semi-consistent read)

是InnoDB在READ COMMITTED级别下,对UPDATE语句的一种优化机制。减少死锁概率,优化锁持有时间,提高并发性能。

当执行UPDATE,遇到已经被锁定的行,InnoDB返回最近已提交的版本给MySQL,MySQL判断是否满足UPDATE的WHERE条件。如果条件符合,再次读取该行数据,并尝试上锁,否则直接跳过。

update.drawio.png

READ UNCOMMITTED

  • 在这个隔离级别下,不使用MVCC机制
  • 事务的读操作(非锁定读),直接从数据页读取最新的原始数据
  • 事务中读操作不加锁,写操作会加记录锁
  • 锁定读,加记录锁

SERIALIZABLE

  • 完全只依赖于锁机制实现,不使用MVCC
  • 改隔离级别下,会多查询的整个结果集加锁
  • 事务的所有读操作(非锁定读)加间隙锁和记录锁(读锁),写操作加互斥锁
  • 锁定读,加记录锁

锁定读(Locking Reads)

普通的SELECT是非锁定一致性读。如果在一个事务内查询数据,然后根据这些数据执行INSERT或者UPDATE,非锁定读就不是那么的安全(其它事务可能已经修改了这些数据,一致性被破坏),就需要锁定读。有两种锁定读:

  1. SELECT ... FOR SHARE(共享锁):适用于读后再插入的场景
  2. SELECT ... FOR UPDATE(互斥锁):适用于读后更新的场景
锁定读的并发策略

在锁定读语句中,有两种并发选项可以加入

  • NOWAIT:立即失败,不等待锁释放
  • SKIP LOCKED:直接跳过被锁的行,继续查询未被锁的数据
-- 会立即失败
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存储引擎