Java笔记 · · By/蜜汁炒酸奶

重学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的过程中决定的。如果你在 protectedprivate 或者默认可见性的方法上使用 @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. 参考资料

评论已关闭

example
C
蜜汁炒酸奶

当前处于试运行期间,可能存在不稳定情况,敬请见谅。

欢迎点击此处反馈访问过程中出现的问题