
不少朋友刚接触异常处理时的状态基本就是在报错的红色提示里手忙脚乱要么逐条试错要么干脆在函数外面套一个try...except Exception把问题全部吞掉。这两个极端我都经历过。异常处理并不是让程序不崩溃的补丁而是程序设计的一部分。这篇内容会把try-except-finally这套机制从头到尾拆透不只讲语法还会把各个子句的执行时机、变量作用域陷阱、异常链传递逻辑这些都讲清楚不管你是刚入门还是写过一阵子 Python都会有用。1. 为什么异常处理经常被用错以及它和语法错误的本质区别在展开语法之前得先把一个基本概念摆正异常不等于程序出 bug。程序因为逻辑错误产生的结果不对那是 bug但异常是程序在执行过程中遇到的非正常状况是发生了不该发生的事的信号。比如文件不存在、网络断开、除数为零、数组越界这些都是异常。Python 希望通过抛出异常这种机制告诉你——这儿有必要的情况你要么处理要么让上一层处理。1.1 异常和语法错误的区别语法错误SyntaxError是代码都没法被解析产生的错误解释器直接拒绝运行这种没有讨论的价值只能改代码。异常是程序已经运行到某一行才触发的代码本身可以编译但运行时的条件不满足比如# 这段代码语法没有任何问题 def divide(a, b): return a / b result divide(10, 0)运行之后就会抛ZeroDivisionError程序终止。问题不是写错代码而是运行条件不满足。理解了这一点你才会明白异常处理不是为了掩盖问题而是为了在问题发生时按你的想法接管控制权。1.2 异常处理的两大错误用法第一类是无脑吞异常用try...except把所有内容包起来然后pass掉。这在长跑任务里非常隐蔽服务可能已经统计数据错了很久日志里却一片空白。你排查问题时根本不知道到底发生了什么。我见过不少项目错误排查全靠删掉 try 看报什么错这不是处理异常是埋雷。第二类是用异常做流程控制比如为了判断一个 key 是否存在就故意用KeyError去检查而不用in操作符。这种习惯一旦成型代码的意图会被严重模糊而且异常处理的性能成本远超直接判断语句。不是说不能用而是要分场合。1.3 异常处理在工程中的真实定位在架构设计上异常处理是多级防线里的一个环节——底层函数检测并处理它自己能兜住的情况处理不了的往上抛由调用方决定是重试、降级还是报警。比如一个发送请求的函数请求超时后可以在自己这层做三次重试重试依然失败就往上抛ConnectionError由业务层决定是否提示用户网络异常。这比把几百行业务代码包进一个大 try 里要科学得多。2. 异常体系的基本结构从 BaseException 到 Exception再到具体的业务异常Python 的异常是一个层层继承的类体系理解这个体系是精准捕获异常的前提。很多人写except Exception时其实并不清楚它捕获了哪些东西更不清楚BaseException和Exception之间的区别。2.1 BaseException 与 Exception 的边界所有的异常类最终都继承BaseException但你要记住一条实践准则永远不要用except BaseException去捕获异常。因为BaseException的子类里包含KeyboardInterrupt用户按 CtrlC 中断程序和SystemExit程序主动退出这两个严格来说不是程序错误而是系统层面的控制信号。你用except BaseException把它们吞掉会导致用户无法用 CtrlC 终止程序这是很糟糕的体验。Exception是绝大多数内建异常的直接或间接基类日常代码里说捕获异常通常指的就是捕获Exception。具体层级关系大致是BaseException ├── SystemExit ├── KeyboardInterrupt ├── GeneratorExit └── Exception ├── ArithmeticError │ └── ZeroDivisionError ├── LookupError │ ├── IndexError │ └── KeyError ├── OSError │ ├── FileNotFoundError │ └── PermissionError ├── ValueError ├── TypeError └── ...2.2 常见的内建异常类型及触发场景很多人学了异常类型却不知道哪种情况对应哪种异常导致except (IndexError, KeyError)这种写法看起来对实际却连具体错误类型都没搞清楚。下面这张表列出最常见的触发场景之后你在写捕获逻辑时会清晰很多异常类型典型触发场景ValueError传入的参数值不合法比如int(abc)TypeError操作类型不匹配比如1 1IndexError序列下标越界如列表lst[10]但列表只有 3 个元素KeyError字典中键不存在如d[age]AttributeError对象不存在对应属性或方法如None.nameZeroDivisionError除数为 0FileNotFoundError打开不存在的文件是OSError的子类PermissionError没有文件操作权限ConnectionError网络连接失败OSError的子类TimeoutError操作超时StopIteration迭代器没有更多元素时调用next()2.3 多个异常的捕获顺序为什么很关键except子句会从上到下逐个匹配匹配到第一个符合条件的就进入后面的不再检查。所以子类异常必须写在基类异常之前否则永远只会命中后面的基类。try: value int(input(请输入数字: )) print(100 // value) except ZeroDivisionError: # 这个放在前面因为前面的可能捕获到除零错误 print(除数不能为 0) except ValueError: # 基类放在后面 print(输入的不是数字) except Exception: print(发生了其他异常)这里如果把Exception放最前后面的ZeroDivisionError和ValueError分支就永远不会执行逻辑被静默改写排查成本很高。除了顺序还要注意捕获范围要贴着实际可能发生的异常来设计不要图省事直接写一个巨大的Exception兜底——除非你真的打算统一处理一切未知情况并记录日志。3. try-except-else-finally 各子句的作用域与时序细节语法层面大多数人都会写但里面包含的细节比如 else 的用途、finally 与 return 的博弈、变量作用域的陷阱都是实战之后才能真正体会到的。这节逐个拆开讲。3.1 基本结构及每个子句的职责完整的语句结构是try-except-else-finally四部分相对位置固定一层层往下排try: # 被监控的代码块一旦出现异常就跳到 except except SomeError: # 捕获并处理指定异常 else: # 逻辑是在 try 没有发生任何异常时执行的 # 不能单独存在必须在 except 之后 finally: # 无论是否发生异常都会执行3.2 各子句的执行时机对照我直接给一个完整的模拟场景看输出顺序就全明白了def demo(): try: print(1 - try 开始) value 10 / 2 print(2 - try 正常结束) except ZeroDivisionError: print(3 - 捕获除零异常) else: print(4 - 没有异常进入 else) finally: print(5 - 无论如何都进入 finally) print(6 - finally 之后函数继续) demo()执行结果是1 - try 开始 2 - try 正常结束 4 - 没有异常进入 else 5 - 无论如何都进入 finally 6 - finally 之后函数继续把除数改成 0 再执行输出是1 - try 开始 3 - 捕获除零异常 5 - 无论如何都进入 finally 6 - finally 之后函数继续注意对比正常路径才会执行else异常路径则跳过else直接进入finally。这里就引出了else的核心价值——把正常执行的代码和监控的代码分开。有人觉得else多余直接写在 try 末尾不也一样吗区别在于如果你把这句代码写在 try 里这句代码本身的异常也会被同一个except捕获写在else里异常不会被前面的except捕获会向外层传递。一个典型的应用就是你只想捕获取数据这步的异常而处理数据的代码不希望被同样处理data {key: 42} try: item data[key] # 可能触发 KeyError except KeyError: item 0 else: item 1 # 这里的 TypeError 不会被上面的 except 捕获3.3 try 块内的变量作用域陷阱这是新手最容易踩的坑——try块里定义的变量如果提前在异常点中断了后面的代码引用它时就会抛UnboundLocalError。看下面这个例子try: result 1 / 0 # 这里抛异常result 从未赋值 except ZeroDivisionError: print(除零了) print(result) # UnboundLocalError: cannot access local variable result解决方案有两个方向。一是预先初始化变量再放进 tryresult None try: result 1 / 2 except ZeroDivisionError: result float(inf) print(result)二是把变量的使用逻辑收敛到 try 里让它在确定可用的范围内被消费。这个习惯要养成尤其在代码长、分支多的场景里忘掉初始化很容易调试时看到UnboundLocalError就有种明明看过却回不去的感觉。3.4 关于 except 的完整语法捕获多个异常与获取异常信息捕获多个异常用元组写法try: bytes_data data.decode(utf-8) except (UnicodeDecodeError, AttributeError): print(解析失败)需要拿到具体异常对象时用as关键字try: conn get_db_connection() except ConnectionError as e: print(f连接失败错误: {e})这里的e就是异常实例可以访问它的args也可以通过str()拿到人类可读的信息。注意拿到异常对象后尽量不要立刻清空它后面第 6 节讲异常链时还会用到。4. finally 的正确打开方式资源回收、return 的博弈、以及 finally 自身的异常finally是整套机制里最容易被忽略但最能出问题的部分。它保证无论分支怎么走最后都要执行的资源回收逻辑但finally里一旦夹杂了 return 或异常处理不当行为的复杂度立刻翻倍。4.1 finally 最典型的使用场景资源清理读文件、数据库连接、锁、网络连接这些场景都要求无论操作是否成功最后都要释放资源。如果异常发生在半途资源没有释放程序可能越跑越卡连接池被占满后彻底失去响应。f None try: f open(data.txt, r) print(f.read()) except FileNotFoundError: print(文件不存在) finally: if f: f.close()在 Python 2.5 之后这个场景更简洁的写法是用with语句很多资源对象本身实现了上下文管理器with open(...) as f退出时会自动关闭文件。但这并不意味着finally没有价值——你处理锁释放、数据库事务回滚、或者需要无论成功与否都要向外部系统发出结束信号这类无法抽象成上下文管理的逻辑时finally仍然是最方便的手段lock.acquire() try: # 共享资源操作 pass finally: lock.release()这段代码保证锁一定被释放即使中间抛了任何异常。用with contextlib里的功能去模拟也不是不行但直接在finally里锁释放是意图最清晰的写法。4.2 finally 和 return 的执行顺序需要明确的是finally中的代码是在return返回值表达式计算之后、实际返回之前插入执行的。也就是说return表达式的值先被算出来存起来再执行 finally最后返回刚才存好的值。def test(): try: return try 的返回值 finally: print(finally 执行了) print(test())这段输出finally 执行了 try 的返回值但这里有个非常容易翻车的情况如果finally里又有return那么这个 return 会覆盖掉 try 里的 return 值def test2(): try: return A finally: return B print(test2()) # 输出 Bfinally里的 return 直接覆盖了 try 准备返回的值。这种写法造成的隐性控制流非常危险代码长一点就会没人看得出来。我强烈建议finally里不要写 return也不要轻易在 finally 里改变返回的变量。4.3 finally 自身抛出的异常还有一个和上面相似的坑如果finally里又写了可能抛异常的代码它会把正在返回的原始异常替换掉。这个行为对缺日志的场景来说非常麻烦——你捕获的异常是 Afinally 里抛了 B最终你看到的却是 B且 A 的痕迹消失。try: raise ValueError(原始异常 A) finally: raise TypeError(finally 里的异常 B) # 这会把 A 顶掉运行后你只能看到TypeError。所以finally里的代码要尽量做不会失败的操作。如果确实存在失败可能比如要在 finally 里发通知请求建议把通知部分用内部的 try-except 包起来保证异常不逃逸finally: try: notify(finished) except Exception: # 通知失败不能覆盖主逻辑的异常 log_error(通知失败)4.4 一个和 with 语句的对比帮你判断该用哪种with适合对象有明确的点开和关闭生命周期比如文件、锁、连接、临时目录。finally适合无论怎么结束都要收尾的过程性逻辑比如多步骤操作后的统一提交或回滚判断、上报状态、释放非托管资源。很多初学朋友追求新语法一律用with结果碰到没有上下文管理器支持的对象时反而绕路也有朋友把所有东西都塞 finally放弃上下文管理器的简洁性。实际工程中的最佳姿势往往是两者配合——能用 with 的用 withwhile 之外的收尾工作交给 finally。5. raise 与自定义异常体系让异常替你说清楚业务上到底发生了什么异常处理的另一半是抛出异常。只会在别人代码抛异常后捕获不算真正掌握异常处理因为你自己设计的函数或类应该能主动声明这个情况我无法处理或调用方违反了约定。5.1 raise 的三种基本用法第一种是直接raise SomeError(描述信息)触发指定类型和信息的异常def check_age(age): if age 0: raise ValueError(年龄不能为负数) if age 150: raise ValueError(年龄超出了一般人范围) return age第二种是raise不带任何参数用在 except 块里表示把当前异常原样重新抛出这在记录日志后继续向外抛的场景里非常有用try: send_request() except ConnectionError: logger.exception(发送请求连接失败) raise # 同一异常往上走调用方还能捕获处理第三种是raise ... from ...用在异常转换时保留原来的异常链这在第 6 节详细说。5.2 什么时候需要自定义异常类我见过不少人自定义异常但只是把Exception换了个名字没有添加任何额外属性——这样意义不大因为异常携带的信息还是只有字符串。自定义异常的核心价值有两个一是让代码的语义更清晰看到PaymentFailedError就知道发生了什么而不是一个裸Exception(payment failed)二是提供结构化的属性让调用方不需要解析字符串就能拿到错误码、状态、下游返回的描述。这是我常用的设计模式class PaymentError(Exception): 支付业务统一异常 def __init__(self, message, codeNone, detailsNone): super().__init__(message) self.message message self.code code self.details details or {}之后在业务代码里如果第三方支付网关返回错误码500就可以抛出raise PaymentError(支付失败, codegateway_500, details{order_id: order_id})调用方捕获时直接读取属性而不用去拆解报错字符串try: process_payment(order) except PaymentError as e: if e.code gateway_500: mark_order_failed(e.details[order_id])5.3 统一业务异常基类的分层策略项目到了一定规模建议把异常按层次组织底层离系统近的放InfrastructureError数据库连接、网络、IO业务层放BusinessError余额不足、重复下单、权限不足最外层只统一捕获业务异常来映射成用户可读的提示。这样做的收益是日志系统可以通过异常类名快速区分故障类型监控告警规则也可以按异常类配置不同级别的通知而不需要在日志文本里做正则匹配。举个例子监控系统会统计BusinessError的占比占比偏高说明业务侧逻辑可能有问题而InfrastructureError占比高往往提示机器环境或依赖服务出了问题——你只靠统一的 Exception 根本做不出这样清晰的度量。5.4 自定义异常时的两个小细节第一自定义异常要继承Exception不要继承BaseException否则使用者按习惯用except Exception捕获时会漏掉你定义的异常造成漏处理。第二不需要一次性建几十个异常类来穷举所有错误先按当前的真实业务边界建个几个核心类随着功能演进逐步拆细分初期过度设计反而导致捕获代码混乱。6. 日志、异常链与上下文管理把异常处理嵌进整个工程体系到这里你可能已经能熟练处理单点异常了但真实工程里一个问题往往跨越了多个函数、多个类甚至多个服务。这时如果对异常链、日志上下文、上下文管理器这些概念没有把握异常信息会在传输过程中被拆得七零八落问题定位难度翻倍。6.1 异常链为什么频繁出现Some exceptions werent caught或堆栈被吞在 except 里捕获异常之后又主动抛一个新异常时默认行为其实是保留异常链的。但很多人的写法丢失了链条def load_user(user_id): try: return db_query(SELECT * FROM users WHERE id%s, user_id) except Exception: raise ValueError(查询用户失败)这样抛出来的ValueErrortraceback 会指向dev ValueError这一行原始数据库报错信息几乎丢失。想保留原始异常堆栈必须用fromdef load_user(user_id): try: return db_query(SELECT * FROM users WHERE id%s, user_id) except Exception as e: raise ValueError(查询用户失败) from eraise ... from e会把原始异常以__cause__的形式挂在新的异常上最终的 traceback 会完整展示异常传递的全过程。这里我特别想强调一个习惯如果新抛出的异常是对底层异常的语义包装务必带上from e如果底层异常信息完全没有参考价值可以省略但最好还是通过日志记录一下原始堆栈因为没有上下文的问题描述很难支撑跨天后的复盘。6.2 logger.exception 与 logging 的配合日志记录异常要讲层次。在 catch 异常后想把堆栈写进日志时最快捷的是logger.exception(说明信息)它会在当前日志级别 ERROR 下自动输出完整堆栈。要注意logger.exception只能在 except 块里调用因为它依赖当前正在处理的异常上下文。import logging logger logging.getLogger(__name__) try: connect_to_database() except ConnectionError as e: logger.exception(数据库连接异常: %s, e) raise不推荐在 except 里只写logger.info否则彻底丢了堆栈信息。如果你既想报错又不抛异常也建议至少用logger.error(error message, exc_infoTrue)把堆栈打出来。6.3 上下文管理器contextmanager和异常处理的融合第 4 节提到了with和finally的选择问题这里把contextmanager也拉进来用它的好处是能把前置准备和后置清理包装成一个语义完整的单元。自定义一个上下文管理器异常在 with 体内抛出时我们的清理代码照样执行from contextlib import contextmanager contextmanager def transactional_scope(): try: conn.begin() yield conn except Exception: conn.rollback() # 出现任何异常回滚事务 raise else: conn.commit() # 一切正常才提交使用时with transactional_scope() as conn: update_order_status(conn, order_id)这段代码把事务边界的职责收拢起来业务函数里不再散落commit和rollback。异常在 with 体内抛出时yield位置会抛出给上下文管理器捕获回滚后重新 raise非常接近第 4 节的 finally 逻辑但语义更集中。6.4 用异常处理满足监控和告警需求在工程实践中很多团队会用统一的装饰器把异常接口化比如def handle_exceptions(default_returnNone): def decorator(func): def wrapper(*args, **kwargs): try: return func(*args, **kwargs) except BusinessError as e: logger.warning(业务异常: %s, e) return default_return except Exception as e: logger.exception(未知异常) raise return wrapper return decorator这个模式可以快速给函数加上已知业务异常返回默认值未知异常继续上抛的规则。配合监控系统按异常类统计频次能很快发现某个接口开始频繁申报业务异常——那往往意味着上游数据出问题了。6.5 一个小技巧异常处理不能替代代码逻辑设计分享一个容易忽略的点异常处理写多了之后很多人开始偏爱在数据条件判断处直接抛异常让上游捕获。但实际上如果某个状况完全可以通过前置判断避免比如索引是否越界、文件是否存在用if判断通常可读性更高。异常机制设计出来是为了应对不可预测的状况——网络中断、数据格式不符、底层服务崩溃——而不是替代普通的条件判断。我个人的经验是可以用异常处理网络和 IO 边界用 if 处理业务逻辑边界两者结合代码的可维护性最好。这一套内容梳理下来最核心的认知是异常处理不是一个救了错误就完事的小工具而是你与代码运行环境之间的一整套沟通协议。你捕获什么、抛出什么、记录什么、转不转换成用户提示这些都是设计决策。下次再改代码的时候不妨对着你写过的 try-except 问一句我在这里捕获的异常到底想传达什么信息想清楚了代码质量会有一个明显的跳跃。