字节一面:事务补偿和事务重试,关系是什么?
文章很长,且持续更新,建议收藏起来,慢慢读!疯狂创客圈总目录 博客园版 为您奉上珍贵的学习资源 :
免费赠送 :《尼恩Java面试宝典》 持续更新+ 史上最全 + 面试必备 2000页+ 面试必备 + 大厂必备 +涨薪必备
免费赠送 :《尼恩技术圣经+高并发系列PDF》 ,帮你 实现技术自由,完成职业升级, 薪酬猛涨!加尼恩免费领
免费赠送 经典图书:《Java高并发核心编程(卷1)加强版》 面试必备 + 大厂必备 +涨薪必备 加尼恩免费领
免费赠送 经典图书:《Java高并发核心编程(卷2)加强版》 面试必备 + 大厂必备 +涨薪必备 加尼恩免费领
免费赠送 经典图书:《Java高并发核心编程(卷3)加强版》 面试必备 + 大厂必备 +涨薪必备 加尼恩免费领
免费赠送 资源宝库: Java 必备 百度网盘资源大合集 价值>10000元 加尼恩领取
字节一面:事务补偿和事务重试,关系是什么?
说在前面
在尼恩的(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
技术自由的实现路径:
实现你的 架构自由:
《阿里二面:千万级、亿级数据,如何性能优化? 教科书级 答案来了》
《峰值21WQps、亿级DAU,小游戏《羊了个羊》是怎么架构的?》
… 更多架构文章,正在添加中
实现你的 响应式 自由:
这是老版本 《Flux、Mono、Reactor 实战(史上最全)》
实现你的 spring cloud 自由:
《Spring cloud Alibaba 学习圣经》 PDF
《分库分表 Sharding-JDBC 底层原理、核心实战(史上最全)》
《一文搞定:SpringBoot、SLF4j、Log4j、Logback、Netty之间混乱关系(史上最全)》
实现你的 linux 自由:
实现你的 网络 自由:
《网络三张表:ARP表, MAC表, 路由表,实现你的网络自由!!》
实现你的 分布式锁 自由:
实现你的 王者组件 自由:
《队列之王: Disruptor 原理、架构、源码 一文穿透》
《缓存之王:Caffeine 源码、架构、原理(史上最全,10W字 超级长文)》
《Java Agent 探针、字节码增强 ByteBuddy(史上最全)》