0%

乐观锁:

  乐观锁不是数据库自带的,需要我们去实现。总是假设最好的情况,每次去拿数据时都会认为数据没有被修改,所以不会上锁,但是在提交更新的时候会去判断一下在此期间别人有没有更改数据,可以使用版本号机制算法或者CAS算法实现。乐观锁适用于读多于写的情况,可以提高吞吐量。

阅读全文 »

基于数据库实现分布式锁:

基于数据库表:

要实现分布式锁,最简单的方法可能就是直接创建一张锁表,然后通过操作该表中的数据来实现了。
   当我们要锁住某个方法或资源时,我们就在该表中增加一条记录,想要释放锁的时候就删除这条记录。

问题:

  1. 这把锁强依赖数据库的可用性,数据库是一个单点,一旦数据库挂掉,会导致业务系统不可用。
  2. 这把锁没有失效时间,一旦解锁操作失败,就会导致锁记录一直在数据库中,其他线程无法再获得锁。
  3. 这把锁只能是非阻塞的,因为数据的 insert 操作,一旦插入失败就会报错。没有获得锁的线程并不会进入排队队列,要想再次获得锁就要再次触发获得锁操作。
  4. 这把锁是非重入的,同一个线程在没有获得锁之前无法再次获得该锁。因为数据库中数据已经存在了。

解决:

  1. 数据库是单点:两个数据库,数据之间双向同步,一旦挂掉快速切换到备库。
  2. 没有失效时间:定时任务,每隔一定时间清理数据库中的超时数据。
  3. 非阻塞的:while 循环,直到 insert 成功在返回。
  4. 非重入的:在数据库表中加个字段,记录当前获得锁的主机信息和线程信息,下次在获取锁时先查询数据库,如果当前机器的主机信息和线程信息在数据库中可以查到的话,直接把锁分配给它就可以。

基于数据库排他锁:

可以借助数据中自带的锁来实现分布式锁。
通过数据库的排他锁,基于 InnoDB 引擎。

总结:

使用数据库来实现分布式锁,这两种方式都是依赖数据库的一张表,一种是通过表中记录的存在情况确定当前是否有锁存在,另一种是通过数据库的排他锁来实现分布式锁。

优点:

直接借助数据库,容易理解。

缺点:

会有各种各样的问题,在解决问题的过程中,会使整个方案变得越来越复杂。操作数据库会有一定的开销,性能问题需要考虑。使用数据库的行级锁并不一定靠谱,尤其是当我们的锁表并不大的时候。


基于缓存实现分布式锁:

相对于基于数据库实现分布式锁的方案来说,基于缓存来实现在性能方面会表现的更好一点。而且很多缓存是可以集群部署的,可以解决单点问题。
   目前有很多成熟的缓存产品,Redis,memcached。

问题:

  1. 这把锁没有失效时间,一旦解锁失败,就会导致锁记录一直在缓存中,其他线程无法再次获得锁。
  2. 这把锁只能是非阻塞的,无论成功还是失败都直接返回。
  3. 这把锁是非重入的,一个线程获得锁之后,在释放锁之前,无法再次获得该锁,因为 key 已经存在,无法进行 put 操作。

解决:

  1. 没有失效时间:设置固定时间,到期后自动删除。失效时间比较难以确定,时间太短,方法没执行完释放锁,就会产生并发问题;时间太长,其他线程就要浪费很多时间。
  2. 非阻塞:while 重复执行。
  3. 非可重入:在一个线程获取到锁之后,把当前主机信息和线程信息保存起来,下次再获取前先检查自己是不是当前锁的拥有者。

总结:

优点:

性能好,实现起来较为方便。

缺点:

通过超时来控制锁的失效时间并不是十分的靠谱。


基于 Zookeeper 实现分布式锁:

基于 zookeeper 临时有序节点可实现的分布式锁。
   大致思想为:每个客户端对每个方法加锁时,在 zookeeper 上的与该方法对应的指定节点的目录下,生成一个唯一的瞬时有序节点。判断是否获取锁的方式很简单,只需要判断有序节点中序号最小的一个。当释放锁的时候,只需将这个瞬时节点删除即可。同时,其可以避免服务宕机导致的锁无法释放,而产生死锁的问题。

如何解决前面的问题:

锁无法释放:

使用 Zookeeper 可以有效的解决锁无法释放的问题,因为在创建的时候,客户端会在 zk 中创建一个临时节点,一旦客户端获取到锁之后突然挂掉,那么这个临时节点就会自动删除掉。其他客户端就可以再次获得锁。

非阻塞锁:

使用 zookeeper 可以实现阻塞的锁,客户端可通过在 zk 中创建顺序节点,并在节点上绑定监听器,一旦节点有变化,zookeeper 会通知客户端,客户端可以检查自己创建的节点是不是当前所有节点中序号最小的,如果是,那么自己就获取到锁,便可以执行业务逻辑。

不可重入:

使用 zookeeper 可以有效解决不可重入的问题,客户端在创建节点时,把当前客户端的主机信息和线程信息直接写入节点中,下次想要获取锁的时候和当前最小节点中的数据对比一下就可以。如果和自己的信息一样,那么自己直接获取到锁,如果不一样就在创建一个临时的顺序节点,参与排队。

单点问题:

使用 zookeeper 可以有效解决单点问题,zk 是集群部署的,只要集群中有半数以上的机器存活,就可以对外提供服务。

使用 zk 实现的分布式锁其实存在一个缺点,那就是性能上可能并没有缓存服务器那么高。因为在每次创建锁和释放锁的过程中,都要动态创建,销毁瞬时节点来实现锁功能。zk 中创建和删除节点只能通过 leader 服务器来执行,然后将数据同步到所有 follower 机器上。
   使用了 zk 也有可能带来并发问题,只是不常见。由于网络抖动,客户端集群的 session 连接断了,那么 zk 以为客户端挂了,就会删除临时节点,这是其他客户端就可以获取到分布式锁了。就可能产生并发问题。这个问题不常见是因为 zk 有重试机制,一旦 zk 集群检测不到客户端的心跳,就会重试,多次重试还不行的话就会删除临时节点。

总结:

优点:

有效的解决单点问题,不可重入问题,非阻塞问题,以及锁无法释放的问题。实现起来较为简单。

缺点:

性能上不如缓存实现分布式锁。需要最 zk 的原理有所了解。


比较:

理解程度:

数据库>缓存>Zookeeper

实现的复杂性角度:

Zookeeper>=缓存>数据库

性能角度:

缓存>Zookeeper>=数据库

可靠性角度:

Zookeeper>缓存>数据库

事务失效的几种类型

  1. 数据库引擎不支持事务。
  2. 没有被 Spring 管理。
  3. 方法不是 public 的。
  4. 自身调用问题。
  5. 数据源没有配置事务管理器。
  6. 不支持事务。
  7. 异常被吃了。
  8. 异常类型错误。

事务失效类型:

数据库引擎不支持事务

这里以 MySQL 为例,其 MyISAM 引擎是不支持事务操作的,InnoDB 才是支持事务的引擎,一般要支持事务都会使用 InnoDB。
   根据 MySQL 的官方文档:https://dev.mysql.com/doc/refman/8.0/en/storage-engine-setting.html

没有被 Spring 管理

Spring 中的事务基于 AOP 实现,则事务类必须被 Spring 管理,进行代理,才能支持事务。

方法不是 public 的

@Transaction 只对方法名为 public 的才会生效,其他的不生效。private,static,final 方法不能添加事务,添加了也不会生效。

自身调用问题

  1. service 类中调用本类自己的方法,由于没有经过 spring 代理,事务不会生效。
  2. 一个无事务的方法调用另一个有事务的方法,事务是不会起作用的。这种情况,可以内部维护一个自己注入的 bean,使用这个属性来调用。或者利用 AOP 上下文来获取代理对象,利用代理对象调用。
  3. 有事务的调用有事务的被调用的不能新开启事务。被调用的开启的新事务不会生效。
  4. 有事务的调用无事务的会生效。
  5. 无事务的调用无事务的,这种情况就会没有事务。

事务是否生效主要看是否通过代理,没有通过代理就不会生效。

数据源没有配置事务管理器

数据源必须开启事务管理器:

  1. @EnableTransactionManagement  // 启注解事务管理,等同于 xml 配置方式的 <tx:annotation-driven />
  2. @EnableTransactionManagement 在 springboot1.4 以后可以不写。框架在初始化的时候已经默认给我们注入了两个事务管理器的 Bean(JDBC 的 DataSourceTransactionManager 和 JPA 的 JpaTransactionManager ),其实这就包含了我们最常用的 Mybatis 和 Hibeanate 了。当然如果不是 AutoConfig 的而是自己自定义的,请使用该注解开启事务

不支持事务

Propagation 设置错误,Propagation 用于配置事务的传播行为。Propagation.NOT_SUPPORTED: 表示不以事务运行,当前若存在事务则挂起。

异常被吃了

异常被捕获了,然后不进行抛出,那么无法认为有异常,事务就不会回滚。在 service 中不应该进行事务的捕获,而进行抛出,在 controller 中进行异常捕获,这样既支持事务也捕获了异常。

异常类型错误

Spring 的事务管理默认是针对 Error 异常和 RuntimeException 异常以及其子类进行事务回滚。对 runtimeException 并不需要抛出,error 需要抛出异常,并进行捕获。如果想对其他异常进行支持,则需要配置:@Transactional(rollbackFor = Exception.class)

业务和事务必须要在同一个线程中

不在同一个线程,则事务影响不到。


事务的隔离级别

事务会引起的问题:

脏读:

当 A 事务对数据进行修改,但是这种修改还没有提交到数据库中,B 事务同时在访问这个数据,由于没有隔离,B 获取的数据有可能被 A 事务回滚,这就导致了数据不一致的问题。

丢失修改:

当 A 事务访问数据 100,并且修改为 100-1=99,同时 B 事务读取数据也是 100,修改数据 100-1=99,最终两个事务的修改结果为 99,但是实际是 98。事务 A 修改的数据被丢失了。

不可重复读:

指 A 事务在读取数据 X=100 的时候,B 事务把数据 X=100 修改为 X=200,这个时候 A 事务第二次读取数据 X 的时候,发现 X=200 了,导致了在整个 A 事务期间,两次读取数据 X 不一致了,这就是不可重复读。

幻读:

幻读和不可重复读类似。幻读表现在,当 A 事务读取表数据时候,只有 3 条数据,这个时候 B 事务插入了 2 条数据,当 A 事务再次读取的时候,发现有 5 条记录了,平白无故多了 2 条记录,就像幻觉一样。

不可重复读的重点是修改: 同样的条件 , 你读取过的数据 , 再次读取出来发现值不一样了,重点在更新操作。
   幻读的重点在于新增或者删除:同样的条件 , 第 1 次和第 2 次读出来的记录数不一样,重点在增删操作。

Spring 定义的隔离级别:

TransactionDefinition.ISOLATION_DEFAULT: 数据库的默认隔离级别,MySQL 默认采用的 REPEATABLE_READ 隔离级别。
TransactionDefinition.ISOLATION_READ_UNCOMMITTED: 最低的隔离级别,最低的隔离级别,允许读取未提交的数据变更,可能会导致脏读、幻读或不可重复读。
TransactionDefinition.ISOLATION_READ_COMMITTED: 允许读取并发事务已经提交的数据,未提交的不可读取,可以阻止脏读,但是幻读或不可重复读仍有可能发生。
TransactionDefinition.ISOLATION_REPEATABLE_READ: 对同一字段的多次重复读取结果都是一致的,除非数据是被本身事务自己所修改,可以阻止脏读和不可重复读,但幻读仍有可能发生。MySQL 中通过 MVCC 解决了该隔离级别下出现幻读的可能。
TransactionDefinition.ISOLATION_SERIALIZABLE: 串行化隔离级别,该级别可以防止脏读、不可重复读以及幻读,但是串行化会影响性能。

Propagation,传播行为:

指多个方法调用时,事务对多个方法之间传播的影响。

PROPAGATION_REQUIRED: 如果当前没有事务,就新建一个事务,如果已经存在一个事务中,加入到这个事务中。这是最常见的选择。
PROPAGATION_SUPPORTS: 支持当前事务,如果当前没有事务,就以非事务方式执行。
PROPAGATION_MANDATORY: 使用当前的事务,如果当前没有事务,就抛出异常。
PROPAGATION_REQUIRES_NEW: 新建事务,如果当前存在事务,把当前事务挂起。
PROPAGATION_NOT_SUPPORTED: 以非事务方式执行操作,如果当前存在事务,就把当前事务挂起。
PROPAGATION_NEVER: 以非事务方式执行,如果当前存在事务,则抛出异常。
PROPAGATION_NESTED: 如果当前存在事务,则在嵌套事务内执行。如果当前没有事务,则执行与 PROPAGATION_REQUIRED 类似的操作。

分布式锁

目的

  1. 解决业务层幂等性
  2. 解决 MQ 消费端多次接受同一消息
  3. 确保串行|隔离级别
  4. 多台机器同时执行定时任务

条件

  1. 互斥性。在任意时刻,只有一个客户端能持有锁。
  2. 不会发生死锁。即使有一个客户端在持有锁的期间崩溃而没有主动解锁,也能保证后续其他客户端能加锁。
  3. 解铃还须系铃人。加锁和解锁必须是同一个客户端,客户端自己不能把别人加的锁给解了,即不能误解锁。
  4. 具有容错性。只要大多数Redis节点正常运行,客户端就能够获取和释放锁。
阅读全文 »

分布式事务类型:

分布式事务处理机制共有四种:

  1. 两阶段提交
  2. TCC事务(事务补偿)
  3. 本地消息表(异步确保),
  4. MQ事务消息。
阅读全文 »

事务失效的几种类型

  1. 数据库引擎不支持事务。
  2. 没有被Spring管理。
  3. 方法不是public的。
  4. 自身调用问题。
  5. 数据源没有配置事务管理器。
  6. 不支持事务。
  7. 异常被吃了。
  8. 异常类型错误。
阅读全文 »

过滤器

过滤器是用来过滤的,java 的过滤器能够为我们提供系统级别的过滤,也就是说能够过滤所有的 web 请求,这一点是拦截器做不到的。
   在 java web 中,你传入的 request,response 提前过滤掉一些信息,或者提前设置一些参数,然后在传入 Servlet 进行业务逻辑,比如过滤掉非法 url 和非法字符串。
  filter 流程是线性的,url 传来之后,检查之后,可保持原来的流程继续向下执行,被下一个 filter,servlet 接收。


拦截器

java 里面的拦截器提供的是非系统级别的拦截,也就是说,就覆盖面来说,拦截器不如过滤器强大但是更有针对性。
  java 中的拦截器是基于 java 反射机制实现的,更准确的划分,应该是基于 jdk 实现的动态代理。它依赖于具体的接口,在运行期间动态生成字节码。
   拦截器是动态拦截 Action 调用的对象,他提供了一种机制可以使开发者在一个 ation 执行的前后执行一段代码,也可以在一个 action 执行前阻止其执行,同时也提供了一种可以提取 action 中可重用代码的方式。在 AOP 中,拦截器用于在某个方法或字段被访问之前,进行拦截然后再之前或之后加入某些操作。java 的拦截器主要用于插件上,扩展件上,有点类似于面向切面的技术,在用之前要先在配置文件里声明。


监听器

java 的监听器也是系统级别的监听。监听器随 web 应用的启动而启动。
  java 的监听器在 c/s 模式里面经常用到,它会对特定的事件产生一个处理。监听器在很多模式下用到,比如说观察者模式,就是使用监听器来实现的,又比如统计网站的在线人数。servlet 监听器用于监听一些重要事件的发生,监听器对象可以在事件发生前,发生后做一些必要的处理。


对照

过滤器

  过滤器是用来过滤的,java的过滤器能够为我们提供系统级别的过滤,也就是说能够过滤所有的web请求,这一点是拦截器做不到的。
  在java web中,你传入的request,response提前过滤掉一些信息,或者提前设置一些参数,然后在传入Servlet进行业务逻辑,比如过滤掉非法url和非法字符串。
  filter流程是线性的,url传来之后,检查之后,可保持原来的流程继续向下执行,被下一个filter,servlet接收。

阅读全文 »

线程池

线程池
线程池demo

newCachedThreadPool:

底层:

  创建一个可缓存的线程池实例,如果线程池长度超过处理需要,可灵活回收空闲线程。
  返回ThreadPoolExecutor实例,corePoolSize为0;maximumPoolSize为Integer.MAX_VALUE;keepAliveTime为60L;unit为TimeUnit.SECOhexoNDS;workQueue为SynchronousQueue(同步队列)。

阅读全文 »

两天,我把分布式事务搞完了

事务

事务的 ACID 想必大家都熟知,这其实是严格意义上的定义,指的是事务的实现必须具备原子性、一致性、隔离性和持久性。

不过严格意义上的事务很难达到,像我们熟知的数据库就有各种隔离级别,隔离级别越高性能越低,所以往往我们都会从中找到属于自己的平衡,不会遵循严格意义上的事务。

并且在我们平日的谈论中,所谓的事务往往简单的指代一系列的操作全部执行成功,或者全部失败,不会出现一些成功一些失败的情形。

清晰了平日我们对事务的定义之后,再来看看什么是分布式事务。

分布式事务

由于互联网的快速发展,以往的单体架构顶不住这么多的需求,这么复杂的业务,这么大的流量。

单体架构的优势在于前期快速搭建、快速上线,并且方法和模块之间都是内部调用,没有网络的开销更加的高效。
从某方面来说部署也方便,毕竟就一个包,扔上去。

不过随着企业的发展,业务的复杂度越来越高,内部耦合极其严重,导致牵一发而动全身,开发不易,测试不易。
并且无法根据热点服务进行动态的伸缩,比如商品服务访问量特别大,如果是单体架构的话我们只能把整个应用复制多份集群部署,浪费资源。

因此拆分势在必行,微服务架构就这么来了。

拆分之后服务之间的边界就清晰了,每个服务都能独立地运行,独立地部署,所以能以服务级别弹性伸缩了。
服务之间的本地调用变成了远程调用,链路更长了,一次调用的耗时更长了,但是总体的吞吐量更大了。

不过拆分之后还会引入其他复杂度,比如服务链路的监控、整体的监控、容错措施、弹性伸缩等等运维监控的问题,还有像分布式事务、分布式锁跟业务息息相关的问题等。
往往解决了一个痛点又会引入别的痛点,所以架构的演进都是权衡的结果,就看你们的系统更能忍受哪种痛点了。
而今天我们谈及的就是分布式事务这个痛点。

分布式事务是由多个本地事务组成的,分布式事务跨越了多设备,之间又经历的复杂的网络,可想而知想要实现严格的事务道路阻且长。
单机版事务都不会严格遵守事务的严格实现,更别说分布式事务了,所以在现实情况下我们只能实现残缺版的事务。
在明确了事务和分布式事务之后,我们就先来看看常见的分布式事务方案:2PC、3PC、TCC、本地消息、事务消息。


2PC

2PC,Two-phase commit protocol,即两阶段提交协议。它引入了一个事务协调者角色,来管理各个参与者(就是各数据库资源)。
整体分为两个阶段,分别是准备阶段和提交/回滚阶段。
我们先来看看第一个阶段,即准备阶段
由事务协调者给每个参与者发送准备命令,每个参与者收到命令之后会执行相关事务操作,你可以认为除了事务的提交啥都做了。
然后每个参与者会返回响应告知协调者自己是否准备成功。

协调者收到每个参与者的响应之后就进入第二阶段,根据收集的响应,如果有一个参与者响应准备失败那么就向所有参与者发送回滚命令,反之发送提交命令。

这个协议其实很符合正常的思维,就像我们大学上课点名的时候,其实老师就是协调者的角色,我们都是参与者。
老师一个一个的点名,我们一个一个的喊到,最后老师收到所有同学的到之后就开始了今天的讲课。
而和点名有所不同的是,老师发现某几个学生不在还是能继续上课,而我们的事务可不允许这样

事务协调者在第一阶段未收到个别参与者的响应,则等待一定时间就会认为事务失败,会发送回滚命令,所以在 2PC 中事务协调者有超时机制。

我们再来分析一下 2PC 的优缺点。
2PC 的优点是能利用数据库自身的功能进行本地事务的提交和回滚,也就是说提交和回滚实际操作不需要我们实现,不侵入业务逻辑由数据库完成,在之后讲解 TCC 之后相信大家对这点会有所体会。

2PC 主要有三大缺点:同步阻塞、单点故障和数据不一致问题。

同步阻塞

可以看到在第一阶段执行了准备命令后,我们每个本地资源都处于锁定状态,因为除了事务的提交之外啥都做了。

所以这时候如果本地的其他请求要访问同一个资源,比如要修改商品表 id 等于 100 的那条数据,那么此时是被阻塞住的,必须等待前面事务的完结,收到提交/回滚命令执行完释放资源后,这个请求才能得以继续。

所以假设这个分布式事务涉及到很多参与者,然后有些参与者处理又特别复杂,特别慢,那么那些处理快的节点也得等着,所以说效率有点低。

单点故障

可以看到这个单点就是协调者,如果协调者挂了整个事务就执行不下去了

如果协调者在发送准备命令前挂了还行,毕竟每个资源都还未执行命令,那么资源是没被锁定的。

可怕的是在发送完准备命令之后挂了,这时候每个本地资源都执行完处于锁定状态了,都杵着了,这就很僵硬了,如果是某个热点资源都阻塞了,这估计就要 GG 了。

数据不一致

因为协调者和参与者之间的交流是经过网络的,而网络有时候就会抽风的或者发生局部网络异常。

那么就有可能导致某些参与者无法收到协调者的请求,而某些收到了。比如是提交请求,然后那些收到命令的参与者就提交事务了,此时就产生了数据不一致的问题。

小结一下 2PC

至此我们来先小结一些 2PC ,它是一个同步阻塞的强一致性两阶段提交协议,分别是准备阶段和提交/回滚阶段。
2PC 的优势在于对业务没有侵入,可以利用数据库自身机制来进行事务的提交和回滚。

它的缺点:是一个同步阻塞协议,会导致高延迟和性能的下降,并且存在协调者单点故障问题,极端情况下会有数据不一致的问题。
当然这只是协议,具体的落地还是可以变通了,比如协调者单点问题,我就搞个主从来实现协调者,对吧。


分布式数据库对 2pc 的改进模型

可能有些人对分布式数据库不熟悉,没有关系,我们主要学的是思想,看看人家的思路。
我简单的讲下 Percolator 模型,它是基于分布式存储系统 BigTable 建立的模型,BigTable 是啥也不清楚的同学没有关系影响不大。
还是拿转账的例子来说,我现在有 200 块钱,你现在有 100 块钱,为了突出重点我也不按正常的结构来画这个表。
然后我要转 100 块给你。
此时事务管理器发起了准备请求,然后我账上的钱就少了,你账上的钱就多了,而且事务管理器还记录下这次操作的日志
此时的数据还是私有版本,别的事务是读不到的,简单的理解 Lock 上有值就还是私有的。
可以看到我的记录 Lock 标记的是 PK,你的记录标记的是指向我的记录指针,这个 PK 是随机选择的。
然后事务管理器会向被选择作为 PK 的那条记录发起提交指令。
此时就会把我的记录的锁给抹去了,这等于我的记录不再是私有版本了,别的事务就都能访问了。
那你的记录上还有锁啊?不用更新吗?
嘿嘿不需要及时更新,因为访问你的这条记录的时候会去根据指针找我的那个记录,发现记录已经提交了所以你的记录就可以被访问了。
有人说这效率不就差了,每次都要去找一次,别急。
后台会有个线程来扫描,然后更新把锁记录给去了。
这不就稳了嘛。

相比于 2pc 的改进

首先 Percolator 在提交阶段不需要和所有的参与者交互,主需要和一个参与者打交道,所以这个提交是原子的!解决了数据不一致问题

然后事务管理器会记录操作日志,这样当事务管理器挂了之后选举的新事务管理器就可以通过日志来得知当前的情况从而继续工作,解决了单点故障问题

并且 Percolator 还会有后台线程,会扫描事务状况,在事务管理器宕机之后会回滚各个参与者上的事务。
可以看到相对于 2PC 还是做了很多改进的,也是巧妙的。

其实分布式数据库还有别的事务模型,不过我也不太熟悉,就不多哔哔了,有兴趣的同学可以自行了解。
还是挺能拓宽思想的。


XA 规范

让我们再回来 2PC,既然说到 2PC 了那么也简单的提一下 XA 规范,XA 规范是基于两阶段提交的,它实现了两阶段提交协议。
在说 XA 规范之前又得先提一下 DTP 模型,即 Distributed Transaction Processing,这模型规范了分布式事务的模型设计。
而 XA 规范又约束了 DTP 模型中的事务管理器(TM) 和资源管理器(RM)之间的交互,简单的说就是你们两之间要按照一定的格式规范来交流!
我们先来看下 XA 约束下的 DTP 模型。

  • AP 应用程序,就是我们的应用,事务的发起者。
  • RM 资源管理器,简单的认为就是数据库,具备事务提交和回滚能力,对应我们上面的 2PC 就是参与者。
  • TM 事务管理器,就是协调者了,和每个 RM 通信。

简单的说就是 AP 通过 TM 来定义事务操作,TM 和 RM 之间会通过 XA 规范进行通信,执行两阶段提交,而 AP 的资源是从 RM 拿的。
从模型上看有三个角色,而实际实现可以由一个角色实现两个功能,比如 AP 来实现 TM 的功能,TM 没必要抽出来单独部署。

mysql XA

知晓了 DTP 之后,我们就来看看 XA 在 MySQL 中是如何操作的,不过只有 InnoDB 支持。
简单的说就是要先定义一个全局唯一的 XID,然后告知每个事务分支要进行的操作。
可以看到图中执行了两个操作,分别是改名字和插入日志,等于先注册下要做的事情,通过 XA START XID 和 XA END XID 来包裹要执行的 SQL。

然后需要发送准备命令,来执行第一阶段,也就是除了事务的提交啥都干了的阶段。

然后根据准备的情况来选择执行提交事务命令还是回滚事务命令。

基本上就是这么个流程,不过 MySQL XA 的性能不高这点是需要注意的。
可以看到虽说 2PC 有缺点,但是还是有基于 2PC 的落地实现的,而 3PC 的引出是为了解决 2PC 的一些缺点,但是它整体下来开销更大,也解决不了网络分区的问题,我也没有找到 3PC 的落地实现。
不过我还是稍微提一下,知晓一下就行,纯理论。


3PC

3PC 的引入是为了解决 2PC 同步阻塞和减少数据不一致的情况。

3PC 也就是多了一个阶段,一个询问的阶段,分别是准备、预提交和提交这三个阶段。
准备阶段单纯就是协调者去访问参与者,类似于你还好吗?能接请求不。
预提交其实就是 2PC 的准备阶段,除了事务的提交啥都干了。
提交阶段和 2PC 的提交一致。

3PC 多了一个阶段其实就是在执行事务之前来确认参与者是否正常,防止个别参与者不正常的情况下,其他参与者都执行了事务,锁定资源。

出发点是好的,但是绝大部分情况下肯定是正常的,所以每次都多了一个交互阶段就很不划算。
然后 3PC 在参与者处也引入了超时机制,这样在协调者挂了的情况下,如果已经到了提交阶段了,参与者等半天没收到协调者的情况的话就会自动提交事务。

不过万一协调者发的是回滚命令呢?你看这就出错了,数据不一致了。
还有维基百科上说 2PC 参与者准备阶段之后,如果协调者挂了,参与者是无法得知整体的情况的,因为大局是协调者掌控的,所以参与者相互之间的状况它们不清楚。
而 3PC 经过了第一阶段的确认,即使协调者挂了参与者也知道自己所处预提交阶段是因为已经得到准备阶段所有参与者的认可了。
简单的说就像加了个围栏,使得各参与者的状态得以统一。

小结 2PC 和 3PC

从上面已经知晓了 2PC 是一个强一致性的同步阻塞协议,性能已经是比较差的了。
而 3PC 的出发点是为了解决 2PC 的缺点,但是多了一个阶段就多了一次通讯的开销,而且是绝大部分情况下无用的通讯。

虽说引入参与者超时来解决协调者挂了的阻塞问题,但是数据还是会不一致。
可以看到 3PC 的引入并没什么实际突破,而且性能更差了,所以实际只有 2PC 的落地实现。
再提一下,2PC 还是 3PC 都是协议,可以认为是一种指导思想,和真正的落地还是有差别的。


TCC

不知道大家注意到没,不管是 2PC 还是 3PC 都是依赖于数据库的事务提交和回滚。

而有时候一些业务它不仅仅涉及到数据库,可能是发送一条短信,也可能是上传一张图片。
所以说事务的提交和回滚就得提升到业务层面而不是数据库层面了,而 TCC 就是一种业务层面或者是应用层的两阶段提交

TCC 分为指代 Try、Confirm、Cancel ,也就是业务层面需要写对应的三个方法,主要用于跨数据库、跨服务的业务操作的数据一致性问题。
TCC 分为两个阶段,第一阶段是资源检查预留阶段即 Try,第二阶段是提交或回滚,如果是提交的话就是执行真正的业务操作,如果是回滚则是执行预留资源的取消,恢复初始状态。

比如有一个扣款服务,我需要写 Try 方法,用来冻结扣款资金,还需要一个 Confirm 方法来执行真正的扣款,最后还需要提供 Cancel 来进行冻结操作的回滚,对应的一个事务的所有服务都需要提供这三个方法。
可以看到本来就一个方法,现在需要膨胀成三个方法,所以说 TCC 对业务有很大的侵入,像如果没有冻结的那个字段,还需要改表结构。
我们来看下流程。

虽说对业务有侵入,但是 TCC 没有资源的阻塞,每一个方法都是直接提交事务的,如果出错是通过业务层面的 Cancel 来进行补偿,所以也称补偿性事务方法。

这里有人说那要是所有人 Try 都成功了,都执行 Comfirm 了,但是个别 Confirm 失败了怎么办?
这时候只能是不停地重试调失败了的 Confirm 直到成功为止,如果真的不行只能记录下来,到时候人工介入了。

TCC 注意点

这几个点很关键,在实现的时候一定得注意了

  • 幂等问题:因为网络调用无法保证请求一定能到达,所以都会有重调机制,因此对于 Try、Confirm、Cancel 三个方法都需要幂等实现,避免重复执行产生错误。
  • 空回滚问题:指的是 Try 方法由于网络问题没收到超时了,此时事务管理器就会发出 Cancel 命令,那么需要支持 Cancel   在未执行 Try 的情况下能正常的 Cancel。
  • 悬挂问题:这个问题也是指 Try 方法由于网络阻塞超时触发了事务管理器发出了 Cancel 命令,但是执行了 Cancel 命令之后 Try 请求到了,你说气不气

这都 Cancel 了你来个 Try,对于事务管理器来说这时候事务已经是结束了的,这冻结操作就被“悬挂”了,所以空回滚之后还得记录一下,防止 Try 的再调用。


TCC 变体

上面我们说的是通用型的 TCC,它需要改造以前的实现,但是有一种情况是无法改造的,就是你调用的是别的公司的接口

没有 Try 的 TCC

比如坐飞机需要换乘,换乘的又是不同的航空公司,比如从 A 飞到 B,再从 B 飞到 C,只有 A - B 和 B - C 都买到票了才有意义。
这时候的选择就没得 Try 了,直接调用航空公司的买票操作,当两个航空公司都买成功了那就直接成功了,如果某个公司买失败了,那就需要调用取消订票接口。
也就是在第一阶段直接就执行完整个业务操作了,所以要重点关注回滚操作,如果回滚失败得有提醒,要人工介入等。
这其实就是 TCC 的思想。

异步 TCC

这 TCC 还能异步?其实也是一种折中,比如某些服务很难改造,并且它又不会影响主业务决策,也就是它不那么重要,不需要及时的执行。
这时候可以引入可靠消息服务,通过消息服务来替代个别服务来进行 Try、Confirm、Cancel 。
Try 的时候只是写入消息,消息还不能被消费,Confirm 就是真正发消息的操作,Cancel 就是取消消息的发送。
这可靠消息服务其实就类似于等下要提到的事务消息,这个方案等于糅合了事务消息和 TCC。

TCC 小结

可以看到 TCC 是通过业务代码来实现事务的提交和回滚,对业务的侵入较大,它是业务层面的两阶段提交

它的性能比 2PC 要高,因为不会有资源的阻塞,并且适用范围也大于 2PC,在实现上要注意上面提到的几个注意点。

它是业界比较常用的分布式事务实现方式,而且从变体也可以得知,还是得看业务变通的,不是说你要用 TCC 一定就得死板的让所有的服务都改造成那三个方法。


本地消息表

本地消息就是利用了本地事务,会在数据库中存放一直本地事务消息表,在进行本地事务操作中加入了本地消息的插入,即将业务的执行和将消息放入消息表中的操作放在同一个事务中提交。

这样本地事务执行成功的话,消息肯定也插入成功,然后再调用其他服务,如果调用成功就修改这条本地消息的状态。

如果失败也不要紧,会有一个后台线程扫描,发现这些状态的消息,会一直调用相应的服务,一般会设置重试的次数,如果一直不行则特殊记录,待人工介入处理。
可以看到还是很简单的,也是一种最大努力通知思想。


事务消息