浅析Java中的事务,从ACID到BASE

本文收录在javaskill.cn中,内有完整的JAVA知识地图,欢迎访问

1. 数据库中的事务

Java中的事务管理,最终都是体现在数据上,因此,了解数据库对事务的处理是非常必要的

1.1 ACID

Atomicity、Consistency、Isolation、Durability
原子性、一致性、隔离性、持久性

  1. 原子性
    事务中的操作必须全部成功或全部失败
  2. 一致性
    事务必须使数据库从一个一致性状态转变到另一个一致性状态
    栗子:A有100元,B有100元,AB共200元,无论A和B怎么转账(不考虑手续费),A和B一共有200元
  3. 隔离性
    事物之间不互相干扰,存在多种隔离级别
  4. 持久性
    事务一旦提交,对数据的改变就是永久性的,即使遇到故障,也不会丢失提交事务的操作

1.2 脏读、不可重复读和幻读

  1. 脏读
    指一个事务读取了另一个未提交的事务中的数据
  2. 不可重复读
    指一个事务中,对一个值多次读取返回的值不一致(读取了其他已提交事务的数据)
  3. 幻读
    栗子: T1把表A中的某个字段从1改为2,T2对表A进行了插入并提交,并且该字段为1,T1修改提交后,发现还有一条数据没有修改(注意和不可重复读的区别)

1.3 数据库的四种隔离级别

  1. Serializable
    避免脏读、不可重复读、幻读
  2. Repeatable Read
    避免脏读、不可重复读
  3. Read Committed
    避免脏读
  4. Read Uncommitted
    毛都避免不了

1.4 如何保证持久性和一致性

持久性和一致性的概念清楚了,那么数据库如何保证这一点呢?如果事务提交后,主机突然断电了呢?
概念很简单,数据库操作事务的时候,会记下这个事务的redo操作日志,在真正操作数据库之前,会把日志写入磁盘,发生异常情况后,会根据当前数据的情况进行undo或者redo,以此保证一致性和持久性,这里不再深究

2. Spring中的事务

上升到Java中,最常使用的应该是Spring中的事务操作。不管是声明式事务还是手动开启事务,在Java中,所关注的不再是数据层面的一致性(数据库已经帮我们保证了),而是事务之间的关系。
通常,事务边界都是设定在Service层,如果一个Service层中的事务方法,调用另一个事务方法,事务是怎样传播的呢?

事务传播

Spring中共有7种不同的传播行为,以被调用方法的视角,可以把它们分为两类

  1. 被调用方支持事务
    1.1 PROPAGATION_REQUIRED 必须要有事务,有就加入,无则创建
    1.2 PROPAGATION_SUPPORTS 支持当前事务,有就加入,没有拉倒
    1.3 PROPAGATION_MANDATORY 使用当前事务,有就加入,没有报错
    1.4 PROPAGATION_REQUIRES_NEW 使用新事务,外层事务挂起,独立提交回滚
    1.5 PROPAGATION_NESTED 使用嵌套事务,独立回滚(出错回滚自身),不独立提交,没有事务则创建
  2. 被调用方不支持事务
    2.1 PROPAGATION_NOT_SUPPORTED 不使用事务,有就挂起,没有拉倒
    2.2 PROPAGATION_NEVER 坚决不使用事务,有就报错

需要注意1.4和1.5的区别,关键在于是否独立提交和回滚

3. 分布式事务

首先要明确的一点,在分布式事务中,ACID已经不适用了。在集群环境下,想要保证ACID几乎是不可能的任务,即使能够达到,效率也是非常低下的。所以,在集群环境下,分布式事务一般追求的是最终一致性。

3.1 BASE理论

Basically Available 基本可用
Soft state 软状态
Eventually consistent 最终一致
分布式系统中,可用性往往比一致性更重要(想象一下,支付宝为了保证强一致性,即A转100给B,A账户马上扣100,B账户马上加100,但是三天两头无服务),BASE理论就是在可用性和一致性中做出了权衡,核心思想是,我们无法做到强一致性,但是每个应用可以结合自身的特点,用适当的方式来达到最终一致性(A支付100元给B,B可能马上收到,也可能5分钟后收到,但是最终一定会收到)。

3.2 TCC补偿事务

TCC的核心是采用了补偿机制,针对每个操作,都要有一个与之对应的补偿(回滚)操作,分为三个阶段:

  1. Try 预留业务资源
    尝试执行业务
    完成所有业务检查
    预留必须业务资源
  2. Confirm 确认执行业务操作,需幂等
    真正执行业务
    不做业务检查
    只使用try阶段预留的资源
  3. Cancel 取消执行业务操作,需幂等
    释放try阶段预留的资源

和数据库中的事务操作进行对比,可以找到类似之处,锁定行->操作行->出错回滚

举个实际的例子来加深理解
假设有A、B、C三个账户,A和B向C支付100元,A支付40元,B支付60元,需要在一个事务中完成

  • try
    检测A、B、C三个账户的状态,是否允许转账
    检测A账户是否有40元,有则冻结
    检测B账户是否有60元,有则冻结
  • confirm
    扣除A、B的冻结金额,增加C账户的金额,不做任何业务检查
  • cancel
    恢复A或B的冻结金额

如果在try阶段发现,A的账户冻结40元成功,B冻结失败,则调用A的cancel方法,恢复A的冻结金额

3.3 本地消息表

这种思路来源于ebay


  • 在本地新建消息表
  • 消息和业务在同一个事务里提交
  • 通过MQ通知消费方
  • 消费方处理消息后通知修改消息状态
  • 消息发送失败,重试
  • 定时扫描未处理的消息进行重发
  • 消费方业务失败,调用生产方补偿方法进行回滚

这种方式遵循BASE理论,保证的是最终一致性,在实际使用中,比TCC更好处理,少写很多代码。需要注意的是,消息处理需要幂等

3.4 MQ事务消息

阿里巴巴的Rocket MQ支持事务消息,Rabbit MQ和Kafka都不支持

3.3中之所以要使用本地消息表,因为更新数据库和发送MQ消息不是一个原子操作,无论谁先谁后,都会有问题

  • 先更新DB,发送消息失败了,怎么办?
  • 先发送消息,DB更新失败了,消息已经发了,怎么办?

3.3中采用了本地消息表,通过消息表中的消息状态来控制重发,以达到最终一致的目的
事务消息模拟了这种操作,只不过把维护消息状态的过程,从数据库转移到了MQ中间件

具体来说,就是把消息发送,分解成两个阶段,准备和确认
具体到业务中,分解成了三步操作

  1. 发送Prepared消息
  2. 更新数据库
  3. 根据2的结果,发送Confirm或Cancel,确认或取消消息

取消的消息会被丢弃,确认后的消息才会真正的发送给消费者

如果第三步失败了,RocketMQ会主动(默认1分钟)询问发送方,喂?这条消息还要吗?此时发送方可以查询本地业务状态,确定消息是否需要发送,以此确保最终一致性

总结

从ACID到BASE,对于事务,不同视角,对它的理解也不同
在数据库层面,通过日志文件确保了事务的一致性,以及确定了不同的事务隔离级别
在Java代码层面,更多的是关注事务之间的关系
而在分布式事务中,为了高可用,在事务一致性上进行了妥协,一般只保证最终一致性

你可能感兴趣的:(浅析Java中的事务,从ACID到BASE)