一次性彻底搞懂本地事务

背景

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

回滚日志(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)

读写锁

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)
sql 复制代码
select ... for share 会设置一个IS锁
select ... for update 会设置一个IX锁

意向锁协议工作如下:

  1. 在事务对表中的某行加共享锁(S Lock)之前,必须先对表加一个 IS 锁或更强的锁。
  2. 在事务对表中的某行加排他锁(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语句是基于最新的数据库状态,而不是事务的一致性快照。

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

  1. 使用READ COMMITTED隔离级别
  2. 使用锁定读(在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
异常解决办法
  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读取的是事务开始的快照,锁定语句读取的是最新的数据,而且会加锁。这样可能会导致事务内部数据的不一致

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

  • 写操作,使用记录锁

  • 锁定读,加记录锁

  • 对于非锁定读取

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

    1. 仅锁定索引记录,不会锁定间隙(不会加任何间隙锁),允许插入新的记录(导致幻读)
  • 对于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,非锁定读就不是那么的安全(其它事务可能已经修改了这些数据,一致性被破坏),就需要锁定读。有两种锁定读:

  1. SELECT ... FOR SHARE(共享锁):适用于读后再插入的场景
  2. 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存储引擎
相关推荐
浪九天15 分钟前
Java直通车系列13【Spring MVC】(Spring MVC常用注解)
java·后端·spring
uhakadotcom1 小时前
Apache CXF 中的拒绝服务漏洞 CVE-2025-23184 详解
后端·面试·github
uhakadotcom1 小时前
CVE-2025-25012:Kibana 原型污染漏洞解析与防护
后端·面试·github
uhakadotcom1 小时前
揭秘ESP32芯片的隐藏命令:潜在安全风险
后端·面试·github
uhakadotcom1 小时前
Apache Camel 漏洞 CVE-2025-27636 详解与修复
后端·面试·github
uhakadotcom1 小时前
OpenSSH CVE-2025-26466 漏洞解析与防御
后端·面试·github
uhakadotcom1 小时前
PostgreSQL的CVE-2025-1094漏洞解析:SQL注入与元命令执行
后端·面试·github
zhuyasen2 小时前
Go语言开发实战:app库实现多服务启动与关闭的优雅方案
后端·go
ITlinuxP2 小时前
2025最新Postman、Apipost和Apifox API 协议与工具选择方案解析
后端·测试工具·postman·开发工具·apipost·apifox·api协议
计算机-秋大田2 小时前
基于Spring Boot的宠物健康顾问系统的设计与实现(LW+源码+讲解)
java·vue.js·spring boot·后端·课程设计