低代码到底是不是行业毒瘤?一线大厂怎么做的?戳此了解>>> 了解详情
写点什么

99% 的人都能看懂的「补偿」以及最佳实践

2019 年 1 月 04 日

99%的人都能看懂的「补偿」以及最佳实践

也许你对降级已经有了一些认识,这次,我们来聊一聊在保证对外高可用的同时,憋出的“内伤”该如何通过「补偿」机制来自行消化。


「补偿」机制的意义

以电商的购物场景为例:


客户端 ---->购物车微服务 ---->订单微服务 ----> 支付微服务。


这种调用链非常普遍。


那么为什么需要考虑补偿机制呢?


正如之前几篇文章所说,一次跨机器的通信可能会经过 DNS 服务,网卡、交换机、路由器、负载均衡等设备,这些设备都不一定是一直稳定的,在数据传输的整个过程中,只要任意一个环节出错,都会导致问题的产生。


而在分布式场景中,一个完整的业务又是由多次跨机器通信组成的,所以产生问题的概率成倍数增加。


但是,这些问题并不完全代表真正的系统无法处理请求,所以我们应当尽可能的自动消化掉这些异常。


可能你会问,之前也看到过「补偿」和「事务补偿」或者「重试」,它们之间的关系是什么?


你其实可以不用太纠结这些名字,从目的来说都是一样的。就是一旦某个操作发生了异常,如何通过内部机制将这个异常产生的「不一致」状态消除掉。


题外话:在笔者看来,不管用什么方式,只要通过额外的方式解决了问题都可以理解为是「补偿」,所以「事务补偿」和「重试」都是「补偿」的子集。前者是一个逆向操作,而后者则是一个正向操作。


只是从结果来看,两者的意义不同。「事务补偿」意味着“放弃”,当前操作必然会失败。



▲事务补偿


「重试」则还有处理成功的机会。这两种方式分别适用于不同的场景。



▲重试


因为「补偿」已经是一个额外流程了,既然能够走这个额外流程,说明时效性并不是第一考虑的因素,所以做补偿的核心要点是:宁可慢,不可错。


因此,不要草率的就确定了补偿的实施方案,需要谨慎的评估。虽说错误无法 100%避免,但是抱着这样的一个心态或多或少可以减少一些错误的发生。


「补偿」该怎么做

做「补偿」的主流方式就前面提到的「事务补偿」和「重试」,以下会被称作「回滚」和「重试」。


我们先来聊聊「回滚」。相比「重试」,它逻辑上更简单一些。


「回滚」


回滚分为 2 种模式,一种叫「显式回滚」(调用逆向接口),一种叫「隐式回滚」(无需调用逆向接口)。


最常见的就是「显式回滚」。这个方案无非就是做 2 个事情:


首先要确定失败的步骤和状态,从而确定需要回滚的范围。一个业务的流程,往往在设计之初就制定好了,所以确定回滚的范围比较容易。但这里唯一需要注意的一点就是:如果在一个业务处理中涉及到的服务并不是都提供了「回滚接口」,那么在编排服务时应该把提供「回滚接口」的服务放在前面,这样当后面的工作服务错误时还有机会「回滚」。


其次要能提供「回滚」操作使用到的业务数据。「回滚」时提供的数据越多,越有益于程序的健壮性。因为程序可以在收到「回滚」操作的时候可以做业务的检查,比如检查账户是否相等,金额是否一致等等。


由于这个中间状态的数据结构和数据大小并不固定,所以 Z 哥建议你在实现这点的时候可以将相关的数据序列化成一个 json,然后存放到一个 nosql 类型的存储中。


「隐式回滚」相对来说运用场景比较少。它意味着这个回滚动作你不需要进行额外处理,下游服务内部有类似“预占”并且“超时失效”的机制的。例如:


电商场景中,会将订单中的商品先预占库存,等待用户在 15 分钟内支付。如果没有收到用户的支付,则释放库存。


下面聊聊可以有很多玩法,也更容易陷入坑里的「重试」。


「重试」 


「重试」最大的好处在于,业务系统可以不需要提供「逆向接口」,这是一个对长期开发成本特别大的利好,毕竟业务是天天在变的。所以,在可能的情况下,应该优先考虑使用「重试」。


不过,相比「回滚」来说「重试」的适用场景更少一些,所以我们第一步首先要判断,当前场景是否适合「重试」。比如:


  • 下游系统返回「请求超时」、「被限流中」等临时状态的时候,我们可以考虑重试

  • 而如果是返回“余额不足”、“无权限”等明确无法继续的业务性错误的时候就不需要重试了

  • 一些中间件或者 rpc 框架中返回 Http503、404 等没有何时恢复的预期的时候,也不需要重试


如果确定要进行「重试」,我们还需要选定一个合适的「重试策略」。主流的「重试策略」主要是以下几种。


策略 1.立即重试。有时故障是候暂时性,可能是因网络数据包冲突或硬件组件流量高峰等事件造成的。在此情况下,适合立即重试操作。不过,立即重试次数不应超过一次,如果立即重试失败,应改用其它的策略。


策略 2.固定间隔。应用程序每次尝试的间隔时间相同。 这个好理解,例如,固定每 3 秒重试操作。(以下所有示例代码中的具体的数字仅供参考。)


策略 1 和策略 2 多用于前端系统的交互式操作中。


策略 3.增量间隔。每一次的重试间隔时间增量递增。比如,第一次 0 秒、第二次 3 秒、第三次 6 秒,9、12、15 这样。


return (retryCount - 1) * incrementInterval;


使得失败次数越多的重试请求优先级排到越后面,给新进入的重试请求让道。


策略 4.指数间隔。每一次的重试间隔呈指数级增加。和增量间隔“殊途同归”,都是想让失败次数越多的重试请求优先级排到越后面,只不过这个方案的增长幅度更大一些。


return 2 ^ retryCount;


策略 5.全抖动。在递增的基础上,增加随机性(可以把其中的指数增长部分替换成增量增长。)。适用于将某一时刻集中产生的大量重试请求进行压力分散的场景。


return random(0 , 2 ^ retryCount);


策略 6.等抖动。在「指数间隔」和「全抖动」之间寻求一个中庸的方案,降低随机性的作用。适用场景和「全抖动」一样。


var baseNum = 2 ^ retryCount;return baseNum + random(0 , baseNum);
复制代码


3、4、5、6 策略的表现情况大致是这样。(x 轴为重试次数)



为什么说「重试」有坑呢?


正如前面聊到的那样,出于对开发成本考虑,你在做「重试」的时候可能是复用的常规调用的接口。那么此时就不得不提一个「幂等性」问题。


如果实现「重试」选用的技术方案不能 100%确保不会重复发起重试,那么「幂等性」问题是一个必须要考虑的问题。哪怕技术方案可以确保 100%不会重复发起重试,出于对意外情况的考量,尽量也考虑一下「幂等性」问题。


幂等性:不管对程序发起几次重复调用,程序表现的状态(所有相关的数据变化)与调用一次的结果是一致的话,就是保证了幂等性。


这意味着可以根据需要重复或重试操作,而不会导致意外的影响。对于非幂等操作,算法可能必须跟踪操作是否已经执行。


所以,一旦某个功能支持「重试」,那么整个链路上的接口都需要考虑幂等性问题,不能因为服务的多次调用而导致业务数据的累计增加或减少。


满足「幂等性」其实就是需要想办法识别重复的请求,并且将其过滤掉。思路就是:


  1. 给每个请求定义一个唯一标识。

  2. 在进行「重试」的时候判断这个请求是否已经被执行或者正在被执行,如果是则抛弃该请求。


第 1 点,我们可以使用一个全局唯一 id 生成器或者生成服务。 或者简单粗暴一些,使用官方类库自带的 Guid、uuid 之类的也行。


然后通过 rpc 框架在发起调用的客户端中,对每个请求增加一个唯一标识的字段进行赋值。


第 2 点,我们可以在服务端通过 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 没法继续进行)。


总结

这篇我们先聊了下做「补偿」的意义,以及做补偿的 2 个方式「回滚」和「重试」的实现思路。


然后,提醒你要注意「重试」的时候需要考虑幂等性问题,并且 z 哥也给出了一个解决思路。


最后,分享了几个针对「重试」的最佳实践。


希望对你有所帮助。


Question:


你之前有哪些时候是通过自己人工来做「补偿」的经历吗?欢迎吐槽~


关于作者:张帆(Zachary),7 年电商行业经验,5 年开发团队管理经验,4 年互联网架构经验,目前任职于某垂直电商技术总监。专注大型系统架构、分布式系统。坚持用心打磨每一篇原创。本文首发于公众号:跨界架构师(ID:Zachary_ZF)。


2019 年 1 月 04 日 10:069797

评论 1 条评论

发布
用户头像
每篇都看完了, 受益匪浅,期待新的分享,感谢!
2019 年 01 月 11 日 10:38
回复
没有更多了
发现更多内容

大型互联网公司技术方案与手段浅析

俊俊哥

分布式 高可用 大型软件 高并发 解决方案

极客时间 - 架构师培训 - 4 期作业

Damon

一文带你学会 Blob(含 7 个使用场景)

pingan8787

Java 前端 Web Blob

架构师训练营 - 第 4 课总结 -20200627- 互联网架构设计

👑👑merlan

架构设计 互联网架构

架构师课程第四周 作业

杉松壁

架构师训练营 - 第四周 - 学习总结

stardust20

关于编码的一点“思考”

damnever

golang 思考 抽象 分层架构 编码

Week4 学习总结

wyzwlj

极客大学架构师训练营

信息的表示与存储-浮点数的运算

引花眠

计算机基础

清华百万年薪架构师,精心编写多线程与高并发实战PDF

互联网架构师小马

Java 程序员 多线程 架构师 多线程与高并发

CECBC带你一图看懂区块链

CECBC区块链专委会

CECBC 区块链技术 去中心化

通过Python来获取北京市乡镇、街道行政区划数据

Puran

Python GIS geopandas QGIS 天地图

架构师训练营 -- 第四周作业

stardust20

【源码系列】Spring Cloud Eureka

Alex🐒

源码 Spring Cloud Eureka

消息队列(三)如何保证消息不被重复消费?

奈何花开

Java MQ 消息队列

阿里待遇那么好,你为什么从阿里离职?

互联网架构师小马

Java 阿里巴巴 程序员 找工作 离职

区块链冷链食品追溯系统

CECBC区块链专委会

区块链技术 上链 溯源 浙冷链

原来使用Postman如此简单,API测试之Postman使用全指南

软测小生

接口 Postman 接口测试 API API测试

架构第四周 - 学习总结

J.Smile

极客大学架构师训练营

ARTS|Week 5 有效的括号、API和地图

Puran

LeetCode ARTS 打卡计划

消息队列(二)如何保证消息队列的高可用?

奈何花开

Java MQ 消息队列

ARTS打卡 第5周

引花眠

ARTS 打卡计划

架构师训练营 - 第四课作业 -20200701- 架构演化

👑👑merlan

极客大学架构师训练营

架构师训练营 - Lesson Week 4

brave heart

极客大学架构师训练营

使用数据卷管理数据 | Docker 系列

AlwaysBeta

Docker 容器 数据 容器技术

太厉害了!阿里年薪120W架构师整理的学习笔记,看完收获良多

互联网架构师小马

Java 学习 阿里巴巴 程序员 架构师

《架构师训练营》第四周总结

学习总结 - 第 4 周

饶军

阿里巴巴的发展史(组织变革+技术变革)

王锟

阿里巴巴

每周学习总结 - 架构师培训 4 期

Damon

SQL运行内幕:从执行原理看调优的本质

arthinking

MySQL 数据库

2021 ThoughtWorks 技术雷达峰会

2021 ThoughtWorks 技术雷达峰会

99%的人都能看懂的「补偿」以及最佳实践-InfoQ