
1. 异常处理的本质先别把它当“错误”看写了这么多年 Python我见过太多把异常处理写成“try 一下然后打印堆栈”的代码。异常处理看上去是 Python 里最好懂的一部分——try、except、finally 三个关键字谁都能写几行——但真正能在生产环境里扛住事的异常处理远不止这几个关键字那么简单。裸 except 吞掉错误、日志只记一个字符串、线程里的异常主线程毫无感知这些问题几乎每个稍微有点规模的 Python 项目里都能翻出来几个。我写这篇关于 Python 中异常处理的文章就是想把我这几年踩过的坑、排过的障、改过的烂代码一次性讲透。它适合刚入门不久、只会写简单 try-except 的初学者也适合写过一段时间却总遇到“诡异 Bug”的进阶用户。我会先讲异常机制本身是怎么回事再讲捕获时最容易翻车的写法然后用一个真实事故的排查过程告诉你“错误为什么不该被吞”最后聊清楚自定义异常、异常链、并发场景和性能边界。看完之后你应该能建立起一套自己的异常处理判断标准。1.1 从 raise 到 except中间发生了一次栈展开很多人以为异常就是“程序出错了打印一段红色文字”其实异常在 Python 里是一种非常标准的控制流。它的运行机制是这样某一行代码执行时发现状况不对主动raise一个异常对象当前这个函数帧立刻停止执行异常顺着函数调用链一层一层往上“冒泡”。这个过程叫栈展开stack unwinding。每一层函数在退出前都会先执行自己的finally块然后继续往上找有没有能匹配上的except分支。看一个最简单的例子def build_user(user_id): return int(user_id) def get_user_name(user_id): user build_user(user_id) return user- str(user) try: get_user_name(abc) except ValueError: print(字符串转整数失败)当int(abc)抛 ValueError 时build_user立刻中断异常上浮到get_user_name它也没有处理继续上浮到try处被except ValueError接住。整个过程里get_user_name里那句return根本没有机会执行。理解了这一点你才能明白为什么“把一大段业务逻辑塞进 try 里”是个坏主意——你根本不知道异常是从哪个调用点冒上来的也就不知道该去哪里修。我还特意强调过异常是给“意外情况”准备的不是给“常规分支”准备的。如果你写的代码里充斥着“try 一下看看会不会出错出错就换个方向”那说明你的业务逻辑需要的是 if/else 判断而不是异常。这个度的把握后面第六章会专门展开。1.2 BaseException 与 Exception 之间隔着一堵墙Python 的内置异常体系看起来像一棵树根是BaseException它的直接子类包括Exception以及几个“非错误型”的异常KeyboardInterrupt、SystemExit、GeneratorExit。大多数人闭着眼睛写的except Exception只能捕获到Exception及其子孙捕获不到这三兄弟。这个设计是有意的KeyboardInterrupt代表用户按了 CtrlCSystemExit代表程序主动退出它们都属于“控制流信号”不应该被普通的业务异常处理器拦走。这是新手最容易懵的地方。举个例子while True: try: print(工作中...) except Exception: continue你按 CtrlC程序照样能退出因为KeyboardInterrupt不是Exception的子类except Exception接不住它。但如果你把except Exception换成裸except:那就惨了CtrlC 会被吞掉进程怎么都杀不死。这个场景我后面会用一个事故案例详细讲这里先记住一条铁律注意绝大多数情况下都不要写裸except:。它不是用来“捕获所有错误”的正确姿势而是在“连 CtrlC 都拦”的错误姿势。常用异常的捕获范围我整理成了一张表方便你随手对照写法捕获范围生产环境建议except:所有异常包括 KeyboardInterrupt / SystemExit不要用except BaseException:同上不要用except Exception:所有常规业务异常慎用要清楚自己在兜什么底except ValueError:单类异常优先按具体类型捕获except (ValueError, TypeError) as e:多类异常统一处理常用适合处理方式相同的场景2. 捕获异常最容易翻车的三种写法try-except 看似简单但我在 Code Review 里见过太多问题代码。今天把最常见的三种拿出来一个一个讲每一种都有真实项目的影子。2.1 裸 except连 CtrlC 都救不了裸 except 的危害我在 1.2 里已经预告过了。这里给它一个完整的“罪状清单”吞掉KeyboardInterrupt导致进程无法被 CtrlC 中断吞掉SystemExit程序自己想退出都退不掉吞掉所有真正的业务异常排错信息全部石沉大海给人造成“这段代码很健壮”的错觉实际上只是把故障从明处挪到了暗处。我以前接手过一个内部工具里面有个循环判断用户输入是否合法写的是try: result validate(input_text) except: result None这段代码在开发环境怎么跑都对一上生产环境就出现“明明输入错了程序却继续往下走最后落了一堆脏数据”的情况。查了半天发现是validate内部因为网络超时抛了一个TimeoutError也被裸 except 兜住转成了None。问题不是出在输入校验逻辑本身而是异常被一股脑全吞了。裸 except 的正确替代方案永远都是写出你预期可能出现的异常类型然后用具体的 except 去接。比如这里应该只接ValueErrortry: result validate(input_text) except ValueError: result None这样网络超时这种“意外之敌”才有机会往外抛日志才会响。2.2 异常到底该“吃掉”还是“往上抛”很多人的异常处理只有两条路要么print一下当作无事发生要么raise把异常原样扔回去。这两条路本身没错错的是选择标准。先说“只打印不处理”的问题try: do_something() except Exception as e: print(e)print(e)只打印了异常对象的字符串表示比如“list index out of range”。你只知道出了问题但不知道是哪一个列表、哪一行、在什么上下文里出的。生产环境里这种日志等于没有。正确做法是记录完整堆栈import logging logger logging.getLogger(__name__) try: do_something() except Exception: logger.exception(调用业务模块失败)logger.exception会把你当前所在位置的完整 traceback 一起写进日志。排查问题的时候堆栈信息远比那句“失败”值钱。再说往上抛。很多人不知道raise和raise e的区别try: run() except ValueError as e: raise # 保留原始 traceback从最底层开始try: run() except ValueError as e: raise e # 生成新的 traceback原始信息会被截断在绝大多数情况下你应该用裸raise这样才能保持异常的完整传播链路。raise e等于把原本从底层函数一路冒上来的堆栈给“洗”掉了排错时看到的起点变成了你raise的这一行。尤其当调用链有三层以上时这个差别直接决定你排查问题花十分钟还是两个小时。到底什么时候该处理什么时候该抛我的经验是只有在当前这一层你还能“做点什么”的时候才去 catch 它。比如你 catch 到一个数据库连接错误决定切换备用库重试这时候 catch 是有意义的如果你 catch 之后既没有补偿逻辑也没有额外上下文可以补充那不如直接让异常往上走让有能力处理的那一层去接。2.3 多 except 分支的顺序是个排序题Python 的多个 except 分支是从上往下依次匹配的第一个匹配上的生效。下面这段代码是典型反例try: fetch_user() except Exception: print(通用错误) except ValueError: print(值错误)第二个except ValueError永远走不到因为ValueError本身就是Exception的子类往上匹配的时候先撞上第一个分支。Python 不会报语法错误但这段代码的意图已经废了。正确的顺序是“具体优先通用兜底”try: fetch_user() except ValueError: print(值错误) except KeyError: print(键错误) except Exception: print(兜底错误)我自己的习惯是能写出具体类型的一定写具体类型多个具体类型用元组合并处理最后才用Exception兜底而且兜底分支里至少要有logger.exception不许静默。写代码时脑海里把异常继承树过一遍比你事后凭借“感觉”排顺序靠谱得多。3. 一次批量任务“无限重投”事故的完整排查链路理论讲再多不如一次实战来的记忆深刻。这一节我完整复盘一个我印象很深的事故一个跑在凌晨的批量消费任务突然从“正常三分钟跑完”变成“永远跑不完”而且 CtrlC 都杀不掉。3.1 现象与第一眼印象日志干净得像什么都没发生那个任务从消息队列里拉取待处理的记录逐条处理。某天早上运维反馈任务已经跑了一个半小时还没结束队列积压越来越多单核 CPU 一直 100%。我上去第一件事是看日志。日志里每隔几秒就会出现同一行业务打印processing msg id20240811-0015这行重复了几百遍。奇怪的是没有任何 traceback没有任何异常日志。一条消息反复出现在日志里说明这个msg id20240811-0015一直在被拉取、被处理、然后又回来。当时的直觉告诉我这条消息的处理一定在某个地方不断失败而失败原因被某种机制吞掉了。3.2 用栈采样工具让卡住的地方现形日志看不出来异常人又没法跟在代码里跑这时候我选择让运行中的进程“自己开口说话”。Python 生态里有个栈采样思路给运行中的进程发信号让它把当前每个线程正在执行的代码栈打印出来。简单说就是让程序告诉我们它此刻“正停在哪一行”。我采到的堆栈长这样省略了无关帧Thread 0x7f3c...: File consume.py, line 24, in module consume_forever() File consume.py, line 18, in consume_forever handler.handle(msg) File consume.py, line 11, in consume_forever continue虽然采样打到的具体行在不断跳动但有一个信息非常明确主线程一直在consume_forever的while True循环里转而且每次经过handler.handle(msg)之后都进入了continue。一个continue本身没什么但结合“同一个 msg id 反复出现”基本可以确定循环体在处理这条固定消息时不断抛错、又不断被吞掉。3.3 根因定位裸 except 把队列重投与中断信号一起吞了打开源码问题全貌立刻浮出水面while True: try: msg broker.receive() handler.handle(msg) broker.ack(msg) except: continue这段代码有三个连环错误。第一handler.handle(msg)对那条坏消息必然抛异常裸except:把所有异常都吞了处理失败的真实原因完全没有记录。第二broker.ack(msg)和handler.handle(msg)挤在同一个 try 块里。处理失败时ack根本没有机会执行。队列服务是按 at-least-once 语义投递消息的不 ack 就当作没消费成功过一会儿重新投递。于是这条消息不停地被重新投递任务就陷入无限重试。第三裸except:连KeyboardInterrupt都吞掉了。我们按 CtrlC 想终止进程进程毫无反应因为中断信号被这个 except 当成普通异常接住然后continue又回到循环顶部继续跑。我把症状和原因对应起来一目了然现象根因日志只有重复的业务打印没有 traceback异常被except: continue吞掉同一 msg_id 反复出现ack 未执行队列按语义重投CtrlC 无效裸 except 捕获了 KeyboardInterruptCPU 打满循环内快速失败、快速重试无限空转3.4 修复方案让异常在该响的地方响起来改法其实不复杂核心是拆分职责接收消息时的超时属于“正常等待”处理失败属于“业务异常”确认消费应该放在处理成功之后。while True: try: msg broker.receive() except TimeoutError: # 短时间内没有新消息正常 continue try: handler.handle(msg) except Exception: logger.exception(处理消息失败, msg_id%s, getattr(msg, id, None)) broker.move_to_dlq(msg) # 送死信队列避免无限重投 continue broker.ack(msg)改动点逐个说用except TimeoutError替代裸 except——TimeoutError才是这个场景里预期会出现的异常continue是合理的正常行为handle失败时调用logger.exception把 traceback 和 msg_id 一起记录让它成为一个“响的异常”失败消息进死信队列后续人工排查而不是让队列服务一遍遍重投ack放在handle成功之后、自己的 try 块之外保证“只有真正处理成功才确认消费”。复盘时我们给团队立了一条规矩异常默认应该大声。吞掉异常的那一刻必须写清楚为什么可以吞以及配套的告警在哪。我现在看别人代码第一个搜索的就是except: pass和except Exception: pass出现一处审一处基本没有误伤。4. finally、return 与上下文管理器资源清理该放哪边异常处理不只包括“捕获什么”还包括“退出时干什么”。Python 里负责“退出清理”的主要是finally和with语句。这一节说的都是我在代码里真实见到过的坑。4.1 finally 里的 return 会悄悄覆盖 try 里的 returnfinally的语义是无论 try 块是正常结束、异常结束、还是执行了 return 结束finally里的代码都一定会跑。很多人以为这是“收尾专用”确实没错但收尾代码里如果写了return就会引发一个反直觉的行为。def compute(): try: return 42 finally: return 0 print(compute()) # 输出 0try里的return 42已经把返回值放进寄存器了但在真正交出控制权之前Python 会先去执行finallyfinally里的return 0直接把这个返回值替换掉了。最终函数返回的是 0不是 42。我最初见到这段代码时以为是 IDE 显示问题后来才意识到这是 CPython 的明确语义。同样危险的是finally里的continue和break它们也会覆盖 try 块里已有的跳转意图。所以我的建议很干脆finally里只做清理动作比如关文件、关连接、恢复配置绝对不要 return、break、continue。还有个更隐蔽的坑finally里的清理代码本身如果又抛异常会覆盖原始异常。try: do_risky() finally: cleanup() # 假如这里抛了 OSError原本的异常会被清理代码的异常顶掉你查日志时只能看到OSError真正的业务异常反而被覆盖了。这也是为什么现代 Python 推荐用with语句而不是手动 try/finally 来做资源清理。4.2 with 语句为什么比手动 try/finally 更省心with语句本质上调用对象的__enter__和__exit__两个魔术方法。文件对象之所以能配合with使用是因为它的__exit__里封装了“关闭文件”的逻辑with open(data.txt, r, encodingutf-8) as f: content f.read()这段代码等价于f open(data.txt, r, encodingutf-8) try: content f.read() finally: f.close()差异在于with把“退出时该做什么”固化在对象自身里调用方不需要每次手写清理逻辑。文件对象、锁对象、数据库连接、socket 连接等只要实现了上下文管理协议你就应该优先用with。它能保证即使函数提前 return、即使函数中途抛异常资源也会被释放。__exit__有个特性值得说一句它会收到exc_type、exc_value、exc_tb三个参数。如果__exit__返回了True异常会被抑制不再往外传播。很多初学者自己实现上下文管理器时容易在这里踩坑__exit__返回一个非 None 的值异常被莫名吞掉。除非你是故意要抑制否则__exit__应该返回False或 None。4.3 contextlib 里的三个实用工具Python 标准库contextlib里有一批好用的工具能让你少写很多样板代码。第一个是contextlib.suppress适合“明确知道某个异常可以忽略”的场景from contextlib import suppress import os with suppress(FileNotFoundError): os.remove(temp.log)等价于try: os.remove(temp.log) except FileNotFoundError: pass它比 try/except/pass 的优势是意图清晰正在表达“这个文件删除失败是因为它本来就不存在这不重要”。注意 suppress 里的异常类型要尽量具体别在这里放Exception否则又变成裸吞了。第二个是contextlib.closing适合那些只有close()方法、没有实现__exit__的资源对象from contextlib import closing with closing(get_legacy_client()) as client: client.query(...)第三个是我用得最多的contextlib.contextmanager它允许你用生成器函数写自定义上下文管理器。比如我想临时改一组配置、用完自动恢复就可以这样写from contextlib import contextmanager contextmanager def patch_config(config, **kwargs): backup {key: config.get(key) for key in kwargs} config.update(kwargs) try: yield finally: for key, value in backup.items(): if value is None: config.pop(key, None) else: config[key] value with patch_config(config, timeout10, retries3): run_experiment()这里的关键点是yield必须放在try里面。因为with块里的代码一旦抛异常异常会从yield处重新抛进生成器函数你才有机会在finally里做恢复。如果把try放在yield外面异常进来时清理逻辑根本不会执行。5. 自定义异常、异常链与日志让报错信息真正可诊断生产环境里最有价值的异常处理产出物是一句“能把问题定位到具体上下文”的报错信息。这方面有三个工具极其重要异常链、自定义异常体系以及正确的日志姿势。5.1 异常链raise from 与causePython 在异常传播时天然支持“链式上下文”。当你在except块里再raise一个新异常时新异常会自动带上原始异常作为上下文打印出来就是两段 traceback。但这样产生的上下文有时不够精确应该用raise ... from ...来显式声明因果关系。看一个我经常举的配置加载例子import json class ConfigError(Exception): pass try: with open(config.json, encodingutf-8) as fp: cfg json.load(fp) except FileNotFoundError as e: raise ConfigError(配置文件不存在: config.json) from e except json.JSONDecodeError as e: raise ConfigError(配置文件不是合法JSON) from efrom e会设置新异常的__cause__属性traceback 里会出现一行“The above exception was the direct cause of the following exception”把底层原因和上层表现串起来。排查线上问题时这种链式信息能让你从“配置文件不是合法JSON”一路追到具体是哪一行解析失败。有时候你想彻底切断上下文比如底层只是一个单纯的字符串转整数失败上层错误信息已经完全说明了情况这时可以用from Nonetry: return int(user_input) except ValueError: raise InvalidInput(必须输入整数) from Nonefrom None会设置__suppress_context__Traceback 只显示新的异常不再展示背景里那个白噪声一样的转换错误。用什么时机我的标准是底层异常信息对解决当前问题没有增量价值时可以用from None切断否则尽量保留。5.2 自定义异常体系怎么搭给一个稍微上点规模的项目写异常处理量身定制的异常类比直接用内置异常合适得多。内置异常表达的是“语法级/运行级”的错误业务异常应该表达“这个业务领域里发生了什么不满足预期的事”。我常用的搭建方式是定义一个项目根异常然后按模块或业务领域分派子类。class ToolError(Exception): 整个工具所有异常的基类 class ConfigError(ToolError): pass class NetworkError(ToolError): pass class DataValidError(ToolError): pass这样设计有立竿见影的好处调用方如果只想整体兜住业务异常可以except ToolError如果只想处理配置错误可以except ConfigError两全其美。自定义异常还可以带上结构化属性。比如配置异常不仅要说明“路径不对”还要把路径字符串本身存起来方便后续自动化处理class ConfigError(ToolError): def __init__(self, message, path): self.path path super().__init__(f{message} (path{path}))带属性的异常在排障时尤其好用日志关键字可以直接提取。但要提醒一点不要在异常体系里继承多个内置异常搞“多重身份”那会让匹配规则变成一团浆糊。一个项目一个根根下面分层就足够解决绝大多数问题。5.3 日志里只记一个 str(e) 等于白记异常处理最常见的败笔之一就是把异常对象转成字符串塞进普通日志。举个反例try: result pay_service.pay(order_id, amount) except PayTimeout: logger.error(f支付超时: {PayTimeout})这段日志记录了“支付超时”四个字仅此而已。没有订单号、没有金额、没有发起支付的时间、没有当时重试到第几次、没有堆栈。事后你想复盘连从哪一行发起支付都查不到。正确写法是import logging logger logging.getLogger(__name__) try: result pay_service.pay(order_id, amount) except PayTimeout: logger.exception(支付超时, order_id%s, amount%s, attempt%s, order_id, amount, attempt) raise OrderHandleError(支付结果未知需要人工确认) from None两个细节值得展开。一是logger.exception只能在except块里调用因为它是基于sys.exc_info()获取当前异常的。如果你在 except 外面调用logger.exception日志里只会出现一句NoneType: None没有任何价值。二是日志参数用占位符而不是 f-string。logger.exception(... order_id%s, order_id)会在日志系统内部按需格式化而 f-string 会在传入前就拼接好字符串。两者在结果上差不多但占位符方式不会额外创建字符串对象在高频日志路径上更省内存也能避免某些奇怪的类型转换异常把日志系统也带崩。如果你用的 Python 版本是 3.11 及以上还有个非常好的原生气笽BaseException.add_note。它可以在异常向上传播的过程中给异常不断追加上下文try: response http_get(url) except TimeoutError as e: e.add_note(frequest_url{url}, attempt{retry}, timeout_ms{timeout_ms}) raise每一层都可以往里加自己观察到的信息最后打出来的 traceback 会把这些 note 一并展示。这比在异常字符串里手工拼上下文优雅得多排查跨层问题时尤其好使。6. 线程、asyncio 与性能边界异常处理的高阶必修课把单线程流程里的异常处理写对已经能解决 80% 的问题。剩下 20% 集中在并发场景和性能权衡上这两个方向是我见过最多“代码看着没问题跑起来就是不对”的重灾区。6.1 线程里抛异常主线程根本接不到Python 的线程各自跑各自的函数栈所谓的“主线程 try/except 包住子线程函数”是完全无效的。看这段import threading def worker(): raise RuntimeError(booom) t threading.Thread(targetworker) t.start() t.join() print(任务执行完成)worker里的RuntimeError只会在子线程内部打印出 traceback然后线程退出。t.join()不会把它抛到主线程主线程照样接着打印“任务执行完成”。如果你的排障方式是在join()外面包一层 try/except那我可以直接告诉你白包了异常根本不会经过那个 try。解决这个问题最简单的路径是让concurrent.futures帮你管理线程并利用Future.result()把异常带回调用方from concurrent.futures import ThreadPoolExecutor def handle(item): return 100 // item with ThreadPoolExecutor(max_workers4) as pool: futures [pool.submit(handle, item) for item in items] for fut in futures: value fut.result() # 这里的异常会抛回主线程fut.result()在任务执行异常时会重新抛出子线程里发生的异常这时候你的 try/except 才有意义。如果你坚持用裸threading.Thread那就得自己用队列把异常传回来代码会繁琐很多除非有特殊理由否则推荐直接上线程池。6.2 asyncio 任务里的异常gather 与取消阻塞asyncio 的异常传播路径同样不直观。asyncio.create_task创建的任务会被事件循环调度如果你创建任务后既不await也不cancel任务里的异常就会被静默吞掉等任务对象被垃圾回收时控制台才会冒出一句“Task exception was never retrieved”。看这个例子import asyncio async def fetch(i): if i 2: raise RuntimeError(ffetch {i} failed) return i async def main(): tasks [asyncio.create_task(fetch(i)) for i in range(3)] # 如果直接走task 2 的异常被静默丢弃 # 正确姿势 results await asyncio.gather(*tasks, return_exceptionsTrue) for result in results: if isinstance(result, Exception): print(任务异常:, type(result).__name__, result) asyncio.run(main())asyncio.gather提供了两种处理模式默认模式下只要有一个任务抛异常整个 gather 立刻传播异常设置return_exceptionsTrue之后异常对象会作为返回值塞进 results 列表里由你逐个判断。批量并发任务时用后者非常顺手因为你通常不想因为一条失败就放弃其它已成功的任务。如果你在create_task和await之间还做了取消操作要格外小心CancelledError。它继承自BaseException不是Exception所以except Exception也接不到通常也不应该去接。正确的做法是让取消信号正常穿透如果需要做清理用try/finally在 finally 里收尾。6.3 别用异常当业务分支也别因为“性能”不敢用异常性能这个事被讨论过很多次很多人对 Python 异常的印象是“异常很慢别用”。这句话只说对了一半。try块本身在正常情况下几乎零开销因为 Python 解释器在执行字节码时并不会预支检查。真正贵的是raise的那一刻解释器要构建异常对象、填写 traceback 栈帧信息、再执行栈展开。所以“写 try/except 会拖慢程序”这个说法不准确准确说法是“频繁抛出异常会拖慢程序”。这个特性给了我们一个清晰的分寸感如果一个错误情况几乎不会发生它是真正的“意外”放心用异常别为了省这点开销去写一堆防御性 if如果一个错误情况经常发生比如用户字段缺失、数据缺失是常规业务状态那就用 if/else 或默认值不要用异常来传业务信号。Python 社区有个经典讨论叫 LBYLLook Before You Leap和 EAFPEasier to Ask for Forgiveness than Permission后者更贴合 Python 风格。看这个例子# LBYL 风格 if age in data: age int(data[age]) else: age 0 # EAFP 风格 try: age int(data[age]) except KeyError: age 0两种写法都能用但我更推荐把“try”缩小到真正可能出错的表达式上。比如上面 EAFP 的写法可以再细分int(data[age])这一步可能抛KeyError、TypeError、ValueError三种异常如果只用except KeyError接其它两种会正常冒出来这反而帮了你——数据存在但格式不对是另一个等级的问题应该让它发出声音。再补一个高频坏习惯把互不相关的多行操作塞进同一个 try 里。比如try: do_a() do_b() except OSError: cleanup()如果do_b抛了OSError你很难判断问题出在 a 还是 b而且你的cleanup()可能只对 a 的状态有效。把 try 块尽量收窄到单条可能失败的操作catch 到异常后你才能准确知道“是谁失败了、需要清理什么”。最后说个我自己的经验异常处理和日志一样属于“平时多花一分钟排查省两小时”的活儿。我写代码时的自检顺序很简单——先问这段代码会不会发生预期外的失败再问失败后我希望它继续沉默还是向上报最后问报出去的信息是否足以让一个不看代码的人定位问题。三个问题过完异常处理基本不会出错。