微服务架构下如何通过弱依赖原则保障系统高可用
前言
当我初次接触高可用这个概念的时候,对高可用的【少依赖原则】和【弱依赖原则】的边界感模糊,甚至有些“傻傻分不清楚”。这两个原则都关注降低模块之间的依赖关系,但它们之间的确存在某些差异。
那么,「少依赖原则」和「弱依赖原则」它们之间本质的区别究竟是啥?
少依赖原则和弱依赖原则都是旨在提高系统的可靠性和稳定性,但是它们之间的本质区别在于对依赖关系的管理和控制。
当然,这两个原则并不是绝对的,两者之间有一定的关联性,我们可以根据系统的复杂性和实际需求,灵活地调整模块之间的依赖关系。在实际应用中,少依赖原则和弱依赖原则也可以相互配合,共同提高系统的高可用性。
一、基于弱依赖原则的架构策略
弱依赖原则:一定要依赖的,尽可能弱依赖,越弱越好 事物a强依赖事物b,一旦b出问题时,那么a也会出问题,一损俱损。 所以任何强依赖都要尽可能的转化成弱依赖,可以直接降低出问题的概率。
1、微服务架构
模块拆分:微服务架构将复杂的应用程序拆分为多个独立的、可组合的服务模块。每个服务具有明确的功能边界和职责,相互之间具备松耦合的特点。这样,当某个服务出现故障时,其他服务可以继续独立运行,保证系统的整体可用性。
独立部署:各个模块有自己独立的代码仓库,可以独立进行分组部署和升级,无需其他模块的配合。当某个服务发生故障或需要升级时,不会影响到其他服务的正常运行。特别针对黄金交易链路上的系统,有条件的话尽量考虑提供独立的数据资源(DB、redis),并进行垂直分组部署。
需要注意的是:部署隔离需要充足的部署资源,以及上下游配合,尽量提前做好该工作。当然,模块隔离必须建立在低耦合的基础上进行才有意义。如果组件之间的耦合关系千头万绪、混乱不堪,模块隔离只会让这种混乱雪上加霜。
2、异步通信
异步通信可以认为是在模块隔离基础上的进一步解耦,将物理上已经分割的模块之间的强依赖关系进一步削弱,使故障无法传播扩散,提高系统可用性。异步在架构上的实现手段主要是使用消息队列,当一个模块发生故障时,另一个模块可以继续处理任务,不会受到太大影响。
以我正在做的职能架构升级项目为例,其中创建员工账号的场景:新员工在PC页面提交创建账号的请求后,需要将员工信息进行数据持久化,并发送创建成功的短信和邮件给员工,此外,还需将员工信息同步给人资系统。
如果用微服务同步调用的方式,那么后续操作任何一个故障,都会导致业务处理失败,员工无法创建成功。我们通过使用消息队列的异步架构,新员工创建时发mq后就立即响应「创建成功」,后续的操作通过消费消息来完成,即使某个操作发生故障,后续再补偿也不会影响员工的创建流程。
![](https://s3.cn-north-1.jdcloud-oss.com/shendengbucket1/2024-01-25-21-22q25CcDw0s0NpQIE9.jpg)
图1.1 创建员工业务流程图
3、接口抽象
耦合度过高是软件设计的万恶之源,也是造成系统可用性问题的罪魁祸首。一个高度耦合的系统,可谓“牵一发而动全身”,任何微小的改动都可能会引发意想不到的bug和系统崩溃。连最基本的功能维护都已经勉为其难,更不用奢谈什么高可用了。
我们可以通过定义抽象的策略接口,这个抽象接口通常是从多个具有共同特征行为的类中抽象出来的,而具体实现类的指定都交给工厂类去完成,从而实现模块之间的松耦合。这样,当某个模块发生变化时,也不会影响其他模块的正常运行。接口抽象的方式,我将在后文的「实际场景分析」,针对具体案例展开详细的讨论。
4、故障切换与容错
设置完善的故障处理机制,包括故障检测、故障切换和故障恢复。当检测到故障时,系统可以快速切换到备用组件或恢复服务,保证系统的可用性。
数据分片:存储数据时,将其分布在多个存储节点上。当某个存储节点发生故障时,数据可以从其他节点恢复,从而提高数据的可用性和容错性。
读写分离:对于接受弱一致性的场景,将读操作分配给从数据库,写操作分配给主数据库,以提高系统的性能和稳定性,并支持主从切换;数据量大时,也可以进行分库分表处理。
兜底降级:当一个系统中的强依赖服务数量较少时,其整体基础稳定性便会越高。对于那些特殊数据依赖较多而逻辑依赖较少的系统,我们可以采取去依赖的架构设计策略。具体来说,就是将依赖服务数据持久化异构到自己的数据库,并通过异步方式进行同步更新维护,从而降低对其他系统的依赖程度,进一步提升系统的稳定性。然而,这种方法也存在一定的弊端:数据冗余可能导致在特定时间窗口内出现数据不一致的情况。
5、松耦合的业务逻辑
将业务逻辑进行解耦,使其相互独立。例如:目前线下店仓系统中专卖店、大商超、超级大店三个业态是公用一套代码,各个业态之间设计上是松耦合的,不同业态扩展点的实现相互隔离,这样当某个业务逻辑出现故障时,其他业务逻辑可以继续运行,降低故障影响范围。
二、实际场景分析
1、case 1:中间件弱依赖
i、消息队列弱依赖
日常开发中经常会遇到分布式事务的场景,同一个事务内涉及「RPC调用、写DB、对外消息发送」等一系列原子操作,可能存在某一个环节请求异常的情况,为了保证事务的最终一致性,需要采用失败重试策略。对一个简单的应用流程来说,抛出异常业务中断回滚即可;但是对于复杂业务流程是不可行的,发生请求异常时,上游应用可能已经执行完毕,尤其是多个异步流程组合一个整体流程的场景,其他前置的流程可能已经执行完毕,无法回滚。
以店仓生产缺货取消单据的场景为例,店仓拣货环节,若商品全部缺货、或者用户选择“缺货取消订单”的发货策略时,此时拣货缺货,会调用下游接口取消订单。经常会出现前置操作(如RPC调用、写DB等)完成后,调用订单取消接口失败或者异常的情况。调用失败会触发UMP报警,需人工干预进行处理。
![](https://s3.cn-north-1.jdcloud-oss.com/shendengbucket1/2024-01-25-21-28a99JXJfzuUcKhkw.jpg)
图2.1 生产缺货取消回写opc流程
为了避免以上问题,保障数据的最终一致性,初期优化采用mq自产自消的方式,进行重试。
![](https://s3.cn-north-1.jdcloud-oss.com/shendengbucket1/2024-01-25-21-31o9eSQkTpgZufVqm.jpg)
图2.2 JMQ解决回调取消接口失败
这样一来,业务系统的稳定性与JMQ中间件的稳定性就强关联了,自然对JMQ的稳定性有较高要求。为了降低对JMQ的强依赖,保证业务的顺利执行,通过技术手段提升用户体验,减轻研发值班人员压力,最终形成了任务重试工具。
其核心思想是将分布式事务拆分成本地事务进行处理,具体实现方式是:将任务落库,保证业务操作表与纯任务表在同一个数据库,通过数据库事务保证业务操作与任务持久化的强一致性。在一定程度上,将业务操作与中间件依赖解耦。
采用回调函数的机制实现调用者和底层驱动的解耦,提升了组件的灵活性,对业务侵入性小。
![](https://s3.cn-north-1.jdcloud-oss.com/shendengbucket1/2024-01-25-21-329MCXQQJ8XkwrEuz.jpg)
图2.3 任务重试组件工作流程
ii、数据库弱依赖
第二个涉及中间件弱依赖的场景是数据库弱依赖,在日常开发中,数据库操作异常的情况屡见不鲜,比如网络链路问题、慢SQL导致的性能下降,以及引发的故障等。这些问题往往会导致交易黄金链路在短时间内无法正常工作,给业务带来不小的损失。为了应对这些情况,我们考虑引入灾备机制,以确保在异常情况下仍能维持较高的交易成功率,保障订单履约时效。
该方案的核心思路是:在DB操作出现故障的时间段内,通过其他存储介质(如redis)临时存储数据。然后,通过MQ异步补偿还原DB操作,从而保障数据的最终一致性。
![](https://s3.cn-north-1.jdcloud-oss.com/shendengbucket1/2024-01-25-21-34PBCapoSHdAG34kO1.jpg)
图2.4 数据灾备方案
这个方案显著提升了我们应对数据库操作异常的处理能力,确保了黄金交易链路的平稳运行。通过实施灾备策略,我们在确保数据最终一致性的同时,也有效减小了故障对业务的影响。
2、case 2:依赖倒置解耦业务逻辑
![](https://s3.cn-north-1.jdcloud-oss.com/shendengbucket1/2024-01-25-21-36Vs0wthpJPVHK9v25.jpg)
图2.5 定义抽象接口进行解耦
i、背景
代码层面的依赖优化案例,是基于依赖倒置的设计原则,将业务模块进行解耦。在需求迭代的过程中,我们经常为了图方便,将具体类直接依赖于具体类,也就是所谓的高层模块依赖于低层模块。但是这样是极其不利于扩展的,随着新功能的不断追加,系统的功能会越来越臃肿,核心功能也会越来越模糊,这种情况下,系统的高可用性会受到影响。
请看下面这个案例:历史代码中,执行发货单取消逻辑十分复杂,不同类型、不同来源的单据在不同生产环节取消处理逻辑存在差异,这里的doCandel方法就是高层模块,而调用取消接口和发送取消消息是低层模块,这是一个典型的高层模块依赖于低层模块的编码形式。
ii、优化前的实现方式
以下一个代码片段是系统中的历史代码负债,耦合性强,可读性和可扩展性差。
![](https://s3.cn-north-1.jdcloud-oss.com/shendengbucket1/2024-04-02-10-40BmVvpCe512rdHxXq.png)
图2.6 历史代码
iii、优化后的实现方式
弱依赖原则强调应该尽量让模块之间的依赖关系变得弱化。这意味着模块之间的相互作用应该尽量简单,避免复杂的依赖关系。我们优化的核心思想是采用 工厂模式+模板模式 去抽象接口,实现不同环节单据取消后续处理逻辑差异。
a.首先,我们通过定义一个抽象的策略类AbstractDoCancelNodeStrategy,将取消单据后的核心流程进行拆解,最终将拆解的四个步骤定义为四个抽象方法。
![](https://s3.cn-north-1.jdcloud-oss.com/shendengbucket1/2024-01-25-21-39W8GGvsXJiDP9kvL.png)
图2.7 抽象策略类定义
b.然后,我们创建了4个具体实现策略类,分别用于处理不同环节取消单据的逻辑。主要是提供相同行为的不同实现,业务上可以根据不同条件选择进入不同的实现类。
![](https://s3.cn-north-1.jdcloud-oss.com/shendengbucket1/2024-01-25-21-39DqjhjKw642V6gLlw.png)
![](https://s3.cn-north-1.jdcloud-oss.com/shendengbucket1/2024-01-25-21-40WyeqiH0JRRPnOjz.jpg)
图2.8 不同策略实现
c.其次,创建获取不同生产环节取消单据的策略工厂:采用启动时加载策略的方式,在项目启动时,把接口的实现类的实例放在Map里,系统运行过程中,可以通过取消节点对应的key,找到这个实现类的标识,进行相应的逻辑处理。
![](https://s3.cn-north-1.jdcloud-oss.com/shendengbucket1/2024-01-25-21-41qeivp0A6kUfd6qO.png)
图2.9 策略工厂
d.这样一来,高层模块可以依赖于这个策略类,而不是具体的策略实现。这样,当业务需求发生变更,需要对新的单据类型进行取消消息广播,只需实现一个新的处理策略类,而无需修改高层模块的代码。
![](https://s3.cn-north-1.jdcloud-oss.com/shendengbucket1/2024-01-25-21-41HzDN20IgL257w41bZT.png)
图2.10 高层模块代码
这样优化后,相当于把高层模块和具体的RPC调用的底层模块逻辑进行了间接解耦。并且提供了对开闭原则的完美支持,可以在不修改主流程代码的情况下,灵活增加新算法。总之,以抽象为基准比以细节为基准搭建起来的架构要稳定得多,因此咱们在日常开发中,要多尝试面相接口编程,采用先顶层设计再细节的去设计代码结构。
三、强弱依赖治理
服务依赖是决定系统复杂度的一个重要因素,随着业务的不断迭代,服务依赖可能会变得越来越复杂,导致系统难以维护和扩展。在没有明确强弱依赖的前提下,我们很难进行熔断、降级、限流的相关操作,也不能有效的对系统进行相关优化改造、持续推进系统稳定性提升。因此,服务依赖治理变得至关重要。我们需要定期检查我们的依赖模型是否合理,识别不合理的依赖将其合理化。具体的治理流程包括:
1、依赖标记:通过人工梳理代码的形式,对系统核心链路上的所有依赖进行梳理,分析并标注依赖关系及强弱。
2、强弱依赖验证:采用混沌工程等方式模拟链路故障,核心思路就是不断给系统“找麻烦”来验证系统能力,模拟某个依赖服务出现故障的场景,从而验证人工标注的有效性。
3、依赖治理:依赖治理的目标体现在如下几个方面:
结语
当然,除了通过以上措施去构建遵循弱依赖原则的高可用系统,还有一些高可用的架构方案:
比如,在架构设计时,我们还需要考虑到不同层级的异常监控(业务层、应用层、中间件层、基础层),数据采集包含日志、埋点、链路追踪等,数据告警通过电话、短信、邮件、京me等方式通知到值班人员。通过建立完善的监控体系,实时收集系统运行状态,并进行预警。这样,当系统出现潜在故障时,可以及时发现并采取措施进行修复。
此外,对于改造量比较大的新业务上线后,可以通过ducc控制灰度切流的方式,降低软件编码错误带来的影响。观察没有问题,再全量切流,保证即使程序有Bug,也可以流量切回,产生的影响也控制在较小的范围内。这样的系统在面对故障时,具有更强的容错能力和抗故障能力,才能确保系统整体运行的稳定性和可用性。