事务基础知识
一. 事务的ACID
- Atomic: 原子性
一组SQL放在一起执行,要么全部执行成功,要么就都别执行,不存在部分执行成功的状态。(关注的是数据的状态) - Consistency: 一致性
在事务的执行过程中,中间状态的数据对外不可见,只有最初和最终的状态对外可见。(关注的是数据的可见性) - Isolation: 隔离性
多个事务在运行时不能互相干扰。防止这种情况: 事务A正在操作某个数据,此时事务B也在操作这个数据,并且还导致事务A报错了。 - Durability: 持久性
只要事务执行成功了,那么它对数据的影响就是永久性的。别搞得过了一会儿,刚刚更新的数据又自动还原了。
二. 事务的隔离级别
2.1 读,未提交(READ UNCOMMITTED)
假设服务B正在更新id=1的数据,将name修改成了张三,且尚未提交事务。在读未提交的事务隔离级别下,服务A发起请求,创建的事务A中,能够看到事务B中修改的数据,也就是说,name等于张三。非常危险。
2.2 读,已提交(READ COMMITTED)
- 服务B修改了id=1的数据,将name的值修改成张三,未提交事务。
- 服务A查询id=1的数据,发现name等于李四。
- 服务B提交了事务。
- 服务A在同一个事务中,再次查询id=1的数据,发现name等于张三。
2.3 可重复读(REPEATABLE READ)
- 服务B正在修改id=1的数据,把name的值修改成张三,未提交事务。
- 服务A请求Mysql,在事务A-1中查询id=1的数据,此时name是"李四"。
- 服务B提交了事务B,
- 服务A在事务A-1中再次查询id=1的数据,此时name的值仍然是"李四"。
- 当且仅当服务A开辟一个新的事务,再次查询id=1的数据,才能查询到name的值是"张三"。
commit操作后的数据只能对新的Session可见,在旧的Session(比如事务A-1)中无论你执行多少次查询语句,都不可能看的到事务B commit之后的数据。Mysql默认的事务隔离级别是可重复度。
我们用MVCC的概念,可以很容易的理解上述结论。
- 为什么某个事务commit后的数据只能对新的Session可见,对旧的Session不可见?
答: 因为这个事务的SessionId比新的SessionId小,比旧的SessionId大,Mysql的MVCC机制告诉我们,事务能看到的数据,必须满足,这条数据的创建事务id小于等于当前事务。
2.4 幻读
- 数据库中存储了id=1,name=张三,这一条数据。
- 服务A创建事务,查询数据库,发现只有id等于1的这一条数据。
- 服务B向数据库中新增了2条数据,并提交了事务。
- 服务A在尚未关闭的事务中,再次查询数据库,发现查到了3条数据,这与之前的查询结果不一样,站在自己的角度,就好像"凭空"多出了2条数据,这就是幻读。
可重复读,可以缓解部分幻读问题,它可以做到在同一个Session中,无论你查多少次,只要自己不做增删改,那么查到的结果一定能保持相同,不管其它Session有没有增删改数据。但是这有一个大前提,那就是只能一直做查询操作。假如说,我用了可重复度,在Session1内首先做了查询并得到结果集A,接着在Session2中新增了两条数据并提交Session2,然后在Session1中对所有的数据进行了更新(比如说不带条件的更新),提交Session1,此时会惊讶的发现,包括Session2新增的两条数据在内,所有的数据全都被Session1的更新操作影响了。
换句话说,虽然在Session1中,我们看不到Session2提交的数据,但却能更新它们,这是为什么呢?
这是因为Select走的是快照读,在执行Select操作时,Mysql会为本次执行的结果生成一份快照,后续多次的Select其实都是在查询这份快照,Session2中新产生的数据当然就没办法看到了。一旦某一行数据执行了增删改,这个事情就发生了本质的变化,因为Mysql会为这一行数据生成新的快照点,再次执行Select显然就能看到这一行数据的最新数据了,但是值得注意的是,其它行的数据仍然走之前生成的快照。
所以,想要彻底解决幻读,需要比可重复度更加严格的隔离机制,那就是串行化。
2.5 串行化(SERIALIZABLE)
服务A的事务A在执行的过程中,其它的客户端都无法开启事务。上图中,服务A开启了事务A,先是查询表中的数据,再做了CRUD操作,最后提交事务,这个过程内其他的客户端都无法创建事务,只能等着,等到事务A提交后,才能开启和执行自己的事务。事务A做完,事务B才能执行,这就是事务的串行化执行。
2.6 MVCC机制
上面说了4种事务的隔离机制,默认使用的就是可重复读(REPETABLE READ),那么这是怎么实现的呢?Mysql是通过MVCC机制实现的,MVCC(multi-version concurrency control)是多版本并发控制的意思。
概念1: 任何一个服务接入到Mysql中创建事务时,Mysql都会为其分配一个事务id,并且事务id全局唯一,自增长。
概念2: 每一行数据,除了自身的字段外,还会维护两个隐藏字段,分别是创建这行数据的事务id和删除这行数据的事务id。
概念3: 每一个事务只能看到以下数据: 1. 当前事务id >= 创建数据的事务id 2.当前事务id < 删除数据的事务id
比如说,现在有一张student表,包含了三个字段,分别是 id, name和age。
① 事务(id=100) 新增了id=1的数据和id=2的数据
id | name | age | 创建数据的事务id | 删除数据的事务id |
---|---|---|---|---|
1 | 张三 | 27 | 100 | 空着 |
2 | 李四 | 26 | 100 | 空着 |
事务(id=101)执行select * from student where id = 1时,能看到 id=1,name=张三,age=27,这是因为101 >= 100
② 事务(id=102)删除了id=1的数据。
id | name | age | 创建数据的事务id | 删除数据的事务id |
---|---|---|---|---|
1 | 张三 | 27 | 100 | 102 |
2 | 李四 | 26 | 100 | 空着 |
事务(id=101)仍然能看到id=1的数据,这是因为101 < 102
③ 事务(id=102)修改了id=2的数据,把name改成了"王五",age改成了28
id | name | age | 创建数据的事务id | 删除数据的事务id |
---|---|---|---|---|
1 | 张三 | 27 | 100 | 102 |
2 | 李四 | 26 | 100 | 空着 |
2 | 王五 | 28 | 102 | 空着 |
事务(id=101)select * from student where id = 2时,只能看到旧数据,也就是name=李四,age=26,这是因为101 < 102。
注意: 当数据表中存在id相同的多条数据时(并非真的有多条数据,毕竟id不可能重复,这里只是因为在事务里面),我们查询到的一定是满足MVCC条件的,且"创建数据的事务id"最大的那条数据。比如说,假设事务(id=102)在执行全表查询时,表中有创建SessionId=100、101以及103的三条数据,它们的id都等于2,那么事务(id=102)查询到的id=2的数据,应该是SessionId=101的。
这里有一个问题,假如事务100创建了id=2的数据,并且提交了事务,此时先后开启了两个事务101和102,接着,102修改了数据,提交了事务。根据之前的分析可知,101看不到102修改后的数据内容,只能看到事务100,但这还没完,101修改了这条数据,并且提交了事务。最后开启了事务103,请问103能看到哪个事务提交的数据?
有人说,这不是废话吗?最后修改id=2数据的事务是101,那么103看到的当然是101修改的数据。但是你难道不觉得很奇怪吗?按照之前的逻辑,id=2的数据应该会"存在"3条,分别对应的创建事务id=100,101,102,为什么103看到的不是102,而是101呢?
我的推测如下: 提交事务时,如果对某个数据做出了修改,那么当前事务会把原先实际存储在表中的数据删掉,接着写入一条新的数据,虽然数据id保持不变,但是创建数据的事务id会写成自己。
这样一来,虽然102比101先提交了事务,但是没关系啊,101提交事务时,会把102提交事务时产生的那条数据给覆盖了。所以,103就只能看到101创建的数据了。
2.7 经典小案例
假设数据表内有id,name,age这三个字段
id | name | age | 创建数据的事务id | 删除数据的事务id |
---|---|---|---|---|
1 | 张三 | 27 | 99 | 无 |
事务A(SessionId=100)和事务B(SessionId=101)依次开启事务,事务A的SessionId < 事务B的SessionId。
事务A新增了两条数据,并且修改了id=1的数据,将age改成了35,并且提交了事务。(下方id=1,age=27的数据被我画了删除线,这是因为当事务A提交事务后,会重新更新实际存储在表中的数据)
id | name | age | 创建数据的事务id | 删除数据的事务id |
---|---|---|---|---|
1 | 张三 | 35 | 100 | 空着 |
2 | 李四 | 26 | 100 | 空着 |
3 | 王五 | 28 | 100 | 空着 |
事务B首先是全表查询,此时只能查到id=1,name=张三,age=27,毕竟读的是快照(别问我为什么age=27明明被删除了,还能查得出来,这里写的清清楚楚,读的是快照,快照)。
接着,事务B更新了id=1的数据,把name修改成了“张三(修改后)”。
id | name | age | 创建数据的事务id | 删除数据的事务id |
---|---|---|---|---|
1 | 张三 | 27 | 99 | 空着 |
1 | 张三 | 35 | 100 | 空着 |
1 | 张三(修改后) | 35 | 101 | 空着 |
2 | 李四 | 26 | 100 | 空着 |
3 | 王五 | 28 | 100 | 空着 |
现在,事务B全表查询,仍然只能查到id=1的数据,但是name=张三(修改后),并且age=35。
通过更新操作,Mysql为id=1的这条数据重新创建了快照点,所以能看到当前数据库中,id=1的最新的一条数据。但是对于其它id的数据而言,仍然读的是旧的快照,所以查不到数据。
三. Spring的事务支持和传播特性
Spring支持声明式、编程式以及注解这三种方式来操作事务。
编程式事务就是搞一个XML文件,在里面添加数据源,添加事务管理器,添加事务模板,接着在代码中使用事务模板(TransactionTemplate),哪里需要事务,就在哪里使用TransactionTemplate。
transactionTemplate.execute(new TransactionCallbackWithoutResult() {
@Override
protected void doInTransactionWithoutResult(TransactionStatus transactionStatus) {
String sql = ...
jdbcTemplate.update(...)
}
})
声明式事务就是搞一个XML,在里面配置DataSourceTransactionManager,然后搞一堆AOP,配置切点,比如到底要把事务用在哪个类的哪个方法上,好处是对代码没有侵入性。
<bean id="txManager" class="org.springframework.jdbc.datasource.DataSourceTransactionManager">
<property name="dataSource" ref="dataSource"/>
</bean>
<tx:advice id="txAdvice" transaction-manager="txManager">
<!-- 事务语义... -->
<tx:attributes>
<!-- 所有用'get'开头的方法都是只读的 -->
<tx:method name="get*" read-only="true"/>
<!-- 其他的方法使用默认的事务配置(看下面) -->
<tx:method name="*"/>
</tx:attributes>
</tx:advice>
<aop:config>
<aop:pointcut id="fooServiceOperation" expression="execution(* x.y.service.FooService.*(..))"/>
<aop:advisor advice-ref="txAdvice" pointcut-ref="fooServiceOperation"/>
</aop:config>
不过现在没人去写这么复杂的配置文件了,通常采用注解。注解就是直接使用@Transational,通过为propagation赋值来指定事务的传播行为。
事务一共有7种传播行为:
- PROPAGATION_REQUIRED (默认)
假设ServiceA.method()调用了ServiceB.method(),若ServiceA.method()开启了事务,ServiceB.method()也开启了事务,那么ServiceB.method()不会单独开启事务,而是把自己放在ServiceA.method()的事务中。ServiceA.method()和ServiceB.method()任何一个报错,都可能导致整个事务回滚。 - PROPAGATION_SUPPORTS
假设ServiceA.method()调用了ServiceB.method(),若ServiceA.method()开启了事务,则ServiceB.method()会把自己加入到前者的事务中。如果ServiceA.method()没有开启事务,则ServiceB.method()也不会开启事务。 - PROPAGATION_MANDATORY
必须由一个开启了事务的方法来调用自己,否则报错。 - PROPAGATION_REQUIRED_NEW
强制自己开启一个事务,假设ServiceA.method()调用了ServiceB.method(),在调用了ServiceB.method()之后,ServiceA会被卡住,当且仅当ServiceB.method()执行完毕后,ServiceA再去执行自己的操作。所以,如果说我们为Service调用ServiceB的代码加上try/catch,那么就算ServiceB.method()报错了,也仅仅只会回滚ServiceB.method()执行了的操作,与ServiceA没有半毛钱关系。 - PROPOGATION_NOT_STOPPED
ServiceB.method()不支持事务,ServiceA.method()在调用ServiceB.method()时,请求将被挂起,等到ServiceB.method()用非事务的方式运行结束之后,ServiceA再执行。这种做法的好处是,ServiceB的代码就算报错了,引起了ServiceA事务回滚,ServiceB执行过的代码也不会被回滚。 - PROPAGATION_NEVER
不能被一个开启了事务的方法调用,比如ServiceA.method()开启了事务,ServiceB.method()被标记了PROPAGATION_NEVER,那么ServiceA.method()在调用ServiceB.method()时就会报错。 - PROPAGATION_NESTED
开启嵌套事务,ServiceB开启一个子事务,一旦ServiceB执行报错,那么它会回滚到ServiceA调用ServiceB,也就是开启子事务的这个savePoint。