今天在java代码中遇到一个数据库相关的bug,在分析和解决问题过程中,调研了一下java的事务传播属性,以及mysql事务隔离级别,这俩知识点以前虽然了解但其实没有完全理解,希望通过这个问题好好总结一下。

问题初现

背景:java中方法a(加了数据库事务注解)调用了方法b(同样加了数据库事务注解),B中对表t的部分行执行了更新操作;方法a中在调用b后,执行了对表t的select操作,但发现select到的数据中并不包含B的修改。用简易代码大概表示为:

@Transactional(propagation = Propagation.REQUIRED, readOnly = false, rollbackFor=Exception.class)
public void a() {
…… ……
//调用方法b
b();
//查询方法b中对表t更新的行
Object result = selectFromTableT();
//oops!! result中没有方法b的更新数据
}
@Transactional(propagation = Propagation.REQUIRES_NEW, readOnly = false, rollbackFor=Exception.class)
public void b() {
//执行对表t的update操作
updateTableT();
}

问题大概可以总结为,嵌套事务中执行的数据库修改操作对外层事务不可见,首先猜测是@Transactional注解中的事务传播属性REQUIRES_NEW的问题,会不会因为b方法新起了事务导致不同事务之间修改不可见?mysql事务隔离级别是默认的Repeatable Read。

事务传播属性

首先全面了解一下集中事务传播属性,spring中总共定义了七种事务传播属性:

public enum Propagation {
//支持当前事务,如果当前没有事务,就新建一个事务,这是最常用的选择
REQUIRED(0),
//支持当前事务,如果当前没有事务,就以非事务方式执行。
SUPPORTS(1),
//支持当前事务,如果当前没有事务,就抛出异常。
MANDATORY(2),
//新建事务,如果当前存在事务,把当前事务挂起。
REQUIRES_NEW(3),
//以非事务方式执行操作,如果当前存在事务,就把当前事务挂起。
NOT_SUPPORTED(4),
//以非事务方式执行,如果当前存在事务,则抛出异常。
NEVER(5),
//如果当前存在事务,则在嵌套事务内执行。如果当前没有事务,则进行与PROPAGATION_REQUIRED类似的操作。
NESTED(6);
}

其中REQUIRES_NEW和NESTED容易混淆,看起来都是在原有事务的基础上再开一个嵌套事务,他们的区别在哪?嵌套事务机制到底是怎样,内外事务的提交和回滚分别是何时触发的?

REQUIRES_NEW会启动一个独立的新事务,这个事务将被完全 commited 或 rollback 而不依赖于外部事务,它拥有自己的隔离范围,自己的锁等等。当内部事务开始执行时,外部事务将被挂起,内务事务结束时,外部事务将继续执行。

NESTED会开始一个 "嵌套的" 事务,它是已经存在事务的一个真正的子事务。 嵌套事务开始执行时,它将取得一个savepoint,如果这个嵌套事务失败,将回滚到此savepoint。 嵌套事务是外部事务的一部分,只有外部事务结束后它才会被提交。

由此可见,REQUIRES_NEW启动的新事务不依赖于外部事务,是完全独立的,这意味着事务commit和rollback操作都是独立的,不受外部事务commit或者rollback影响。

NESTED是依赖于外部事务的子事务,只有当外部事务commit时,子事务才能commit;外部事务发生异常rollback,子事务也要回滚。

回到上面的问题,方法b的事务传播属性设置为REQUIRES_NEW,意味着会开启一个完全独立的事务。当方法a中调用包含新事务的方法b之后,执行selectFromTableT方法查询方法b的修改行时,方法b中对表的修改操作已经提交,已经提交的修改为什么对方法a不可见?看起来这个问题还和mysql事务隔离级别有关,是时候捡起数据库隔离级别细读一番了。

mysql事务隔离级别

首先回顾一下mysql的四种事务隔离级别:

1. Read Uncommitted(读取未提交内容)

在该隔离级别,所有事务都可以看到其他未提交事务的执行结果。本隔离级别很少用于实际应用,因为它的性能也不比其他级别好多少。读取未提交的数据,也被称之为脏读(Dirty Read)。

2. Read Committed(读取提交内容)

这是大多数数据库系统的默认隔离级别(但不是MySQL默认的)。它满足了隔离的简单定义:一个事务只能看见已经提交事务所做的改变。这种隔离级别 也支持所谓的不可重复读(Nonrepeatable Read),因为同一事务的其他实例在该实例处理其间可能会有新的commit,所以同一select可能返回不同结果。

3. Repeatable Read(可重读)

这是MySQL的默认事务隔离级别,它确保同一事务的多个实例在并发读取数据时,会看到同样的数据行。不过理论上,这会导致另一个棘手的问题:幻读 (Phantom Read)。简单的说,幻读指当用户读取某一范围的数据行时,另一个事务又在该范围内插入了新行,当用户再读取该范围的数据行时,会发现有新的“幻影” 行。InnoDB和Falcon存储引擎通过多版本并发控制(MVCC,Multiversion Concurrency Control)机制解决了该问题。

4. Serializable(可串行化)

这是最高的隔离级别,它通过强制事务排序,使之不可能相互冲突,从而解决幻读问题。简言之,它是在每个读的数据行上加上共享锁。在这个级别,可能导致大量的超时现象和锁竞争。

不同事务隔离级别下几种典型问题:

1. 脏读

主要表现为一个事务中前后两次读取数据不一致,例如在Read Uncommitted隔离级别下,一个事务可以读取其他未提交事务的修改,前后两次读取之间可能有其他事务的修改或者回滚操作,导致前后数据不一致;

2. 幻读

主要表现为一个事务前后读取行数不一致或者读到了不存在的数据,用户读取某一范围的数据行时,另一个事务又在该范围内插入了新行,当用户再读取该范围的数据行时,会发现有新的“幻影” 行。

3. 不可重复读

和脏读类似,表现为一个事务内前后两次读取数据不一致,不可重复读问题是由于在一个事务执行过程中有其他事务已经commit的修改,导致前后读取不一致。

.

脏读

幻读

不可重复读

Read Uncommitted

Yes

Yes

Yes

Read Committed

No

Yes

Yes

Repeatable Read

No

Yes

No

Serializable

No

No

No

现在想来以前根本没有理解脏读和不可重复度两种问题的区别,虽然两种问题都表现为一个事务内对同一条数据前后读取结果不一致。

脏读是因为当前事务内在两次读取之间有其他事务修改了同样的数据行但未提交;不可重复度是因为在当前事务两次读取之间有其他事务修改了同样的数据行而且已经提交。

以前理解的误区在于,理所当然的以为已经提交事务的修改对任何隔离级别的事务都是可见的,还是太天真了┐(´д`)┌

那么上面问题的原因也很清晰了,因为事务隔离级别是mysql默认的Repeatable Read,这种隔离级别下要保证一个事务内前后读取到同样的数据,也就意味着对其它已提交或者未提交的修改都不可见,所以上述问题中方法a的事务对b方法中事务已提交的修改也选择不见(即使已经提交(|||゚д゚))。

例行总结

上面问题的原因总结为,在事务传播属性REQUIRES_NEW和mysql事务隔离级别Repeatable Read的组合情况下,由于方法b设置的传播属性REQUIRES_NEW会开启一个独立的新事务,同时Repeatable Read隔离级别下,为了保证不可重复读,即方法a在调用方法b前后读取的数据一致,因此方法b中对数据库的修改在a中不可见。

解决方法:将方法b的事务传播属性设置为REQUIRED,保证不开启新事务,方法b与方法a共用同一个事务,同一个事务内修改一定都是可见的;如果某些特殊情况下,一定需要将方法b的事务传播属性设置为REQUIRES_NEW,那么可以修改事务隔离级别为READ_COMMITED,不保证重复读,从而可以读到其它事务已提交的修改。

java的优势就在于可以灵活配置事务传播属性和事务隔离级别满足不同场景的数据库操作。但是使用过程中要清楚每种组合情况下可能会产生什么影响,例如我踩的坑是因为组合使用了REQUIRES_NEW和REPATABLE READ,导致子事务中提交的修改对外部事务不可见,同时还会造成外部事务发生异常回滚后,子事务并未回滚的问题。

我理解的REQUIRES_NEW的使用场景是内外事务之间完全独立,不需要保证数据一致性,不需要跟随外部事务一起回滚,而且可以有自己的隔离范围和锁。但如果要使用该属性,一定要确认自己的使用场景,内部事务和外部事务真的是完全独立的,也不需要保证内外数据一致性。我理解大部分场景下,更适合使用REQUIRED或NESTED,最主要是需要保证内外数据一致性。