重学Java-本地事务

重学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 记录了事务的提交点,它来决定事务是否应该安全提交。 Undo-Redo-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
  • 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 中就有使用该锁的解决方案。

简单说一下 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.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
  • 同一个类中方法调用,导致@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. 参考资料

本文采用 CC BY-NC-SA 4.0 许可,转载请注明出处与作者。

支持一下

如果这篇文章对你有帮助,欢迎打赏或分享。

打赏支持

评论