在尼恩的(50+)读者社区中,经常指导大家面试架构,拿高端offer。最近,小伙伴在面试字节、平安的过程中,遇到一个 非常、非常高频的一个面试题,但是很不好回答,类似如下:
- 说说分布式中的补偿机制, 补偿和重试有何关系?
- 「事务补偿」和「重试」,它们之间的关系是什么?
- 谈谈分布式系统中的补偿机制如何设计
这里尼恩给大家做一下系统化、体系化的梳理,使得大家可以充分展示一下大家雄厚的 “技术肌肉”,让面试官爱到 “不能自已、口水直流”。
也一并把这个题目以及参考答案,收入咱们的 《尼恩Java面试宝典 PDF》V99版本,供后面的小伙伴参考,提升大家的 3高 架构、设计、开发水平。
最新《尼恩 架构笔记》《尼恩高并发三部曲》《尼恩Java面试宝典》的PDF,请到公号【技术自由圈】获取
我们都知道,在分布式环境中运行的应用程序在通信时可能会遇到一个主要问题,即一个业务流程通常需要整合多个服务,而仅一次通信就可能涉及 DNS 服务、网卡、交换机、路由器、负载均衡等设备。
以电商的购物场景为例:
客户端 ---->购物车微服务 ---->订单微服务 ----> 支付微服务。
这种调用链非常普遍。
那么为什么需要考虑补偿机制呢?
正如前面所说,一次跨机器的通信可能会经过DNS 服务,网卡、交换机、路由器、负载均衡等设备,这些设备都不一定是一直稳定的,在数据传输的整个过程中,只要任意一个环节出错,都会导致问题的产生。
而在分布式场景中,一个完整的业务又是由多次跨机器通信组成的,所以产生问题的概率成倍数增加。
这些服务和设备并不总是稳定可靠的。在数据传输过程中,只要任何一个环节出现问题,都可能引发故障。
在微服务环境中,这种情况更加突出,因为业务需要在一致性上得到保障。
也就是说,如果一个步骤出现失败,要么需要持续重试以确保所有步骤都顺利完成,要么将服务调用回滚到之前的状态。
因此,我们可以这样理解业务补偿:当某个操作出现异常时,通过内部机制消除由该异常引发的「不一致」状态。
大家经常看到:「补偿」和「事务补偿」或者「重试」,它们之间的关系是什么?
业务补偿设计的实现方式主要可分为两种:回滚(事务补偿)和重试
通常情况下,业务事务补偿需要一个工作流引擎的支持。这个事务工作流引擎将各种服务连接在一起,并在工作流上进行业务补偿,以达到最终一致性。
因为「补偿」已经是一个额外的流程,既然能够走额外的流程,说明时效性并不是第一考虑的因素,所以做补偿的核心要点是:宁可慢,不可错。
因此,不能草率地确定补偿方案,需要谨慎评估。虽然错误无法完全避免,但我们应以尽量减少错误为目标。
回滚分为两种形式:
最常见的显示回滚就是做两件事:
在这个过程中,数据结构和大小并不确定。因此,最好将相关数据序列化为 JSON,并存储在 NoSQL 数据库中。
隐式回滚的使用场景相对较少。它意味着回滚操作无需额外处理,下游服务内部具有类似"预占"和"超时失效"的机制。
例如:
在电商场景中,会将订单中的商品预占库存,等待用户在规定时间内支付。如果用户未在规定时间内支付,则释放库存。
对于跨库的事务,常见的解决方案有:两阶段提交、三阶段提交(ACID)但是这 2 种方式,在高可用的架构中一般都不可取,因为跨库锁表会消耗很大的性能。
在高可用架构中,通常不要求强一致性,而是追求最终一致性。可以考虑使用事务表、消息队列、补偿机制、TCC 模式(占位/确认或取消)和 Sagas 模式(拆分事务 + 补偿机制)来实现最终一致性。
“重试”的含义是我们认为故障是暂时的,而不是永久的,所以,我们会去重试。这种方法的最大优势在于无需提供额外的逆向接口,这对于代码维护和长期开发的成本有优势,同时考虑到业务的变化,逆向接口也需要随之变化。因此,在许多情况下,可以考虑使用重试。
然而,相较于回滚操作,重试的使用场景较少。
为了实施重试,我们需要制定一个重试策略,主流的重试策略主要包括以下几种:
**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 无法继续进行)。
在分布式系统中,ACID 和 BASE 代表了两种不同层次的一致性理论。
在分布式系统里,ACID 还是 BASE的区别:
在重试或回滚的情境下,我们通常不需要强一致性,只需确保最终一致性即可。
业务补偿设计的注意事项:
结合 字节的方案,大家回到前面的面试题:
- 说说分布式中的补偿机制, 补偿和重试有何关系?
- 「事务补偿」和「重试」,它们之间的关系是什么?
- 谈谈分布式系统中的补偿机制如何设计
以上的方案,才是完美的答案,才是“教科书式” 答案。
后续,尼恩会给大家结合行业案例,分析出更多,更加劲爆的答案。
当然,如果遇到这类问题,可以找尼恩求助。
https://zhuanlan.zhihu.com/p/258741780
《炸裂,靠“吹牛”过京东一面,月薪40K》
《太猛了,靠“吹牛”过顺丰一面,月薪30K》
《炸裂了…京东一面索命40问,过了就50W+》
《问麻了…阿里一面索命27问,过了就60W+》
《百度狂问3小时,大厂offer到手,小伙真狠!》
《饿了么太狠:面个高级Java,抖这多硬活、狠活》
《字节狂问一小时,小伙offer到手,太狠了!》
《收个滴滴Offer:从小伙三面经历,看看需要学点啥?》
《尼恩 架构笔记》《尼恩高并发三部曲》《尼恩Java面试宝典》PDF,请到下面公号【技术自由圈】取↓↓↓