
1. 先弄明白异常到底是怎么一回事1.1 异常不是 bug是运行时的一种“信号”很多初学者刚接触Python 异常处理时会下意识地把“异常”和“程序出错”画等号觉得异常就是代码写错了。这个理解只对了一半。我更喜欢把异常理解成一种“信号”程序在运行过程中遇到无法继续执行的情况时Python 解释器会主动抛出一个异常对象通知调用方“这里出问题了你打算怎么处理”。举个例子你让用户输入一个数字用户手滑输入了一个字母。如果用int(abc)强行转换程序当场崩溃。但如果你提前知道用户可能输错你就可以用try-except-finally把这个“信号”接住给出友好提示让程序继续跑下去。这就是异常处理存在的意义它不是为了消灭错误而是让错误发生之后程序还能体面地活下去。Python 的异常体系是一个继承树。最顶层是BaseException它下面有两个主要分支Exception和SystemExit、KeyboardInterrupt这类特殊异常。我们日常编程中遇到的几乎所有异常ValueError、TypeError、KeyError、FileNotFoundError...都继承自Exception。这个细节很重要因为后文会提到捕获异常的时候尽量捕获Exception及其子类别去碰BaseException。你总不希望程序连用户按 CtrlC 终止运行KeyboardInterrupt都给“接住”了导致用户关不掉程序吧1.2 异常是怎么传播的异常还有一个“传播”机制它会在函数调用链中一层一层往上冒泡。如果当前函数没有捕获异常异常就会传给调用它的上一层函数如果一直没人处理最终传给 Python 解释器解释器就打印一串红色 traceback程序终止。这个机制用生活类比特别容易理解就像你在公司里发现了一个严重问题你这个岗位解决不了就把问题上报给组长组长解决不了上报给经理……一直到有人能处理为止。如果谁也处理不了事情就“爆”了。所以try-except能捕获的不只是try块里直接写的代码抛出的异常还包括try块里调用的函数以及函数再调用的函数抛出的异常。这意味着你可以在代码的“关键入口”统一做异常处理把错误挡在这一层而不是每个函数里都写一堆 if 判断。当然这是一把双刃剑——后面我会专门说不要在太外层“裸吸”所有异常否则问题会被掩盖。2. try-except-finally 的核心用法每个细节都不能错过2.1 基本结构try-except 的三种形态先看最基础的骨架try: # 可能会出问题的代码 num int(input(请输入一个数字: )) print(10 / num) except ValueError: print(输入的不是数字请重试) except ZeroDivisionError: print(不能除以 0)这里的语法拆解是try:把“我怀疑可能出问题的代码”圈起来。except ValueError:监听特定的异常类型命中后执行缩进块里的逃生代码。有一种情况要注意except后面可以同时接多个异常类型用括号包成元组except (ValueError, TypeError) as e: print(f转换失败: {e})还有最完善的写法except Exception as e捕获所有继承自Exception的常规异常。as e是把你捕获到的异常对象赋值给变量e这样你可以看到具体的错误信息而不是只接住一个“未知信号”却不知道发生了什么。捕获顺序很重要。Python 的except子句会从上往下逐个匹配命中第一个匹配的就执行后面的全部跳过。所以你把except Exception写在最前面后面的except ValueError就永远没有机会执行了。正确的顺序是先写具体的异常再写宽泛的异常。我在实际项目中见过不止一次这种把具体异常放在通用异常后面的写法结果程序出问题时永远走的是通用分支日志里全是笼统的提示排查起来特别痛苦。除了上面这种单块捕获还有一种形式——try-except不带任何异常类型try: do_something() except: print(出错了)这就是所谓的“裸 except”。我强烈不建议你这么写原因在第 4 部分详细说。你至少应该写成except Exception:至少把KeyboardInterrupt、SystemExit排除在外。2.2 else 子句的用途明确区分“正常路径”和“异常路径”try-except-else-finally的完整结构里else是一个很容易被忽视的子句。它的语义是当try块中没有发生任何异常时执行else块里的代码。什么场景会用到else举个例子你要从配置里读一个端口号读到之后根据端口号启动服务。try: port int(config[port]) except KeyError: print(配置中缺少 port 字段使用默认端口 8080) port 8080 except ValueError: print(port 不是合法数字使用默认端口 8080) port 8080 else: # 只有在 try 块成功拿到合法端口时才执行 print(f读取到端口: {port}) start_server(port)如果你把start_server(port)直接放进try块里那么当start_server本身抛出运行时异常时你上面的except KeyError和except ValueError也会被触发——但你明明已经正确读取了端口问题出在启动服务这个环节。用else把“尝试读取配置”和“基于读取结果做事”这两步分开异常处理的范围会更精准不会出现“一个异常分支管两件事”的混乱局面。还有一个好处是代码可读性读代码的人一眼就能看出try块里是“危险的尝试”else块里是“安全的正常流程”。不过也要说实话else子句的使用频率没那么高很多资深开发者也习惯把所有代码堆在try里。这不能说错但当你遇到“尝试成功后再顺手做点别的”这种场景else是更优雅的解法。2.3 finally 子句善后工作的“保底选手”finally是try结构里最常见的“收尾”子句。它的特点是无论try块里有没有异常、except有没有命中、程序是通过 return 还是抛异常离开finally块里的代码一定会在离开之前执行。最经典的finally使用场景就是资源释放file None try: file open(data.txt, encodingutf-8) data file.read() except FileNotFoundError: print(文件不存在) finally: if file is not None: file.close()如果open失败file仍然是Noneclose不会执行如果open成功但读取过程中抛出异常close在finally里还是会被执行文件句柄不会泄漏。这就是“保底选手”的含义不管中间出了什么幺蛾子善后工作一定能做完。这里有一个隐蔽的细节如果finally块里出现了return它会覆盖try或except块里的return值。看这个例子def demo(): try: return 1 finally: return 2 print(demo()) # 输出了什么答案是 2我第一次看到这个行为时也愣了一下。原因是 Python 规定finally块在函数返回之前一定会执行而finally里的return会直接“截胡”把原本要返回的 1 换成了 2。所以我的建议是finally块里只做资源清理、日志记录、状态复位这类“副作用”操作永远不要写return语句。这个坑虽然不常见但一旦踩到bug 非常隐蔽——你看不出函数哪里错了只能一个个排查。为了帮你记忆我把 try-except-else-finally 的执行路径整理成了下面这张表场景try 块except 块else 块finally 块try 正常没异常执行不执行执行执行try 抛异常except 命中异常点终止执行不执行执行try 抛异常except 未命中异常点终止不执行不执行执行然后向上抛异常try/except 中有 returnreturn 前停下记下返回值按情况执行按情况执行执行可能覆盖返回值若 finally 中有 return3. 实战场景让异常处理真正派上用场3.1 文件操作with 语句与 try 的组合使用文件操作是新手接触try-finally最常见的场景。我前面写过手动close()的写法但现实中我推荐你优先用with语句自动管理上下文。with语句本质上就是帮你在内部处理了try-finally的资源释放问题try: with open(config.yaml, r, encodingutf-8) as f: content f.read() except FileNotFoundError: print(没找到配置文件用默认配置启动) content default_config() except UnicodeDecodeError: print(配置文件编码不是 UTF-8尝试用 GBK 解码) with open(config.yaml, r, encodinggbk) as f: content f.read()我这么说可能显得有点“双重保险”但是请你放心这不是多余。with处理的是“资源生命周期”try-except处理的是“异常信号”它们解决的问题不同。with保证文件一定会关闭try-except保证文件打不开或读不出时程序不崩溃。再说一个处理 JSON 文件的实操细节。读取并解析 JSON 是最容易出问题的操作之一因为不止 JSON 文件可能不存在文件内容还可能不是合法 JSONimport json try: with open(settings.json, r, encodingutf-8) as f: settings json.load(f) except FileNotFoundError: settings {} except json.JSONDecodeError as e: print(fsettings.json 内容解析失败: {e}) settings {}注意json.JSONDecodeError实际上继承自ValueError但我建议直接捕获JSONDecodeError这样日志里的错误语义更明确。抓异常时越具体你的代码自述性就越强——别人看你的代码不需要读日志就能知道你为什么区分了“文件不存在”和“内容不合法”这两种情况。3.2 网络请求与爬虫超时、连接失败的兜底方案很多刚接触爬虫的朋友都有过这种经历写了一个循环去抓网页跑到一半遇到网络抖动程序直接抛ConnectionError或被Timeout中断前面抓的数据全都白费。这时候try-except的价值就体现出来了。用requests库举例import requests from requests.exceptions import Timeout, ConnectionError def fetch_url(url, timeout5): try: resp requests.get(url, timeouttimeout) resp.raise_for_status() # 状态码不是 200 会抛 HTTPError return resp.text except Timeout: print(f请求超时: {url}) return except ConnectionError: print(f连接失败: {url}) return except requests.exceptions.HTTPError as e: print(fHTTP 错误: {url}, 状态码: {e.response.status_code if e.response else 未知}) return 这里我特别想提resp.raise_for_status()这个用法。很多新手直接拿resp.status_code做判断但如果你不检查状态码请求即使返回 404代码也照样往下走拿到一个错误页面的内容还以为自己爬数据成功了。raise_for_status()的作用是把“4xx/5xx 状态码”这个逻辑错误转换成 Python 可以捕获的异常信号从而纳入try-except的统一异常管理。这也是一个典型的“用异常来统一错误处理流程”的思路。如果你要抓大量页面我建议把try-except放在每一次请求的外层单次失败不致命记录日志后跳过继续抓下一个。同时配合time.sleep()控制请求频率以及一个简单的失败重试机制def fetch_with_retry(url, retries3): for attempt in range(retries): try: resp requests.get(url, timeout5) resp.raise_for_status() return resp.text except (Timeout, ConnectionError) as e: if attempt retries - 1: print(f多次尝试仍失败: {url}, 错误: {e}) return time.sleep(2 * (attempt 1)) # 递增等待 return 这里的核心思想是网络环境的错误是“可重试”的所以你可以设计递进式的等待策略但像 404、403 这类 HTTP 状态错误重试多少遍结果都一样就应该直接放弃别浪费时间。3.3 数据处理与量化策略让程序在脏数据下活下来如果你常年跟 pandas、NumPy 打交道或者写过量化交易策略一定对这句话有共鸣真实世界的数据没有一天是干净的。数据库里某个字段明明是数字类型偏偏混进了一个字符串接口返回的某一行突然少了几个字段CSV 文件里多了一列乱码。这些“脏数据”不会直接让你语法报错但会让你的运行逻辑在不知不觉中跑偏或者直接抛异常中断整个策略的回测。拿最常见的数据类型转换举例import pandas as pd def safe_float_convert(value, default0.0): try: return float(value) except (TypeError, ValueError): return default这个函数你可以直接应用到 DataFrame 列上把“强制转换”变成“尝试转换失败给默认值”。但你得注意一个平衡如果默认值用的是0.0那表示你主动接受了“缺失值当 0 处理”的语义。对于策略回测来说这个语义可能引起很大的误差。所以我实际做量化日志的时候还会把转换失败的行记录下来事后人工检查到底有多少数据被“静默修正”了failed_indices [] for idx, row in data.iterrows(): try: data.at[idx, price] float(row[price]) except (TypeError, ValueError): failed_indices.append(idx) print(f警告: {len(failed_indices)} 行价格数据无法转换已保留原值)这就是“让程序在异常下活下来”与“让异常悄悄消失”之间的区别。前者的目标是可见性——异常被接住了但异常本身是可追踪的后者的目标是假装什么都没发生——这在投资、数据上报场景里是很危险的。写异常处理代码时每次加默认值的分支都值得多问自己一句“这个默认值合理吗它会不会掩盖一个真实的问题”3.4 自定义异常让业务错误有名字随着项目变大你会发现 Python 自带的异常类型不够用。比如你写一个交易系统账户余额不足、下单数量非法、风控校验失败这些都是“业务错误”但直接抛ValueError的话你的except ValueError不知道具体是哪个环节出了问题排查成本很高。这时候就该自定义异常了。自定义异常非常简洁只需要继承Exceptionclass InsufficientBalanceError(Exception): 余额不足 def __init__(self, balance, amount): self.balance balance self.amount amount super().__init__(f余额不足: 当前余额 {balance}, 需要 {amount})注意一个细节自定义异常一定要继承Exception而不是BaseException。原因前面提过——如果你继承BaseException你的自定义异常类就会和KeyboardInterrupt、SystemExit混在一起大多数except Exception的代码都接不住它程序会直接崩溃。这种隐性问题很难排查。有了自定义异常业务代码就可以这么写def place_order(account_balance, order_amount): if order_amount 0: raise ValueError(下单数量必须大于 0) if order_amount account_balance: raise InsufficientBalanceError(account_balance, order_amount) return execute_order(order_amount) try: place_order(1000, 5000) except InsufficientBalanceError as e: print(f下单失败: {e}) send_alert_to_user(e) except ValueError as e: print(f参数校验失败: {e})这种写法的好处是调用方可以通过异常类型明确区分“参数不对”和“业务规则不满足”并采取不同的处理方式。异常不再是笼统的“出错了”而是带有精确语义的消息。我在实际项目中维护过一套有几十个自定义异常类的代码库每个异常类的名字直接对应一种业务失败场景运维人员看报警日志时几乎不需要翻代码就能判断是哪个环节出了问题。4. 常见问题与排查技巧实录4.1 裸 except 为什么是坏味道我见过不少代码是这样写的try: do_something() except: pass每当我看到这种代码我的第一反应不是“这代码能跑”而是“项目里的 bug 到底藏了多少”。原因有三个。第一except:会捕获所有异常包括KeyboardInterrupt用户按 CtrlC和SystemExitsys.exit()触发。这意味着用户想终止一个卡死的程序按 CtrlC 没用——程序把终止信号也吞了继续在那傻跑。这个体验极其糟糕。第二裸except加上pass等于把错误“无声吞掉”。程序可能已经在错误的道路上越跑越远了但没有任何日志、没有任何输出你完全不知道问题出在哪里。等你发现结果不对的时候排查范围已经覆盖了半个项目。第三它破坏了异常处理的结构。一个负责任的项目除了处理已知异常还应该给“未知异常”留一个日志出口。正确写法至少是这样try: do_something() except Exception as e: logging.exception(do_something 发生未预期的异常: %s, e) raise # 如果不打算吞掉就用 raise 重新抛出raise不带参数会把当前正在处理的异常原样重新抛出保留完整的 traceback。这样既做了记录又没有掩盖问题。4.2 吞掉异常后问题去哪了吞异常最典型的后果是数据静默丢失。我举一个真实的例子有同事写了一个把订单写入数据库的函数为了“防止程序崩溃”在try块里捕获了所有异常并且没有记录。有一天数据库连接断开所有写操作都失败被吞掉了但业务方完全没发现等到对账的时候才发现少了一大笔订单。花了大半天排查最终定位到那行except: pass。所以在这里我要说一句近乎偏执的建议如果你在一个except块里除了pass什么也不干请停下来想想你是不是真的想这么做。哪怕你写一句print(某个异常被忽略了)也比静默吞掉强。生产环境里正确做法是把异常日志写入专门的日志文件用logging.exception()输出完整的堆栈信息。还有一个折中方案你可以指定捕获某个特定异常并忽略但其他异常仍然正常抛出try: send_metrics(data) except MetricsSinkUnavailable: # 指标上报服务不可用不影响主流程忽略 pass except Exception as e: # 其他异常还是交给日志去吧 logging.exception(send_metrics 发生未知异常: %s, e)这样你“确实故意忽略”的范围是明确的将来删掉这个忽略分支时你能清楚地知道影响面是什么。4.3 finally 与 return 的隐雷我在第 2.3 节已经提到了finally里写return会覆盖返回值这是实际使用中返工率很高的一个点。我再补充一种更隐蔽的情况def parse_config(path): try: with open(path, r) as f: return json.load(f) except FileNotFoundError: return {} finally: cleanup_temp_files() # 假设这里是清理操作这种情况下如果json.load(f)成功函数会先记下要返回的settings对象然后执行finally里的cleanup_temp_files()最后返回settings。finally里的“非 return 操作”不会影响返回值。但如果cleanup_temp_files()本身抛出了异常这个异常会覆盖原本正常返回的结果让parse_config函数抛出一个与读取配置文件完全无关的错误。所以finally块里放的操作最好是“绝不应该抛异常”的操作比如状态位复位或者你自己用try-except把finally里的操作再包一层finally: try: cleanup_temp_files() except Exception: # 清理失败不影响主流程只记录日志 logging.exception(清理临时文件失败)这个嵌套写法虽然看着有点啰嗦但它保证了“善后动作”本身不会喧宾夺主。4.4 如何保留完整的错误现场排查问题时让人最头疼的就是只看到一行错误信息没有完整堆栈。Python 的 traceback 是定位问题的黄金线索但在某些情况下你捕获异常后不处理 traceback问题就丢了。我会推荐两个工具logging.exception()和raise ... from ...。logging.exception()的使用场景是在except块里记录异常。它和logging.error()的区别是它会自动把当前异常对象的 traceback 信息追加到日志里。比如import logging try: 1 / 0 except ZeroDivisionError: logging.exception(计算出现除零错误)你会在日志里看到完整的堆栈——是哪一行代码、调用了什么函数、走到了什么分支一目了然。raise ... from ...则是处理“异常链”的工具。当你捕获 A 异常后想转成 B 异常抛出同时保留 A 的信息就可以这样写try: data json.loads(raw_text) except ValueError as e: raise ConfigParseError(配置文件内容无法解析) from e有了from e你抛出的新异常会自带一个“cause”解释新旧异常的因果关系。排查时你会看到类似 “The above exception was the direct cause of the following exception” 的输出两个异常的堆栈串在了一起。没有from的话原始异常信息会被“改头换面”排查时只能看到新异常找不到根因。这个细节在大型项目里真的能救命。最后再分享一个关于异常处理边界的个人体会。并不是所有“可能出错”的地方都适合用try-except。如果你在写一个解析器或者输入校验很多错误都是可以提前预判的这时候优先用if判断来预防比如检查字典是否包含某个 key、字符串是否为空、列表长度是否够。try-except更适合处理的是“你无法通过前置判断完全避免”的场景——文件不存在、网络断了、数据库连接池耗尽、第三方接口返回异常。把if能解决的交给if把if解决不了的交给try-except代码的逻辑反而更清晰。还有一个小技巧try块尽量小。很多新人把一大段逻辑全部塞进一个try里美其名曰“统一处理”结果异常发生的具体位置根本定位不到——就算捕获到了你也只看到“某段代码在某处失败了”而不是“某个具体操作在哪一行失败了”。我自己的习惯是每个try块只包围“一步操作”异常处理分支对应“这个操作失败后的转义逻辑”。这样代码的可读性、可维护性都会好很多排查日志的时候也能更快定位问题。