事务,就是为了使得一些更新插入操作要么都成功,要么都失败。
严格意义上的事务实现应该是具备原子性、一致性、隔离性和持久性,简称 ACID。
原子性是指事务是一个不可分割的工作单位,整个事务中的所有操作要么全部提交成功,要么全部失败回滚,对于一个事务来说,不可能只执行其中的一部分操作。
一致性是指事务必须使数据库从一个一致性状态变换到另一个一致性状态。
也就是说事务前后数据的完整性必须保持一致。
隔离性是指一个事务的执行不能有其他事务的干扰,事务的内部操作和使用数据对其他的并发事务是隔离的,互不干扰。
持久性是指一个事务一旦提交,对数据库中数据的改变就是永久性的。此时即使数据库发生故障,修改的数据也不会丢失。接下来其他的操作不会对已经提交了的事务产生影响。
多个事务并发执行时,读取数据方面可能碰到三个问题:
备注:不可重复读对应的是修改update操作。幻读对应插入操作。幻读是不可重复读的特殊场景。
提高事务隔离级别的目的:解决脏读、不可重复读、幻读等读现象。
矛盾:隔离级别越高,并发能力就越低。所以,需要根据业务来场景来衡量使用哪种隔离级别。
隔离级别由低到高如下:
Read uncommitted(读未提交) :一个事务可以读取另一个未提交事务的数据。如果一个事务已经开始写数据,则另外一个事务则不允许同时进行写操作,但允许其他事务读此行数据。
示例:小明去商店买衣服,付款时,小明发起事务,但还没有提交。而商店老板查看自己账户,发现钱已到账(读未提交),于是小明正常离开。【可能事故:小明在走出商店后,马上回滚差点提交的事务,撤销了本次交易,于是小明没花钱就买到了衣服】
Read committed (读已提交):一个事务要等另一个事务提交后才能读取数据。在事务A处理期间,如果事务B修改了相应的表,则事务A的同一读sql在事务B执行前后的返回结果是不同的。(会造成幻读、不可重复读)。读取数据的事务允许其他事务继续访问该行数据,但是未提交的写事务将会禁止其他事务访问该行,会对该写锁一直保持直到到事务提交。
示例:小明卡里有1000元,聚餐时,准备计算1000元(事务开启),收费系统检测到他卡里有1000元。收费系统检测完毕时,小明的老婆转成功走了卡里的钱。【可能事故:当收费系统准备扣款时,再检查小明卡里的金额,发现已经没钱了,付款不成功。】
Repeatable read (重复读):在开始读取数据(事务开启)时,不再允许修改操作。可重复读会对读的行加锁,导致他事务修改不了这条数据,直到事务结束,但是这种方案只能锁住数据行,如果有新的数据进来,是阻止不了的,所以会有幻读问题。但是!!!Mysql已经是个成熟的数据库了,怎么会采用如此低效的方法呢? 其实这里的锁,是通过next-key锁实现的。
推荐博客:详解可重复度
- 悲观锁:读取数据时给加锁,其它事务无法修改这些数据。修改删除数据时也要加锁,其它事务无法读取这些数据。缺点:数据库性能消耗大。
- 乐观锁:大多是基于数据版本( Version )记录机制实现。通过为数据库表增加一个 “version” 字段来实现。读取出数据时,将此版本号一同读出,之后更新时,对此版本号加一。此时,将提交数据的版本数据与数据库表对应记录的当前版本信息进行比对,如果提交的数据版本号大于数据库表当前版本号,则予以更新,否则认为是过期数据。
示例:还是小明有1000元,准备跟朋友聚餐消费这个场景,当他买单(事务开启)时,收费系统检测到他卡里有1000元,这个时候,他的女朋友不能转出金额。接下来,收费系统就可以扣款成功了
问题拓展:写和读在同一事务的操作
第一步:更新A表id=1的记录
第二步:查询A表id=1的记录
第三步:使用第二步的查询结果作为依据继续业务逻辑
第四步:提交事务
问题:同一个事务中,事务未提交前,第二步的查询结果是第一步执行前的结果还是第一步执行后的结果?
答案:是第一步执行后的记过。事务隔离级别是针对不通事务的,同一事务中的未提交的更新,在后续是可以查询到的。
Serializable (序列化)
数据库事务的最高隔离级别。在此级别下,事务串行执行。可以避免脏读、不可重复读、幻读等读现象。但是效率低下,耗费数据库性能,不推荐使用。它在选定对象上的读锁和写锁保持直到事务结束后才能释放,所以能防住上诉所有问题,但因为是串行化的,所以效率较低。
在InnoDB中,会在每行数据后添加两个额外的隐藏的值来实现MVCC,这两个值一个记录这行数据何时被创建,另外一个记录这行数据何时过期(或者被删除)。 在实际操作中,存储的并不是时间,而是事务的版本号,每开启一个新事务,事务的版本号就会递增。 在可重读Repeatable reads事务隔离级别下:
通过MVCC,虽然每行记录都要额外的存储空间来记录version,需要更多的行检查工作以及一些额外的维护工作,但可以减少锁的使用,大多读操作都不用加锁,读取数据操作简单,性能好。
细心的同学应该也看到了,通过MVCC读取出来的数据其实是历史数据,而不是最新数据,这在一些对于数据时效特别敏感的业务中,很可能出问题,这也是MVCC的短板之处,有办法解决吗? 当然有.
MCVV这种读取历史数据的方式称为快照读(snapshot read),而读取数据库当前版本数据的方式,叫当前读(current read).
MySQL和Spring默认的事务隔离级别:重复读REPEATABLE-READ,可以避免脏读,不可重复读,不可避免幻读
mysql查看数据库实例默认的全局隔离级别sql
(1) Mysql8以前:SELECT @@GLOBAL.tx_isolation, @@tx_isolation;
(2) Mysql8开始:SELECT @@GLOBAL.transaction_isolation, @@transaction_isolation;
修改MySQL隔离级别命令:
SET SESSION TRANSACTION ISOLATION LEVEL READ UNCOMMITTED
如果MySQL与代码的事务隔离级别不一致,会使用代码指定的事务隔离级别。
声明式事务管理原理:使用了 AOP 实现的,本质就是对需要spring管理事务的bean生成了代理对象,然后通过代理对象拦截了目标方法的执行,在方法前后添加了事务的功能,根据实际情况选择提交或是回滚事务。所以必须通过代理对象调用目标方法的时候,事务才会起效。
优点:使用这种方式,对代码没有侵入性,方法内只需要写业务逻辑就可以了。
缺点:
(1) 声明式事务的最小粒度是方法。如果想要给一部分代码块增加事务的话,就需要把这个部分代码块单独独立出来作为一个方法。
(2)由于声明式事务既可以通过注解使用,也可以通过配置实现,这就导致某些事务可能被开发者忽略。如果开发者没有注意到一个方法是被事务嵌套的,那么就可能会再方法中加入一些如RPC远程调用、消息发送、缓存更新、文件写入等操作,这些操作如果被包在事务中,有两个问题:1、这些操作自身是无法回滚的,这就会导致数据的不一致。可能RPC调用成功了,但是本地事务回滚了,可是PRC调用无法回滚了。2、在事务中有远程调用,就会拉长整个事务。时间久会导致本事务的数据库连接一直被占用,类似操作过多就会导致数据库连接池耗尽。
推荐使用编程式事务,这样业务代码中就会清清楚楚看到什么地方开启事务,什么地方提交,什么时候回滚。有人想改这段代码时,就会强制考虑要加的代码是否应该方法事务内。
如以下几种场景导致的声明式事务失效,如果使用编程式事务的话,很多都是可以避免的:
1、@Transactional 应用在非 public 修饰的方法上
2、@Transactional 注解属性 propagation 设置错误
3、@Transactional 注解属性 rollbackFor 设置错误
4、同一个类中方法调用,导致@Transactional失效
5、异常被catch捕获导致@Transactional失效
6、数据库引擎不支持事务
有些人可能会说,已经有了声明式事务,但是写代码的人没注意,这能怪谁。话虽然是这么说,但是我们还是希望可以通过一些机制或者规范,降低这些问题发生的概率。因为,工作这么多年来,发生过不止一次开发者没注意到声明式事务而导致的故障。有些时候,声明式事务确实不够明显,另外,声明式事务用不对容易失效。
Spring的事务是基于AOP实现的,但是在代码中,有时候我们会有很多切面,不同的切面可能会来处理不同的事情,多个切面之间可能会有相互影响。在之前的一个项目中,我就发现我们的Service层的事务全都失效了,一个SQL执行失败后并没有回滚,排查下来才发现,是因为一位同事新增了一个切面,这个切面里面做个异常的统一捕获,导致事务的切面没有捕获到异常,导致事务无法回滚。
推荐博客:spring事务失效场景
@EnableTransactionManagement 注解用来启用spring事务自动管理事务的功能,这个注解千万不要忘记写了。
@Transaction 可以用在类上、接口上、public方法上,如果将@Trasaction用在了非public方法上,事务将无效。
spring是通过事务管理器了来管理事务的,一定不要忘记配置事务管理器了,要注意为每个数据源配置一个事务管理器:
@Bean
public PlatformTransactionManager transactionManager(DataSource dataSource) {
return new DataSourceTransactionManager(dataSource);
}
spring是通过aop的方式,对需要spring管理事务的bean生成了代理对象,然后通过代理对象拦截了目标方法的执行,在方法前后添加了事务的功能,所以必须通过代理对象调用目标方法的时候,事务才会起效。
看下面代码,思考一个问题:当外部直接调用m1的时候,m2方法的事务会生效么?
@Component
public class UserService {
public void m1(){
this.m2();
}
@Transactional
public void m2(){
//执行db操作
}
}
显然不会生效,因为m1中通过this的方式调用了m2方法,而this并不是代理对象,this.m2()不会被事务拦截器,所以事务是无效的。如果外部直接调用通过UserService这个bean来调用m2方法,事务是有效的,上面代码可以做一下调整,如下,@1在UserService中注入了自己,此时m1中的m2事务是生效的。
重点:必须通过代理对象访问方法,事务才会生效。
@Component
public class UserService {
@Autowired //@1
private UserService userService;
public void m1() {
this.userService.m2();
}
@Transactional
public void m2() {
//执行db操作
}
}
spring事务回滚的机制:对业务方法进行try catch,当捕获到有指定的异常时,spring自动对事务进行回滚,那么问题来了,哪些异常spring会回滚事务呢?
并不是任何异常情况下,spring都会回滚事务,默认情况下,RuntimeException和Error的情况下,spring事务才会回滚。
也可以自定义回滚的异常类型:
@Transactional(rollbackFor = {异常类型列表})
当业务方法抛出异常,spring感知到异常的时候,才会做事务回滚的操作,若方法内部将异常给吞了,那么事务无法感知到异常了,事务就不会回滚了。
如下代码,事务发生了异常,但是被捕获了,此时事务并不会被回滚
spring事务实现中使用了ThreadLocal,可以实现同一个线程中数据共享,必须是同一个线程的时候,数据才可以共享,这就要求业务代码必须和spring事务的源码执行过程必须在一个线程中,才会受spring事务的控制,比如下面代码,方法内部的子线程内部执行的事务操作将不受m1方法上spring事务的控制,这个一定要注意
@Transactional
public void m1() {
new Thread() {
一系列事务操作
}.start();
}
分布式锁:解决分布式资源抢占的问题;
分布式事务:解决流程化提交问题。在分布式系统中实现事务,它其实是由多个本地事务组合而成。
分布式事务为什么复杂?
(1)存储端的多样性。本地事务的情况下,所有数据都会落到同一个DB中,但是,在分布式的情况下,就会出现数据可能要落到多个DB,或者还会落到Redis,落到MQ等中。
(2)请求链路被延展拉长,一个操作会被拆分成多个服务,它们呈现线状或网状,依靠网络通信构建成一个整体。
分布式事务要求:保证分布式系统中的数据一致性,保证数据在子系统中始终保持一致,避免业务出现问题。分布式系统中对数要么一起成功,要么一起失败,必须是一个整体性的事务。
举个例子:在电商网站中,用户对商品进行下单,需要在订单表中创建一条订单数据,同时需要在库存表中修改当前商品的剩余库存数量,两步操作一个添加,一个修改,我们一定要保证这两步操作一定同时操作成功或失败,否则业务就会出现问题。
参见博客:专项攻克——CAP理论
2PC 和 3PC 都是数据库层面的,而 TCC 是业务层面的分布式事务。
TCC 分布式事务模型需要业务系统提供三段业务逻辑:
TCC的难点、注意事项、缺点、优点:
参见博客:两阶段、三阶段
2PC(Two-phase commit protocol),中文叫二阶段提交。第一阶段是准备**【资源锁定】**,第二阶段是提交。
2PC 引入一个事务协调者,用于协调管理各参与者(也可称之为各本地资源)的提交和回滚。
总结:二阶段的核心是对每个事务先锁定后提交的处理方式,该提交方式是一个强一致性的算法。
优点:原理简单、实现方便
缺点:同步阻塞、单点问题、脑裂、太过保守
流程及分析:
协调者故障分析:
2PC 是一个同步阻塞协议,像第一阶段协调者会等待所有参与者响应才会进行下一步操作,当然第一阶段的协调者有超时机制,假设因为网络原因没有收到某参与者的响应或某参与者挂了,那么超时后就会判断事务失败,向所有参与者发送回滚命令。协调者是一个单点,存在单点故障问题。
- 假设协调者在发送准备命令之前挂了,等于事务还没开始,还行。
- 假设协调者在发送准备命令之后挂了,这就不太行了,有些参与者等于都执行了处于事务资源锁定的状态。不仅事务执行不下去,还会因为锁定了一些公共资源而阻塞系统其它操作。
- 假设协调者在发送回滚事务命令之前挂了,那么事务也是执行不下去,且在第一阶段那些准备成功的参与者都阻塞着。
- 假设协调者在发送回滚事务命令之后挂了,这个还行,至少命令发出去了,很大的概率都会回滚成功,资源都会释放。但是如果出现网络分区问题,某些参与者将因为收不到命令而阻塞着。
- 假设协调者在发送提交事务命令之前挂了,这个不行,傻了!所有资源都阻塞着。
- 假设协调者在发送提交事务命令之后挂了,这个还行,也是至少命令发出去了,很大概率都会提交成功,然后释放资源,但是如果出现网络分区问题某些参与者将因为收不到命令而阻塞着。
3PC 是为了单点故障问题和减少二阶段的资源阻塞问题,包含三阶段:询问阶段、预提交阶段、提交阶段(CanCommit、PreCommit 和 DoCommit)。
2PC和3PC的差异:
缺陷:(1)2PC 和 3PC 都不能保证数据100%一致,因此一般都需要有定时扫描补偿机制。