用try-except意外废掉重试机制:一次线上事故的复盘与修复指南 凌晨两点半监控告警把我从床上叫醒。订单同步失败率从 1% 飙升到 40%看着第三方支付网关的查询成功率曲线一片飘红我的第一反应是网关挂了。结果登上跳板机一看日志满屏都是我们自己打的业务异常。那一瞬间我知道问题不在外部而是我们上周刚合进去的 20 行 try-except把原本跑得稳稳的重试机制整个废掉了。这段经历我后来复盘了很久。它不是某个新人的低级错误而是一类“看着在加固、实际在拆台”的典型代码改动。今天把它完整写出来重试机制是怎么被 try-except 短路的、为什么 review 时没人发现、正确的写法应该是什么以及遇到线上事故时怎么快速定位这类问题。1. 重试机制本质上是一套“决策系统”很多同学对重试的理解是“失败了我再调一次”。这个理解不能说错但太粗糙。真正跑在生产环境的重试是一个有明确退出条件的循环状态机它靠四个要素活着循环入口、失败识别、退出通道、退避策略。这四个要素任何一个被破坏重试就名存实亡。先看一个最简单的重试结构for attempt in range(3): try: resp call_third_party(order_id) if resp[code] 200: return resp[data] except NetworkTimeout: time.sleep(0.5 * (2 ** attempt))这段代码里循环入口是for失败识别是except NetworkTimeout退出通道是return退避策略是time.sleep。看起来很完整但它有个隐蔽的弱点一旦出现NetworkTimeout以外的异常比如KeyError、JSONDecodeError、ConnectionResetError整个循环会直接中断异常一路冒泡到外层。这个“未被识别的失败”会绕过所有重试决策。这就要说到异常处理的核心位置问题。重试代码里try-except 一共有三个可以放的位置捕获位置含义对重试的影响单次尝试内部捕获后决定本次尝试的结果正常决策点循环可控循环内部、单次尝试外部捕获后决定是否继续循环部分场景可用需配合计数整个循环外部捕获后整条链路结束危险极容易短路重试一个合格的重试机制真正关心的只有两件事这次失败是不是“值得重试”的失败如果值得退避多久再试。而“值得重试”这件事必须由代码通过异常类型来显式表达不能靠返回值去猜更不能在循环外层一次性兜住。我用一个生活化类比解释一下。重试机制就像你给一个重要客户打电话第一次没打通你等一会再打第二次还是没打通再等更久再打打了三次都没通你才判定“今天联系不上”去走人工备案流程。现在有个人在旁边给你加了一道保险他不管电话为什么没通只要听到任何异常动静就直接把电话挂断然后跟你说“客户那边出问题了我们走人工吧”。你原本准备的“再打一次”的机会一次都没用上。这就是那 20 行 try-except 干的事。2. 那 20 行 try-except 是怎么废掉重试的2.1 事故现场还原改之前的稳定结构我们的订单同步服务里有一段核心逻辑从本地数据库取出待同步的订单号调用第三方支付网关的查询接口根据返回状态更新订单状态。这个接口偶发超时比例大概在 2% 左右所以代码带着重试上线的头两个月非常稳定几乎没出过问题。改之前的代码大概长这样四十行出头def sync_order(order_id): max_retries 3 for attempt in range(1, max_retries 1): try: resp query_third_party(order_id) if resp[code] SUCCESS: update_order_status(order_id, PAID) return True elif resp[code] PENDING: time.sleep(2) continue else: # 其它业务码暂时当成可重试处理 raise QueryError(resp[code]) except NetworkTimeout: logger.warning(第 %d 次尝试超时, 准备重试, attempt) time.sleep(0.5 * (2 ** (attempt - 1))) mark_order_exception(order_id) return False注意最后一行三次重试全部用尽之后才把订单标记为异常交给人工处理。这是“先重试、后兜底”的正确顺序。然后需求来了。第三方偶尔会在某些边界条件下返回签名错误这种错误重试多少次都不可能成功业务方希望出现这类错误时订单能第一时间进入人工处理而不是被重试耽误几秒。需求本身是合理的问题出在实现方式上。实现的同学拿到需求后思路是“给函数加一个安全网”不管什么异常都要兜住不能让过程静默挂掉而且遇到不可重试的错误要立刻标记人工处理。于是他加了二十来行代码变成了下面这样。2.2 反模式一把整个循环包进大 try在 except 里做终态操作这是事故的直接原因。大致改动如下def sync_order(order_id): max_retries 3 try: for attempt in range(1, max_retries 1): try: resp query_third_party(order_id) if resp[code] SUCCESS: update_order_status(order_id, PAID) return True elif resp[code] PENDING: time.sleep(2) continue else: raise QueryError(resp[code]) except NetworkTimeout: logger.warning(第 %d 次尝试超时, 准备重试, attempt) time.sleep(0.5 * (2 ** (attempt - 1))) except Exception as e: logger.error(查询出现未知异常, 订单标记人工处理: %s, e) mark_order_exception(order_id) return False从结构上看“外面包一层 try-except”似乎只是多了个安全网。但它引入了一个致命变化内层只捕获NetworkTimeout而新的“安全网”捕获的是所有异常。这意味着只要query_third_party内部出现任何不属于NetworkTimeout的异常——比如第三方返回 500、连接被重置、JSON 解析失败、resp[code]抛KeyError——异常就会跳过内层except直接突破整个for循环被最外层的except抓住。后果是什么原本这些异常只是“第 1 次尝试失败”应该触发第 2 次重试。现在却直接执行了mark_order_exception(order_id)把订单标记成了终态。原本 2% 的可重试失败瞬间变成了 100% 的不可逆失败。重试次数从3直接变成1甚至变成了“0.5”——因为连这一次尝试的结果都没能被正确评估。真正让问题恶化的是mark_order_exception本身会写库。它把一个还在“同步中”的订单直接推到了“人工处理”状态。人工处理一旦介入业务链路就把它当成最终结果后面的定时补偿任务也被绕开了。一条本来可以自愈的订单就这样被白白打进了异常堆。2.3 反模式二在 except 分支里“顺带”做业务兜底还有一种更常见的写法看起来不像第一个那么“明显”但同样会把重试废掉。它的特点是把业务兜底逻辑直接塞进了每次尝试的异常处理里def sync_order(order_id): for attempt in range(3): try: resp query_third_party(order_id) if resp[code] SUCCESS: update_order_status(order_id, PAID) return True except Exception as e: logger.error(第 %d 次失败: %s, attempt, e) mark_order_exception(order_id) return False time.sleep(0.5 * (2 ** attempt)) return False这个版本的危害更隐蔽。它看着有重试循环也确实执行了循环但只要任何一次尝试抛异常函数就在except里直接return False。等于说不管这次是不是可重试的瞬时故障也不管还有多少次重试机会都被提前结束了。有同学可能会说那我把return False去掉不就能继续重试了吗问题是mark_order_exception这个动作本身是终态操作。订单一旦被标记为异常即使函数继续重试并成功订单状态也已经错了。所以这种写法的问题不只是“要不要继续循环”而是把终态操作放进了每一次尝试的失败路径里。正确的做法是终态操作只能放在重试次数耗尽之后不能放在单次失败的异常分支中。2.4 反模式三在底层函数里吞异常返回 None/False另一个我见过很多次、但不容易和重试扯上关系的问题是底层被调用的函数自己包了 try-except然后在异常时返回一个“伪成功”的默认值def query_third_party(order_id): try: raw http_client.get(QUERY_URL, timeout3) return parse_response(raw) except Exception: return None # 异常被吞掉了调用方看到的永远是“函数没抛异常就是返回了 None”。如果你再往上包一层重试逻辑for attempt in range(3): data query_third_party(order_id) if data: return data time.sleep(1)初看好像问题不大失败会触发下一次循环重试还生效。但这里有一个严重隐患——重试机制的失败识别完全失效了。它把“网络超时”“接口 500”“JSON 解析失败”这些不同类型的失败统统折叠成一个None调用方没法判断“这次到底该不该重试”。如果错误是不可重试的签名错误程序还是会傻傻地重试三次如果错误是快速失败的参数错误程序同样会重试白白增加下游压力。更闹心的是排查问题的时候日志里干干净净没有任何异常栈只有无穷无尽的“data is None”排障成本直线上升。我见过一个项目为了“让接口更稳定”从底层函数一路吞异常到最上层每个函数里都是 try-except return None。最后重试层判断了 N 次 None 之后还是不知道失败原因只能把所有可疑的异常打印都删掉用一个全局兜底日志糊弄过去。这种代码跑起来就像是“盲人开车遇到障碍物就停下来按喇叭但从来不知道障碍物是石头还是人”。3. 正确写法把捕获位置和异常分类一次想清楚3.1 自定义异常类型可重试与不可重试必须分开我把这个模块重写之后给自己定了一条硬规矩所有可能触发的失败先分类再谈重试。分类的标准是这次失败在下一次尝试中有没有可能成功。按这个标准异常天然分成三类可重试的瞬时故障网络超时、连接被重置、下游 429/500/502/503/504。这类失败往往是服务短暂不可用或网络抖动的结果隔一两秒再试大概率能成。不可重试的确定性错误参数格式错误、签名错误、404、业务方明确拒绝。这类失败无论重试多少次都不可能改变结果。未知错误暂时无法确定的异常。保守起见可以纳入重试但必须受次数限制最终也要显式抛出或落到兜底流程。在 Python 里我习惯用自定义异常类来承载这个分类信息而不是靠异常消息里的字符串去判断class RetryableError(Exception): 可重试的瞬时故障超时、连接错误、下游 5xx、限流 429 等 class FatalError(Exception): 不可重试的确定性错误参数错误、签名错误、404、业务拒绝等这样做的价值是把“要不要重试”从一个运行时的环境判断变成一个类型层面的编译期或解释期约定。调用方只需要捕获对应的异常类型就能立刻知道该怎么做。3.2 一套可以直接抄作业的重试模板下面这个模板是我目前用在生产环境里的完整版本你在自己的服务里做类似改造时可以直接照搬。代码里我用三次重试 指数退避 抖动 耗尽后统一兜底import random import time import logging logger logging.getLogger(__name__) MAX_RETRIES 3 BASE_BACKOFF 0.5 # 首次退避基数 MAX_BACKOFF 8 # 退避上限 class RetryableError(Exception): 可重试的瞬时故障 class FatalError(Exception): 不可重试的确定性错误 def call_third_party(order_id): 底层函数不吞异常只负责把失败归类。 - 瞬时故障抛 RetryableError - 确定性错误抛 FatalError - 未知异常让 Exception 继续向上抛由上层兜底 resp http_get(f/order/{order_id}/status, timeout(2, 5)) if resp.status_code 200: return resp.json() if resp.status_code in (429, 500, 502, 503, 504): raise RetryableError(fthird party returned {resp.status_code}) raise FatalError(fthird party returned {resp.status_code}) def sync_order(order_id): last_error None for attempt in range(1, MAX_RETRIES 1): try: data call_third_party(order_id) if data.get(status) SUCCESS: update_order_status(order_id, PAID) return True # 响应格式异常按可重试处理留给下一次机会 raise RetryableError(funexpected response: {data}) except RetryableError as e: last_error e if attempt MAX_RETRIES: break # 指数退避 随机抖动避免多个任务同时重试 sleep_time BASE_BACKOFF * (2 ** (attempt - 1)) sleep_time min(sleep_time, MAX_BACKOFF) sleep_time * random.uniform(0.8, 1.2) logger.warning(第 %d 次尝试失败(%s)%.2f 秒后重试, attempt, e, sleep_time) time.sleep(sleep_time) except FatalError as e: logger.error(不可重试错误直接终止: %s, e) raise # 走到这里说明所有重试都耗尽了 logger.error(重试全部耗尽最后一次错误: %s, last_error) mark_order_exception(order_id) raise last_error这个模板有三个关键设计值得细看。第一个是**“底层只抛不吞”**。call_third_party里没有任何except Exception: return None。成功的路径和失败的路径非常清晰重试层能拿到完整的异常信息不会丢失任何排查线索。第二个是**“终态操作放在循环结束之后”**。mark_order_exception只在重试次数彻底耗尽之后执行单次失败不会触发它。这样就保证了“可重试失败”永远不会被误标记为“最终失败”。即使外部条件要求“不能静默失败”也应该在明确了“确实已经重试完 N 次”的前提下再走兜底流程。第三个是**“重试失败后显式抛出”**。重试机制不是保险箱它的职责是“给三次机会”而不是“保证成功”。如果三次都失败代码必须把最后一次异常抛出去让上层链路感知到失败。这个看起来和“兜底”的要求冲突但实际上完全不冲突mark_order_exception落库之后异常继续向上抛定时任务和监控系统都能根据异常做后续处理。怕就怕把异常吞了业务以为没事监控也看不到。3.3 重试参数的选取次数、退避、超时、抖动写过一段时间重试代码之后你就会发现参数比代码本身更容易出问题。我们线上多次踩坑之后形成了一套默认值不同场景可以按照下表调整参数默认值选取理由最大重试次数3 次超过 3 次后问题往往不是偶发抖动继续重试只会放大压力首次退避0.5s给下游一个喘息窗口同时避免用户等太久退避因子2 倍最常用的指数退避增长快且可控退避上限8s防止长时间等待导致任务堆积抖动比例±20%打破多个任务的同步重试节奏避免“重试风暴”抖动这一点必须强调。想象一下线上有 2000 个订单同时失败如果你不做抖动0.5 秒后这 2000 个请求同时打向第三方网关网关被流量打挂下一次 1 秒后又来 2000 个。这种“自我造成的雪崩”在真实环境中非常常见。加一个random.uniform(0.8, 1.2)的乘数就可以让请求分散到一个时间区间里成本几乎为零效果立竿见影。另外重试的前提是幂等。查询类接口天然幂等放心重试没问题如果是扣款、关单、发货这类有副作用的操作你的重试请求必须携带幂等键否则一旦第一次请求其实成功、只是响应丢失重试就会造成重复操作。这部分不在今天代码的范围内但每次讨论重试我都提醒一句不保证幂等就别写重试。4. 线上排查怎么发现“重试被废掉了”4.1 可疑信号失败率曲线背后的三个线索回到开头的线上事故。当时我们看到的现象是第三方查询成功率曲线一路向下达到 40% 的失败率。但如果重试机制正常工作最终业务成功率应该仍然接近 100%因为大部分失败只是瞬时抖动重试一次就能恢复。所以第一个可疑信号就是下游失败率上升的同时业务最终成功率也在同步大幅下降——这基本可以断定重试层没生效。第二个信号是日志里的异常关键词。如果日志里大量出现业务端的终态操作日志我们这里是“订单标记人工处理”而没有对应的重试日志说明失败发生后直接走了终态路径。第三个信号是重试次数分布。正常健康系统的重试次数应该像一个递减的柱子大部分请求 0 次重试成功、少部分 1 次重试成功、极少数 3 次全部耗尽。如果突然有一天几乎所有失败请求都直接终结没有任何重试分布基本上就是短路。4.2 定位到那 20 行git diff 和复现场景定位过程其实不复杂。先看监控发现失败率从某个发版时间点开始抬升于是直接去翻那次发版的 diff。那 20 行 try-except 几乎一眼就能扫出来——它们不属于任何逻辑分支只是围着整个循环包了一圈。这就是嫌疑犯。但光看代码还不能 100% 确定问题需要复现。我在测试环境做了一次模拟写一个 mock 的query_third_party第一次返回 500模拟瞬时故障第二次返回 200 成功。改动前的代码能够顺利重试并返回成功改动后的代码第一次 500 就被外层except Exception捕获然后直接mark_order_exception日志里根本没有任何重试打印。对比实验做完证据链就闭合了。这次排查给我的收获是遇到“别人说稳定我一改就炸”的代码优先怀疑异常处理层的粒度和位置而不是怀疑业务逻辑本身。因为业务逻辑的 bug 通常能通过单元测试发现而异常处理的结构问题恰恰是测试用例最不容易覆盖到的盲区。4.3 修复后的验证与监控建议修复方案就是第三节里那套模板自定义异常类型、循环内捕获、终态操作放在重试耗尽之后。改完之后我做了一轮验证在测试环境模拟 10% 的瞬时故障率连续跑 1000 次同步任务确认重试日志按预期打印业务成功率恢复到 99% 以上。然后又验证了确定性错误路径签名错误直接抛FatalError订单立刻进入人工处理没有等待重试。监控层面我给这个模块加了几个长期看的指标retry_total所有重试触发次数用来观察故障导致的波动。retry_success_total重试后成功的次数这个值高说明重试机制在干活。retry_giveup_total重试全部耗尽后放弃的次数这个值持续升高需要赶紧排查下游健康度。fatal_error_total确定性错误导致的直接失败次数用来发现参数、签名类的代码问题。有了这些指标以后再想判断“重试层是不是被废掉了”看一眼retry_giveup_total和重试成功率的比值就行不用再靠半夜爬起来刷日志。5. 这次事故之后我沉淀下来的几条铁律第一捕获点决定决策权。可重试异常的捕获必须发生在“单次尝试”内部终态操作的兜底必须放在“重试次数耗尽之后”任何跨越这两个边界的大 try-except 都是在剥夺重试机制的决策权。第二用异常类型表达重试语义不要用返回值猜。return None只会让重试层丢失信息而自定义异常类可以让“是否重试”这个判断变成代码结构的一部分。第三任何 except 都尽量不要吞异常。至少要把异常栈完整打到日志里。重试代码里出现一个连日志都不打的except Exception: pass后面的排障会让你怀疑人生。第四重试不是一个字段、一个装饰器或者一屏代码的事它是“次数 超时 退避 幂等 异常分类”的组合问题。任何一个参数配错、任何一个层级放错整体就可能失效。第五如果一段代码里 try-except 超过三个停下来重新看一眼设计。我见过大量的重试问题其实不是 try-except 语法用错了而是函数边界没想清楚哪里该捕获、哪里该上抛、哪里该兜底全混在一起。这次事故之后我写重试逻辑的习惯变成了一句话先把不 try-except 的版本写出来再一个个往里加。每加一个捕获都要问自己一句“这个异常发生后我应该继续试、直接失败、还是换一种方式试” 想明白这个问题再动手比事后靠告警教你做人要省心得多。