重试不是越多越好:退避、抖动和幂等怎么配合
前几天朋友问我,他的脚本老失败,于是给每个请求都套了三层重试。结果失败是少了点,耗时却翻了好几倍,偶尔还把对方的接口搞得返回来一堆重复数据。他问我,重试到底该怎么写。
说实话,这问题我太熟了,因为我自己也这么干过。重试就几行代码,可要写对,里面门道不少。
为什么会想到重试
网络这层本来就不稳。一次请求没成,未必是对方把你拒了,也许只是这一下丢了个包,也许对方正好在重启,还有可能是你撞上了它的限流窗口。这类碰运气的失败,隔一小会儿再来一次,往往就过去了。
所以重试是给偶发故障用的,不是给铁定复现的错误用的。这个前提想不清楚,后面全白搭。要是错误每次必现,比如参数写错、权限不够、接口压根不存在,那你重试一百遍也还是同一个结果,白耗时间和资源,弄不好对方还觉得你在恶意刷它。
我现在动手之前会先问自己一句:这个错误,等两秒再来一次,结果会不会不一样?会,那就值得重试;不会,那就别绕弯子,回去老老实实改代码。
我见过最多的三种写法
第一种,固定间隔猛冲。重试三次,间隔全设成 0 秒或者 100 毫秒。对方一过载,你的请求就和一堆别人的请求一起涌上去,把它压得更喘不过气,最后一锅端。
第二种,什么错都重试。404 也重试,500 也重试。可 404 是东西没了,重试并不能让它冒出来;真该重试的只是那些临时的、服务端掉链子的错误。
第三种,忘了设上限。有人重试不设次数,配着固定间隔,理论上能跑到地老天荒。线上真出一次问题,线程全卡那儿,整个服务跟着遭殃。
退避与抖动
改法叫退避,也就是每重试一次,等待时间比上一次长一点。最简单的做法是每次翻倍,涨得快,对方过载时你能迅速让路。嫌涨太猛,也可以用线性增长,每次多加固定的一段。
参数我心里有套常用的:基础间隔 1 秒,每次翻倍,单次最长别超过 30 秒,重试 3 到 4 次。这样算下来,总等待大概是十几秒到一分钟,多数偶发故障都能兜住。
光有退避还不够。要是同一批任务在同一秒全失败,退避又是固定倍数,它们就会齐刷刷地在同一刻重试,然后又一齐失败,如此往复。这个场景有个名字,叫重试风暴。
治法是在等待时间上撒一把随机,业内叫抖动。原本要等 8 秒,就让它落在 4 到 8 秒之间的某个随机点。每个任务错开了,就不会全挤在门口。这招几乎不花钱,我却觉得它是重试里最值的一个细节。
退避也不是越久越好。有人一听要退避,直接把间隔设成几十秒,结果一次小抖动就让任务干等半分钟,整体效率掉得厉害。退避的目的是避开那一小段拥堵,不是让你停下来睡大觉。间隔该给多少,得看对方恢复正常大概要多久,实在拿不准,就从小往大调,别一上来就拉满。
哪些错误能重试
把错误分个类,会清楚很多。连接超时、读取超时、连接被重置,这些都值得再来一次;服务端的 5xx,尤其 502、503、504,通常也值得。429 表示请求太多,也能重试,但得看对方给的 Retry-After 头,人家说等多久就等多久,别自作聪明。
那 4xx 呢?除了 408 和 429,剩下的基本别重试。401、403 是身份问题,你再来一遍还是那张脸;404 是没有;400 是请求本身就写错了。这些错误赶紧记下来去查,比你反复撞墙有用。
一句话的判据:这个错误,是不是缓一缓可能会好。是,就重试;不是,就省省。
幂等:最容易被忘的一环
这点我吃过亏,所以特别想强调。重试的意思是,同一个操作可能被执行两遍甚至更多遍。要是这个操作本身不允许重复,麻烦就来了。
所谓幂等,是执行一次和执行多次,最终结果一样。读取、查询这种,天生幂等,随便重试;可下一单、扣一次库存、发一条消息这种带副作用的,重试就可能冒出一份多出来的。
所以写操作要么做成幂等,比如每次带一个唯一的请求号,服务端看到重复号就直接忽略;要么干脆不重试,把失败交给上层去判断。我刚写重试那会儿完全没这根弦,结果一批任务重复提交,事后清理得我头大。
判断也不难。问自己:如果对方其实已经收到了、只是回执在半路丢了,我再发一次,会不会出事?会,那就别盲目重试。
顺便说下幂等号怎么给。简单点的做法,是用一个能唯一标识这次业务的字符串,比如订单号,原样带在请求里;复杂点的,可以拿业务字段拼一个摘要。关键是这个号要对同一次业务保持稳定——你重试的时候得带同一个号,不能每次重新生成,不然服务端看到的就是两个不一样的请求,照样重复处理。
封装起来
散着写 try/except 太乱,策略也不好统一。我一般封个小函数,把退避、抖动、次数、该不该重试的判断都塞进去,业务代码管调用就行。
importtimeimportrandomimportrequestsdefshould_retry(status=None,exc=None):ifexcisnotNone:returnisinstance(exc,(requests.ConnectionError,requests.Timeout))ifstatusisNone:returnFalseifstatus==429orstatus>=500:returnTruereturnstatus==408defrequest_with_retry(method,url,retries=4,base=1.0,cap=30.0,**kwargs):forattemptinrange(retries+1):try:resp=requests.request(method,url,**kwargs)ifresp.status_code<400ornotshould_retry(status=resp.status_code):returnrespifattempt==retries:returnresp delay=min(cap,base*(2**attempt))delay*=(0.5+random.random()*0.5)time.sleep(delay)except(requests.ConnectionError,requests.Timeout):ifattempt==retries:raisedelay=min(cap,base*(2**attempt))delay*=(0.5+random.random()*0.5)time.sleep(delay)几个地方顺带提一句。退避用 2 的幂,外面套个 cap,别让它无限翻。抖动取半区间到全区间的随机,目的是把大家的节奏打散。重试的触发条件收窄,只认连接错误、超时和那几个状态码,其余原样返回。
另外有个小坑:requests 的超时一定要显式写,不然某些情况下会一直挂着不返回,你的重试逻辑根本没机会跑。这事我踩过,记到现在。
一个让我长记性的例子
说个真事。有回我做一个对账任务,给扣减接口加了重试。跑了没两天,账面上冒出来一批重复扣减,客户找上来我才发现。查了半天,问题就出在那个接口不幂等,而我重试的时候又没带唯一号。那次之后我给自己立了条规矩:凡是要重试的写操作,先问清楚它重复执行会不会出事,问不明白就先别加重试。
后来我给那个接口补了一个业务单号,服务端认单号,重复的请求直接丢。补完之后,同样的问题再没出现过。代价其实很小,就是一个字段,可没这个字段之前,我怎么调重试参数都没用。
重试和超时怎么搭
这两个常常一起出现,也常常一起被配错。超时管的是这一次请求等多久算失败,重试管的是失败之后要不要再试。它们得配合着设,单看哪一个都不对。
我一般的思路是,单次超时别设太长,几秒到十几秒,够用就行;总的等待时间要有个上限,别让一个任务卡在那里几十分钟。重试次数乘以单次超时,再加上退避的等待,不该超过你业务能容忍的最长时间。超出这个时间的失败,宁可让它报错,也别硬拖着。
还有个细节,重试的时候通常会把超时稍微放大一点。第一次 5 秒就断,第二次不妨给到 8 秒,给对方多一点喘息的余地。具体加多少看场景,但把这个念头带上,比全用同一个值更从容。
日志别偷懒
加了重试,排查反而变难,因为一次失败背后可能藏着好几次尝试。我的习惯是每次尝试都记一条,写清楚第几次、耗时多久、为什么败。回头一看就明白,是对方一直不听话,还是我自己的策略太激进。
要是日志里只有干巴巴一句请求失败,那基本等于没记。
最后唠一句
重试的参数网上有现成的,抄一抄很快,难的是知道自己为什么这么设。我见过太多人,把重试当成一块遮羞布,以为加上了就等于稳了,结果把真正的问题盖住,等到量一大才集中爆出来。参数是死的,判断是活的,花点时间想清楚上面那几个问题,比背参数管用得多。
也说句实在的,重试做得再漂亮,也救不了一个本身就不该重试的操作。前面幂等那一节,才是这套东西的地基。地基没打,楼盖得越高越危险。我自己就是先摔了一跤,才回头补的这门课。
小结
重试这东西,目标不是把失败硬盖过去,而是给偶发毛病一次机会,同时别引出新麻烦。退避管节奏,抖动管错开,幂等保安全,日志让你看得见。四样凑齐,才算写得靠谱。
我现在的体会是,真稳的程序不是从不失手,而是摔了能自己爬起来,还不留尾巴。
互动时间:你在项目里重试一般设几次?有没有被重试风暴坑过?评论区聊聊。
免责声明:本文为原创技术分享,涉及的思路仅供学习与合法用途参考。请遵守目标平台的服务条款与相关法律法规,勿将技术用于任何违规场景。