重学Java-本地事务
这是事务部分的第一篇,预计从本地事务到分布式事务逐步递进的方式进行整理。同时这个系列默认大家对本地事务已经比较熟悉,所以内容相对会少一些,重点放在分布式事务部分。
1. 本地事务基础
这里主要说一下事务涉及到的一些基本概念,第二部分说一下实现方式和一些注意事项。
1.1 ACID
说起事务,一般都认为应该具有 原子性、一致性、隔离性、持久性 四个特性,根据四个属性的英文单词首字母,又简称为ACID,具体的如下:
- 原子性( Atomicity ):一个事务包含的所有操作是不可被分割的,要不全部完成,要不全部失败,不会出现中间的某个状态。即使中间出错,也会被回滚(RollBack)到事务执行之前的状态。
- 一致性( Consistency ): 在事务开始之前到结束之后,数据库中的数据完整性不会被破坏,只是从一个一致性状态转到另一个新的一致性状态。简单来说类似“能量守恒”。写入数据库的任何数据都必须在遵守数据库的定义的相关规则(例如主键、外键等约束、级联、触发器及其任意组合等)的情况下达到一致性。这意味着既要保证数据库的完整性约束,又要保证应用程序层面的上的一致性控制,做好相关约束检测。如:
- 用户A 账户上有50元,就不能给 用户B 转账100元。
- 用户A 转账给 用户B 20元,用户A的账户-20,剩余30元。正常流程用户B的账户+20元,但是因为一些原因没能为用户B增加这20元,此时就进入了不一致状态。
- 隔离性( Isolation ): 数据库允许多个事务同时进行,隔离确保事务的并发执行能达到与按顺序执行时相同的状态,从而防止多个事务并发执行时,由于交叉执行导致数据出现异常状况。不同的隔离级别有着不同的保证,这点之后单独整理。
- 持久性( Durability ): 事务结束后,对数据的修改是永久的,即使发生系统故障,对数据的变更也不会丢失。实际项目中意味着数据库会将数据写入磁盘(现在常指硬磁盘,如HDD、SSD)等非易失性存储设备中。
事务一致性的实现需要多维度来保证,比如底层存储的多副本数据强一致性,事务原子性、隔离性和持久性的一起协作,以及数据库层和应用层的约束检测等各方面来保障,它不单单是事务层面的一致性问题。所以说上面的 原子性、隔离性、持久性三个特性的最终目的是尽最大努力实现一致性。
1.2 原子性
对于单节点事务,一般是在存储引擎上,通过 Undo Log 、 Redo Log 和 Commit 记录来实现。
- 先写入 Undo Log 和 Redo Log ,然后再写入 Commit 记录:
- 其中事务的提交或中止由 Commit 记录来决定,
- 如果在写入 Commit 记录之前发生崩溃,那么事务就需要中止,通过 Undo Log 回滚已执行的操作;
- 如果事务已经写入了 Commit 记录,就表明事务已经安全提交,后面发生了崩溃的话,就要等待系统重启后,通过 Redo Log 恢复事务,然后再提交。
- 简单来说比如想将 A=1 修改为A=3,先在 Undo Log中记录原始的 A=1 用于之后的回滚操作,再在 Redo Log中记录 A=3 用于之后更新持久化操作,最后记录 commit,提交事务写入磁盘,完成事务。
- Undo Log 保证了事务的原子性, Redo Log 保证了事务的持久性,写入 Commit 记录了事务的提交点,它来决定事务是否应该安全提交。
对于 MySQL 本身没有单独的 Commit日志,而是在redo日志记录上打上COMMIT标记表示记录提交完成。本文重点不是介绍MySQL中的具体实现,想快速深入了解的可以参考写的比较多的一篇文章:
【图文详解】MySQL系列之redo log、undo log和binlog详解
1.3 持久性
对于 SATA 硬盘,可以当做n多的同心圆,顺序写可以忽略寻道和寻址的时间,随机写会经历以下几个步骤,性能会非常差:
- 寻道,找到数据所在的同心圆,这个时间是毫秒级别的;
- 寻址,找到数据所在的同心圆的位置,这个时间也是毫秒级别的;
- 开始读写数据,每秒可以读写的数据量为 100M 级别的数据,这个是非常快的。
SSD 硬盘虽然不需要机械寻道和寻址,但不能够覆盖写,对于已有数据的磁盘,每次写需要执行擦除 SSD 上已有的数据 和写入新的数据两个步骤。同时其可能会存在写放大现象,随机写会比顺序写更可能产生该问题。
对于这种随机读写操作都会面临严重的性能问题,目前主要是通过重做日志(RedoLog)或预写日志(Write Ahead Log),将随机读写转化为顺序读写来提高事务的性能。
同时磁盘故障也是可能存在的,为了应对这种情况,常常需要将数据复制到多个磁盘,具体方案可参考如下两种方式:
- 一是通过磁盘阵列,从磁盘内部复制数据来解决;
- 另一种是通过外部的数据复制来解决。
1.4 隔离级别
常见的隔离级别:
隔离级别 | 描述 | 可能产生的异常现象 |
---|---|---|
读未提交( Read Uncommitted ) | 一个事务还未提交,其所做的变更就能被其他事务看到。 | 可能会导致脏读、幻读或不可重复读。 |
读已提交( Read Committed ) | 一个事务提交后,其所做的变更才会被其他事务看到。 | 可以阻止脏读,但是幻读或不可重复读仍有可能发生。 |
可重复读( Repeatable Read ) | 一个事务执行过程中看到的数据,总是跟这个事务启动时看到的数据时一致的。在可重复读的隔离级别下,未提交变更对其他事务也是不可见的。 | 可以阻止脏读和不可重复读,但幻读仍有可能发生。 |
串行化( Serializability ) | 对同一行记录,写加“写锁”,读加“读锁”。当出现读写冲突时,后访问的事务需等前一个事务执行完才可继续执行。 | 可以阻止脏读、不可重复读以及幻读 |
这四种隔离级别来自最初的 【ANSI SQL-92 标准】 ,同样来自于此标准的还有下面介绍的三种隔离现象。
针对隔离级别,实际除了上述提到的,随着技术演进,出现了更多的隔离级别,如快照隔离(Snapshot Isolation, SI)等。
关于快照隔离可以查看文章A Critique of ANSI SQL Isolation Levels,下面仅简单提一下:
- 在快照隔离中每个事务从事务启动时(已提交)数据的快照中读取读取数据,称为其开始时间戳(Start-Timestamp)。
- 此时间可能是事务首次读取之前的任何时间。在快照隔离中运行的事务永远不会被阻止尝试读取,只要可以维护其 Start-Timestamp 中的快照数据。
- 事务的写入(更新、插入和删除)也将反映在此快照中,如果事务再次访问(即读取或更新)数据,则再次读取。
- 事务开始时间戳之后处于活动状态的其他事务的更新对事务不可见。
- 快照隔离 是一种多版本并发控制(MVCC , Multi-Version Concurrency Control)。
- 对于 MVCC 来说,相对于悲观锁,乐观锁是一个更常见的选择。
SI 隔离级别下可能出现 write skew 异常,之后出现了各种改进算法。Serializable Snapshot Isolation(SSI,可序列化快照隔离)便是出现较早也是较流行的一个,简单来说:
- 通过 MVCC 来实现隔离性,由于读操作都是读取旧版本的数据,所以数据库需要知道哪些读取结果可能已经改变了,然后中止事务,不然就会导致写偏斜的问题出现。
- 这需要数据库能够检测出异常情况,然后中止事务,而实现这个异常检测机制的 MVCC ,我们称为可序列化快照隔离(SSI , Serializable Snapshot Isolation)
部分常用数据库默认隔离级别:
- MySQL 默认的事务处理级别是
REPEATABLE-READ
,也就是可重复读- 该级别下 DML statements (
UPDATE
,DELETE
,INSERT
) and locking-reads (SELECT ... IN SHARE MODE
,SELECT ... FOR UPDATE
)与不一定使用与普通 SELECT 相同的一致视图/快照。 - 这个主要源自多版本控制,当发出一致读取(即普通 SELECT语句)时, InnoDB为你的事务提供一个时间点,你的查询会查看这个时间点内的数据库中的结果。从而可能导致如下情况:
- 表中有x=10,现在顺序开启两个事务T1和T2,
- T1 先查询 x 得到 x = 10
- T2 update set x=12,commit 提交;
- T1 查询 x 得到 x = 10
- T1 update set x=11
- T1 查询 x得到 x=12
- 具体说明可见 15.7.2.3 Consistent Nonlocking Reads
- 该级别下 DML statements (
- Oracle 默认系统事务隔离级别是
READ COMMITTED
,也就是读已提交 - etcd 默认的事务处理级别是
SerializableSnapshot
,可序列化快照隔离(SSI)
1.5 事务隔离的实现
最容易想到的是通过加锁来实现,如
- 两阶段锁(2PL):第一阶段(当事务正在执行时)获取锁,第二阶段(在事务结束时)释放所有的锁。
- 针对幻读和写偏斜,在幻读的情况下,可能会导致写偏斜。对此
- 可以通过增加 谓词锁(Predicate Lock)它类似于读写 / 互斥锁,但是它的加锁对象不属于特定的对象(例如表中的一行),它属于所有符合某些搜索条件的对象,如果对符合下面 SELECT 条件的对象加锁。
SELECT * FROM bookings WHERE room_id = 888 AND start_time < ‘2022-02-02 13:00’ AND end_time > ‘2022-02-02 12:00’;
- 间隙锁(Next-Key Locking)也可以解决这个问题,它是关于谓词锁的简化以及性能更好的一个实现,MySQL 中就有使用该锁的解决方案。
- 可以通过增加 谓词锁(Predicate Lock)它类似于读写 / 互斥锁,但是它的加锁对象不属于特定的对象(例如表中的一行),它属于所有符合某些搜索条件的对象,如果对符合下面 SELECT 条件的对象加锁。
简单说一下 MySQL 中的实现方式,主要是基于加锁以及是否创建视图以及创建时机实现:
隔离级别 | 视图创建时间 |
---|---|
读未提交 | 直接返回记录上的最新值,没有视图概念。 |
读已提交 | 在每个SQL语句开始执行时创建的视图。 |
可重复读 | 在事务启动时创建的,整个事务存在期间都用这个视图。 |
串行化 | 直接用加锁的方式避免并行访问,没有视图概念。 |
这里仅是简单介绍,更多的可以参考MySQL基础隔离性小结,里面对这块有较多的整理。
1.6 隔离现象
- 脏读( Dirty Read ) : 一个事务访问到另一个事务修改但未提交的数据。
- 不可重复读( Non-Repeatable Read ) : 一个事务中,两次查询同一行数据得到不同的结果。
- 幻读( Phantom Read ) :一个事务中,同一个查询语句在不同时间查询出的数据行数不同。例如,执行了两次 SELECT,但第二次返回的行没有第一次返回,则该行是“幻影”行。一般出现在一个事务在执行条件查询过程中,另一个事务新增或删除了匹配该条件的数据时。
不可重复读 与 幻读 的不同之处在某些方面有些类似 缓存击穿 与缓存雪崩 的区别。
除了上面所列,《A Critique of ANSI SQL Isolation Levels》中提到并发事务原则上还可能产生 脏写( Dirty Write)、更新丢失( Lost Update )、读偏斜( Read Skew )、写偏斜( Write Skew ) 的异常情况:
- 脏写( Dirty Write) :指一个事务覆盖了另一个正在执行中、尚未提交的事务写入的值。
- 如有两个事务 T1 和 T2 , T1 需要更改 [x=1,y=1] , T2 需要更改 [x=2,y=2] ,如果 T2 在 T1 调整期间完成修改,最终结果可能是 [x=2,y=1],这就是脏写,这时因为 T1 还没有提交,所以 T2 更改的就是 T1 的中间状态。
- 脏写最重要的问题是会破坏数据库的完整性约束,使系统无法正确回滚事务。大多数情况下都需要防止脏写,实际上几乎所有的数据库都可以防止该异常的出现,所以可以不用担心。
- 更新丢失 ( Lost Update ) :当两个事务读取同一个值,都试图将其更新为不同的值时,就会发生更新丢失,其最终结果就是两者中只有一个成功了,同时另一个事务也没有被通知自己的更新没生效。如两个事务 T1 和 T2:
- T1 读取x的值得到 x=0,T2读取得到x=0
- T1 执行x=x+3,为x增加3,提交
- T2 执行x=x+4,为x增加4,提交
- x的最终值为4,此时T1的更新便丢失了,如果是串行,x的最终值应该为7。
- 读偏斜 ( Read Skew ):读到了数据一致性被破坏的数据,这里的一致性约束通常是业务逻辑层面上的。 如数据的一致性约束为 x+y = 50,最开始 x=20,y=30 时:
- 并发事务 T2 一开始读到 x 的值为 20
- 事务 T1 将 x 和 y 的值分别改为 40 和 10
- 事务 T2 读取到 y=15,从而 x+y=20+10,违反了数据一致性。
- 写偏斜 ( Write Skew ) :也称写倾斜,指两个并发事务读到了同一个数据集,随后各自更改了不相干的数据集,导致最终的数据一致性被破坏。如 x 和 y需要满足 x+y<50,初始时 x=20,y=10,且事务 T1 和 T2 都读到了这一相同的数据,随后:
- T1 将 x 更新为 30,在 T1 看来 30+10<50,满足数据一致性约束
- T2 将 y 更新为 20,在 T2 看来 20+20<50,满足数据一致性约束
- 但是两个事务提交之后 30+20=50,从而违反了一致性约束。
隔离级别可能产生的隔离现象汇总:
隔离级别 | 脏写 | 脏读 | 不可重复读 | 幻读 | 更新丢失 | 读偏斜 | 写偏斜 |
---|---|---|---|---|---|---|---|
读未提交 | 不可能 | 可能 | 可能 | 可能 | 可能 | 可能 | 可能 |
读已提交 | 不可能 | 不可能 | 可能 | 可能 | 可能 | 可能 | 可能 |
可重复读 | 不可能 | 不可能 | 不可能 | 可能 | 不可能 | 不可能 | 不可能 |
串行化 | 不可能 | 不可能 | 不可能 | 不可能 | 不可能 | 不可能 | 不可能 |
快照隔离 | 不可能 | 不可能 | 不可能 | 不可能(广义幻读可能) | 不可能 | 不可能 | 可能 |
标准的定义有时还与实现有关系,而各个数据库对隔离级别的具体实现又各不相同。比如在A beginner’s guide to Read and Write Skew phenomena 一文中说的 MySQL 的 可重复读 隔离级别是存在 写偏斜 的。而在一些文章中又说其 可重复读级别 其实是 快照隔离级别,从具体实现上看,也确实更偏向 快照隔离级别。
1.7 避免方式
- 脏读 : 提供“读已提交”隔离级别及以上的数据库,都可以防止异常的出现,如果业务中不能接受脏读,那么隔离级别最少在“读已提交”隔离级别或者以上。
- 不可重复读或读偏斜 :“可重复读”隔离级别及以上的数据库都可以防止问题的出现。“可重复读”隔离级别是底线。
- 更新丢失 : 需要达到可重复读”隔离级别或者以上,如果达不到,可以考虑如下方式:
- 如果数据库提供了原子写操作,那么一定要避免在应用层代码中,进行“读-修改-写”操作,应该直接通过数据库的原子操作执行,避免更新丢失的问题。如
udpate table set value = value + 1 where key = XXX
- 如果数据库不支持原子操作,或者在某些场景中,原子操作不能处理时,可以通过对查询结果显式加锁来解决。对于 MySQL 来说,就是
select for update
:- 通过
for update
告诉数据库,查询出来的数据行过一会是需要更新的,需要加锁防止其他的事务,对同一块数据也进行读取加更新操作,从而导致更新丢失的情况。
- 通过
- 还可以通过原子比较和设置来实现,如
update table set value = newvalue where id = * and value = oldvalue
- 这种方式中如果 where 条件的判断是基于某一个旧快照来执行的,那么 where 的判断就是没有意义的。
- 所以要确认数据库 【比较-设置】操作的安全运行条件
- 如果数据库提供了原子写操作,那么一定要避免在应用层代码中,进行“读-修改-写”操作,应该直接通过数据库的原子操作执行,避免更新丢失的问题。如
- 幻读和写偏斜 :最简单是 可串行化的隔离级别。如果数据库的隔离级别达不到,可以考虑显式加锁。
- 通过对事务利用 select for update 显式加锁,可以确保事务以可串行化的隔离级别运行
- 但是如果在 select 阶段没有查询到临界区的数据,就会导致无法加锁。从而对写偏斜无效。
- 这种情况下,我们可以人为引入用于加锁的数据,然后通过显式加锁来避免写偏斜的问题。如 在订阅会议室时,为所有会议室的所有时间都创建好数据,每一个【时间-会议室】 一条数据,这个数据没有其他的意义,只是在 select for update 时,数据库可以 select 查询到数据来进行加锁操作。
2. 本地事务实现
一个事务的执行从数据库语句上来说需要经历如下过程:
- 通常使用
BEGIN
或者START TRANSACTION
开启一个事务 - 执行完一系列操作后,执行
COMMIT
提交事务,将所有操作一起提交,事务成功完成。 - 如果中间出现异常,执行
ROLLBACK
语句结束/回滚事务,放弃事务中执行的所有操作,回到事务执行之前的数据状态。
对于程序中的实现,这里简单介绍一下。
2.1 Java中JDBC的方式原始实现
public static void main(String[] args) {
Connection con = null;
try {
Class.forName("com.mysql.cj.jdbc.Driver");
String url = "jdbc:mysql://localhost:3306/user";
String user = "root";
String password = "123456";
Long sID = 1L;
con = (Connection) DriverManager.getConnection(url, user, password);
// 取消自动提交事务
con.setAutoCommit(false);
Statement stmt = (Statement) con.createStatement();
// 执行一系列操作
stmt.executeUpdate("delete from user where ID=" + sID);
stmt.executeUpdate("delete from xiao_affix where user_id=" + sID);
// 提交JDBC事务
con.commit();
// 恢复JDBC事务的默认提交方式
con.setAutoCommit(true);
} catch (Exception e) {
e.printStackTrace();
if (con!=null) {
try {
// 出现问题回滚
con.rollback();
} catch (SQLException ex) {
ex.printStackTrace();
}
}
} finally {
if (con!=null) {
try {
// 关闭连接
con.close();
} catch (SQLException ex) {
ex.printStackTrace();
}
}
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
2.2 @Transactional
spring 中 事务管理分为编码式和声明式的两种方式,这里不具体讨论具体区别,主要说常用的通过注解 @Transactional
来实现本地事务的声明式的方式。其底层原理也是AOP。这块暂不深入说明,简单记录一些,后期视情况整理,目前具体可以先看下面的参考资料中的内容。
此处基于的版本为 5.3.3
2.2.1 作用范围:
- 作用于类:当把
@Transactional
注解放在类上时,表示所有该类的public方法都配置相同的事务属性信息。 - 作用于方法:当类配置了
@Transactional
,方法也配置了@Transactional
,方法的事务会覆盖类的事务配置信息。@Transactional
注解应该只被应用到 public 方法上,这是由Spring AOP的过程中决定的。如果你在protected
、private
或者默认可见性的方法上使用@Transactional
注解,这将被忽略,也不会抛出任何异常。
- 作用于接口:不推荐这种使用方法,因为一旦标注在Interface上并且配置了Spring AOP 使用CGLib动态代理,将会导致
@Transactional
注解失效
2.2.2 隔离设置
通过属性 isolation
实现隔离配置。默认是后端数据库默认的隔离级别。
隔离级别 | 含义 |
---|---|
Isolation.DEFAULT | 后端数据库默认的隔离级别 |
Isolation.READ_UNCOMMITTED | 读未提交 |
Isolation.READ_COMMITTED | 读已提交 |
Isolation.REPEATABLE_READ | 可重复读 |
Isolation.SERIALIZABLE | 串行化 |
- 事务隔离级别为
READ_UNCOMMITTED
时,写数据只会锁住相应的行。 - 事务隔离级别为可
REPEATABLE_READ
时:- 如果检索条件有索引(包括主键索引)的时候,默认加锁方式是next-key锁;
- 如果检索条件没有索引,更新数据时会锁住整张表。
- 一个间隙被事务加了锁,其他事务是不能在这个间隙插入记录的,这样可以防止幻读。但可能会导致同样的语句锁住更大的范围,这其实是影响了并发度的。更多关于锁的部分可见之前的整理 MySQL基础锁小结
- 事务隔离级别为
SERIALIZABLE
时,读写数据都会锁住整张表。 - 隔离级别越高,越能保证数据的完整性和一致性,但是对并发性能的影响也就越大。
2.2.3 失效情况
@Transactional
应用在非public
修饰的方法上- 内部 AOP 实现过程中设置的,应该只被应用到 public 方法上,其余的会被忽略。可见:
AbstractFallbackTransactionAttributeSource#computeTransactionAttribute
- 内部 AOP 实现过程中设置的,应该只被应用到 public 方法上,其余的会被忽略。可见:
- 同一个类中方法调用,导致@Transactional失效
- 由于使用Spring AOP代理造成的,因为只有当事务方法被当前类以外的代码调用时,才会由Spring生成的代理对象来管理。
- 异常被
catch
“吃了”导致@Transactional
失效- 异常被捕获后,由于没有抛出新异常,导致无法自动触发回滚,这时需要考虑手动触发主动回滚
@Transactional
注解属性propagation
设置错误- 本来期望目标方法进行事务管理,但若是错误的配置下面这三种
propagation
(传播行为),事务将不会发生回滚: Propagation.SUPPORTS
:如果当前存在事务,则加入该事务;如果当前没有事务,则以非事务的方式继续运行。Propagation.NOT_SUPPORTED
: 以非事务方式运行,如果当前存在事务,则把当前事务挂起。Propagation.NEVER
: 以非事务方式运行,如果当前存在事务,则抛出异常。
- 本来期望目标方法进行事务管理,但若是错误的配置下面这三种
- 抛出的异常类型不符合触发条件
- 回滚条件:默认情况下,如果在事务中抛出了未检查异常(继承自 RuntimeException 的异常)或者 Error,则 Spring 将回滚事务;除此之外,Spring 不会回滚事务。
- 如果在事务中抛出其他类型的异常,并期望 Spring 能够回滚事务,可以指定 rollbackFor。如
@Transactional(rollbackFor= MyException.class)
- 数据库引擎不支持事务
- 如 MySQL 中 myisam 引擎不支持事务,innodb引擎才支持事务。
3. 参考资料
除特别注明外,本站所有文章均为 windcoder 原创,转载请注明出处来自: zhongxuejava-bendishiwu

暂无数据