Java工程师面试题-分布式

推荐先阅读Java工程师面试题

什么是CAP原则?

参考答案

CAP定理又称CAP原则,指的是在一个分布式系统中,Consistency(一致性)、 Availability(可用性)、Partition tolerance(分区容错性),最多只能同时三个特性中的两个,三者不可兼得。

  • Consistency (一致性):

    “all nodes see the same data at the same time”,即更新操作成功并返回客户端后,所有节点在同一时间的数据完全一致,这就是分布式的一致性。一致性的问题在并发系统中不可避免,对于客户端来说,一致性指的是并发访问时更新过的数据如何获取的问题。从服务端来看,则是更新如何复制分布到整个系统,以保证数据最终一致。

  • Availability (可用性):

    可用性指“Reads and writes always succeed”,即服务一直可用,而且是正常响应时间。好的可用性主要是指系统能够很好的为用户服务,不出现用户操作失败或者访问超时等用户体验不好的情况。

  • Partition Tolerance (分区容错性):

    即分布式系统在遇到某节点或网络分区故障的时候,仍然能够对外提供满足一致性和可用性的服务。分区容错性要求能够使应用虽然是一个分布式系统,而看上去却好像是在一个可以运转正常的整体。比如现在的分布式系统中有某一个或者几个机器宕掉了,其他剩下的机器还能够正常运转满足系统需求,对于用户而言并没有什么体验上的影响。

说一说你对高并发的理解

参考答案

\1. 如何理解高并发?

高并发意味着大流量,需要运用技术手段抵抗流量的冲击,这些手段好比操作流量,能让流量更平稳地被系统所处理,带给用户更好的体验。我们常见的高并发场景有:淘宝的双11、春运时的抢票、微博大V的热点新闻等。除了这些典型事情,每秒几十万请求的秒杀系统、每天千万级的订单系统、每天亿级日活的信息流系统等,都可以归为高并发。很显然,上面谈到的高并发场景,并发量各不相同,那到底多大并发才算高并发呢?

  1. 不能只看数字,要看具体的业务场景。不能说10W QPS的秒杀是高并发,而1W QPS的信息流就不是高并发。信息流场景涉及复杂的推荐模型和各种人工策略,它的业务逻辑可能比秒杀场景复杂10倍不止。因此,不在同一个维度,没有任何比较意义。
  2. 业务都是从0到1做起来的,并发量和QPS只是参考指标,最重要的是:在业务量逐渐变成原来的10倍、100倍的过程中,你是否用到了高并发的处理方法去演进你的系统,从架构设计、编码实现、甚至产品方案等维度去预防和解决高并发引起的问题?而不是一味的升级硬件、加机器做水平扩展。

此外,各个高并发场景的业务特点完全不同:有读多写少的信息流场景、有读多写多的交易场景,那是否有通用的技术方案解决不同场景的高并发问题呢?我觉得大的思路可以借鉴,别人的方案也可以参考,但是真正落地过程中,细节上还会有无数的坑。另外,由于软硬件环境、技术栈、以及产品逻辑都没法做到完全一致,这些都会导致同样的业务场景,就算用相同的技术方案也会面临不同的问题,这些坑还得一个个趟。

\2. 高并发系统设计的目标是什么?

先搞清楚高并发系统设计的目标,在此基础上再讨论设计方案和实践经验才有意义和针对性。

2.1 宏观目标

高并发绝不意味着只追求高性能,这是很多人片面的理解。从宏观角度看,高并发系统设计的目标有三个:高性能、高可用,以及高可扩展。

  1. 高性能:性能体现了系统的并行处理能力,在有限的硬件投入下,提高性能意味着节省成本。同时,性能也反映了用户体验,响应时间分别是100毫秒和1秒,给用户的感受是完全不同的。
  2. 高可用:表示系统可以正常服务的时间。一个全年不停机、无故障;另一个隔三差五出线上事故、宕机,用户肯定选择前者。另外,如果系统只能做到90%可用,也会大大拖累业务。
  3. 高扩展:表示系统的扩展能力,流量高峰时能否在短时间内完成扩容,更平稳地承接峰值流量,比如双11活动、明星离婚等热点事件。

这3个目标是需要通盘考虑的,因为它们互相关联、甚至也会相互影响。比如说:考虑系统的扩展能力,你会将服务设计成无状态的,这种集群设计保证了高扩展性,其实也间接提升了系统的性能和可用性。再比如说:为了保证可用性,通常会对服务接口进行超时设置,以防大量线程阻塞在慢请求上造成系统雪崩,那超时时间设置成多少合理呢?一般,我们会参考依赖服务的性能表现进行设置。

2.2 微观目标

再从微观角度来看,高性能、高可用和高扩展又有哪些具体的指标来衡量?为什么会选择这些指标呢?

2.2.1 性能指标

通过性能指标可以度量目前存在的性能问题,同时作为性能优化的评估依据。一般来说,会采用一段时间内的接口响应时间作为指标。

  1. 平均响应时间:最常用,但是缺陷很明显,对于慢请求不敏感。比如1万次请求,其中9900次是1ms,100次是100ms,则平均响应时间为1.99ms,虽然平均耗时仅增加了0.99ms,但是1%请求的响应时间已经增加了100倍。

  2. TP90、TP99等分位值:将响应时间按照从小到大排序,TP90表示排在第90分位的响应时间, 分位值越大,对慢请求越敏感。

    img

  3. 吞吐量:和响应时间呈反比,比如响应时间是1ms,则吞吐量为每秒1000次。

通常,设定性能目标时会兼顾吞吐量和响应时间,比如这样表述:在每秒1万次请求下,AVG控制在50ms以下,TP99控制在100ms以下。对于高并发系统,AVG和TP分位值必须同时要考虑。另外,从用户体验角度来看,200毫秒被认为是第一个分界点,用户感觉不到延迟,1秒是第二个分界点,用户能感受到延迟,但是可以接受。因此,对于一个健康的高并发系统,TP99应该控制在200毫秒以内,TP999或者TP9999应该控制在1秒以内。

2.2.2 可用性指标

高可用性是指系统具有较高的无故障运行能力,可用性 = 平均故障时间 / 系统总运行时间,一般使用几个9来描述系统的可用性。

img

对于高并发系统来说,最基本的要求是:保证3个9或者4个9。原因很简单,如果你只能做到2个9,意味着有1%的故障时间,像一些大公司每年动辄千亿以上的GMV或者收入,1%就是10亿级别的业务影响。

2.2.3 可扩展性指标

面对突发流量,不可能临时改造架构,最快的方式就是增加机器来线性提高系统的处理能力。

对于业务集群或者基础组件来说,扩展性 = 性能提升比例 / 机器增加比例,理想的扩展能力是:资源增加几倍,性能提升几倍。通常来说,扩展能力要维持在70%以上。

但是从高并发系统的整体架构角度来看,扩展的目标不仅仅是把服务设计成无状态就行了,因为当流量增加10倍,业务服务可以快速扩容10倍,但是数据库可能就成为了新的瓶颈。

像MySQL这种有状态的存储服务通常是扩展的技术难点,如果架构上没提前做好规划(垂直和水平拆分),就会涉及到大量数据的迁移。

因此,高扩展性需要考虑:服务集群、数据库、缓存和消息队列等中间件、负载均衡、带宽、依赖的第三方等,当并发达到某一个量级后,上述每个因素都可能成为扩展的瓶颈点。

\3. 高并发的实践方案有哪些?

了解了高并发设计的3大目标后,再系统性总结下高并发的设计方案,会从以下两部分展开:先总结下通用的设计方法,然后再围绕高性能、高可用、高扩展分别给出具体的实践方案。

3.1 通用的设计方法

通用的设计方法主要是从「纵向」和「横向」两个维度出发,俗称高并发处理的两板斧:纵向扩展和横向扩展。

3.1.1 纵向扩展(scale-up)

它的目标是提升单机的处理能力,方案又包括:

  1. 提升单机的硬件性能:通过增加内存、CPU核数、存储容量、或者将磁盘升级成SSD等堆硬件的方式来提升。
  2. 提升单机的软件性能:使用缓存减少IO次数,使用并发或者异步的方式增加吞吐量。

3.1.2 横向扩展(scale-out)

因为单机性能总会存在极限,所以最终还需要引入横向扩展,通过集群部署以进一步提高并发处理能力,又包括以下2个方向:

  1. 做好分层架构:这是横向扩展的提前,因为高并发系统往往业务复杂,通过分层处理可以简化复杂问题,更容易做到横向扩展。

    img

    上面这种图是互联网最常见的分层架构,当然真实的高并发系统架构会在此基础上进一步完善。比如会做动静分离并引入CDN,反向代理层可以是LVS+Nginx,Web层可以是统一的API网关,业务服务层可进一步按垂直业务做微服务化,存储层可以是各种异构数据库。

  2. 各层进行水平扩展:无状态水平扩容,有状态做分片路由。业务集群通常能设计成无状态的,而数据库和缓存往往是有状态的,因此需要设计分区键做好存储分片,当然也可以通过主从同步、读写分离的方案提升读性能。

3.2 具体的实践方案

下面再结合我的个人经验,针对高性能、高可用、高扩展3个方面,总结下可落地的实践方案。

3.2.1 高性能的实践方案

  1. 集群部署,通过负载均衡减轻单机压力。
  2. 多级缓存,包括静态数据使用CDN、本地缓存、分布式缓存等,以及对缓存场景中的热点key、缓存穿透、缓存并发、数据一致性等问题的处理。
  3. 分库分表和索引优化,以及借助搜索引擎解决复杂查询问题。
  4. 考虑NoSQL数据库的使用,比如HBase、TiDB等,但是团队必须熟悉这些组件,且有较强的运维能力。
  5. 异步化,将次要流程通过多线程、MQ、甚至延时任务进行异步处理。
  6. 限流,需要先考虑业务是否允许限流(比如秒杀场景是允许的),包括前端限流、Nginx接入层的限流、服务端的限流。
  7. 对流量进行削峰填谷,通过MQ承接流量。
  8. 并发处理,通过多线程将串行逻辑并行化。
  9. 预计算,比如抢红包场景,可以提前计算好红包金额缓存起来,发红包时直接使用即可。
  10. 缓存预热,通过异步任务提前预热数据到本地缓存或者分布式缓存中。
  11. 减少IO次数,比如数据库和缓存的批量读写、RPC的批量接口支持、或者通过冗余数据的方式干掉RPC调用。
  12. 减少IO时的数据包大小,包括采用轻量级的通信协议、合适的数据结构、去掉接口中的多余字段、减少缓存key的大小、压缩缓存value等。
  13. 程序逻辑优化,比如将大概率阻断执行流程的判断逻辑前置、For循环的计算逻辑优化,或者采用更高效的算法。
  14. 各种池化技术的使用和池大小的设置,包括HTTP请求池、线程池(考虑CPU密集型还是IO密集型设置核心参数)、数据库和Redis连接池等。
  15. JVM优化,包括新生代和老年代的大小、GC算法的选择等,尽可能减少GC频率和耗时。
  16. 锁选择,读多写少的场景用乐观锁,或者考虑通过分段锁的方式减少锁冲突。

上述方案无外乎从计算和 IO 两个维度考虑所有可能的优化点,需要有配套的监控系统实时了解当前的性能表现,并支撑你进行性能瓶颈分析,然后再遵循二八原则,抓主要矛盾进行优化。

3.2.2 高可用的实践方案

  1. 对等节点的故障转移,Nginx和服务治理框架均支持一个节点失败后访问另一个节点。
  2. 非对等节点的故障转移,通过心跳检测并实施主备切换(比如redis的哨兵模式或者集群模式、MySQL的主从切换等)。
  3. 接口层面的超时设置、重试策略和幂等设计。
  4. 降级处理:保证核心服务,牺牲非核心服务,必要时进行熔断;或者核心链路出问题时,有备选链路。
  5. 限流处理:对超过系统处理能力的请求直接拒绝或者返回错误码。
  6. MQ场景的消息可靠性保证,包括producer端的重试机制、broker侧的持久化、consumer端的ack机制等。
  7. 灰度发布,能支持按机器维度进行小流量部署,观察系统日志和业务指标,等运行平稳后再推全量。
  8. 监控报警:全方位的监控体系,包括最基础的CPU、内存、磁盘、网络的监控,以及Web服务器、JVM、数据库、各类中间件的监控和业务指标的监控。
  9. 灾备演练:类似当前的“混沌工程”,对系统进行一些破坏性手段,观察局部故障是否会引起可用性问题。

高可用的方案主要从冗余、取舍、系统运维3个方向考虑,同时需要有配套的值班机制和故障处理流程,当出现线上问题时,可及时跟进处理。

3.2.3 高扩展的实践方案

  1. 合理的分层架构:比如上面谈到的互联网最常见的分层架构,另外还能进一步按照数据访问层、业务逻辑层对微服务做更细粒度的分层(但是需要评估性能,会存在网络多一跳的情况)。
  2. 存储层的拆分:按照业务维度做垂直拆分、按照数据特征维度进一步做水平拆分(分库分表)。
  3. 业务层的拆分:最常见的是按照业务维度拆(比如电商场景的商品服务、订单服务等),也可以按照核心接口和非核心接口拆,还可以按照请求源拆(比如To C和To B,APP和H5)。

如何实现分布式存储?

参考答案

分布式存储是一个大的概念,其包含的种类繁多,除了传统意义上的分布式文件系统、分布式块存储和分布式对象存储外,还包括分布式数据库和分布式缓存等。下面我们探讨一下分布式文件系统等传统意义上的存储架构,实现这种存储架构主要有三种通用的形式,其它存储架构也基本上基于上述架构,并没有太大的变化。

中间控制节点架构(HDFS)

分布式存储最早是由谷歌提出的,其目的是通过廉价的服务器来提供使用与大规模,高并发场景下的Web访问问题。下图是谷歌分布式存储(HDFS)的简化的模型。在该系统的整个架构中将服务器分为两种类型,一种名为namenode,这种类型的节点负责管理管理数据(元数据),另外一种名为datanode,这种类型的服务器负责实际数据的管理。

img

上图分布式存储中,如果客户端需要从某个文件读取数据,首先从namenode获取该文件的位置(具体在哪个datanode),然后从该位置获取具体的数据。在该架构中namenode通常是主备部署,而datanode则是由大量节点构成一个集群。由于元数据的访问频度和访问量相对数据都要小很多,因此namenode通常不会成为性能瓶颈,而datanode集群可以分散客户端的请求。因此,通过这种分布式存储架构可以通过横向扩展datanode的数量来增加承载能力,也即实现了动态横向扩展的能力。

完全无中心架构—计算模式(Ceph)

下图是Ceph存储系统的架构,在该架构中与HDFS不同的地方在于该架构中没有中心节点。客户端是通过一个设备映射关系计算出来其写入数据的位置,这样客户端可以直接与存储节点通信,从而避免中心节点的性能瓶颈。

img

在Ceph存储系统架构中核心组件有Mon服务、OSD服务和MDS服务等。对于块存储类型只需要Mon服务、OSD服务和客户端的软件即可。其中Mon服务用于维护存储系统的硬件逻辑关系,主要是服务器和硬盘等在线信息。Mon服务通过集群的方式保证其服务的可用性。OSD服务用于实现对磁盘的管理,实现真正的数据读写,通常一个磁盘对应一个OSD服务。 客户端访问存储的大致流程是,客户端在启动后会首先从Mon服务拉取存储资源布局信息,然后根据该布局信息和写入数据的名称等信息计算出期望数据的位置(包含具体的物理服务器信息和磁盘信息),然后该位置信息直接通信,读取或者写入数据。

完全无中心架构—一致性哈希(Swift)

与Ceph的通过计算方式获得数据位置的方式不同,另外一种方式是通过一致性哈希的方式获得数据位置。一致性哈希的方式就是将设备做成一个哈希环,然后根据数据名称计算出的哈希值映射到哈希环的某个位置,从而实现数据的定位。

img

上图是一致性哈希的基本原理,为了绘制简单,本文以一个服务器上的一个磁盘为例进行介绍。为了保证数据分配的均匀性及出现设备故障时数据迁移的均匀性,一致性哈希将磁盘划分为比较多的虚拟分区,每个虚拟分区是哈希环上的一个节点。整个环是一个从0到32位最大值的一个区间,并且首尾相接。当计算出数据(或者数据名称)的哈希值后,必然落到哈希环的某个区间,然后以顺时针,必然能够找到一个节点。那么,这个节点就是存储数据的位置。 Swift存储的整个数据定位算法就是基于上述一致性哈希实现的。在Swift对象存储中,通过账户名/容器名/对象名三个名称组成一个位置的标识,通过该唯一标识可以计算出一个整型数来。而在存储设备方面,Swift构建一个虚拟分区表,表的大小在创建集群是确定(通常为几十万),这个表其实就是一个数组。这样,根据上面计算的整数值,以及这个数组,通过一致性哈希算法就可以确定该整数在数组的位置。而数组中的每项内容是数据3个副本(也可以是其它副本数量)的设备信息(包含服务器和磁盘等信息)。也就是经过上述计算,可以确定一个数据存储的具体位置。这样,Swift就可以将请求重新定向到该设备进行处理。

img

上述计算过程是在一个名为Proxy的服务中进行的,该服务可以集群化部署。因此可以分摊请求的负载,不会成为性能瓶颈。

说一说你对分布式事务的了解

参考答案

分布式事务就是指事务的参与者、支持事务的服务器、资源服务器以及事务管理器分别位于不同的分布式系统的不同节点之上。简单的说,就是一次大的操作由不同的小操作组成,这些小的操作分布在不同的服务器上,且属于不同的应用,分布式事务需要保证这些小操作要么全部成功,要么全部失败。本质上来说,分布式事务就是为了保证不同数据库的数据一致性。

要实现分布式事务,有如下几种常见的解决方案:

2PC

说到2PC就不得不聊数据库分布式事务中的 XA Transactions。

img

如上图,在XA协议中分为两阶段:

第一阶段:事务管理器要求每个涉及到事务的数据库预提交(precommit)此操作,并反映是否可以提交。

第二阶段:事务协调器要求每个数据库提交数据,或者回滚数据。

优点:

  • 尽量保证了数据的强一致,实现成本较低,在各大主流数据库都有自己实现,对于MySQL是从5.5开始支持。

缺点:

  • 单点问题:事务管理器在整个流程中扮演的角色很关键,如果其宕机,比如在第一阶段已经完成,在第二阶段正准备提交的时候事务管理器宕机,资源管理器就会一直阻塞,导致数据库无法使用。
  • 同步阻塞:在准备就绪之后,资源管理器中的资源一直处于阻塞,直到提交完成,释放资源。
  • 数据不一致:两阶段提交协议虽然为分布式数据强一致性所设计,但仍然存在数据不一致性的可能,比如在第二阶段中,假设协调者发出了事务commit的通知,但是因为网络问题该通知仅被一部分参与者所收到并执行了commit操作,其余的参与者则因为没有收到通知一直处于阻塞状态,这时候就产生了数据的不一致性。

总的来说,XA协议比较简单,成本较低,但是其单点问题,以及不能支持高并发依然是其最大的弱点。

TCC

关于TCC(Try-Confirm-Cancel)的概念,最早是由Pat Helland于2007年发表的一篇名为《Life beyond Distributed Transactions:an Apostate’s Opinion》的论文提出。 TCC事务机制相比于上面介绍的XA,解决了其几个缺点:

  1. 解决了协调者单点,由主业务方发起并完成这个业务活动。业务活动管理器也变成多点,引入集群。
  2. 同步阻塞:引入超时,超时后进行补偿,并且不会锁定整个资源,将资源转换为业务逻辑形式,粒度变小。
  3. 数据一致性,有了补偿机制之后,由业务活动管理器控制一致性。

对于TCC做出如下的解释:

  • Try阶段:尝试执行,完成所有业务检查(一致性),预留必须业务资源(准隔离性)。
  • Confirm阶段:确认执行真正执行业务,不作任何业务检查,只使用Try阶段预留的业务资源,Confirm操作满足幂等性。要求具备幂等设计,Confirm失败后需要进行重试。
  • Cancel阶段:取消执行,释放Try阶段预留的业务资源 Cancel操作满足幂等性Cancel阶段的异常和Confirm阶段异常处理方案基本上一致。

举个简单的例子如果你用100元买了一瓶水,在Try阶段你需要向你的钱包检查是否够100元并锁住这100元,水也是一样的。如果有一个失败,则进行cancel(释放这100元和这一瓶水),如果cancel失败不论什么失败都进行重试cancel,所以需要保持幂等。如果都成功,则进行confirm,确认这100元扣,和这一瓶水被卖,如果confirm失败无论什么失败则重试(会依靠活动日志进行重试)。

对于TCC来说适合以下场景:

  • 强隔离性,严格一致性要求的活动业务。
  • 执行时间较短的业务。

本地消息表

本地消息表这个方案最初是ebay提出的,此方案的核心是将需要分布式处理的任务通过消息日志的方式来异步执行。消息日志可以存储到本地文本、数据库或消息队列,再通过业务规则自动或人工发起重试。人工重试更多的是应用于支付场景,通过对账系统对事后问题的处理。

img

对于本地消息队列来说核心是把大事务转变为小事务,还是举上面用100元去买一瓶水的例子:

  1. 当你扣钱的时候,你需要在你扣钱的服务器上新增加一个本地消息表,你需要把你扣钱和写入减去水的库存到本地消息表放入同一个事务(依靠数据库本地事务保证一致性。
  2. 这个时候有个定时任务去轮询这个本地事务表,把没有发送的消息,扔给商品库存服务器,叫他减去水的库存,到达商品服务器之后这个时候得先写入这个服务器的事务表,然后进行扣减,扣减成功后,更新事务表中的状态。
  3. 商品服务器通过定时任务扫描消息表或者直接通知扣钱服务器,扣钱服务器本地消息表进行状态更新。
  4. 针对一些异常情况,定时扫描未成功处理的消息,进行重新发送,在商品服务器接到消息之后,首先判断是否是重复的,如果已经接收,在判断是否执行,如果执行在马上又进行通知事务,如果未执行,需要重新执行需要由业务保证幂等,也就是不会多扣一瓶水。

本地消息队列是BASE理论,是最终一致模型,适用于对一致性要求不高的场景,实现这个模型时需要注意重试的幂等。

MQ事务

在RocketMQ中实现了分布式事务,实际上其实是对本地消息表的一个封装,将本地消息表移动到了MQ内部,下面简单介绍一下MQ事务。

img

基本流程如下:

第一阶段Prepared消息,会拿到消息的地址。

第二阶段执行本地事务。

第三阶段通过第一阶段拿到的地址去访问消息,并修改状态。消息接受者就能使用这个消息。

如果确认消息失败,在RocketMq Broker中提供了定时扫描没有更新状态的消息,如果有消息没有得到确认,会向消息发送者发送消息,来判断是否提交,在rocketmq中是以listener的形式给发送者,用来处理。

img

如果消费超时,则需要一直重试,消息接收端需要保证幂等。如果消息消费失败,这个就需要人工进行处理,因为这个概率较低,如果为了这种小概率时间而设计这个复杂的流程反而得不偿失。

Saga事务

Saga是30年前一篇数据库伦理提到的一个概念。其核心思想是将长事务拆分为多个本地短事务,由Saga事务协调器协调,如果正常结束那就正常完成,如果某个步骤失败,则根据相反顺序一次调用补偿操作。 Saga的组成:

每个Saga由一系列sub-transaction Ti 组成 每个Ti 都有对应的补偿动作Ci,补偿动作用于撤销Ti造成的结果,这里的每个T,都是一个本地事务。 可以看到,和TCC相比,Saga没有“预留 try”动作,它的Ti就是直接提交到库。

Saga的执行顺序有两种:

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

Saga定义了两种恢复策略:

向后恢复,即上面提到的第二种执行顺序,其中j是发生错误的sub-transaction,这种做法的效果是撤销掉之前所有成功的sub-transation,使得整个Saga的执行结果撤销。 向前恢复,适用于必须要成功的场景,执行顺序是类似于这样的:T1, T2, …, Tj(失败), Tj(重试),…, Tn,其中j是发生错误的sub-transaction。该情况下不需要Ci。这里要注意的是,在saga模式中不能保证隔离性,因为没有锁住资源,其他事务依然可以覆盖或者影响当前事务。

还是拿100元买一瓶水的例子来说,这里定义:

  • T1=扣100元,T2=给用户加一瓶水,T3=减库存一瓶水;
  • C1=加100元,C2=给用户减一瓶水,C3=给库存加一瓶水;

我们一次进行T1,T2,T3如果发生问题,就执行发生问题的C操作的反向。 上面说到的隔离性的问题会出现在,如果执行到T3这个时候需要执行回滚,但是这个用户已经把水喝了(另外一个事务),回滚的时候就会发现,无法给用户减一瓶水了。这就是事务之间没有隔离性的问题。

可以看见saga模式没有隔离性的影响还是较大,可以参照华为的解决方案:从业务层面入手加入一 Session 以及锁的机制来保证能够串行化操作资源。也可以在业务层面通过预先冻结资金的方式隔离这部分资源, 最后在业务操作的过程中可以通过及时读取当前状态的方式获取到最新的更新。

分布式系统如何保证最终一致性?

参考答案

国际开放标准组织Open Group定义了DTS(分布式事务处理模型),模型中包含4种角色:应用程序、事务管理器、资源管理器和通信资源管理器。事务管理器是统管全局的管理者,资源管理器和通信资源管理器是事务的参与者。

JEE(Java企业版)规范也包含此分布式事务处理模型的规范,并在所有AppServer中进行实现。在JEE规范中定义了TX协议和XA协议,TX协议定义应用程序与事务管理器之间的接口,XA协议则定义事务管理器与资源管理器之间的接口。在过去使用 AppServer如WebSphere、 WebLogic、JBoss等配置数据源时会看见类似XADatasource的数据源,这就是实现了分布式事务处理模型的关系型数据库的数据源。在企业级开发JEE中,关系型数据库、JMS服务扮演资源管理器的角色,而EJB容器扮演事务管理器的角色。

下面我们介绍两阶段提交协议、三阶段提交协议及阿里巴巴提出的 TCC,它们都是根据DTS这一思想演变而来的。

两阶段提交协议

两阶段提交协议把分布式事务分为两个阶段,一个是准备阶段,另一个是提交阶段。准备阶段和提交阶段都是由事务管理器发起的,为了接下来讲解方便,我们将事务管理器称为协调者,将资源管理器称为参与者。

两阶段提交协议的流程如下所述。

  • 准备阶段:协调者向参与者发起指令,参与者评估自己的状态,如果参与者评估指令可以完成,则会写redo或者undo日志(Write-Ahead Log的一种),然后锁定资源,执行操作,但是并不提交。

  • 提交阶段:如果每个参与者明确返回准备成功,也就是预留资源和执行操作成功,则协调者向参与者发起提交指令,参与者提交资源变更的事务,释放锁定的资源;如果任何一个参与者明确返回准备失败,也就是预留资源或者执行操作失败,则协调者向参与者发起中止指令,参与者取消已经变更的事务,执行undo日志,释放锁定的资源。两阶段提交协议的成功场景如下图所示。

    img

    我们看到两阶段提交协议在准备阶段锁定资源,这是一个重量级的操作,能保证强一致性,但是实现起来复杂、成本较高、不够灵活,更重要的是它有如下致命的问题。

  • 阻塞:从上面的描述来看,对于任何一次指令都必须收到明确的响应,才会继续进行下一步,否则处于阻塞状态,占用的资源被一直锁定,不会被释放。

  • 单点故障:如果协调者宕机,参与者没有协调者指挥,则会一直阻塞,尽管可以通过选举新的协调者替代原有协调者,但是如果协调者在发送一个提交指令后宕机,而提交指令仅仅被一个参与者接收,并且参与者接收后也宕机,则新上任的协调者无法处理这种情况。

  • 脑裂:协调者发送提交指令,有的参与者接收到并执行了事务,有的参与者没有接收到事务就没有执行事务,多个参与者之间是不一致的。

上面的所有问题虽然很少发生,但都需要人工干预处理,没有自动化的解决方案,因此两阶段提交协议在正常情况下能保证系统的强一致性,但是在出现异常的情况下,当前处理的操作处于错误状态,需要管理员人工干预解决,因此可用性不够好,这也符合CAP协议的一致性和可用性不能兼得的原理。

三阶段提交协议

三阶段提交协议是两阶段提交协议的改进版本。它通过超时机制解决了阻塞的问题,并且把两个阶段增加为以下三个阶段。

  • 询问阶段:协调者询问参与者是否可以完成指令,协调者只需要回答是或不是,而不需要做真正的操作,这个阶段超时会导致中止。
  • 准备阶段:如果在询问阶段所有参与者都返回可以执行操作,则协调者向参与者发送预执行请求,然后参与者写redo和undo日志,执行操作但是不提交操作;如果在询问阶段任意参与者返回不能执行操作的结果,则协调者向参与者发送中止请求,这里的逻辑与两阶段提交协议的准备阶段是相似的。
  • 提交阶段:如果每个参与者在准备阶段返回准备成功,也就是说预留资源和执行操作成功,则协调者向参与者发起提交指令,参与者提交资源变更的事务,释放锁定的资源;如果任何参与者返回准备失败,也就是说预留资源或者执行操作失败,则协调者向参与者发起中止指令,参与者取消已经变更的事务,执行 undo 日志,释放锁定的资源,这里的逻辑与两阶段提交协议的提交阶段一致。

三阶段提交协议的成功场景示意图如下图所示:

img

三阶段提交协议与两阶段提交协议主要有以下两个不同点:

  • 增加了一个询问阶段,询问阶段可以确保尽可能早地发现无法执行操作而需要中止的行为,但是它并不能发现所有这种行为,只会减少这种情况的发生。
  • 在准备阶段以后,协调者和参与者执行的任务中都增加了超时,一旦超时,则协调者和参与者都会继续提交事务,默认为成功,这也是根据概率统计超时后默认为成功的正确性最大。

三阶段提交协议与两阶段提交协议相比,具有如上优点,但是一旦发生超时,系统仍然会发生不一致,只不过这种情况很少见,好处是至少不会阻塞和永远锁定资源。

TCC

签名讲解了两阶段提交协议和三阶段提交协议,实际上它们能解决常见的分布式事务的问题,但是遇到极端情况时,系统会产生阻塞或者不一致的问题,需要运营或者技术人员解决。两阶段及三阶段方案中都包含多个参与者、多个阶段实现一个事务,实现复杂,性能也是一个很大的问题,因此,在互联网的高并发系统中,鲜有使用两阶段提交和三阶段提交协议的场景。

后来有人提出了TCC协议,TCC协议将一个任务拆分成Try、Confirm、Cancel三个步骤,正常的流程会先执行Try,如果执行没有问题,则再执行Confirm,如果执行过程中出了问题,则执行操作的逆操作Cancel。从正常的流程上讲,这仍然是一个两阶段提交协议,但是在执行出现问题时有一定的自我修复能力,如果任何参与者出现了问题,则协调者通过执行操作的逆操作来Cancel之前的操作,达到最终的一致状态。

可以看出,从时序上来说,如果遇到极端情况,则TCC会有很多问题,例如,如果在取消时一些参与者收到指令,而另一些参与者没有收到指令,则整个系统仍然是不一致的。对于这种复杂的情况,系统首先会通过补偿的方式尝试自动修复,如果系统无法修复,则必须由人工参与解决。

从TCC的逻辑上看,可以说TCC是简化版的三阶段提交协议,解决了两阶段提交协议的阻塞问题,但是没有解决极端情况下会出现不一致和脑裂的问题。然而,TCC通过自动化补偿手段,将需要人工处理的不一致情况降到最少,也是一种非常有用的解决方案。某著名的互联网公司在内部的一些中间件上实现了TCC模式。

我们给出一个使用TCC的实际案例,在秒杀的场景中,用户发起下订单请求,应用层先查询库存,确认商品库存还有余量,则锁定库存,此时订单状态为待支付,然后指引用户去支付,由于某种原因用户支付失败或者支付超时,则系统会自动将锁定的库存解锁以供其他用户秒杀。

TCC协议的使用场景如下图所示:

img

在大规模、高并发服务化系统中,一个功能被拆分成多个具有单一功能的子功能,一个流程会有多个系统的多个单一功能的服务组合实现,如果使用两阶段提交协议和三阶段提交协议,则确实能解决系统间的一致性问题。除了这两个协议的自身问题,其实现也比较复杂、成本比较高,最重要的是性能不好,相比来看,TCC协议更简单且更容易实现,但是TCC协议由于每个事务都需要执行Try,再执行Confirm,略显臃肿,因此,现实系统的底线是仅仅需要达到最终一致性,而不需要实现专业的、复杂的一致性协议。实现最终一致性有一些非常有效、简单的模式,下面就介绍这些模式及其应用场景。

查询模式

任何服务操作都需要提供一个查询接口,用来向外部输出操作执行的状态。服务操作的使用方可以通过查询接口得知服务操作执行的状态,然后根据不同的状态来做不同的处理操作。

为了能够实现查询,每个服务操作都需要有唯一的流水号标识,也可使用此次服务操作对应的资源ID来标识,例如:请求流水号、订单号等。首先,单笔查询操作是必须提供的,也鼓励使用单笔订单查询,这是因为每次调用需要占用的负载是可控的。批量查询则根据需要来提供,如果使用了批量查询,则需要有合理的分页机制,并且必须限制分页的大小,以及对批量查询的吞吐量有容量评估、熔断、隔离和限流等措施。

补偿模式

有了上面的查询模式,在任何情况下,我们都能得知具体的操作所处的状态,如果整个操作都处于不正常的状态,则我们需要修正操作中有问题的子操作,这可能需要重新执行未完成的子操作,后者取消已经完成的子操作,通过修复使整个分布式系统达到一致。为了让系统最终达到一致状态而做的努力都叫作补偿。

对于服务化系统中同步调用的操作,若业务操作发起方还没有收到业务操作执行方的明确返回或者调用超时,业务发起方需要及时地调用业务执行方来获得操作执行的状态,这里使用在前面学习的查询模式。在获得业务操作执行方的状态后,如果业务执行方已经完成预设工作,则业务发起方向业务的使用方返回成功;如果业务操作执行方的状态为失败或者未知,则会立即告诉业务使用方失败,也叫作快速失败策略,然后调用业务操作的逆向操作,保证操作不被执行或者回滚已经执行的操作,让业务使用方、业务操作发起方和业务操作执行方最终达到一致状态。

异步确保模式

异步确保模式是补偿模式的一个典型案例,经常应用到使用方对响应时间要求不太高的场景中,通常把这类操作从主流程中摘除,通过异步的方式进行处理,处理后把结果通过通知系统通知给使用方。这个方案的最大好处是能够对高并发流量进行消峰,例如:电商系统中的物流、配送,以及支付系统中的计费、入账等。

在实践中将要执行的异步操作封装后持久入库,然后通过定时捞取未完成的任务进行补偿操作来实现异步确保模式,只要定时系统足够健壮,则任何任务最终都会被成功执行。

异步确保模式如下图所示:

img

定期校对模式

系统在没有达到一致之前,系统间的状态是不一致的,甚至是混乱的,需要通过补偿操作来达到最终一致性的目的,但是如何来发现需要补偿的操作呢?

在操作主流程中的系统间执行校对操作,可以在事后异步地批量校对操作的状态,如果发现不一致的操作,则进行补偿,补偿操作与补偿模式中的补偿操作是一致的。

另外,实现定期校对的一个关键就是分布式系统中需要有一个自始至终唯一的ID,生成全局唯一ID有以下两种方法:

  • 持久型:使用数据库表自增字段或者Sequence生成,为了提高效率,每个应用节点可以缓存一个批次的ID,如果机器重启则可能会损失一部分ID,但是这并不会产生任何问题。
  • 时间型:一般由机器号、业务号、时间、单节点内自增ID组成,由于时间一般精确到秒或者毫秒,因此不需要持久就能保证在分布式系统中全局唯一、粗略递增等。

可靠消息模式

在分布式系统中,对于主流程中优先级比较低的操作,大多采用异步的方式执行,也就是前面提到的异步确保模型,为了让异步操作的调用方和被调用方充分解耦,也由于专业的消息队列本身具有可伸缩、可分片、可持久等功能,我们通常通过消息队列实现异步化。对于消息队列,我们需要建立特殊的设施来保证可靠的消息发送及处理机的幂等性。

缓存一致性模式

在大规模、高并发系统中的一个常见的核心需求就是亿级的读需求,显然,关系型数据库并不是解决高并发读需求的最佳方案,互联网的经典做法就是使用缓存来抗住读流量。

  • 如果性能要求不是非常高,则尽量使用分布式缓存,而不要使用本地缓存。
  • 写缓存时数据一定要完整,如果缓存数据的一部分有效,另一部分无效,则宁可在需要时回源数据库,也不要把部分数据放入缓存中。
  • 使用缓存牺牲了一致性,为了提高性能,数据库与缓存只需要保持弱一致性,而不需要保持强一致性,否则违背了使用缓存的初衷。
  • 读的顺序是先读缓存,后读数据库,写的顺序要先写数据库,后写缓存。

谈谈你对分布式的单点问题的了解

参考答案

在分布式系统中,单点问题是一个比较常见的问题,对于单点问题可以分为有状态服务的单点问题和无状态服务的单点问题。

无状态服务的单点问题

对于无状态的服务,单点问题的解决比较简单,因为服务是无状态的,所以服务节点很容易进行平行扩展。比如,在分布式系统中,为了降低各进程通信的网络结构的复杂度,我们会增加一个代理节点,专门做消息的转发,其他的业务进行直接和代理节点进行通信,类似一个星型的网络结构。

参考上面两个图,图中proxy是一个消息转发代理,业务进程中的消息都会经过该代理,这也是比较场景的一个架构。在上图中,只有一个proxy,如果该节点挂了,那么所有的业务进程之间都无法进行通信。由于proxy是无状态的服务,所以很容易想到第二个图中的解决方案,增加一个proxy节点,两个proxy节点是对等的。增加新节点后,业务进程需要与两个Proxy之间增加一个心跳的机制,业务进程在发送消息的时候根据proxy的状态,选择一个可用的proxy进行消息的传递。从负载均衡的角度来看,如果两个proxy都是存活状态的话,业务进程应当随机选择一个proxy。

那么该解决方案中会存在什么问题呢?主要存在的问题是消息的顺序性问题。一般来说,业务的消息都是发送、应答,再发送、再应答这样的顺序进行的,在业务中可以保证消息的顺序性。但是,在实际的应用中,会出现这样一个情况:在业务进程1中,有个业务需要给业务进程3发送消息A和消息B,根据业务的特性,消息A必须要在消息B之前到达。如果业务进程1在发送消息A的时候选择了proxy1,在发送消息B的时候选择了proxy2,那么在分布式环境中,我们并不能确保先发送的消息A一定就能比后发送的消息B先到达业务进程3。那么怎么解决这个问题?其实方案也比较简单,对于这类对消息顺序有要求的业务,我们可以指定对应的proxy进行发送,比如消息A和消息B都是使用proxy1进行发送,这样就可以保证消息A比消息B先到达业务进程3。

整体来说,对于无状态的服务的单点问题的解决方案还是比较简单的,只要增加对应的服务节点即可。

有状态服务的单点问题

相对无状态服务的单点问题,有状态服务的单点问题就复杂多了。如果在架构中,有个节点是单点的,并且该节点是有状态的服务,那么首先要考虑的是该节点是否可以去状态,如果可以,则优先选择去除状态的方案(比如说把状态存储到后端的可靠DB中,可能存在性能的损耗),然后就退化成了一个无状态服务的单点问题了,这就可以参考上一方案了。 但是,并不是所有的服务都是可以去状态的,比如说对于一些业务它只能在一个节点中进行处理,如果在不同的节点中处理的话可能会造成状态的不一致,这类型的业务是无法去除状态的。对于这种无法去除状态的单点的问题的解决方案也是有多种,但是越完善的方案实现起来就越复杂,不过整体的思路都是采用主备的方式。

第一个方案就是就是增加一个备用节点,备用节点和业务进程也可以进行通信,但是所有的业务消息都发往Master节点进行处理。Master节点和Slave节点之间采用ping的方式进行通信。Slave节点会定时发送ping包给Master节点,Master节点收到后会响应一个Ack包。当Slave节点发现Master节点没有响应的时候,就会认为Master节点挂了,然后把自己升级为Master节点,并且通知业务进程把消息转发给自己。该方案看起来也是挺完美的,好像不存在什么问题,Slave升级为Master后所有的业务消息都会发给它。但是,如果在Master内部有一些自己的业务逻辑,比如说随机生成一些业务数据,并且定时存档。那么当Master和Slave之间的网络出现问题的时候,Slave会认为Master挂了,就会升级为Master,同样会执行Master的相应的业务逻辑,同样也会生成一些业务数据回写到DB。但是,其实Master是没有挂的,它同样也在运行对应的业务逻辑(即使业务进程的消息没有发给旧的Master了),这样就会出现两个Master进行写同一份数据了,造成数据的混乱。所以说,该方案并不是一个很好的方案。

那怎么解决可能会出现多个Master的问题?换个角度看,该问题其实就是怎么去裁决哪个节点是Master的问题。

方案一:引入第三方的服务进行裁决。

我们可以引入ZooKeeper,由ZooKeeper进行裁决。同样,我们启动两个主节点,“节点A”和节点B。它们启动之后向ZooKeeper去注册一个节点,假设节点A注册的节点为master001,节点B注册的节点为master002,注册完成后进行选举,编号小的节点为真正的主节点。那么,通过这种方式就完成了对两个Master进程的调度。

img

方案二: 通过选举算法和租约的方式实现Master的选举。

对于方案一的缺点主要要多维护一套ZooKeeper的服务,如果原本业务上并没有部署该服务的话,要增加该服务的维护也是比较麻烦的事情。这个时候我们可以在业务进程中加入Master的选举方案。目前有比较成熟的选举算法,比如Paxos和Raft。然后再配合租约机制,就可以实现Master的选举,并且确保当前只有一个Master的方案。但是,这些选举算法理解起来并不是那么地容易,要实现一套完善的方案也是挺难的。所以不建议重复造轮子,业内有很多成熟的框架或者组件可以使用,比如微信的PhxPaxos。

img

比如上图的方案中,三个节点其实都是对等的,通过选举算法确定一个Master。为了确保任何时候都只能存在一个Matster,需要加入租约的机制。一个节点成为Master后,Master和非Master节点都会进行计时,在超过租约时间后,三个节点后可以发起“我要成为Master”的请求,进行重新选举。由于三个节点都是对等的,任意一个都可以成为Master,也就是说租期过后,有可能会出现Master切换的情况,所以为了避免Master的频繁切换,Master节点需要比另外两个节点先发起自己要成为Master的请求(续租),告诉其他两个节点我要继续成为Master,然后另外两个节点收到请求后会进行应答,正常情况下另外两个节点会同意该请求。关键点就是,在租约过期之前,非Master节点不能发起“我要成为Master”的请求,这样就可以解决Master频繁切换的问题。

HTTP和RPC有什么区别?

参考答案

传输协议

  • RPC,可以基于TCP协议,也可以基于HTTP协议。
  • HTTP,基于HTTP协议。

传输效率

  • RPC,使用自定义的TCP协议,可以让请求报文体积更小,或者使用HTTP2协议,也可以很好的减少报文的体积,提高传输效率。
  • HTTP,如果是基于HTTP1.1的协议,请求中会包含很多无用的内容,如果是基于HTTP2.0,那么简单的封装一下是可以作为一个RPC来使用的,这时标准RPC框架更多的是服务治理。

性能消耗

  • RPC,可以基于thrift实现高效的二进制传输。
  • HTTP,大部分是通过json来实现的,字节大小和序列化耗时都比thrift要更消耗性能。

负载均衡

  • RPC,基本都自带了负载均衡策略。
  • HTTP,需要配置Nginx,HAProxy来实现。

服务治理

  • RPC,能做到自动通知,不影响上游。
  • HTTP,需要事先通知,修改Nginx/HAProxy配置。

总之,RPC主要用于公司内部的服务调用,性能消耗低,传输效率高,服务治理方便。HTTP主要用于对外的异构环境,浏览器接口调用,APP接口调用,第三方接口调用等。

来源https://www.nowcoder.com/tutorial/94/ea1986fcff294f6292385703e94689e8