说在前面
在尼恩的(50+)读者社区中,经常指导大家面试架构,拿高端offer。最近,小伙伴在面试字节、平安的过程中,遇到一个 非常、非常高频的一个面试题,但是很不好回答,类似如下:
- 说说分布式中的补偿机制, 补偿和重试有何关系?
- 「事务补偿」和「重试」,它们之间的关系是什么?
- 谈谈分布式系统中的补偿机制如何设计
这里尼恩给大家做一下系统化、体系化的梳理,使得大家可以充分展示一下大家雄厚的 “技术肌肉”,让面试官爱到 “不能自已、口水直流”。
也一并把这个题目以及参考答案,收入咱们的 《尼恩Java面试宝典 PDF》V99版本,供后面的小伙伴参考,提升大家的 3高 架构、设计、开发水平。
最新《尼恩 架构笔记》《尼恩高并发三部曲》《尼恩Java面试宝典》的PDF,请到公号【技术自由圈】获取
文章目录
一、为什么要考虑补偿机制?
我们都知道,在分布式环境中运行的应用程序在通信时可能会遇到一个主要问题,即一个业务流程通常需要整合多个服务,而仅一次通信就可能涉及 DNS 服务、网卡、交换机、路由器、负载均衡等设备。
以电商的购物场景为例:
客户端 ---->购物车微服务 ---->订单微服务 ----> 支付微服务。
这种调用链非常普遍。
那么为什么需要考虑补偿机制呢?
正如前面所说,一次跨机器的通信可能会经过DNS 服务,网卡、交换机、路由器、负载均衡等设备,这些设备都不一定是一直稳定的,在数据传输的整个过程中,只要任意一个环节出错,都会导致问题的产生。
而在分布式场景中,一个完整的业务又是由多次跨机器通信组成的,所以产生问题的概率成倍数增加。
这些服务和设备并不总是稳定可靠的。在数据传输过程中,只要任何一个环节出现问题,都可能引发故障。
在微服务环境中,这种情况更加突出,因为业务需要在一致性上得到保障。
也就是说,如果一个步骤出现失败,要么需要持续重试以确保所有步骤都顺利完成,要么将服务调用回滚到之前的状态。
因此,我们可以这样理解业务补偿:当某个操作出现异常时,通过内部机制消除由该异常引发的「不一致」状态。
大家经常看到:「补偿」和「事务补偿」或者「重试」,它们之间的关系是什么?
二、如何进行补偿?
业务补偿设计的实现方式主要可分为两种:回滚(事务补偿)和重试
- 回滚(事务补偿),这是一种逆向操作,通过回滚业务流程来解决问题,这意味着当前的操作已经失败;
- 重试,这是一种正向操作,通过不断地尝试来完成业务流程,代表着仍有成功的可能性。
通常情况下,业务事务补偿需要一个工作流引擎的支持。这个事务工作流引擎将各种服务连接在一起,并在工作流上进行业务补偿,以达到最终一致性。
因为「补偿」已经是一个额外的流程,既然能够走额外的流程,说明时效性并不是第一考虑的因素,所以做补偿的核心要点是:宁可慢,不可错。
因此,不能草率地确定补偿方案,需要谨慎评估。虽然错误无法完全避免,但我们应以尽量减少错误为目标。
1、回滚
回滚分为两种形式:
- 显式回滚(逆向调用接口):通过调用逆向接口,执行上一次操作的反操作,或者取消上一次未完成的操作(需要锁定资源);
- 隐式回滚(无需逆向调用接口):意味着这个回滚动作无需额外处理,通常由下游提供失败处理机制。
显式回滚
最常见的显示回滚就是做两件事:
- 首先,确定失败的操作和状态,从而确定回滚范围。一个业务流程在设计之初就已经规划好,因此确定回滚范围相对容易。但需要注意的是,如果在一个业务处理过程中涉及到的服务并非都提供了「回滚接口」,那么在服务编排时应将提供「回滚接口」的服务放在前面,以便在后续服务出错时还有机会进行「回滚」。
简而言之,要确保回滚接口有机会被调用。最优的选择是将其放在第一个。 - 其次要提供进行「回滚」操作所需的业务数据。提供的回滚数据越多,越有利于程序的健壮性。因为程序在接收到「回滚」操作时可以进行业务检查,例如检查账户是否相等,金额是否一致等。
在这个过程中,数据结构和大小并不确定。因此,最好将相关数据序列化为 JSON,并存储在 NoSQL 数据库中。
隐式回滚
隐式回滚的使用场景相对较少。它意味着回滚操作无需额外处理,下游服务内部具有类似"预占"和"超时失效"的机制。
例如:
在电商场景中,会将订单中的商品预占库存,等待用户在规定时间内支付。如果用户未在规定时间内支付,则释放库存。
回滚的实现方式
对于跨库的事务,常见的解决方案有:两阶段提交、三阶段提交(ACID)但是这 2 种方式,在高可用的架构中一般都不可取,因为跨库锁表会消耗很大的性能。
在高可用架构中,通常不要求强一致性,而是追求最终一致性。可以考虑使用事务表、消息队列、补偿机制、TCC 模式(占位/确认或取消)和 Sagas 模式(拆分事务 + 补偿机制)来实现最终一致性。
2、重试
“重试”的含义是我们认为故障是暂时的,而不是永久的,所以,我们会去重试。这种方法的最大优势在于无需提供额外的逆向接口,这对于代码维护和长期开发的成本有优势,同时考虑到业务的变化,逆向接口也需要随之变化。因此,在许多情况下,可以考虑使用重试。
使用场景
然而,相较于回滚操作,重试的使用场景较少。
- 当下游系统返回请求超时,或受到限流等临时状态影响时,我们可以考虑采用重试。
- 如果返回的结果是余额不足,无权限等明确的业务错误,就不需要重试。
- 对于一些中间件或 RPC 框架,如果返回的是 503,404 等无法预期恢复时间的错误,也不需要重试。
重试策略
为了实施重试,我们需要制定一个重试策略,主流的重试策略主要包括以下几种:
**1.立即重试:**如果故障是暂时性的,可能是由于网络数据包冲突或硬件组件高峰流量等事件引起的,这种情况下,适合立即重试。但是,立即重试的次数不应超过一次,如果立即重试失败,应改用其他策略。
2.固定间隔: 这个很容易理解,比如每隔 5 分钟重试一次。需要注意的是,策略 1 和策略 2 通常用于前端系统的交互操作。
3.增量间隔: 这个也很简单,比如间隔 15 分钟重试一次。
return (retryCount - 1) * incrementInterval;
其主要目的是让重试失败的任务优先级靠后,让新的重试任务进入队列。
4.指数间隔: 与增量间隔类似,只是增长的幅度更大。
return 2 ^ retryCount;
5.全抖动: 在递增的基础上,增加随机性,适用于在某一时刻有大量请求需要分散压力的场景。
return random(0 , 2 ^ retryCount);
6.等抖动: 在指数间隔和全抖动之间找到一个平衡点,降低随机性的使用。
int baseNum = 2 ^ retryCount;
return baseNum + random(0 , baseNum);
3、4、5、6 策略的表现大致如下所示。(x 轴为重试次数)
为什么说重试有坑呢?
正如之前所提到的,出于对开发成本的考虑,如果重试涉及到接口调用,就需要考虑 幂等性 的问题。
幂等性起源于数学概念,后来被引入到程序设计中。它意味着一个操作可以被多次执行,而不会产生错误。
因此,一旦某个功能支持重试,整个链路上的解耦都需要考虑幂等性的问题,以确保多次调用不会导致业务数据的变化。
实现幂等性的方法是将其过滤掉:
- 为每个请求分配一个唯一的标识符。
- 在重试过程中,判断该请求是否已经执行过或正在执行。如果是,就丢弃该请求。
对于第一点,可以使用全局 ID 生成器、ID 生成服务,或者简单地使用 Guid、UUID 为每个请求赋值。
对于第二点,可以使用 AOP 在业务代码前后进行校验。
//【方法执行前】
if(isExistLog(requestId)){
//1。判断请求是否已被接收过。对应序号3
var lastResult = getLastResult(); //2。获取用于判断之前的请求是否已经处理完成。对应序号4
if(lastResult == null){
var result = waitResult(); //挂起等待处理完成
return result;
}
else{
return lastResult;
}
}
else{
log(requestId); //3。记录该请求已接收
}
//do something。。【方法执行后】
logResult(requestId, result); //4。将结果也更新一下。
如果 「补偿」 这个过程是通过消息队列(MQ)进行的,那么可以在 MQ 封装的 SDK 中直接实现。在生产端为请求分配全局唯一标识符,在消费端通过唯一标识进行去重。
重试的最佳实践
重试特别适合在高负载情况下进行降级。同时,它也应受到限流和熔断机制的影响。当重试与限流熔断结合使用时,才能达到最佳效果。
在增加补偿机制时,需要权衡投入与产出。对于一些不太重要的问题,应该选择 「快速失败」 而不是 「重试」 。
过度积极的重试策略(例如间隔太短或重试次数过多)可能会对下游服务产生负面影响,这一点需要特别注意。
一定要为 「重试」 设定一个终止策略。当回滚过程困难或代价较大时,可以接受较长的间隔和较多的重试次数。实际上,DDD 中经常提到的「saga」模式也是基于这种思路。但前提是不会因为保留或锁定稀缺资源而阻止其他操作(例如,1、2、3、4、5 个串行操作,由于 2 操作一直未完成,导致 3、4、5 无法继续进行)。
三、业务补偿机制的注意事项
1、ACID 还是 BASE
在分布式系统中,ACID 和 BASE 代表了两种不同层次的一致性理论。
在分布式系统里,ACID 还是 BASE的区别:
- ACID 的一致性较强,但可扩展性较差,仅在必要时使用;
- 而 BASE 的一致性相对较弱,但具有很好的可扩展性,并支持异步批量处理,适用于大多数分布式事务。
在重试或回滚的情境下,我们通常不需要强一致性,只需确保最终一致性即可。
2、业务补偿设计的注意事项
业务补偿设计的注意事项:
- 为了完成一个业务流程,需要涉及到的服务支持幂性,并且上游需要有重试机制;
- 我们需要仔细维护和监控整个过程的状态,所以最好不要将这些状态分布在不同的组件中,最好是由一个业务流程的控制方来负责,也就是一个工作流引擎。因此,这个工作流引擎需要具有高可用性和稳定性;
- 补偿的业务逻辑和流程不一定要是严格的反向操作。有时可以并行执行,有时可能会更简单。
总的来说,在设计业务正向流程时,也需要考虑业务的反向补偿流程; - 我们需要明确,业务补偿的业务逻辑与具体业务紧密相关,很难做到通用;
- 下层的业务方最好提供短期的资源预留机制。例如在电商中,可以将商品库存预留以便等待用户在 15 分钟内支付。如果没有收到用户的支付,就释放库存,然后回滚到之前的下单操作,等待用户重新下单。
所以,这才是“教科书式” 答案
结合 字节的方案,大家回到前面的面试题:
- 说说分布式中的补偿机制, 补偿和重试有何关系?
- 「事务补偿」和「重试」,它们之间的关系是什么?
- 谈谈分布式系统中的补偿机制如何设计
以上的方案,才是完美的答案,才是“教科书式” 答案。
后续,尼恩会给大家结合行业案例,分析出更多,更加劲爆的答案。
当然,如果遇到这类问题,可以找尼恩求助。
参考文献
https://zhuanlan.zhihu.com/p/258741780
推荐阅读
《尼恩 架构笔记》《尼恩高并发三部曲》《尼恩Java面试宝典》PDF,请到下面公号【技术自由圈】取↓↓↓