基于Spring的事务、分布式事务以及基于redis、zookeeper的分布式锁
一、事务
事务是逻辑上的一组操作,要么都执行,要么都不执行
事务有以下四个特性:
原子性:事务是最小的执行单位,不允许分割。事务的原子性确保动作要么全部完成,要么完全不起作用
一致性:执行事务前后,数据保持一致
隔离性:事务不能被其它事务干扰
持久性:一个事务被提交之后,它对数据库中数据的改变是持久的,即使数据库发生故障也不应该对其有任何影响
Spring中事务的5个隔离级别:
1、Default:跟随数据库的隔离级别
2、未提交读:事务可以读取其它事务未提交的数据。会造成脏读。
3、提交读:事务可以读其他事务已提交的数据。会造成不可重复读和幻读。
4、可重复读:保证事务多次读同一个字段的值相同。会造成幻读。
5、序列化执行:事务按序列化逐个执行。完全解决脏读、不可重复读、幻读,但是对性能影响很大,通常不使用。
Spring中事务的7个传播特性:
1、外层有其他事务在执行,就运行在外层的事务中,如果外层没有事务,也可以不运行在事务中(就写不写一样)
2、外层有其他事务在执行,就运行在外层的事务中,如果外层没有事务,则自己新建一个事务运行
3、外层有其他事务在执行,就嵌套运行在外层的事务中,如果外层没有事务,则自己新建一个事务运行(一个事务嵌套另一个事务,外层事务实执行失败则所有事务回滚,内部事务执行失败则只有内部事务回滚)
4、外层有其他事务在执行,就新建一个事务运行,不运行在外层事务中(两个事务互不影响)
5、标注本方法必须运行在事务中
6、标注本方法必须不运行在事务中
7、标注该方法不运行在事务中,但如果外层有事务在运行,将事务挂起
二、分布式事务
普通事务只运行在一个server上,而当有多个节点需要组合运行事务,需要保证事务的 AICD 特性时,则使用分布式事务。
分布式事务解决方案:
1、两阶段提交:协调者加入。向各个节点发送预执行命令(让各个节点启动事务,预执行sql语句,而不提交)----》各个节点执行完后把执行结果发送给协调者----》协调者再根据各个节点执行的结果决定事务是否提交----》把决定下发给各个节点
2、三阶段提交:在两阶段的基础上增加询问环节(先询问各个节点是否能正常工作),增加超时时间
3、利用消息中间件实现最终一致性。
4、利用阿里开源项目seata。
5、MySQL XA 次方式性能损耗过大,不做介绍
分析:
1、两阶段和三阶段存在很多问题:代码侵入性强,阻塞,容错,单点故障,数据不一致等问题
2、利用消息中间件实现最终一致性:能解决两阶段提交的所有问题,但是不能保证消息的时效性。代码0侵入,流程步骤解耦
实现原理:将事务才分为多个小事务,创建事务表来记录事务,如工行向农行转账:
工行:转账发起,在自己的服务中开启单机事务,保证转账发起扣钱和向数据库事务表中提交一条事务记录;创建定时任务读事务表新增的记录,读到新增的记录后,开启单机事务,保证把事务记录放入消息列队中,并更改事务记录状态为已提交到消息列队。
农行:创建定时任务消费消息列队中的消息,当有新消息时,开启单机事务,保证把工行发过来的消息插入事务表中,并成功消费消息(否则等下次重新消费);创建定时任务读取事务表中的事务记录,当读取到新记录时,开启单机事务,保证收款账户余额增加,并更新事务表的事务记录状态为已处理。
3、利用阿里开源项目seata:去seata官网,拉一个使用与当前框架的demo,根据demo的readme去配置seate server以及seata client即可。seata目前是分布式事务最好的解决方案,一个注解搞定。
三、seata原理(摘抄自官网)
前提
- 基于支持本地 ACID 事务的关系型数据库。
- Java 应用,通过 JDBC 访问数据库。
整体机制
两阶段提交协议的演变:
-
一阶段:业务数据和回滚日志记录在同一个本地事务中提交,释放本地锁和连接资源。
-
二阶段:
- 提交异步化,非常快速地完成。
- 回滚通过一阶段的回滚日志进行反向补偿。
写隔离
- 一阶段本地事务提交前,需要确保先拿到 全局锁 。
- 拿不到 全局锁 ,不能提交本地事务。
- 拿 全局锁 的尝试被限制在一定范围内,超出范围将放弃,并回滚本地事务,释放本地锁。
以一个示例来说明:
两个全局事务 tx1 和 tx2,分别对 a 表的 m 字段进行更新操作,m 的初始值 1000。
tx1 先开始,开启本地事务,拿到本地锁,更新操作 m = 1000 - 100 = 900。本地事务提交前,先拿到该记录的 全局锁 ,本地提交释放本地锁。 tx2 后开始,开启本地事务,拿到本地锁,更新操作 m = 900 - 100 = 800。本地事务提交前,尝试拿该记录的 全局锁 ,tx1 全局提交前,该记录的全局锁被 tx1 持有,tx2 需要重试等待 全局锁 。
tx1 二阶段全局提交,释放 全局锁 。tx2 拿到 全局锁 提交本地事务。
如果 tx1 的二阶段全局回滚,则 tx1 需要重新获取该数据的本地锁,进行反向补偿的更新操作,实现分支的回滚。
此时,如果 tx2 仍在等待该数据的 全局锁,同时持有本地锁,则 tx1 的分支回滚会失败。分支的回滚会一直重试,直到 tx2 的 全局锁 等锁超时,放弃 全局锁 并回滚本地事务释放本地锁,tx1 的分支回滚最终成功。
因为整个过程 全局锁 在 tx1 结束前一直是被 tx1 持有的,所以不会发生 脏写 的问题。
总结:
seata自己根据生成分布式事务ID维护了一个全局锁,如A,B两个事务同时运行,A先拿到本地锁,做预处理(写undo_log),准备提交数据(此时B事务则等待A事务的本地锁);A再去seata申请全局锁,如果申请到了就提交数据,没申请到就等待,超时就回滚,全当A事务没来过;A申请到了全局锁,本地数据提交,在本地分支事务表中添加undo_log日志记录(用于回滚),然后释放本地锁(此时B事务就拿到本地锁,开始准备提交,但也要申请到全局锁才能提交;此时其他节点的B事务也不能提交,等全局锁);A事务全部执行完成,要么回滚要么成功(成功后删除本地分支事务表的undo_log),释放全局锁,这时B事务才能拿到全局锁,正在提交数据。
seata回滚是有可能发生脏写:如果一个事务不经过seata,也就不受全局锁控制,直接修改数据,当seata事务回滚的时候,可能会发生脏写。所以用seata维护分布式事务,最好把涉及到seata事务的表的所有事务都交给seata来处理。
四、分布式锁
1、利用zookeeper目录树节点的特性来做分布式锁,session创建的节点会随着session销毁,一个节点下创建相同名称的子节点时,zookeeper会为子节点维护一个序列号。当并发时,所有线程都去某个节点下创建session子节点,谁的序列号小,锁就归谁,而没有得到锁的线程就监听自己前面一个序列号,当前一个序列号失效时,在监听的回调方法中,认为已经获得锁做处理
2、redis做锁,利用redis的setnx命令特性,setnx只有key不存在时,才能新建成功,所以只有一个线程会成功,其他都会失败,在创建key的时候应该要设置过期时间,避免死锁