本文介绍: CAP理论、BASE理论、分布式事务、分布式ID、RPC、分布式锁

一、CAP定理和BASE定理

1.1 CAP定理

  在分布式系统中,一个Web应用最多只能同时支持的两个属性:

  1. 一致性(C): 在分布式系统中的所有数据备份,在同一时刻是否同样的值。(等同于所有节点访问同一份最新的数据副本)
  2. 可用性(A): 在集群中一部分节点故障后,集群整体是否还能响应客户端的读写请求。(对数据更新具备高可用性)
  3. 分区容错性(P): 即使出现单个组件无法可用,操作依然可以完成。
  • 1、一致性
      更新操作成功并返回客户端完成后,所有节点在同一时间的数据完全一致,不能存在中间状态。分布式环境中,一致性是指多个应用之间能否保持一致的特性。
      数据一致性分为强一致性、弱一致性、最终一致性。

  如果时刻保证客户端看到的数据都是一致的,那么称之为强一致性
  如果允许存在中间状态,只要求经过一段时间后,数据最终是一致的,则称之为最终一致性
  此外,如果允许存在部分数据不一致,那么就称之为弱一致性

  • 2、可用性
      系统提供的服务必须一直处于可用哪个的状态,对于用户的每个操作请求总是能够在有限的时间内返回结果。
      有限时间内:对于用户的一个操作请求,系统必须能够在指定的时间(响应时间)内返回对应的处理结果,如果超过了这个时间范围,那么系统就被认为是不可用的。
      返回正常结果:要求系统在完成对用户请求的处理后,返回一个正常的响应结果。正常的响应结果通常能够明确地反映出对请求的处理结果,即成功或失败。
  • 3、分区容错性
      分布式系统在遇到任何网络分区故障时,仍然需要能够保证对外提供满足一致性和可用性的服务,除非是整个网络环境都发生了故障。
      网络分区,是指分布式系统中,不同的节点分布在不同的子网络(机房/异地网络)中,由于一些特殊的原因导致这些子网络之间出现网络不连通的状态,但各个子网络的内部网络是正常的,从而导致整个系统的网络环境被切分成了若干孤立的区域。

1.2 CAP取舍

  CAP理论就是说在分布式存储系统中,最多只能实现上面的两点。而由于当前的网络硬件肯定会出现延迟丢包等问题,所以分区容忍性是我们必须需要实现的。所以我们只能在一致性和可用性之间进行权衡。3种取舍策略:

  • 放弃P
      放弃P的同时也就意味着放弃了系统的扩展性,也就是分布式节点受限,没办法部署子节点,这是违背分布式系统设计的初衷的。
  • 放弃A
      如果不要求A(可用),相当于每个请求都需要在服务器之间保持强一致,而P(分区)会导致同步时间无限延长(也就是等待数据同步完才能正常访问服务),一旦发生网络故障或者消息丢失等情况,就要牺牲用户的体验,等待所有数据全部一致了之后再让用户访问系统。
      设计成CP的系统其实不少,最典型的就是分布式数据库,如Redis、HBase等。对于这些分布式数据库来说,数据的一致性是最基本的要求,因为如果连这个标准都达不到,那么直接采用关系型数据库就好,没必要再浪费资源来部署分布式数据库。
  • 放弃C
      要高可用并允许分区,则需放弃一致性。一旦分区发生,节点之间可能会失去联系,为了高可用,每个节点只能用本地数据提供服务,而这样会导致全局数据的不一致性。典型的应用就如抢购手机场景,可能前几秒你浏览商品的时候页面提示是有库存的,当你选择完商品准备下单的时候,系统提示你下单失败,商品已售完。这其实就是先在 A(可用性)方面保证系统可以正常的服务,然后在数据的一致性方面做了些牺牲,虽然多少会影响一些用户体验,但也不至于造成用户购物流程的严重阻塞。

  对于分布式系统来说,P是不能放弃的,因此通常是在可用性和强一致性之间权衡。

  在互联网领域的绝大多数的场景中,都需要牺牲强一致性来换取系统的高可用性,系统往往只需要保证最终一致性。

  • 为什么分布式系统中无法同时保证一致性和可用性
      对于分布式系统而言,分区容错性是一个最基本的要求,因此基本上我们在设计分布式系统的时候只能从一致性(C)和可用性(A)之间进行取舍。
      如果保证了一致性(C):对于节点N1和N2,当往N1里写数据时,N2上的操作必须被暂停,只有当N1同步数据到N2时才能对N2进行读写请求,在N2被暂停操作期间客户端提交的请求会收到失败或超时。显然,这与可用性是相悖的。
      如果保证了可用性(A):那就不能暂停N2的读写操作,但同时N1在写数据的话,这就违背了一致性的要求。

  对于多数大型互联网应用的场景,主机众多、部署分散,且现在的集群规模越来越大,所以节点故障、网络故障是常态,且要保证服务可用性达到 N 个 9,即保证 P 和 A,舍弃C(退而求其次保证最终一致性)。虽然某些地方会影响客户体验,但没达到造成用户流程的严重程度。
  对于涉及到钱财这样不能有一丝让步的场景,C 必须保证。网络发生故障宁可停止服务,这是保证 CA,舍弃 P。

  • CAP和ACID中,A和C的区别
      ACID中的A指的是原子性(Atomicity),是指事务被视为一个不可分割的最小操作单元,事务中的所有操作要么全部提交成功,要么全部失败回滚;CAP中的A指的是可用性(Availability),是指集群中一部分节点故障后,集群整体是否还能响应客户端的读写请求。
      ACID一致性是有关数据库规则,数据库总是从一个一致性的状态转换到另外一个一致性的状态;CAP的一致性是分布式多服务器之间复制数据令这些服务器拥有同样的数据,由于网速限制,这种复制在不同的服务器上所消耗的时间是不固定的,集群通过组织客户端查看不同节点上还未同步的数据维持逻辑视图,这是一种分布式领域的一致性概念。

  ACID里的一致性指的是事务执行前后,数据库完整性。而CAP的一致性,指的是分布式节点的数据的一致性。

1.3 BASE定理

  CAP是分布式系统设计理论,BASE是CAP理论中AP方案的延伸。对于C,我们采用的方式和策略就是保证最终一致性。
  BASE是Basically Available(基本可用)、Soft state(软状态)和Eventually consistent(最终一致性)三个短语的缩写。BASE基于CAP定理演化而来,核心思想是即使无法做到强一致性,但每个应用都可以根据自身业务特点,采用适当的方式来使系统达到最终一致性。

  • 1、Basically Available(基本可用)
      基本可用是指分布式系统在出现不可预知的故障的时候,允许损失部分可用性,但不等于系统不可用。两个就是“基本可用”的典型例子:
      1、响应时间上的损失:正常情况下,一个在线搜索引擎需要0.5秒内返回给用户相应的查询结果,但由于出现异常(比如系统部分机房发生断电或断网故障),查询结果的响应时间增加到了1~2秒。
      2、功能上的损失:正常情况下,在一个电子商务网站上进行购物,消费者几乎能够顺利地完成每一笔订单,但是在一些节日大促购物高峰的时候,由于消费者的购物行为激增,为了保护购物系统的稳定性,部分消费者可能会被引导到一个降级页面。
  • 2、Soft state(软状态)
      指允许系统中的数据存在中间状态,并认为该中间状态的存在不会影响系统的整体可用性。
      允许系统中的数据存在中间状态,并认为该中间状态的存在不会影响系统的整体可用性,即允许系统在不同节点的数据副本之间进行数据同步的过程存在延时。
  • 3、Eventually consistent(最终一致性)
      强调系统中所有的数据副本,在经过一段时间的同步后,最终能够达到一个一致的状态。其本质是需要系统保证最终数据能够达到一致,而不需要实时保证系统数据的强一致性。

  在实际工程实践中,最终一致性存在以下五类主要变种:

  • 1、因果一致性
      因果一致性是指,如果进程A在更新完某个数据项后通知了进程B,那么进程B之后对该数据项的访问都应该能够获取到进程A更新后的最新值,并且如果进程B要对该数据项进行更新操作的话,务必基于进程A更新后的最新值,即不能发生丢失更新情况。与此同时,与进程A无因果关系的进程C的数据访问则没有这样的限制。
  • 2、读己之所写
      读己之所写是指,进程A更新一个数据项之后,它自己总是能够访问到更新过的最新值,而不会看到旧值。也就是说,对于单个数据获取者而言,其读取到的数据一定不会比自己上次写入的值旧。因此,读己之所写也可以看作是一种特殊的因果一致性。
  • 3、会话一致性
      会话一致性将对系统数据的访问过程框定在了一个会话当中:系统能保证在同一个有效的会话中实现“读己之所写”的一致性,也就是说,执行更新操作之后,客户端能够在同一个会话中始终读取到该数据项的最新值。
  • 4、单调读一致性
      单调读一致性是指如果一个进程从系统中读取出一个数据项的某个值后,那么系统对于该进程后续的任何数据访问都不应该返回更旧的值。
  • 5、单调写一致性
      单调写一致性是指,一个系统需要能够保证来自同一个进程的写操作被顺序地执行。

  在实际系统实践中,可以将其中的若干个变种互相结合起来,以构建一个具有最终一致性的分布式系统。事实上,可以将其中的若干个变种相互结合起来,以构建一个具有最终一致性特性的分布式系统。
  总的来说,BASE理论面向的是大型高可用可扩展的分布式系统,和传统事务的ACID特性使相反的,它完全不同于ACID的强一致性模型,而是提出通过牺牲强一致性来获得可用性,并允许数据在一段时间内是不一致的,但最终达到一致状态。

二、分布式事务

2.1 刚性事务

  在X/Open DTP(Distributed Transaction Process)模型里,有三个角色:
  AP:Application,应用程序。也就是业务层。哪些操作属于一个事务,就是AP定义的。
  TM:Transaction Manager,事务管理器。接收AP的事务请求,对全局事务进行管理,管理事务分支状态,协调RM的处理,通知RM哪些操作属于哪些全局事务以及事务分支等等。这个也是整个事务调度模型的核心部分。
  RM:Resource Manager,资源管理器。一般是数据库,也可以是其他的资源管理器,如消息队列(如JMS数据源),文件系统等。

  XA把参与事务的角色分成AP、RM、TM。AP,即应用,也就是我们的业务服务。RM指的是资源管理器,即DB、MQ等。TM则是事务管理器。
  AP自己操作TM,当需要事务时,AP向TM请求发起事务,TM负责整个事务的提交,回滚等。
  XA规范是 X/Open 组织定义的分布式事务处理(DTP,Distributed Transaction Processing)标准。

  XA 规范描述了全局的事务管理器与局部的资源管理器之间的接口。XA规范的目的是允许的多个资源(如数据库,应用服务器,消息队列等)在同一事务中访问,这样可以使ACID属性跨越应用程序而保持有效。
  XA规范使用两阶段提交(2PC,Two-Phase Commit)协议来保证所有资源同时提交或回滚任何特定的事务。

  XA规范(XA Specification) 是X/OPEN 提出的分布式事务处理规范。XA则
规范了TM与RM之间的通信接口,在TM与多个RM之间形成一个双向通信桥梁,从而在多个数据库资源下保证ACID四个特性。目前知名的数据库,如Oracle、DB2、mysql等,都是实现了XA接口的,都可以作为RM。
  XA是数据库的分布式事务,强一致性,在整个过程中,数据一张锁住状态,即从prepare到commit、rollback的整个过程中,TM一直把持着数据库的锁,如果有其他⼈要修改数据库的该条数据,就必须等待锁的释放,存在长事务风险。

  • XA协议的实现
      1、两阶段提交(2PC)
      2、三阶段提交(3PC):对 2PC协议的⼀种扩展。
      3、Seata。Seata 是一款开源的分布式事务解决方案,致力于在微服务架构下提供高性能和简单易用的分布式事务服务。Seata为用户提供了AT、TCC、SAGA 和XA事务模式。
      4、作为Java平台上事务规范JTA(Java Transaction API)也定义了对XA事务的支持。实际上,JTA是基于XA架构上建模的,在JTA中,事务管理器抽象为TransactionManager接口,并通过底层事务服务(即JTS)实现。像很多其他的Java规范一样,JTA仅仅定义了接口,具体的实现则是由供应商(如J2EE厂商)负责提供,目前JTA的实现主要由以下2种:
  1. J2EE容器所提供的JTA实现(JBoss)。
    2.独立的JTA实现:如JOTM、Atomikos。

  这些实现可以应用在那些不使用J2EE应用服务器的环境里用以提供分布事
事务保证。如Tomcat、Jetty以及普通的java应用。
  5、JTS规范。为了规范事务开发,Java增加了关于事务的规范,即JTA和JTS。
  JTA定义了一套接口,其中约定了几种主要的角色:TransactionManager、UserTransaction、Transaction、XAResource,并定义了这些角色之间需要遵守的规范,如Transaction的委托给TransactionManager等。
  JTS也是一组规范,上面提到JTA中需要角色之间的交互,那应该如何交互?JTS就是约定了交互细节的规范。
  总体上来说JTA更多的是从框架的⻆度来约定程序角色的接口,而JTS则是从具体实现的⻆度来约定程序角色之间的接口,两者各司其职。
  6、Seata AT模式。Seata AT模式是增强型2pc模式。AT 模式: 两阶段提交协议的演变,没有一直锁表:

一阶段:业务数据和回滚日志记录在同一个本地事务中提交,释放本地锁和连接资源。
二阶段:提交异步化,非常快速地完成。或回滚通过一阶段的回滚日志进行反向补偿。

  • XA的主要限制
      1、必须要拿到所有数据源,且数据源还要支持XA协议。目前MySQL中只有InnoDB存储引擎支持XA协议。
      性能比较差,要把所有涉及到的数据都要锁定,是强一致性的,会产生长事务。
2.1.1 两阶段提交协议

  2PC分为两个阶段处理,阶段一:提交事务请求;阶段二:执行事务提交。
  如果阶段一超时或者出现异常,2PC的阶段二就会中断事务。

  • 阶段一:提交事务请求
      1、事务询问。协调者向所有参与者发送事务内容,询问是否可以执行提交操作,并开始等待各参与者进⾏响应;
      2、执行事务。各参与者节点,执行事务操作,并将Undo和Redo操作计入本机事务日志;
      3、各参与者向协调者反馈事务问询的响应。成功执行返回Yes,否则返回No。
  • 阶段二:执行事务提交
      协调者在阶段二决定是否最终执行事务提交操作。这一阶段包含两种情形。其中一种情形为:所有参与者reply Yes,那么执行事务提交。
  1. 发送提交请求。协调者向所有参与者发送Commit请求;
  2. 事务提交。参与者收到Commit请求后,会正式执行事务提交操作,并在完成提交操作之后,释放在整个事务执行期间占用的资源;
  3. 反馈事务提交结果。参与者在完成事务提交后,写协调者发送Ack消息确认;
  4. 完成事务。协调者在收到所有参与者的Ack后,完成事务。

  • 阶段二:中断事务
      事情总会出现意外,当存在某一参与者向协调者发送No响应,或者等待超时。协调者只要无法收到所有参与者的Yes响应,就会中断事务。
  1. 发送回滚请求。协调者向所有参与者发送Rollback请求;
  2. 回滚。参与者收到请求后,利用本机Undo信息,执行Rollback操作。并在回滚结束后释放该事务所占用的系统资源;
  3. 反馈回滚结果。参与者在完成回滚操作后,向协调者发送Ack消息;
  4. 中断事务。协调者收到所有参与者的回滚Ack消息后,完成事务中断。

  2pc中存在两个角色:事务协调者(seata等)和事务参与者,事务参与者通常是指应用的数据库。

  2PC方案实际很少用,一般来说某个系统内部如果出现跨多个库的这么一个操作,是不合规的。 现在微服务,一个大的系统分成几十甚至上百个服务。一般来说,我们的规定和规范,是要求每个服务只能操作自己对应的一个数据库。

  • 二阶段事务的缺点
      1)同步阻塞问题/性能问题。执行过程中,所有参与节点都是事务阻塞型的。XA协议遵循强一致性。在事务执行过程中,各个节点占用着数据库资源,只有当所有节点准备完毕,事务协调者才会通知提交,参与者提交后释放资源。这样的过程有着非常明显的性能问题。
      2)单点故障。事务协调者是整个XA模型的核心,一旦事务协调者节点挂掉,参与者收不到提交或是回滚通知,参与者会一直处于中间状态无法完成事务。
      3)数据不一致/脑裂问题。在二阶段提交的阶段二中,当协调者向参与者发送 commit 请求之后,发生了局部网络异常或者在发送commit请求过程中协调者发生了故障,导致只有一部分参与者接受到了commit请求。于是整个分布式系统便出现了数据部一致性的现象(脑裂现象)。
      4)数据状态不确定(二阶段无法解决的问题 )。 协调者再发出 commit 消息之后宕机,而唯一接收到这条消息的参与者同时也宕机了。那么即使协调者通过选举协议产生了新的协调者,这条事务的状态也是不确定的,没人知道事务是否被已经提交。
2.1.2 三阶段提交协议

  三阶段提交协议(Three-phase commit protocol),是二阶段提交(2PC)的改进版本。
  与两阶段提交不同的是,三阶段提交有两个改动点:

  1、引入超时机制。同时在协调者和参与者中都引入超时机制。
  2、在第一阶段和第二阶段中插入一个准备阶段。保证了在最后提交阶段之前各参与节点的状态是一致的。也就是说,除了引入超时机制之外,3PC把2PC的准备阶段再次一分为二,这样三阶段提交就有CanCommit、PreCommit、DoCommit三个阶段。

  • 阶段1、CanCommit
      1、事务询问。协调者向所有参与者发送包含事务内容的canCommit的请求,询问是否可以执行事务提交,并等待应答;
      2、各参与者反馈事务询问。正常情况下,如果参与者认为可以顺利执行事
    务,则返回Yes,否则返回No。

  • 阶段2、PreCommit
      在本阶段,协调者会根据上一阶段的反馈情况来决定是否可以执行事务的
    PreCommit操作。有以下两种可能:
    【执行事务预提交】
      1、发送预提交请求。协调者向所有节点发出PreCommit请求,并进入prepared阶段;
      2、事务预提交。参与者收到PreCommit请求后,会执行事务操作,并将Undo和Redo日志写入本机事务日志;
      3、各参与者成功执行事务操作,同时将反馈以Ack响应形式发送给协调者,同事等待最终的Commit或Abort指令。
    【中断事务】
      假如任意一个参与者向协调者发送No响应,或者等待超时,协调者在没有得到所有参与者响应时,即可以中断事务。
      1、发送中断请求。 协调者向所有参与者发送Abort请求;
      2、中断事务。无论是收到协调者的Abort请求,还是等待协调者请求过程中出现超时,参与者都会中断事务。

  • 阶段3、doCommit
      在这个阶段,会真正的进行事务提交,同样存在两种可能。
    【执行提交】
      1、发送提交请求。假如协调者收到了所有参与者的Ack响应,那么将从预提交转换到提交状态,并向所有参与者,发送doCommit请求;
      2、事务提交。参与者收到doCommit请求后,会正式执行事务提交操作,并在完成提交操作后释放占用资源;
      3、反馈事务提交结果。参与者将在完成事务提交后,向协调者发送Ack消
    息;
      4、完成事务。协调者接收到所有参与者的Ack消息后,完成事务。
    【中断事务】
      在该阶段,假设正常状态的协调者接收到任一个参与者发送的No响应,或在超时时间内,仍旧没收到反馈消息,就会中断事务。
      1、发送中断请求。协调者向所有的参与者发送abort请求;
      2、事务回滚。参与者收到abort请求后,会利用阶段二中的Undo消息执行事务回滚,并在完成回滚后释放占用资源;
      3、反馈事务回滚结果。参与者在完成回滚后向协调者发送Ack消息;
      4、中端事务。协调者接收到所有参与者反馈的Ack消息后,完成事务中断。

2.1.3 2PC和3PC的区别

  3PC有效降低了2PC带来的参与者阻塞范围,并且能够在出现单点故障后
继续达成一致;
  但3PC带来了新的问题,在参与者收到preCommit消息后,如果网络出现分区,协调者和参与者无法进行后续的通信,这种情况下,参与者在等待超时后,依旧会执行事务提交,这样会导致数据的不一致。

  • 2PC和3PC的区别
      三阶段提交协议在协调者和参与者中都引入超时机制,并且把两阶段提交协议的第一个阶段拆分成了两步:询问,然后再锁资源,最后真正提交。三阶段提交的三个阶段分别为:can_commit,pre_commit,do_commit。

      在doCommit阶段,如果参与者无法及时接收到来自协调者的doCommit或者abort请求时,会在等待超时之后,继续进行事务的提交。
  • 3PC主要解决的单点故障问题
      相对于2PC,3PC主要解决的单点故障问题,并减少阻塞, 因为一旦参与者无法及时收到来自协调者的信息之后,他会默认执行commit。而不会一直持有事务资源并处于阻塞状态。
      但是这种机制也会导致数据一致性问题,因为,由于网络原因,协调者发送的abort响应没有及时被参与者接收到,那么参与者在等待超时之后执行了commit操作。这样就和其他接到abort命令并执行回滚的参与者之间存在数据不一致的情况。
  • 3PC相对于2PC阶段到底优化了什么地方
      相比较2PC阶段,3PC对于协调者(Coordinator)和参与者(Partcipant)都设置了超时时间,而2PC只有协调者才拥有超时机制。这解决了什么问题呢?
      这个优化点,主要是避免了参与者在长时间无法与协调者节点通讯(协调者挂掉了)的情况下,无法释放资源的问题,因为参与者自身拥有超时机制会在超时后,自动进行本地commit从而进行释放资源。而这种机制也侧面降低了整个事务的阻塞时间和范围。
2.1.4 XA规范的问题

  XA也算分布式事务处理的规范了,但在互联网中很少使用,究其原因有以下几个:

  • 性能(阻塞性协议,增加响应时间、锁时间、死锁);
  • 数据库支持完善度(MySQL 5.7之前都有缺陷);
    *协调者依赖独立的J2EE中间件(早期重量级Weblogic、Jboss、后期轻量级Atomikos、Narayana和Bitronix);
  • 运维复杂,DBA缺少这方面经验;
  • 并不是所有资源都支持XA协议。

2.2 柔性事务

  在电商领域等互联网场景下,传统的事务在数据库性能和处理能力上都暴露出了瓶颈。柔性事务有两个特性:基本可用和柔性状态。

  基本可用是指分布式系统出现故障的时候允许损失一部分的可用性。
  柔性状态是指允许系统存在中间状态,这个中间状态不会影响系统整体的可用性,如数据库读写分离的主从同步延迟等。柔性事务的一致性指的是最终一致性。

  柔性事务主要分为补偿型和通知型,补偿型事务又分:TCC、Saga;通知型事务分:MQ事务消息、最大努力通知型。

  补偿型事务都是同步的,通知型事务都是异步的。

2.2.1 通知型事务

  通知型事务的主流实现是通过MQ(消息队列)来通知其他事务参与者自己事务的执行状态,引入MQ组件,有效的将事务参与者进行解耦,各参与者都可以异步执行,所以通知型事务又被称为异步事务。
  通知型事务主要适用于那些需要异步更新数据,并且对数据的实时性要求较低的场景,主要包含:异步确保型事务和最大努力通知事务。
  异步确保型事务:主要适用于内部系统的数据最终一致性保障,因为内部相对比较可控,如订单和购物车、收货与清算、支付与结算等等场景;
  最大努力通知:主要用于外部系统,因为外部的网络环境更加复杂和不可信,所以只能尽最大努力去通知实现数据最终一致性,如充值平台与运营商、支付对接等等跨网络系统级别对接。

2.2.2 异步确保型

  指将一系列同步的事务操作修改为基于消息队列异步执行的操作,来避免分布式事务中同步阻塞带来的数据操作性能的下降。
  基于MQ的事务消息方案主要依靠MQ的半消息机制来实现投递消息和参与者自身本地事务的一致性保障。半消息机制实现原理其实借鉴的2PC的思路,是二阶段提交的广义拓展。
  半消息:在原有队列消息执行后的逻辑,如果后面的本地逻辑出错,则不发送该消息,如果通过则告知MQ发送。
  流程:

  1. 事务发起首先先发送半消息到MQ;
  2. MQ通知发送方消息发送成功;
  3. 在发送半消息成功后执行本地事务;
  4. 根据本地事务执行结果返回commit或者是rollback;
  5. 如果消息是rollback, MQ将丢弃该消息不投递;如果是commit,MQ将会消息发送给消息订阅方;
  6. 订阅方根据消息执行本地事务;
  7. 订阅方执行本地事务成功后再从MQ中将该消息标记为已消费;
  8. 如果执行本地事务过程中,执行端挂掉,或者超时,MQ服务器端将不停的询问producer来获取事务状态;
  9. Consumer端的消费成功机制由MQ保证。

  举个例自子,假设存在业务规则:某笔订单成功后,为用户加一定的积分。在这条规则里,管理订单数据源的服务为事务发起方,管理积分数据源的服务为事务跟随者。基于消息队列实现的事务存在以下操作:

订单服务创建订单,提交本地事务;
订单服务发布一条消息;
积分服务收到消息后加积分。


  它的整体流程是比较简单的,同时业务开发工作量也不大:编写订单服务里订单创建的逻辑、编写积分服务里增加积分的逻辑。该事务形态过程简单,性能消耗小,发起方与跟随方之间的流量峰谷可以使用队列填平,同时业务开发工作量也基本与单机事务没有差别,都不需要编写反向的业务逻辑过程。因此基于消息队列实现的事务是我们除了单机事务外最优先考虑使用的形态。
  有一些第三方的MQ是支持事务消息的,这些消息队列,支持半消息机制,如RocketMQ、ActiveMQ。但是有一些常用的MQ也不支持事务消息,如RabbitMQ、Kafka。
  以阿里的 RocketMQ 中间件为例,其思路大致为:

  1. producer(用A系统表示)发送半消息到broker,这个半消息包含完整的消息内容, 在producer端和普通消息的发送逻辑一致;
  2. broker存储半消息,半消息存储逻辑与普通消息一致,只是属性有所不同,topic是固定的RMQ_SYS_TRANS_HALF_TOPIC,queueId也是固定为0,这个tiopic中的消息对消费者是不可见的,所以里面的消息永远不会被消费。这就保证了在半消息提交成功之前,消费者是消费不到这个半消息的;
  3. broker端半消息存储成功并返回后,A系统执行本地事务,并根据本地事务的执行结果来决定半消息的提交状态为提交或者回滚;
  4. A系统发送结束半消息的请求,并带上提交状态(提交 or 回滚);
  5. broker端收到请求后,首先从RMQ_SYS_TRANS_HALF_TOPIC的queue中查出该消息,设置为完成状态。如果消息状态为提交,则把半消息从RMQ_SYS_TRANS_HALF_TOPIC队列中复制到这个消息原始topic的queue中去(之后这条消息就能被正常消费了);如果消息状态为回滚,则什么也不做;
  6. producer发送的半消息结束请求是 oneway 的,也就是发送后就不管了,只靠这个是⽆法保证半消息一定被提交的,rocketMq提供了个兜底方案,这个方案叫消息反查机制,Broker启动时,会启动一个TransactionalMessageCheckService 任务,该任务会定时从半消息队列中读出所有超时未完成的半消息,针对每条未完成的消息,Broker会给对应的Producer发送一个消息反查请求,根据反查结果来决定这个半消息是需要提交还是回滚,或者后面继续来反查;
  7. consumer(用B系统表示)消费消息,执行本地数据变更(至于B是否能消费成功,消费失败是否重试,这属于正常消息消费需要考虑的问题);


  在rocketMq中,不论是producer收到broker存储半消息成功返回后执行本地事务,还是broker向producer反查消息状态,都是通过回调机制完成。
  producer端的代码示例:

  半消息发送时,会传入一个回调类TransactionListener,使用时必须实现其中的两个方法,executeLocalTransaction方法会在broker返回半消息存储成功后执行,我们会在其中执行本地事务;checkLocalTransaction方法会在broker向producer发起反查时执行,我们会在其中查询库表状态。两个方法的返回值都是消息状态,就是告诉broker应该提交或者回滚半消息。
  有时候我们目前的MQ组件并不支持事务消息,或者我们想尽量少的侵入业务方。这时我们需要另外一种方案“基于DB本地消息表”。
  本地消息表是目前业界使用的较多的方案之一,它的核心思想就是将分布式事务拆分成本地事务进行处理。
  本地消息表流程:

  发送消息方:

  1、需要有一个消息表,记录着消息状态相关信息。
  2、业务数据和消息表在同一个数据库,要保证它俩在同一个本地事务。直接利用本地事务,将业务数据和事务消息直接写入数据库。
  3、在本地事务中处理完业务数据和写消息表操作后,通过写消息到 MQ 消息队列。使用专门的投递工作线程进行事务消息投递到MQ,根据投递ACK去删除事务消息表记录。
  4、消息会发到消息消费方,如果发送失败,即进行重试。

  消息消费方:

  1、处理消息队列中的消息,完成自己的业务逻辑。
  2、如果本地事务处理成功,则表明已经处理成功了。
  3、如果本地事务处理失败,那么就会重试执行。
  4、如果是业务层面的失败,给消息生产方发送一个业务补偿消息,通知进行回滚等操作。

  生产方和消费方定时扫描本地消息表,把还没处理完成的消息或者失败的消息再发送一遍。如果有靠谱的自动对账补账逻辑,这种方案还是非常实用的。
  本地消息表的优点:

  本地消息表建设成本较低,实现了可靠消息的传递确保了分布式事务的最终一致性。
  无需提供回查方法,进一步减少的业务的侵入。
  在某些场景下,还可以进一步利用注解等形式进行解耦,有可能实现无业务代码侵入式的实现。

  本地消息表的缺点:

  本地消息表与业务耦合在一起,难于做成通用性,不可独立伸缩。
  本地消息表是基于数据库来做的,而数据库是要读写磁盘IO的,因此在高并发下是有性能瓶颈的。

  MQ事务消息和本地消息表的共同点:

  1、 事务消息都依赖MQ进行事务通知,所以都是异步的。
  2、 事务消息在投递方都是存在重复投递的可能,需要有配套的机制去降低重复投递率,实现更友好的消息投递去重。
  3、 事务消息的消费方,因为投递重复的无法避免,因此需要进行消费去重设计或者服务幂等设计。

  MQ事务消息和本地消息表的不同点:
  MQ事务消息:

  需要MQ支持半消息机制或者类似特性,在重复投递上具有较好的去重处理;
  具有较大的业务侵⼊性,需要业务方进行改造,提供对应的本地操作成功的回查功能;

  DB本地消息表:

  使用了数据库来存储事务消息,降低了对MQ的要求,但是增加了存储成本;
  事务消息使用了异步投递,增加了消息重复投递的可能性。

2.2.3 最大努力通知型

  最大努力通知方案的目标,就是发起通知方通过一定的机制,最大努力将业务处理结果通知到接收方。
  最大努力通知型的最终一致性:本质是通过引入定期校验机制实现最终一致性,对业务的侵入性较低,适合于对最终一致性敏感度度较低、业务链路较短的场景。
  最大努力通知事务主要用于外部系统,因为外部的网络环境更加复杂和不可信,所以只能尽最大努力去通知实现数据最终一致性,如充值平台与运营商、支付对接、商户通知等等跨平台、跨企业的系统间业务交互场景;
  异步确保型事务主要适用于内部系统的数据最终一致性保障,因为内部相对比较可控,如订单和购物车、收货与清算、支付与结算等等场景。

  普通消息是无法解决本地事务执行和消息发送的⼀致性问题的。因为消息发送是个网络通信的过程,发送消息的过程就有可能出现发送失败、或者超时的情况。超时有可能发送成功了,有可能发送失败了,消息的发送方是无法确定的,所以此时消息发送方无论是提交事务还是回滚事务,都有可能不一致性出现。
  所以,通知型事务的难度在于: 投递消息和参与者本地事务的一致性保障。
  因为核心要点一致,都是为了保证消息的一致性投递,所以,最大努力通知事务在投递流程上跟异步确保型是一样的,也有两个分支:基于MQ自身的事务消息方案、基于DB的本地事务消息表方案。

  • MQ事务消息方案
      要实现最大努力通知,可以采用MQ的ACK机制。
      最大努力通知事务在投递之前,跟异步确保型流程都差不多,关键在于投递后的处理。
      因为异步确保型在于内部的事务处理,所以MQ和系统是直连并且无需严格的权限、安全等方面的思路设计。最大努力通知事务在于第三方系统的对接,所以最大努力通知事务有几个特性:

  业务主动方在完成业务处理后,向业务被动方(第三方系统)发送通知消息,允许存在消息丢失。
  业务主动方提供递增多挡位时间间隔(5min、10min、30min、1h、24h),用于失败重试调用业务被动方的接口;在通知N次之后就不再通知,报警+记日志+人工介入。
  业务被动方提供幂等的服务接口,防止通知重复消费。
  业务主动方需要有定期校验机制,对业务数据进行兜底;防止业务被动方无法履行责任时进行业务回滚,确保数据最终一致性。


  过程:

  1. 业务活动的主动方,在完成业务处理之后,向业务活动的被动方发送消息,允许消息丢失。
  2. 主动方可以设置时间阶梯型通知规则,在通知失败后按规则重复通知,直到通知N次后不再通知。
  3. 主动方提供校对查询接口给被动方按需校对查询,用于恢复丢失的业务消息。
  4. 业务活动的被动方如果正常接收了数据,就正常返回响应,并结束事务。
  5. 如果被动方没有正常接收,根据定时策略,向业务活动主动方查询,恢复丢失的业务消息。

  特点:

  1. 用到的服务模式:可查询操作、幂等操作;
  2. 被动方的处理结果不影响主动方的处理结果;
  3. 适用于对业务最终一致性的时间敏感度低的系统;
  4. 适合跨企业的系统间的操作,或者企业内部⽐较独⽴的系统间的操作,如银行通知、商户通知等;
  • 本地消息表方案
      要实现最大努力通知,也可以采用定期检查本地消息表的机制 。

      发送消息方需要有一个消息表,记录着消息状态相关信息。
      业务数据和消息表在同一个数据库,要保证它俩在同一个本地事务。直接利用本地事务,将业务数据和事务消息直接写入数据库。
      在本地事务中处理完业务数据和写消息表操作后,通过写消息到 MQ 消息队列。使用专门的投递工作线程进行事务消息投递到MQ,根据投递ACK去删除事务消息表记录。
      消息会发到消息消费方,如果发送失败,即进行重试。
      生产方定时扫描本地消息表,把还没处理完成的消息或者失败的消息再发送一遍。如果有靠谱的自动对账补账逻辑,这种方案还是非常实用的。
      最大努力通知事务在于第三方系统的对接,所以最大努力通知事务有几个特性:

  业务主动方在完成业务处理后,向业务被动方(第三方系统)发送通知消息,允许存在消息丢失。
  业务主动方提供递增多挡位时间间隔(5min、10min、30min、1h、24h),用于失败重试调用业务被动方的接口;在通知N次之后就不再通知,报警+记日志+人工介入。
  业务被动方提供幂等的服务接口,防止通知重复消费。
  业务主动方需要有定期校验机制,对业务数据进行兜底;防止业务被动方无法履行责任时进行业务回滚,确保数据最终一致性。

  • 最大努力通知事务 VS 异步确保型事务
      从参与者来说:最大努力通知事务适用于跨平台、跨企业的系统间业务交互;异步确保型事务更适用于同网络体系的内部服务交付。
      从消息层面说:最大努力通知事务需要主动推送并提供多档次时间的重试机制来保证数据的通知;而异步确保型事务只需要消息消费者主动去消费。
      从数据层面说:最大努力通知事务还需额外的定期校验机制对数据进行兜底,保证数据的最终一致性;而异步确保型事务只需保证消息的可靠投递即可,自身无需对数据进行兜底处理。
2.2.4 通知型事务的问题

  通知型事务,是无法解决本地事务执行和消息发送的一致性问题的。因为消息发送是一个网络通信的过程,发送消息的过程就有可能出现发送失败、或者超时的情况。超时有可能发送成功了,有可能发送失败了,消息的发送方是无法确定的,所以此时消息发送方无论是提交事务还是回滚事务,都有可能不一致性出现。
  常规的MQ队列处理流程无法实现消息的一致性。所以,需要借助半消息、本地消息表,保障一致性。
  对于未确认的消息,采用按规则重新投递的方式进行处理。如果允许消息重复发送,那么消费方应该实现业务接方的幂等性设计。

2.2.5 补偿型

  补偿模式使用一个额外的协调服务来协调各个需要保证一致性的业务服务,协调服务按顺序调用各个业务微服务,如果某个业务服务调用异常(包括业务异常和技术异常)就取消之前所有已经调用成功的业务服务。
  补偿模式大致有TCC,和Saga两种细分的方案:

2.2.6 TCC 事务模型

  TCC 分布式事务模型包括三部分:

1.主业务服务:主业务服务为整个业务活动的发起方,服务的编排者,负责发起并完成整个业务活动。
2. 从业务服务:从业务服务是整个业务活动的参与方,负责提供 TCC 业务操作,实现初步操作(Try)、确认操作(Confirm)、取消操作(Cancel)三个接口,供主业务服务调用。
3. 业务活动管理器:业务活动管理器管理控制整个业务活动,包括记录维护 TCC 全局事务的事务状态和每个从业务服务的子事务状态,并在业务活动提交时调用所有从业务服务的 Confirm 操作,在业务活动取消时调用所有从业务服务的 Cancel 操作。

  TCC 把事务运行过程分成 Try、Confirm / Cancel 两个阶段,每个阶段的逻辑由业务代码控制,避免了长事务,可以获取更高的性能。
  TCC(Try-Confirm-Cancel)分布式事务模型相对于 XA 等传统模型,其特征在于它不依赖资源管理器(RM)对分布式事务的支持,而是通过对业务逻辑的分解来实现分布式事务。
  TCC 模型认为对于业务系统中一个特定的业务逻辑,其对外提供服务时,必须接受一些不确定性,即对业务逻辑初步操作的调用仅是一个临时性操作,调用它的主业务服务保留了后续的取消权。如果主业务服务认为全局事务应该回滚,它会要求取消之前的临时性操作,这就对应从业务服务的取消操作。而当主业务服务认为全局事务应该提交时,它会放弃之前临时性操作的取消权,这对应从业务服务的确认操作。每一个初步操作,最终都会被确认或取消。
  针对一个具体的业务服务,TCC 分布式事务模型需要业务系统提供三段业务逻辑:

  初步操作 Try:完成所有业务检查,预留必须的业务资源。
  确认操作 Confirm:真正执⾏的业务逻辑,不作任何业务检查,只使用Try阶段预留的业务资源。因此,只要 Try 操作成功,Confirm 必须能成功。另外,Confirm 操作需满足幂等性,保证一笔分布式事务有且只能成功一次。
  取消操作 Cancel:释放 Try 阶段预留的业务资源。同样的,Cancel 操作也需要满足幂等性。


  TCC 分布式事务模型:

  Try 阶段: 调用 Try 接⼝,尝试执行业务,完成所有业务检查,预留业务资源。
  Confirm 或 Cancel 阶段: 两者是互斥的,只能进入其中一个,并且都满足幂等性,允许失败重试。
  Confirm 操作: 对业务系统做确认提交,确认执行业务操作,不做其他业务检查,只使用 Try 阶段预留的业务资源。
  Cancel 操作: 在业务执行错误,需要回滚的状态下执行业务取消,释放预留资源。
  Try 阶段失败可以 Cancel ,如果 Confirm 和 Cancel 阶段失败了怎么办?TCC 中会添加事务日志,如果 Confirm 或者 Cancel 阶段出错,则会进行重试,所以这两个阶段需要支持幂等;如果重试失败,则需要人工介入进行恢复和处理等。
【TCC事务模型的要求】
  1、可查询操作:服务操作具有全局唯一的标识,操作唯一的确定的时间。
  2、幂等操作:重复调用多次产生的业务结果与调用一次产生的结果相同。一是通过业务操作实现幂等性,二是系统缓存所有请求与处理的结果,最后是检测到重复请求之后,自动返回之前的处理结果。
  3、TCC操作:Try阶段,尝试执行业务,完成所有业务的检查,实现一致性;预留必须的业务资源,实现准隔离性。Confirm阶段:真正的去执行业务,不做任何检查,仅适用Try阶段预留的业务资源,Confirm操作还要满⾜幂等性。Cancel阶段:取消执行业务,释放Try阶段预留的业务资源,Cancel操作要满足幂等性。TCC与2PC(两阶段提交)协议的区别:TCC位于业务服务层而不是资源层,TCC没有单独准备阶段,Try操作兼备资源操作与准备的能力,TCC中Try操作可以灵活的选择业务资源,锁定粒度。TCC的开发成本比2PC高。实际上TCC也属于两阶段操作,但是TCC不等同于2PC操作。
  4、可补偿操作:Do阶段:真正的执行业务处理,业务处理结果外部可见。Compensate阶段:抵消或者部分撤销正向业务操作的业务结果,补偿操作满足幂等性。约束:补偿操作在业务上可行,由于业务执行结果未隔离或者补偿不完整带来的风险与成本可控。实际上,TCC的Confirm和Cancel操作可以看做是补偿操作。

  • TCC与2PC对比
      TCC其实本质和2PC是差不多的:

  T就是Try,两个C分别是Confirm和Cancel。
  Try就是尝试,请求链路中每个参与者依次执行Try逻辑,如果都成功,就再执行Confirm逻辑,如果有失败,就执行Cancel逻辑。


  在阶段1:

在XA中,各个RM准备提交各自的事务分支,事实上就是准备提交资源的更新操作(insert、delete、update等);而在TCC中,是主业务活动请求(try)各个从业务服务预留资源。

  在阶段2:

XA根据第一阶段每个RM是否都prepare成功,判断是要提交还是回滚。如果都prepare成功,那么就commit每个事务分支,反之则rollback每个事务分支。TCC中,如果在第一阶段所有业务资源都预留成功,那么confirm各个从业务服务,否则取消(cancel)所有从业务服务的资源预留请求。

  • TCC与2PC不同点
      XA是资源层面的分布式事务,强一致性,在两阶段提交的整个过程中,一直会持有资源的锁。基于数据库锁实现,需要数据库支持XA协议,由于在执行事务的全程都需要对相关数据加锁,一般高并发性能会比较差。
      TCC是业务层面的分布式事务,最终一致性,不会一直持有资源的锁,性能较好。但是对微服务的侵入性强,微服务的每个事务都必须实现try、confirm、cancel等3个方法,开发成本高,今后维护改造的成本也高。为了达到事务的一致性要求,try、confirm、cancel接口必须实现幂等性操作由于事务管理器要记录事务日志,必定会损耗一定的性能,并使得整个TCC事务时间拉长。
  • TCC 的使用场景
      TCC是可以解决部分场景下的分布式事务的,但是,它的一个问题在于,需要每个参与者都分别实现Try,Confirm和Cancel接口及逻辑,这对于业务的侵入性是巨大的。
      TCC 方案严重依赖回滚和补偿代码,最终的结果是:回滚代码逻辑复杂,业务代码很难维护。所以,TCC 方案的使用场景较少,但是也有使用的场景。
      如说跟钱打交道的,支付、交易相关的场景,大家会用TCC方案,严格保证分布式事务要么全部成功,要么全部自动回滚,严格保证资金的正确性,保证在资金上不会出现问题。
2.2.7 SAGA长事务模型

  SAGA可以看做一个异步的、利用队列实现的补偿事务。
  Saga模型是把一个分布式事务拆分为多个本地事务,每个本地事务都有相应的执行模块和补偿模块(对应TCC中的Confirm和Cancel),当Saga事务中任意一个本地事务出错时,可以通过调用相关的补偿方法恢复之前的事务,达到事务最终一致性。
  Saga 模型由三部分组成:

LLT(Long Live Transaction):由⼀个个本地事务组成的事务链。
本地事务:事务链由⼀个个⼦事务(本地事务)组成,LLT =T1+T2+T3+…+Ti。
补偿:每个本地事务 Ti 有对应的补偿 Ci。

  Saga的执行顺序有两种:

T1, T2, T3, …, Tn
T1, T2, …, Tj, Cj,…, C2, C1,其中0 < j < n

  • Saga 两种恢复策略
      1、向后恢复(Backward Recovery):撤销掉之前所有成功子事务。如果任意本地子事务失败,则补偿已完成的事务。如异常情况的执行顺序T1,T2,T3,…Ti,Ci,…C3,C2,C1。

      2、向前恢复(Forward Recovery):即重试失败的事务,适用于必须要成功的场景,该情况下不需要Ci。执行顺序:T1,T2,…,Tj(失败),Tj(重试),…,Ti。
      向前恢复没有必要提供补偿事务,如果你的业务中,子事务(最终)总会成功,或补偿事务难以定义或不可能,向前恢复更符合你的需求。
  • SAGA模型的解决方案
      SAGA模型的核心思想是,通过某种方案,将分布式事务转化为本地事务,从而降低问题的复杂性。
      如以DB和MQ的场景为例,业务逻辑:1. 向DB中插入一条数据。2. 向MQ中发送一条消息。对应了两种存储端,即DB和MQ,所以,简单的通过本地事务是无法解决的。那么,依照SAGA模型,可以有两种解决方案。
      方案一:半消息模式。RocketMQ新版本中,就支持了这种模式。
      半消息。简单来说,就是在消息上加了一个状态。当发送者第一次将消息放入MQ后,该消息为待确认状态。该状态下,该消息是不能被消费者消费的。发送者必须二次和MQ进行交互,将消息从待确认状态变更为确认状态后,消息才能被消费者消费。待确认状态的消息,就称之为半消息。
      半消息的完整事务逻辑:
  1. 向MQ发送半消息。
  2. 向DB插入数据。
  3. 向MQ发送确认消息。

  MQ引入了一个扫描的机制。即MQ会每隔一段时间,对所有的半消息进行扫描,并就扫描到的存在时间过长的半消息,向发送者进行询问,询问如果得到确认回复,则将消息改为确认状态,如得到失败回复,则将消息删除。

  半消息机制的一个问题是:要求业务方提供查询消息状态接口,对业务方依然有较大的侵入性。
  方案二:本地消息表。在DB中,新增一个消息表,用于存放消息。如下:

  1. 在DB业务表中插入数据。
  2. 在DB消息表中插入数据。
  3. 异步将消息表中的消息发送到MQ,收到ack后,删除消息表中的消息。


  通过上述逻辑,将一个分布式的事务,拆分成两大步。第1和第2,构成了一个本地的事务,从而解决了分布式事务的问题。这种解决方案,不需要业务端提供消息查询接口,只需要稍微修改业务逻辑,侵入性是最小的。

2.3 总体的方案对比

属性 2PC TCC Saga 异步确保型事务 尽最大努力通知
事务一致性
复杂性
业务侵入性
使用局限性
性能
维护成本

三、分布式ID

3.1 数据库自增ID

  基于数据库的自增ID,需要单独使用一个数据库实例,在这个实例中新建一个单独的表:

CREATE DATABASE `SEQID`;
CREATE TABLE SEQID.SEQUENCE_ID (
    id bigint(20) unsigned NOT NULL auto_increment,
    stub char(10) NOT NULL default '',
    PRIMARY KEY (id),
    UNIQUE KEY stub (stub)
) ENGINE=MyISAM;

  可以使用下面的语句生成并获取到一个自增ID:

begin;
replace into SEQUENCE_ID (stub) VALUES ('anyword');
select last_insert_id();
commit;

  stub字段在这里并没有什么特殊的意义,只是为了方便的去插入数据,只有能插入数据才能产生自增id。而对于插入我们用的是replace,replace会先看是否存在stub指定值一样的数据,如果存在则先delete再insert,如果不存在则直接insert。
  这种生成分布式ID的机制,需要一个单独的Mysql实例,虽然可行,但是基于性能与可靠性来考虑的话都不够,业务系统每次需要一个ID时,都需要请求数据库获取,性能低,并且如果此数据库实例下线了,那么将影响所有的业务系统

3.2 数据库多主模式

  如果两个数据库组成一个主从模式集群,正常情况下可以解决数据库可靠性问题,但是如果主库挂掉后,数据没有及时同步到从库,这个时候会出现ID重复的现象。我们可以使用双主模式集群,也就是两个Mysql实例都能单独的生产自增ID,这样能够提高效率,但是如果不经过其他改造的话,这两个Mysql实例很可能会生成同样的ID。需要单独给每个Mysql实例配置不同的起始值和自增步长。
  第一台Mysql实例配置:

set @@auto_increment_offset = 1;   -- 起始值
set @@auto_increment_increment = 2;  -- 步长

  第二台Mysql实例配置:

set @@auto_increment_offset = 2;   -- 起始值
set @@auto_increment_increment = 2;  -- 步长

  经过上面的配置后,这两个Mysql实例生成的id序列如下: mysql1,起始值为1,步长为2。ID生成的序列为:1,3,5,7,9,… mysql2,起始值为2,步长为2,ID生成的序列为:2,4,6,8,10,…
  对于这种生成分布式ID的方案,需要单独新增一个生成分布式ID应用,比如DistributIdService,该应用提供一个接口供业务应用获取ID,业务应用需要一个ID时,通过rpc的方式请求DistributIdService,DistributIdService随机去上面的两个Mysql实例中去获取ID。实行这种方案后,就算其中某一台Mysql实例下线了,也不会影响DistributIdService,DistributIdService仍然可以利用另外一台Mysql来生成ID。
  但是这种方案的扩展性不太好,如果两台Mysql实例不够用,需要新增Mysql实例来提高性能时,这时就会比较麻烦。

3.3 号段模式

  使用号段的方式来获取自增ID,号段可以理解成批量获取,比如DistributIdService从数据库获取ID时,如果能批量获取多个ID并缓存在本地的话,那样将大大提供业务应用获取ID的效率。
  比如DistributIdService每次从数据库获取ID时,就获取一个号段,比如(1,1000],这个范围表示了1000个ID,业务应用在请求DistributIdService提供ID时,DistributIdService只需要在本地从1开始自增并返回即可,而不需要每次都请求数据库,一直到本地自增到1000时,也就是当前号段已经被用完时,才去数据库重新获取下一号段。
  需要对数据库表进行改动:

CREATE TABLE id_generator (
    id int(10) NOT NULL,
    current_max_id bigint(20) NOT NULL COMMENT '当前最大id',
    increment_step int(10) NOT NULL COMMENT '号段的长度',
    PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8;

  这个数据库表用来记录自增步长以及当前自增ID的最大值(也就是当前已经被申请的号段的最后一个值),因为自增逻辑被移到DistributIdService中去了,所以数据库不需要这部分逻辑了。
  这种方案不再强依赖数据库,就算数据库不可用,那么DistributIdService也能继续支撑一段时间。但是如果DistributIdService重启,会丢失一段ID,导致ID空洞。
  为了提高DistributIdService的高可用,需要做一个集群,业务在请求DistributIdService集群获取ID时,会随机的选择某一个DistributIdService节点进行获取,对每一个DistributIdService节点来说,数据库连接的是同一个数据库,那么可能会产生多个DistributIdService节点同时请求数据库获取号段,那么这个时候需要利用乐观锁来进行控制,比如在数据库表中增加一个version字段,在获取号段时使用如下SQL:

update id_generator 
set current_max_id=#{newMaxId}, version=version+1 
where version = #{version}

  因为newMaxId是DistributIdService中根据oldMaxId+步长算出来的,只要上面的update更新成功了就表示号段获取成功了。
  为了提供数据库层的高可用,需要对数据库使用多主模式进行部署,对于每个数据库来说要保证生成的号段不重复,这就需要利用最开始的思路,再在刚刚的数据库表中增加起始值和步长,比如如果现在是两台Mysql,那么 mysql1将生成号段(1,1001],自增的时候序列为1,3,4,5,7… mysql1将生成号段(2,1002],自增的时候序列为2,4,6,8,10…
  在TinyId中还增加了一步来提高效率,在上面的实现中,ID自增的逻辑是在DistributIdService中实现的,而实际上可以把自增的逻辑转移到业务应用本地,这样对于业务应用来说只需要获取号段,每次自增时不再需要请求调用DistributIdService了。

3.4 雪花算法

  上面的三种方法总的来说是基于自增思想的。
  我们可以换个角度来对分布式ID进行思考,只要能让负责生成分布式ID的每台机器在每毫秒内生成不一样的ID就行了。snowflake是twitter开源的分布式ID生成算法,是一种算法,所以它和上面的三种生成分布式ID机制不太一样,它不依赖数据库。核心思想是:分布式ID固定是一个long型的数字,一个long型占8个字节,也就是64个bit,原始snowflake算法中对于bit的分配如下图:

  第一个bit位是标识部分,在java中由于long的最高位是符号位,正数是0,负数是1,一般生成的ID为正数,所以固定为0。
  时间戳部分占41bit,这个是毫秒级的时间,一般实现上不会存储当前的时间戳,而是时间戳的差值(当前时间-固定的开始时间),这样可以使产生的ID从更小值开始;41位的时间戳可以使用69年,(1L << 41) / (1000L * 60 * 60 * 24 * 365) = 69年。
  工作机器id占10bit,这里比较灵活,比如,可以使用前5位作为数据中心机房标识,后5位作为单机房机器标识,可以部署1024个节点。
  序列号部分占12bit,支持同一毫秒内同一个节点可以生成4096个ID。

  snowflake算法可以根据自身项目的需要进行一定的修改。比如估算未来的数据中心个数,每个数据中心的机器数以及统一毫秒可以能的并发数来调整在算法中所需要的bit数。
  根据这个算法的逻辑,只需要将这个算法用Java语言实现出来,封装为一个工具方法,那么各个业务应用可以直接使用该工具方法来获取分布式ID,只需保证每个业务应用有自己的工作机器id即可,而不需要单独去搭建一个获取分布式ID的应用。

  • 优点
      1、不依赖于数据库,灵活方便,且性能优于数据库。
      2、 ID按照时间在单机上是递增的。
  • 缺点
      在单机上是递增的,但是由于涉及到分布式环境,每台机器上的时钟不可能完全同步,也许有时候也会出现不是全局递增的情况。

3.5 Leaf

  美团的Leaf也是一个分布式ID生成框架。它非常全面,即支持号段模式,也支持snowflake模式。Leaf中的snowflake模式和原始snowflake算法的不同点,也主要在workId的生成,Leaf中workId是基于ZooKeeper的顺序Id来生成的,每个应用在使用Leaf-snowflake时,在启动时都会都在Zookeeper中生成一个顺序Id,相当于一台机器对应一个顺序节点,也就是一个workId。

3.6 使用Redis生成ID

  当使用数据库来生成ID性能不够要求的时候,我们可以尝试使用Redis来生成ID。这主要依赖于Redis是单线程的,所以也可以用生成全局唯一的ID。可以用Redis的原子操作 INCR和INCRBY来实现。
  使用Redis来生成分布式ID,其实和利用Mysql自增ID类似,可以利用Redis中的incr命令来实现原子性的自增与返回,比如:

127.0.0.1:6379> set seq_id 1 // 初始化自增ID为1
OK
127.0.0.1:6379> incr seq_id // 增加1,并返回
(integer) 2
127.0.0.1:6379> incr seq_id // 增加1,并返回
(integer) 3

  可以使用Redis集群来获取更高的吞吐量。假如一个集群中有5台Redis。可以初始化每台Redis的值分别是1,2,3,4,5,然后步长都是5。各个Redis生成的ID为:

A:1,6,11,16,21 B:2,7,12,17,22 C:3,8,13,18,23 D:4,9,14,19,24 E:5,10,15,20,25

  这个,随便负载到哪个机确定好,未来很难做修改。但是3-5台服务器基本能够满足器上,都可以获得不同的ID。但是步长和初始值一定需要事先需要了。使用Redis集群也可以方式单点故障的问题。
  另外,比较适合使用Redis来生成每天从0开始的流水号。比如订单号=日期+当日自增长号。可以每天在Redis中生成一个Key,使用INCR进行累加。

  • 优点
      1、不依赖于数据库,灵活方便,且性能优于数据库。
      2、数字ID天然排序,对分页或者需要排序的结果很有帮助。
  • 缺点
      需要编码和配置的工作量比较大。

3.7 UUID

  一般来说全球唯一。

  • 优点
      1、简单,代码方便。
      2、生成ID性能非常好,基本不会有性能问题。
      3、全球唯一,在遇见数据迁移,系统数据合并,或者数据库变更等情况下,可以从容应对。
  • 缺点
      1、没有排序,无法保证趋势递增。
      2、UUID往往是使用字符串存储,查询的效率比较低。
      3、存储空间比较大,如果是海量数据库,就需要考虑存储量的问题。
      4、传输数据量大。
      5、不可读。

四、限流算法

4.1 固定窗口计数器算法

  规定我们单位时间处理的请求数量。比如我们规定我们的一个接口一分钟只能访问10次的话。使用固定窗口计数器算法的话可以这样实现:给定一个变量counter来记录处理的请求数量,当1分钟之内处理一个请求之后counter+1,1分钟之内的如果counter=100的话,后续的请求就会被全部拒绝。等到 1分钟结束后,将counter回归成0,重新开始计数(ps:只要过了一个周期就讲counter回归成0)。
  这种限流算法无法保证限流速率,因而无法保证突然激增的流量。比如我们限制一个接口一分钟只能访问10次的话,前半分钟一个请求没有接收,后半分钟接收了10个请求。

4.2 滑动窗口计数器算法

  算的上是固定窗口计数器算法的升级版。滑动窗口计数器算法相比于固定窗口计数器算法的优化在于:它把时间以一定比例分片。例如我们的借口限流每分钟处理60个请求,我们可以把 1 分钟分为60个窗口。每隔1秒移动一次,每个窗口一秒只能处理 不大于 60(请求数)/60(窗口数) 的请求, 如果当前窗口的请求计数总和超过了限制的数量的话就不再处理其他请求。
  很显然:当滑动窗口的格子划分的越多,滑动窗口的滚动就越平滑,限流的统计就会越精确。

4.3 漏桶算法

  我们可以把发请求的动作比作成注水到桶中,我们处理请求的过程可以比喻为漏桶漏水。我们往桶中以任意速率流入水,以一定速率流出水。当水超过桶流量则丢弃,因为桶容量是不变的,保证了整体的速率。如果想要实现这个算法的话也很简单,准备一个队列用来保存请求,然后我们定期从队列中拿请求来执行就好了。

4.4 令牌桶算法

  令牌桶算法也比较简单。和漏桶算法算法一样,我们的主角还是桶。不过现在桶里装的是令牌了,请求在被处理之前需要拿到一个令牌(Token),请求处理完毕之后将这个令牌丢弃(删除)。我们根据限流大小,按照一定的速率往桶里添加令牌。

五、API网关

5.1 什么是API网关

  API网关可以看做系统与外界联通的入口,我们可以在网关进行处理一些非业务逻辑的逻辑,比如权限验证,监控,缓存,请求路由等等。
  API网关的作用如下:

  • 1、RPC协议转成HTTP
      由于在内部开发中我们都是以RPC协议(thrift or dubbo)去做开发,暴露给内部服务,当外部服务需要使用这个接口的时候往往需要将RPC协议转换成HTTP协议。
  • 2、请求路由
      在我们的系统中由于同一个接口新老两套系统都在使用,我们需要根据请求上下文将请求路由到对应的接口。
  • 3、统一鉴权
      对于鉴权操作不涉及到业务逻辑,那么可以在网关层进行处理,不用下层到业务逻辑。
  • 4、统一监控
      由于网关是外部服务的入口,所以我们可以在这里监控我们想要的数据,比如入参出参,链路时间。
  • 5、流量控制,熔断降级
      对于流量控制,熔断降级非业务逻辑可以统一放到网关层。

5.2 网关的设计

  • 1、异步化请求
      对于统一的网关层,如何用少量的机器接入更多的服务,这就需要我们的异步,用来提高更多的吞吐量。对于异步化一般有下面两种策略:
      Tomcat/Jetty+NIO+servlet3:这种策略使用的比较普遍,京东、有赞、Zuul,都选取的是这个策略,这种策略比较适合HTTP。在Servlet3中可以开启异步。
      Netty+NIO:Netty为高并发而生,目前唯品会的网关使用这个策略,在唯品会的技术文章中在相同的情况下Netty是每秒30w+的吞吐量,Tomcat是13w+,可以看出是有一定的差距的,但是Netty需要自己处理HTTP协议,这一块比较麻烦。
      对于网关是HTTP请求场景比较多的情况可以采用Servlet,毕竟有更加成熟的处理HTTP协议。如果更加重视吞吐量那么可以采用Netty。
  • 2、链式处理
      在设计模式中有一个模式叫责任链模式,他的作用是避免请求发送者与接收者耦合在一起,让多个对象都有可能接收请求,将这些对象连接成一条链,并且沿着这条链传递请求,直到有对象处理它为止。通过这种模式将请求的发送者和请求的处理者解耦了。在我们的各个框架中对此模式都有实现,比如servlet里面的filter,springmvc里面的Interceptor。
      在Netflix Zuul中也应用了这种模式,如下图所示:

      这种模式在网关的设计中我们可以借鉴到自己的网关设计:

  preFilters:前置过滤器,用来处理一些公共的业务,比如统一鉴权,统一限流,熔断降级,缓存处理等,并且提供业务方扩展。
  routingFilters: 用来处理一些泛化调用,主要是做协议的转换,请求的路由工作。
  postFilters: 后置过滤器,主要用来做结果的处理,日志打点,记录时间等等。
  errorFilters: 错误过滤器,用来处理调用异常的情况。

  • 3、业务隔离
      如果在提供的自定义FiIlter中进行了某些同步调用,一旦超时频繁那么就会对其他业务产生影响。所以我们需要采用隔离之术,降低业务之间的互相影响。
      1)信号量隔离。信号量隔离只是限制了总的并发数,服务还是主线程进行同步调用。这个隔离如果远程调用超时依然会影响主线程,从而会影响其他业务。因此,如果只是想限制某个服务的总并发调用量或者调用的服务不涉及远程调用的话,可以使用轻量级的信号量来实现。有赞的网关由于没有自定义filter所以选取的是信号量隔离。
      2)线程池隔离。最简单的就是不同业务之间通过不同的线程池进行隔离,就算业务接口出现了问题由于线程池已经进行了隔离那么也不会影响其他业务。在京东的网关实现之中就是采用的线程池隔离,比较重要的业务比如商品或者订单 都是单独的通过线程池去处理。但是由于是统一网关平台,如果业务线众多,大家都觉得自己的业务比较重要需要单独的线程池隔离,如果使用的是Java语言开发的话那么,在Java中线程是比较重的资源比较受限,如果需要隔离的线程池过多不是很适用。如果使用一些其他语言比如Golang进行开发网关的话,线程是比较轻的资源,所以比较适合使用线程池隔离。
      3)集群隔离。如果有某些业务就需要使用隔离但是统一网关又没有线程池隔离那么应该怎么办呢?那么可以使用集群隔离,如果你的某些业务真的很重要那么可以为这一系列业务单独申请一个集群或者多个集群,通过机器之间进行隔离。
  • 4、请求限流
      流量控制可以采用很多开源的实现,比如阿里最近开源的Sentinel和比较成熟的Hystrix。
      一般限流分为集群限流和单机限流:

  利用统一存储保存当前流量的情况,一般可以采用Redis,这个一般会有一些性能损耗。
  单机限流:限流每台机器我们可以直接利用Guava的令牌桶去做,由于没有远程调用性能消耗较小。

  • 5、熔断降级
      可以参照开源的实现Sentinel和Hystrix。
  • 6、泛化调用
      泛化调用指的是一些通信协议的转换,比如将HTTP转换成Thrift。在一些开源的网关中比如Zuul是没有实现的,因为各个公司的内部服务通信协议都不同。比如在唯品会中支持HTTP1、HTTP2以及二进制的协议,然后转化成内部的协议;淘宝的支持HTTPS、HTTP1、HTTP2这些协议都可以转换成HTTP、HSF、Dubbo等协议。
      如何去实现泛化调用呢?由于协议很难自动转换,那么其实每个协议对应的接口需要提供一种映射。简单来说就是把两个协议都能转换成共同语言,从而互相转换。

      一般来说共同语言有三种方式指定:
      1)json:json数据格式比较简单,解析速度快,较轻量级。在Dubbo的生态中有一个HTTP转Dubbo的项目是用JsonRpc做的,将HTTP转化成JsonRpc再转化成Dubbo。比如可以将一个www.baidu.com/id = 1 GET可以映射为json:
{
    "method": "getBaidu"
    "param" : {
        "id" : 1 
     }
}

  2)xml:xml数据比较重,解析比较困难。
  3)自定义描述语言:一般来说这个成本比较高需要自己定义语言来进行描述并进行解析,但是其扩展性,自定义个性化性都是最高。例:spring自定义了一套自己的SPEL表达式语言。

  • 7、管理平台
      上面介绍的都是如何实现一个网关的技术关键。这里需要介绍网关的一个业务关键。有了网关之后,需要一个管理平台如何去对我们上面所描述的技术关键进行配置,包括但不限于下面这些配置:

限流
熔断
缓存
日志
自定义filter
泛化调用

  • 总结
      一个合理的标准网关应该按照如下去实现:

六、一致性算法

6.1 Paxos

  Paxos算法解决的问题是一个分布式系统如何就某个值(决议)达成一致。一个典型的场景是,在一个分布式数据库系统中,如果各节点的初始状态一致,每个节点执行相同的操作序列,那么他们最后能得到一个一致的状态。为保证每个节点执行相同的命令序列,需要在每一条指令上执行一个“一致性算法”以保证每个节点看到的指令一致。Zookeeper使用的zab算法是该算法的一个实现。 在Paxos算法中,有三种角色:Proposer、Acceptor、Learners。

  • Proposer(提议人)
      只要Proposer发的提案被半数以上Acceptor接受,Proposer就认为该提案里的value被选定了。
  • Acceptor
      只要Acceptor接受了某个提案,Acceptor就认为该提案里的value被选定了。
  • Learner
      Acceptor告诉Learner哪个value被选定,Learner就认为那个value被选定。

  Paxos算法分为两个阶段。

  • 阶段一(准Leader确定)
      1、Proposer选择一个提案编号N,然后向半数以上的Acceptor发送编号为N的Prepare请求。
      2、如果一个Acceptor收到一个编号为N的Prepare请求,且N大于该Acceptor已经响应过的所有Prepare请求的编号,那么它就会将它已经接受过的编号最大的提案(如果有的话)作为响应反馈给Proposer,同时该Acceptor承诺不再接受任何编号小于N的提案。
  • 阶段二 ( Leader确认)
      1、如果 Proposer 收到半数以上 Acceptor 对其发出的编号为 N 的 Prepare 请求的响应,那么它就会发送一个针对[N,V]提案的 Accept 请求给半数以上的 Acceptor。注意:V 就是收到的响应中编号最大的提案的 value,如果响应中不包含任何提案,那么 V 就由 Proposer 自己决定。
      2、如果Acceptor收到一个针对编号为N的提案的Accept请求,只要该Acceptor没有对编号大于N的Prepare请求做出过响应,它就接受该提案。

6.2 Zab

  ZAB( ZooKeeper Atomic Broadcast , ZooKeeper原子消息广播协议)协议包括两种基本的模式:崩溃恢复和消息广播。

  1. 当整个服务框架在启动过程中,或是当Leader服务器出现网络中断崩溃退出与重启等异常情况时,ZAB就会进入恢复模式并选举产生新的Leader服务器。
  2. 当选举产生了新的Leader服务器,同时集群中已经有过半的机器与该Leader服务器完成了状态同步之后,ZAB协议就会退出崩溃恢复模式,进入消息广播模式。
  3. 当有新的服务器加入到集群中去,如果此时集群中已经存在一个Leader服务器在负责进行消息广播,那么新加入的服务器会自动进入数据恢复模式,找到Leader服务器,并与其进行数据同步,然后一起参与到消息广播流程中去。

  以上其实大致经历了三个步骤:

  1. 崩溃恢复:主要就是Leader选举过程。
  2. 数据同步: Leader服务器与其他服务器进行数据同步。
  3. 消息广播: Leader服务器将数据发送给其他服务器。

6.3 Raft

  与Paxos不同,Raft 强调的是易懂(Understandability),Raft和Paxos一样只要保证n/2+1节点正常就能够提供服务;raft把算法流程分为三个子问题:选举(Leader election)、日志复制(Log replication)、安全性(Safety)三个子问题。
  Raft把集群中的节点分为三种状态(角色):Leader、Follower、Candidate,理所当然每种状态负责的任务也是不一样的,Raft运行时提供服务的时候只存在Leader与Follower两种状态。

  • Leader(领导者-日志管理)
      负责日志的同步管理,处理来自客户端的请求,与Follower保持着heartBeat的联系。

  • Follower(追随者-日志同步)
      刚启动时所有节点为Follower状态,响应Leader的日志同步请求,响应Candidate的请求,把请求到Follower的事务转发给Leader。

  • Candidate(候选者-负责)
      负责选举投票,Raft刚启动时由一个节点从Follower转为Candidate发起选举,选举出Leader后从Candidate转为Leader状态。

  • Term(任期)
      在Raft中使用了一个可以理解为周期(第几届、任期)的概念,用Term作为一个周期,每个Term都是一个连续递增的编号,每一轮选举都是一个Term周期,在一个Term中只能产生一个Leader;当某节点收到的请求中Term比当前Term小时则拒绝该请求。

  • 选举(Election)
      Raft的选举由定时器来触发,每个节点的选举定时器时间都是不一样的,开始时状态都为Follower某个节点定时器触发选举后Term递增,状态由Follower转为Candidate,向其他节点发起RequestVote RPC请求,这时候有三种可能的情况发生:

  1:该 RequestVote请求接收到 n/2+1(过半数)个节点的投票,从Candidate 转为 Leader,向其他节点发送 heartBeat 以保持 Leader 的正常运转。
  2:在此期间如果收到其他节点发送过来的 AppendEntries RPC 请求,如该节点的 Term 大则当前节点转为 Follower,否则保持 Candidate 拒绝该请求。
  3:Election timeout 发生则 Term 递增,重新发起选举。

  在一个Term期间每个节点只能投票一次,所以当有多个Candidate存在时就会出现每个Candidate发起的选举都存在接收到的投票数都不过半的问题,这时每个Candidate都将Term递增、重启定时器并重新发起选举,由于每个节点中定时器的时间都是随机的,所以就不会多次存在有多个Candidate同时发起投票的问题。
  在Raft中当接收到客户端的日志(事务请求)后先把该日志追加到本地的Log中,然后通过heartbeat把该Entry同步给其他Follower,Follower接收到日志后记录日志然后向Leader发送ACK,当Leader收到大多数(n/2+1)Follower 的ACK信息后将该日志设置为已提交并追加到本地磁盘中,通知客户端并在下个heartbeat中Leader将通知所有的Follower将该日志存储在自己的本地磁盘中。

  • 安全性(Safety )
      安全性是用于保证每个节点都执行相同序列的安全机制如当某个Follower在当前Leader commit Log时变得不可用了,稍后可能该Follower又会被选举为Leader,这时新Leader可能会用新的Log覆盖先前已committed的Log,这就是导致节点执行不同序列;Safety就是用于保证选举出来的 Leader 一定包含先前commited Log的机制;
      选举安全性(Election Safety):每个Term只能选举出一个Leader。
      Leader完整性(Leader Completeness):这里所说的完整性是指Leader日志的完整性,Raft在选举阶段就使用Term的判断用于保证完整性:当请求投票的该Candidate的Term较大或Term相同Index更大则投票,该节点将容易变成Leader。

七、RPC

  http接口是在接口不多、系统与系统交互较少的情况下,解决信息孤岛初期常使用的一种通信手段;优点就是简单、直接、开发方便。利用现成的http协议进行传输。但是如果是一个大型的网站,内部子系统较多、接口非常多的情况下,RPC框架的好处就显示出来了,首先就是长链接,不必每次通信都要像http一样去3次握手什么的,减少了网络开销;其次就是RPC框架一般都有注册中心,有丰富的监控管理;发布、下线接口、动态扩展等,对调用方来说是无感知、统一化的操作。第三个来说就是安全性。 最后就是对于服务化架构、服务化治理而言,RPC框架是一个强力的支撑。
  socket只是一个简单的网络通信方式,只是创建通信双方的通信通道,而要实现rpc的功能,还需要对其进行封装,以实现更多的功能。
  RPC一般配合netty框架、spring自定义注解来编写轻量级框架,其实netty内部是封装了socket的,较新的jdk的IO一般是NIO,即非阻塞IO,在高并发网站中,RPC的优势会很明显。

  • 什么是RPC
      RPC(Remote Procedure Call Protocol)远程过程调用协议,它是一种通过网络从远程计算机程序上请求服务,而不需要了解底层网络技术的协议。简言之,RPC使得程序能够像访问本地系统资源一样,去访问远端系统资源。比较关键的一些方面包括:通讯协议、序列化、资源(接口)描述、服务框架、性能、语言支持等。

      简单的说,RPC就是从一台机器(客户端)上通过参数传递的方式调用另一台机器(服务器)上的一个函数或方法(可以统称为服务)并得到返回的结果。
  • PRC架构组件
      一个基本的RPC架构里面应该至少包含以下4个组件:

  1、客户端(Client):服务调用方(服务消费者)。
  2、客户端存根(Client Stub):存放服务端地址信息,将客户端的请求参数数据信息打包成网络消息,再通过网络传输发送给服务端。
  3、服务端存根(Server Stub):接收客户端发送过来的请求消息并进行解包,然后再调用本地服务进行处理。
  4、服务端(Server):服务的真正提供者。


  具体调用过程:
  1、服务消费者(client客户端)通过调用本地服务的方式调用需要消费的服务;
  2、客户端存根(client stub)接收到调用请求后负责将方法、入参等信息序列化(组装)成能够进行网络传输的消息体;
  3、客户端存根(client stub)找到远程的服务地址,并且将消息通过网络发送给服务端;
  4、服务端存根(server stub)收到消息后进行解码(反序列化操作);
  5、服务端存根(server stub)根据解码结果调用本地的服务进行相关处理;
  6、本地服务执行具体业务逻辑并将处理结果返回给服务端存根(server stub);
  7、服务端存根(server stub)将返回结果重新打包成消息(序列化)并通过网络发送至消费方;
  8、客户端存根(client stub)接收到消息,并进行解码(反序列化);
  9、 服务消费方得到终结果。

  RPC框架的实现目标则是将上图的第2-10步完好地封装起来,也就是把调用、编码/解码的过程给封装起来,让用户感觉上像调用本地服务一样的调用远程服务。

  • RPC和SOA、SOAP、REST
      REST:可以看着是HTTP协议的一种直接应用,默认基于JSON作为传输格式,使用简单, 学习成本低效率高,但是安全性较低。
      SOAP:一种数据交换协议规范,是一种轻量的、简单的、基于XML的协议的规范。而SOAP可以看着是一个重量级的协议,基于XML、SOAP在安全方面是通过使用XML-Security和XML-Signature两个规范组成了WS-Security来实现安全控制的,当前已经得到了各个厂商的支持 。其优点:易用、灵活、跨语言、跨平台。
      SOA:面向服务架构,它可以根据需求通过网络对松散耦合的粗粒度应用组件进行分布式部署、组合和使用。服务层是SOA的基础,可以直接被应用调用,从而有效控制系统中与软件代理交互的人为依赖性。SOA是一种粗粒度、松耦合服务架构,服务之间通过简单、精确定义接口进行通讯,不涉及底层编程接口和通讯模型。SOA可以看作是B/S模型、XML(标准通用标记语言的子集)/Web Service技术之后的自然延伸。
  • RPC框架需要解决的问题
      1、如何确定客户端和服务端之间的通信协议?
      2、如何更高效地进行网络通信?
      3、服务端提供的服务如何暴露给客户端?
      4、客户端如何发现这些暴露的服务?
      5、如何更高效地对请求对象和响应结果进行序列化和反序列化操作?
  • RPC的实现基础
      1、需要有非常高效的网络通信,比如一般选择Netty作为网络通信框架;
      2、需要有比较高效的序列化框架,比如谷歌的Protobuf序列化框架;
      3、可靠的寻址方式(主要是提供服务的发现),比如可以使用Zookeeper来注册服务等等;
      4、如果是带会话(状态)的RPC调用,还需要有会话和状态保持的功能。

7.1 RPC使用的关键技术

  • 1、动态代理
      生成Client Stub(客户端存根)和Server Stub(服务端存根)的时候需要用到 Java动态代理技术,可以使用JDK提供的原生的动态代理机制,也可以使用开源的:CGLib代理,Javassist字节码生成技术。
  • 2、序列化和反序列化
      在网络中,所有的数据都将会被转化为字节进行传送,所以为了能够使参数对象在网络中进行传输,需要对这些参数进行序列化和反序列化操作。
  • 3、NIO通信
      出于并发性能的考虑,传统的阻塞式 IO 显然不太合适,因此我们需要异步的
    IO,即 NIO。Java 提供了 NIO 的解决方案,Java 7 也提供了更优秀的 NIO.2 支持。可以选择Netty或者MINA来解决NIO数据传输的问题。
  • 4、服务注册中心
      可选:Redis、Zookeeper、Consul 、Etcd。一般使用ZooKeeper提供服务注册与发现功能,解决单点故障以及分布式部署的问题(注册中心)。

7.2 主流RPC框架

  • 1、RMI
      利用java.rmi包实现,基于Java远程方法协议(Java Remote Method Protocol) 和java的原生序列化。
  • 2、Hessian
      一个轻量级的remoting onhttp工具,使用简单的方法提供了RMI的功能。 基于HTTP协议,采用二进制编解码。
  • 3、protobuf-rpc-pro
      一个Java类库,提供了基于 Google 的 Protocol Buffers 协议的远程方法调用的框架。基于 Netty 底层的 NIO 技术。支持 TCP 重用/ keep-alive、SSL加密、RPC 调用取消操作、嵌入式日志等功能。
  • 4、Thrift
      一种可伸缩的跨语言服务的软件框架。它拥有功能强大的代码生成引擎,无缝地支持C + +,C#,Java,Python和PHP和Ruby。thrift允许你定义一个描述文件,描述数据类型和服务接口。依据该文件,编译器方便地生成RPC客户端和服务器通信代码。
  • 5、Avro
      在Thrift已经相当流行的情况下推出Avro的目标不仅是提供一套类似Thrift的通讯中间件,更是要建立一个新的,标准性的云计算的数据交换和存储的Protocol。支持HTTP,TCP两种协议。
  • 6、Dubbo
      Dubbo是 阿里巴巴公司开源的一个高性能优秀的服务框架,使得应用可通过高性能的 RPC 实现服务的输出和输入功能,可以和 Spring框架无缝集成。

7.3 RPC过程



  说两台服务器A,B,一个应用部署在A服务器上,想要调用B服务器上应用提供的函数/方法,由于不在一个内存空间,不能直接调用,需要通过网络来表达调用的语义和传达调用的数据。比如说,A服务器想调用B服务器上的一个方法:
User getUserByName(String userName),其过程:

  • 1、建立通信首先要解决通讯的问题:即A机器想要调用B机器,首先得建立起通信连接
      主要是通过在客户端和服务器之间建立TCP连接,远程过程调用的所有交换的数据都在这个连接里传输。连接可以是按需连接,调用结束后就断掉,也可以是长连接,多个远程过程调用共享同一个连接。
      通常这个连接可以是按需连接(需要调用的时候就先建立连接,调用结束后就立马断掉),也可以是长连接(客户端和服务器建立起连接之后保持长期持有,不管此时有无数据包的发送,可以配合心跳检测机制定期检测建立的连接是否存活有效),多个远程过程调用共享同一个连接。
  • 2、服务寻址要解决寻址的问题
      也就是说,A服务器上的应用怎么告诉底层的RPC框架,如何连接到B服务器(如主机或IP地址)以及特定的端口,方法的名称名称是什么。
      通常情况下我们需要提供B机器(主机名或IP地址)以及特定的端口,然后指定调用的方法或者函数的名称以及入参出参等信息,这样才能完成服务的一个调用。
      可靠的寻址方式(主要是提供服务的发现)是RPC的实现基石,比如可以采用Redis或者Zookeeper来注册服务等等。
      从服务提供者的角度看:当服务提供者启动的时候,需要将自己提供的服务注册到指定的注册中心,以便服务消费者能够通过服务注册中心进行查找;当服务提供者由于各种原因致使提供的服务停止时,需要向注册中心注销停止的服务;服务的提供者需要定期向服务注册中心发送心跳检测,服务注册中心如果一段时间未收到来自服务提供者的心跳后,认为该服务提供者已经停止服务,则将该服务从注册中心上去掉。
      从调用者的角度看:服务的调用者启动的时候根据自己订阅的服务向服务注册中心查找服务提供者的地址等信息;当服务调用者消费的服务上线或者下线的时候,注册中心会告知该服务的调用者;服务调用者下线的时候,则取消订阅。
  • 3、网络传输
      序列化:当A机器上的应用发起一个RPC调用时,调用方法和其入参等信息需要通过底层的网络协议如TCP传输到B机器,由于网络协议是基于二进制的,所有我们传输的参数数据都需要先进行序列化(Serialize)或者编组(marshal)成二进制的形式才能在网络中进行传输。然后通过寻址操作和网络传输将序列化或者编组之后的二进制数据发送给B机器。
      反序列化:当B机器接收到A机器的应用发来的请求之后,又需要对接收到的参数等信息进行反序列化操作(序列化的逆操作),即将二进制信息恢复为内存中的表达方式,然后再找到对应的方法(寻址的一部分)进行本地调用(一般是通过生成代理Proxy去调用,通常会有JDK动态代理、CGLIB动态代理、Javassist生成字节码技术等),之后得到调用的返回值。
  • 4、服务调用
      B机器进行本地调用(通过代理Proxy和反射调用)之后得到了返回值,此时还需要再把返回值发送回A机器,同样也需要经过序列化操作,然后再经过网络传输将二进制数据发送回A机器,而当A机器接收到这些返回值之后,则再次进行反序列化操作,恢复为内存中的表达方式, 后再交给A机器上的应用进行相关处理(一般是业务逻辑处理操作)。通常,经过以上四个步骤之后,一次完整的RPC调用算是完成了,另外可能因为网络抖动等原因需要重试等。

八、分布式锁

8.1 Zookeeper

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

  优点:锁安全性高,zk可持久化,且能实时监听获取锁的客户端状态。一旦客户端宕机,则瞬时节点随之消失,zk因而能第一时间释放锁。这也省去了用分布式缓存实现锁的过程中需要加入超时时间判断的这一逻辑。
  缺点:性能开销比较高。因为其需要动态产生、销毁瞬时节点来实现锁功能。所以不太适合直接提供给高并发的场景使用。
  实现:可以直接采用zookeeper第三方库curator即可方便便地实现分布式锁。
  适用场景:对可靠性要求非常高,且并发程度不高的场景下使用。如核心数据的定时全量/增量同步等。

8.2 memcached

  memcached带有add函数,利用add函数的特性即可实现分布式锁。add和set的区别在于:如果多线程并发set,则每个set都会成功,但最后存储的值以最后的set的线程为准。而add的话则相反,add会添加第一个到达的值,并返回true,后续的添加则都会返回false。利用该点即可很轻松地实现分布式锁。
  优点:并发高效。
  缺点:1、memcached采用列入LRU置换策略,所以如果内存不不够,可能导致缓存中的锁信息丢失。2、memcached无法持久化,一旦重启,将导致信息丢失。
  使用场景:高并发场景。需要 1)加上超时时间避免死锁;2)提供足够支撑锁服务的内存空间;3)稳定的集群化管理理。

8.3 redis

  redis分布式锁,可以结合zk分布式锁高度安全和memcached并发场景下效率很好的优点,其实现方式和memcached类似,采用setnx即可实现。需要注意的是,这⾥里的redis也需要设置超时间。以避免死锁。可以利用jedis客户端实现。

原文地址:https://blog.csdn.net/m0_37741420/article/details/134918253

本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。

如若转载,请注明出处:http://www.7code.cn/show_60420.html

如若内容造成侵权/违法违规/事实不符,请联系代码007邮箱:suwngjj01@126.com进行投诉反馈,一经查实,立即删除!

发表回复

您的邮箱地址不会被公开。 必填项已用 * 标注