高峰期配置服务超时,线上告警刷屏。打开日志,我看到了最诡异的一幕:重试配置明明生效,日志里却永远只有一次attempt=1,之后就没了动静。排查到最后,凶手是我自己写的 20 行 try-except——它们把该抛出去的异常全部接住,然后若无其事地return None。重试机制靠异常信号驱动,信号没了,重试自然就停了。这篇文章不卖关子,直接把事故现场、代码根因、重构方案和排查清单全部分享出来,希望能帮到那些正准备给服务加“保险”的同行。
1. 事故现场:20行 try-except 如何废掉了整套重试机制
1.1 设计初期的“美好想法”
先说我原本的架构。当时我们在做一个配置下发服务,配置中心挂了三个节点,偶发超时是家常便饭。为了提升容错,我在调用链路上引入了重试机制,核心是一个自定义的重试装饰器。设计的意图很简单:接口调用失败时,由装饰器捕获异常并决定是否重新发起请求。
@retry(max_attempts=3, interval=0.5, backoff=2) def fetch_config(key: str) -> dict: return client.get(f"/config/{key}").json()重试器的实现逻辑也很直白:wrapper 内部 try 调用被装饰的函数,一旦捕获到指定异常类型,就 sleep 一段时间后继续下一次尝试。换句话讲,重试机制完全依赖“函数抛异常”这个信号来驱动。
我当时的想法是:网络请求这种不可控操作,万一抛异常把主线程搞崩怎么办?于是手一抖,在fetch_config里又加了一层保护:
@retry(max_attempts=3, interval=0.5, backoff=2) def fetch_config(key: str) -> dict: try: resp = client.get(f"/config/{key}", timeout=3) return resp.json() except Exception as e: logger.error("请求失败,返回 None: %s", e) return None当时自己还挺满意,觉得异常被兜住了,主流程不会因为配置中心抖动而挂掉,返回None之后上层再做兜底判断就行。现在回头看,这哪里是防御性编程,这分明是把重试机制的逃生通道给堵死了。
1.2 日志里“永远只有一次”的诡异现场
上线后的表现相当迷惑:调用端拿到的全是None,配置项全部默默变成了默认值。因为fetch_config内部把异常全部吞掉并返回None,重试器看到函数“正常返回”,自然不会启动第二次尝试。日志里清一色是“第 1 次 attempt”的调用记录,每条请求都像被人按下了暂停键。
最让我接受不了的是,整个链路的代码逻辑看起来“无比正确”:有 try、有 except、有日志、有返回值。可正是这种看似正确的写法,让所有监控都失真了。异常计数器显示 0,重试率显示 0,系统就像是死得很安详。
到这一步我才真正想明白:异常不是“错误”,它在重试机制里就是驱动信号。把信号吞掉,等于把重试器变成一个聋子。函数假装成功,重试器真的以为很成功。
1.3 问题的根源:异常不是错误,是指令
很多同学写 try-except 时有个错觉:异常越少越好,只要接住就不让堆栈露出来。这种想法把“日志可读性”和“系统健壮性”混为一谈了。在重试场景下,异常是唯一合法的指令信号:函数告诉调用方“我没完成任务,你看着办”,重试器接住之后决定“要不要再来一次”。一旦你把它转成普通返回值,指令就变成了“一切正常,请放心使用”。
这不是简单的信息丢失,而是整个系统的语义被改写了。一个None回来,上层代码根本分不清是“配置不存在”“服务超时”还是“响应体解析失败”,只能傻傻地走默认值分支。更恐怖的是,一旦上游持续异常,这种“健康假象”还会自动掩盖故障,直到业务指标全线告警才被发现。
2. 为什么“捕获异常”会悄悄变成“吞掉异常”
2.1 异常的本质不是错误,而是带上下文的控制流
我在复盘时把问题拆成三类:吞、宽、乱。
- 吞:
except之后既不raise,也不返回明确错误标记,异常信息从此消失。 - 宽:
except Exception甚至裸except,把程序自身的 bug,比如AttributeError、KeyError、TypeError,也当成网络故障来兜。 - 乱:try 包裹范围过大,把业务逻辑错误、响应解析错误全卷进来,catch 到的异常根本不是最初的请求异常。
以我的代码为例,最狠的是“吞”和“宽”的组合拳。TimeoutError、ConnectionError会被接住,我自己代码里写错的KeyError也会被接住。后两者其实是代码 bug,既不值得重试,更不应该被静默处理。这就像把所有门铃都拆了,只留一个不太响的旧铃铛,出了事也分不清是客人来了还是小偷在撬门。
2.2 三个隐藏炸弹:吞、宽、乱
炸弹一:except Exception看上去安全,实际上把网络异常、解析异常、代码逻辑异常全部混在一起。对于一些更底层的SystemExit、KeyboardInterrupt,except Exception确实接不住,但裸except会连这种进程级信号一起吞,应用连 Ctrl-C 都退不干净。
炸弹二:日志记录之后return None。这是最危险的一步,因为它把异常现货转换成了业务空值。上游拿到None之后还得猜:是配置不存在?还是服务挂了?还是代码逻辑有 bug?每一个猜测的代价都比直接抛异常高得多。
炸弹三:在循环体内部写 try-except。如果你是自己手写重试用 for 循环包裹 try,一旦异常被接住后没有抛出去,第一轮循环就会“成功结束”,后续的重试迭代永远无法触发。这个坑尤其隐蔽,因为代码里明明写了循环,就是不执行第二轮。
2.3 现场代码逐行拆解
把那 20 行贴出来,逐段说明哪里出了问题:
@retry(max_attempts=3, interval=0.5, backoff=2) def fetch_config(key): try: # 第6行 resp = client.get(key, timeout=3) # 第7行 data = resp.json() # 第8行 if data["code"] != 0: # 第9行 return {} # 第10行 return data["result"] # 第11行 except Exception as exc: # 第12行 logger.error("请求失败: %s", exc) # 第13行 return None # 第14行问题集中在三处。第 12 行捕获范围过宽,把超时、5xx、JSON 解析错误、甚至data["code"]可能引发的KeyError全都混为一谈。第 13 行只写日志,没有重新抛出异常,也没有保留原始异常的完整堆栈上下文。第 14 行最致命:return None让重试装饰器误判调用成功,整个重试机制形同虚设。
这里补充一个细节:第 8 行resp.json()如果因为响应体不是合法 JSON 抛异常,也会被第 12 行抓住。可能上游网关真的挂了,也可能只是我自己设置请求头的方式不对。盲目返回None,导致下一步排查只能靠猜。
3. 重试机制要救回来,先建立正确的异常观
3.1 分清“可重试异常”和“不可重试异常”
重试不是所有异常都无脑重来。网络抖动、超时、上游返回 5xx,这些属于可重试异常,因为下一秒服务器可能就恢复了。但 404、403、参数校验失败、字段缺失,这些属于不可重试异常,就算重试一万次,结果也不会变。
我后来在项目里建立了一套异常层级,把分类写进代码里:
class ConfigError(Exception): """配置服务异常基类""" class ConfigTimeoutError(ConfigError): """请求超时,可重试""" class ConfigUnavailableError(ConfigError): """配置中心不可用,可重试""" class ConfigNotFoundError(ConfigError): """配置不存在,不可重试"""这样做的核心价值是:异常类型本身就是决策依据。重试器只需捕获ConfigTimeoutError和ConfigUnavailableError,完全不用关心具体是哪一种网络错误。业务方看到ConfigNotFoundError时也一目了然,可以直接响应“这个 key 真的没有”。
3.2 让异常往外跑,而不是往里憋
正确的写法不是把异常压成None,而是把底层的、不可直接理解的异常,转换成业务语义明确的自定义异常后继续向外抛。这一层“翻译”非常重要,它保留了原始原因,又给上层提供了清晰的决策信号。
@retry(max_attempts=3, interval=0.5, backoff=2) def fetch_config(key): try: resp = client.get(key, timeout=3) except (TimeoutError, ConnectionError) as exc: raise ConfigUnavailableError("配置中心请求失败") from exc if resp.status_code == 404: raise ConfigNotFoundError(f"配置不存在: {key}") if resp.status_code >= 500: raise ConfigUnavailableError(f"配置中心返回 {resp.status_code}") return resp.json()注意我用的是raise ... from exc。这样做会在异常链里保留最原始的TimeoutError,日志打印堆栈时能看到完整的上下文。你如果只在except里写pass,或者直接raise ConfigUnavailableError("请求失败")而不带from,最底层的真实原因就丢了,排查故障时只能看到一层被包装过的模糊描述。
3.3 指数退避、抖动与幂等性
重试不能是无脑的 for 循环,否则服务端刚有点恢复的迹象,所有客户端同时重试,瞬间又被打垮。业界标准做法是指数退避加抖动:
backoff_time = interval * (2 ** (attempt - 1)) sleep_time = backoff_time + random.uniform(0, jitter)其中jitter是随机抖动。加抖动的目的就是打散各客户端的重试节奏,避免“羊群效应”触发二次雪崩。这部分网上资料很多,但在我的实际项目里,interval=0.5、max_attempts=3、jitter=0.1的组合已经足够平稳。
重试另一个容易被忽视的前提是幂等性。重试发送同样的请求,上游可能第一次已经处理成功,只是响应包在网络上丢掉了。如果接口不是幂等的,重试就会造成重复下单、重复扣费。所以设计接口时要么用 GET 这类查询语义,要么带上幂等键,让上游去重。
4. 救火实战:从“吞异常”到“可重试异常”的完整重构
4.1 第一步:盘点业务对异常的真正需求
修复前我先列了一个需求清单,这件事比写代码更重要:
- 网络连接失败、超时:必须重试,大概率是瞬时故障。
- 配置中心返回 5xx:必须重试,服务端可能正在重启或流量过载。
- 配置中心返回 404/403:不重试,重试没有任何意义。
- 响应体解析失败:先查自身代码,重试不是第一选择。
有了这个清单,才能确定异常体系怎么建、重试器捕获哪些类型、上层如何降级。
4.2 第二步:定义异常体系并替换散落的 try-except
我把原有的“万能兜底”拆掉,在配置客户端内部统一做异常翻译。所有网络层异常都在入口处转换成自定义异常,再向外抛。这一步做完之后,业务代码里的 try-except 数量反而减少了,因为很多场景只需要依赖重试器统一处理,不需要每个调用点都写一遍兜底逻辑。
4.3 第三步:重构后的完整代码长什么样
重构之后,整条链路由三部分组成:
第一部分,底层客户端只负责“把底层异常翻译成业务异常”,不做任何返回值兜底:
def _request(key: str) -> dict: try: resp = client.get(key, timeout=3) except (TimeoutError, ConnectionError) as exc: raise ConfigUnavailableError("配置中心请求失败") from exc if resp.status_code == 404: raise ConfigNotFoundError(f"配置不存在: {key}") if resp.status_code >= 500: raise ConfigUnavailableError(f"配置中心返回 {resp.status_code}") return resp.json()第二部分,重试器只负责“捕获可重试异常并重新发起调用”:
@retry(max_attempts=3, interval=0.5, backoff=2, retry_on=(ConfigUnavailableError, ConfigTimeoutError)) def fetch_config(key: str) -> dict: return _request(key)第三部分,上层负责“真正拿不到配置时怎么降级”:
def get_config_with_fallback(key: str) -> dict: try: return fetch_config(key) except ConfigNotFoundError: return default_config_for(key) except ConfigError: logger.warning("配置服务不可用,使用本地缓存") return cache.get(key)这样职责就分清楚了:底层函数负责“用异常说明发生了什么”,重试器负责“根据异常类型决定是否再来一遍”,上层负责“最终都不行时如何降级”。不再有一个函数既发起请求、又吞异常、还返回None的多重身份。
4.4 第四步:用模拟服务验证重试行为
重构完不能只靠眼睛看,要真的验证重试有没有生效。我写了一个本地 mock server,让它第一次返回 503,第二次返回 503,第三次返回 200,然后观察调用日志。
# mock server 核心逻辑 def handler(request): count = state["count"] state["count"] += 1 if count < 2: return 503 return {"code": 0, "result": {"config": "mock"}}跑一遍之后,日志里出现了三条 attempt 记录,时间间隔符合指数退避,第三次返回了正确结果。接着再把 mock server 改成永远返回 503,确认调用方最终抛出ConfigUnavailableError,并成功走了本地缓存降级分支。这个验证过程只花了几分钟,却彻底确认了修复生效。
5. 异常处理避坑清单:这些反模式我全踩过
5.1 try-except 反模式速查表
把常见的反模式整理成了表格,方便大家直接对照检查:
| 反模式 | 危害 | 正确做法 |
|---|---|---|
except Exception: return None | 吞掉异常信号,重试器失效 | 让异常冒泡,或转为自定义异常后抛出 |
裸except: pass | 连进程级信号都吞掉 | 明确捕获目标类型,或用except Exception并在最后raise |
| try 包住整个重试循环 | 重试逻辑被短路 | 单次调用放 try,重试交给外层装饰器 |
| 捕获后仅打日志 | 日志刷屏但系统无任何动作 | 日志之后必须根据策略抛出或处理 |
except里做清理操作 | 清理操作再抛异常会覆盖原异常 | 清理放finally,或用上下文管理器 |
这张表里的每一种写法,我都在不同项目里见过真实事故。尤其是第一行,“返回None”三个字毁掉的可不止重试机制,还有上游所有依赖异常语义的监控和报警。
5.2 重试失效的快速排查三条路径
如果你现在也遇到“重试好像没生效”的诡异问题,按下面三条路径排查,效率最高。
- 第一看日志:重试器一般都会输出 attempt 标识。如果只有第一条 attempt,没有后续重试,直接怀疑被装饰的函数是否在内部吞了异常。
- 第二搜代码:在代码库中全局搜索
except Exception、return None、pass这几个关键词,重点看那些出现在网络请求函数内部的吞异常点。这招快、准、狠。 - 第三看监控:异常计数器为 0 不一定代表系统健康,很可能是异常被吞掉了。要额外关注降级次数、默认值命中率、调用耗时突变,这些指标才是隐藏故障的照妖镜。
5.3 写异常处理的几条原则
复盘这次事故之后,我给自己定了几条规则,现在写接口时基本都会遵守:
- 原则一:不再在
except里return“假装成功”的占位值。返回值只代表真正成功的业务结果。 - 原则二:捕获范围越窄越好。能用具体异常类型,就不用
Exception。 - 原则三:
raise ... from exc保留异常链,保证日志里的堆栈信息完整可追溯。 - 原则四:重试器只负责“怎么重试”,业务方只负责“抛什么异常”,两层职责不做混淆。
- 原则五:凡是新增了 try-except 的代码,都必须配套一条测试用例,至少覆盖“异常路径”和“成功路径”。
我个人最大的体会是:写except之前,先问自己一句——接住这个异常之后,系统能不能继续做正确的事?如果不能,就该raise,而不是return。那次“20 行 try-except 废掉整套重试机制”的教训,最终教会我的判断标准其实很简单:重试机制靠异常信号驱动,任何把异常变成None的代码,都在亲手拆掉自己的逃生通道。现在再写这类底层调用,我会在代码评审时特意盯住一个点:这个函数的返回值,到底能不能代表“真正的成功”?想清楚这一点,很多疑难杂症根本不会发生。