ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

Python异常处理实战:try-except、Traceback与自定义异常

Python异常处理实战:try-except、Traceback与自定义异常 你有没有过这种经历自己写的Python脚本在本地跑得欢天喜地一上生产环境或者换了一批真实数据瞬间崩成一堆红色Traceback我前几年做爬虫和数据清洗的时候就经常撞上这种尴尬——明明逻辑想得挺周密偏偏在凌晨三点自动任务挂掉第二天一看日志全折在了一个空列表的下标越界上。后来我才想明白一件事代码正常运行只是及格线真正拉开差距的是程序在不正常的时候怎么表现。这就要靠异常处理了。它本质上不是一种补救措施而是一套让程序在错误发生时有预案、有退路、有现场记录的机制。这篇文章我会从异常的本质讲起把try-except-else-finally的完整语义、异常对象和Traceback的细节、自定义异常的设计思路以及几个高频实战场景一次讲透。不管你是刚学Python的入门者还是写过一阵子脚本但总觉得异常处理用得不踏实的开发者这篇都适合当一份能反复翻的参考。1. 程序崩溃的真相异常是什么很多初学者把异常处理理解成用try包住代码出错了别让它崩。这个理解没错但太浅了。真正要搞懂异常处理首先得理解异常这个机制在Python里到底扮演什么角色。1.1 一次线上事故的复盘先说个我印象很深的事故。之前写过一个自动报表脚本每天定时从数据库拉数据再算指标写进Excel。脚本里有这么一段result query_db(SELECT amount FROM orders WHERE date today) total sum(row[amount] for row in result)逻辑本身没问题数据库也有数据。但有一天报表没生成翻日志才发现那天零点到两点之间系统在做例行维护订单表为空query_db返回了一个空列表然后sum()对空列表求和返回0——这本来也不算错。真正炸的是后面拿到result[0]去取某条明细的时候直接抛出IndexError整个脚本没有捕获它程序当场终止后续所有报表都没发出去。这个事故里有两个教训第一数据为空是正常业务情况不是异常代码应该提前判断第二程序崩溃时如果没有异常处理机制后续步骤全部停摆连优雅退出并通知管理员都做不到。异常处理真正解决的不是防止错误发生而是错误发生时程序如何可控地响应该错误。1.2 异常对象的完整生命周期Python里异常是一个对象。当某行代码出错时解释器会做这么几件事创建一个异常对象比如IndexError(list index out of range)如果当前代码块被try包裹就把这个对象交给对应的except去匹配如果没有任何except接住它这个对象会沿着函数调用栈一层一层往外抛一直抛到最外层还没人接解释器就打印Traceback并终止程序。所以异常处理本质上是在正确的位置拦截异常对象。错误信息就是异常对象身上的三个关键属性type异常类型比如ValueError、value异常描述比如invalid literal for int() with base 10、traceback异常发生时的完整调用栈记录了从哪个函数、哪一行开始出错的。用traceback.print_exc()可以把这三者一起打印出来这也是排查问题时最有用的信息。1.3 常见的异常类型速查Python内置的异常类有继承层级这里给你一张我在实际开发中最高频遇到的核心异常速查表异常类型触发场景典型代码示例严重程度SyntaxError语法错误代码无法被解释print(hi编译期必改ValueError值本身不合法int(abc)运行时需捕获或校验TypeError类型不匹配1 1运行时通常是逻辑bugIndexError下标越界lst[99]运行时可能因数据问题KeyError字典缺键d[name]运行时数据缺失FileNotFoundError文件不存在open(x.txt)运行时需捕获ZeroDivisionError除以零a / 0运行时需前置判断AttributeError对象没有该属性None.len()通常是逻辑bugTimeoutError操作超时网络请求超时需捕获并重试ConnectionError连接失败数据库/网络连接需捕获并重试注意看这张表里有两类完全不同的异常一类是逻辑BugTypeError、AttributeError这类错误说明代码写得有问题应该修复代码而不是用try包起来掩盖另一类是外部环境问题文件缺失、网络超时、数据格式变化这类错误无法靠修代码完全避免恰恰是异常处理的主战场。很多初学者一上来把所有代码都包进try结果把逻辑Bug也吞掉了程序不崩了但结果全错——这是最大的误区。2. 异常处理五件套try-except-else-finally的执行语义try、except、else、finally这四个关键字加一个raise是Python异常处理的全部语法。但这五个词并不是平级的关系它们的执行顺序和语义差别很大用错了会让代码行为完全超出预期。2.1 各子句的定位与选择try放可能出错的代码。注意是可能出错不是所有代码。except捕获并处理指定的异常。可以写多个except来分别处理不同类型的异常。elsetry里没有发生异常时才会执行。很多人不知道这个子句但它在区分正常流程和异常流程上非常有用。finally无论是否发生异常都会执行通常用来释放资源关文件、关连接。raise主动抛出异常把错误往上层传递或自定义错误。我给你一个最典型的应用场景读取配置文件的函数同时照顾到三种情况——文件存在且能解析、文件不存在、文件存在但解析出错三种流程要输出不同的日志。def load_config(path): try: with open(path, r, encodingutf-8) as f: config json.load(f) except FileNotFoundError: log.warning(配置文件 %s 不存在使用默认配置, path) return {} except json.JSONDecodeError as e: raise RuntimeError(f配置文件 {path} 是非法JSON: {e}) from e else: log.info(配置文件 %s 加载成功共 %d 项配置, path, len(config)) return config finally: log.debug(config 加载流程结束)这里else的价值在于它只属于try块成功分支失败分支走的路径完全冲突。如果用else配置文件加载成功的日志就不会和异常日志混合在一起代码逻辑链路清晰得多。如果不写else而是把成功逻辑硬塞进try末尾一旦成功逻辑本身报了错你很难判断到底是try里的代码错的还是成功逻辑错的。else就是给正常执行的代码一块独立空间。2.2 多个except的匹配顺序与多异常捕获Python里多个except是从上到下依次匹配的先匹配先执行。所以顺序很重要子类异常必须写在父类异常前面。一个新手常犯的错try: num int(input(输入数字: )) except Exception: print(出错了) except ValueError: print(不是数字!)这段代码第二个except ValueError永远不会执行因为ValueError是Exception的子类已经被第一个except拦截了。正确写法是把ValueError写在前面。这一点在项目代码里特别重要——你捕获的异常越具体就能给出越有针对性的处理方案捕获得越宽泛处理方式就越笼统越容易掩盖真实问题。还有一种常见的需求多个不同类型的异常走同一个处理逻辑。这时候推荐用元组方式写在同一个except里而不是写多个重复的except块try: result external_api_call() except (ConnectionError, TimeoutError) as e: log.warning(网络请求失败稍后重试: %s, e) schedule_retry()2.3 一段代码看懂完整执行流程我用一段带输出的代码演示完整的执行流程建议你亲手跑一遍理解每个子句的时序def demo(value): try: print(1. try开始) if value 0: raise ValueError(值不能为0) result 100 / value except ZeroDivisionError: print(2. 捕获ZeroDivisionError) return 除零错误处理完毕 except ValueError as e: print(3. 捕获ValueError:, e) else: print(4. 无异常进入elseresult , result) return result finally: print(5. finally一定执行) print(demo(10)) print(---) print(demo(0))运行结果一目了然传入10时走的是try → else → finally → return传入0时走的是try → except → finally → return。注意finally在return之前执行——这也是为什么finally里最好不要写return因为它的return会覆盖函数正常的返回值。我早期就在这里栽过跟头后面会专门讲。3. 异常对象与捕获粒度从最底层的Traceback说起异常处理不是try加except六个字那么简单的。真正用好它还得理解Traceback的信息结构、捕获时该拿到哪一层、多级调用时异常链怎么传递。3.1 捕获粒度怎么定实际写代码时最纠结的问题是except后面到底该写哪种异常我见过三种典型倾向裸except:—— 捕获所有异常包括KeyboardInterrupt用户CtrlC和SystemExitsys.exit()这俩本来是用来控制程序退出流程的被吞掉之后程序按兵不动你在终端按CtrlC都退不出去非常难受。except Exception:—— 捕获除BaseException直接子类以外的所有异常。这是业务代码中最常用的兜底级写法推荐在顶层用于记录日志后重新抛出或优雅退出。except具体异常:—— 只捕获自己预期内的那一类。这是推荐的主力写法因为处理方案最明确。我自己的经验是捕获粒度要跟你能做什么匹配。如果能重试就捕获网络类异常如果能给用户提示就捕获输入校验类异常如果什么都做不了比如数据库彻底连不上就别吞掉它记录下来往上抛让上层决定怎么办。3.2 异常链raise from的妙用当你在一个except块里要抛出另一个异常时如果不小心原来的Traceback信息会丢失排查问题时就只能看到新异常看不到它最初是从哪里冒出来的。比如def fetch_data(): try: resp requests.get(https://api.example.com/data) resp.raise_for_status() return resp.json() except Exception as e: raise ValueError(获取数据失败)这段代码最后只会打印获取数据失败但你不知道底层到底是网络超时、DNS解析失败还是状态码错误。正确的做法是加fromexcept Exception as e: raise ValueError(获取数据失败) from efrom e的语义是新异常和原始异常建立链接Traceback从上到下依次显示新异常 → 直接原因 → 根本原因排查链路一下子通了。文档里叫异常链实际用起来就像错误现场的来龙去脉。另外还有一个很实用的写法raise ... from None它会抑制原始异常。什么时候用当你明确原始异常信息对上层用户无意义且可能包含敏感信息时比如底层API的详细报错不想暴露给前端用from None把链切断。3.3 日志记录的正确姿势异常处理中日志怎么写同样是个容易被忽视的细节。很多人的习惯是except Exception as e: print(出错啦:, e)str(e)只能拿到异常描述字符串拿不到出错的代码行号和调用栈。线上排错的时候你看到list index out of range却不知道是哪个文件的哪一行等于没记。正确写法有两套配置import logging import traceback # 方式一logging模块自带exc_info推荐 try: risky_operation() except Exception: logging.exception(risky_operation 执行失败) # 方式二手动使用traceback适合自定义格式 try: risky_operation() except Exception: traceback.print_exc()其中logging.exception会自动在日志里附上完整的Traceback包含文件名、行号、调用栈。我在项目里对logging的配置是统一把日志写到文件加控制台双通道这样凌晨定时任务挂掉第二天醒来直接翻文件日志就能定位不用再靠肉眼盯屏幕。4. 自定义异常让错误有自己的身份证内置异常类型再多也只覆盖了通用场景。实际业务项目里领域相关的错误会层出不穷。比如用户注册时用户名重复、订单金额非法、库存不足——这些都不是Python内置异常能准确表达的。这时候就该上自定义异常了。4.1 什么时候该自定义异常一个判断标准当你需要在捕获异常时根据异常类型做出不同反应而内置异常类型表达不出这种区分就需要自定义异常。举个最简单的例子爬虫项目里遇到页面结构变化和遇到请求频率限制两者都需要重试但处理策略完全不同页面结构变化可能要发报警让人介入修改解析逻辑频率限制则是睡几秒后重试。如果都用内置Exception你只能在except里用if判断字符串内容非常脆弱自定义异常则能把类型本身就当作决策依据。自定义异常其实特别简单只需继承内置异常类class BizError(Exception): 业务逻辑层统一异常基类 class UserNotFoundError(BizError): 用户不存在 class OrderAmountError(BizError): 订单金额非法4.2 业务异常与系统异常分层在分层架构的项目里比如Web应用我习惯把异常隔离成两层系统异常SystemException底层基础设施的问题如数据库连接失败、Redis超时、第三方API不可用。这类异常的特征是重试可能有效、通常不属于某一笔请求本身的问题。它们应该被捕获后在顶层统一记录并根据策略做重试或降级。业务异常BizException业务规则被违反的情况如余额不足、商品已下架、验证码错误。这类异常特征是有明确的错误码和面向用户的中文提示。它们应当被捕获后转化为API响应而不是打印一大堆堆栈。这里给你一段典型代码class BizError(Exception): code BIZ_ERROR message 业务异常 def __init__(self, messageNone): if message: self.message message super().__init__(self.message) class BalanceNotEnoughError(BizError): code BALANCE_NOT_ENOUGH message 账户余额不足 class ProductOffShelfError(BizError): code PRODUCT_OFF_SHELF message 商品已下架这样设计的好处是调用方只要except BizError as e就能拿到e.code和e.message统一返回给前端既不用在业务代码里到处写if判断也不会把所有内部异常细节暴露给用户。加上内置异常的兜底系统层面还能区分开这是已知的业务拒绝还是这是未知的系统故障。4.3 一个用户注册模块的异常体系实例下面给一个相对完整的实例把自定义异常用在用户注册的场景里class BizError(Exception): 业务异常基类 code BIZ_ERROR detail 业务异常 def __init__(self, detailNone): if detail: self.detail detail super().__init__(self.detail) class EmailAlreadyRegisteredError(BizError): code EMAIL_ALREADY_REGISTERED detail 该邮箱已被注册 class UsernameInvalidError(BizError): code USERNAME_INVALID detail 用户名格式不正确 def register(username, email): if not is_valid_username(username): raise UsernameInvalidError() if email_exists(email): raise EmailAlreadyRegisteredError() # 真正写入数据库 create_user(username, email) return True try: register(test_user, testexample.com) except EmailAlreadyRegisteredError as e: # 在这里把 e.code 返回给前端提示用户更换邮箱 return {error: e.code, message: e.detail} except UsernameInvalidError as e: return {error: e.code, message: e.detail}这套写法如果配合基类统一处理连每个except都不用重复写只要在最外层捕获一次BizError根据code分发处理即可。项目代码一多这个优势非常明显。5. 实战案例让程序在真实场景中稳如泰山学异常处理最忌讳的就是只在玩具代码里打转。真正的价值体现在这些真实场景里网络请求失败要不要重试爬虫解析数据时遇到脏数据怎么办文件写一半崩溃了怎么保证资源释放我拆三个高频案例给你。5.1 网络请求的自动重试调用第三方API的时候网络抖动是常态。如果你不做任何处理一次超时可能就让整个任务失败。业内通行的做法是对可重试的异常做有限次重试对不可重试的异常立即抛出不等待。什么样的异常可重试连接错误、超时、HTTP 5xx什么不可重试HTTP 4xx比如参数错误、权限不足重试一百遍结果都一样。import time import requests from requests.exceptions import ConnectionError, TimeoutError def request_with_retry(url, max_retries3, timeout5): for attempt in range(max_retries): try: resp requests.get(url, timeouttimeout) if resp.status_code 500: # 服务端错误继续重试 raise ConnectionError(fHTTP {resp.status_code}) resp.raise_for_status() return resp.json() except (ConnectionError, TimeoutError) as e: wait_time 2 ** attempt # 指数退避0,1,2,4,8秒 log.warning(第 %d 次请求失败: %s%d秒后重试, attempt 1, e, wait_time) if attempt max_retries - 1: raise time.sleep(wait_time)这个函数里有一个关键细节attempt max_retries - 1时不再time.sleep而是raise直接把异常抛给上层。这样上层可以决定是继续降级处理还是终止任务而不是无限等待。指数退避策略2 ** attempt是为了避免重试时对服务端造成二次冲击尤其是多个任务同时失败的情况下全员同时重试很容易把服务端打挂。稳妥的做法还可以在基础退避上加随机抖动比如wait_time random.uniform(0, 1)。5.2 爬虫数据清洗中的容错爬虫是异常处理的重灾区因为你永远不知道目标页面下一秒会变成什么样。字段缺失、值类型和预期不符、页面弹窗干扰导致元素定位失败——每一个都可能让解析器崩溃。我写爬虫时的铁律是解析层必须做字段级容错而不是整条数据容错。一条数据少一个字段记录下来跳过去一条数据解析都失败了也记录下来跳过不能因为一条脏数据把整个批次的数据全搞丢。USEFUL_FIELDS [title, price, stock, url] def parse_item(html_element): item {} for field in USEFUL_FIELDS: try: value extract_field(html_element, field) if value is None: raise FieldMissingError(f字段 {field} 缺失) item[field] value except FieldMissingError as e: log.warning(跳过无效数据: %s, e) return None # 字段缺失整条数据不要 except Exception: log.exception(字段 %s 解析时发生未知错误, field) return None return item这里的重点是except Exception兜底未知错误时仍然返回None主流程继续跑不会因为某一条脏数据让整个爬虫停摆。同时完整日志让事后定位可以直接定位到哪个字段、哪个元素、什么异常。爬虫跑一晚上的稳定性靠的就是这种朴实无华的容错。5.3 文件处理时的资源释放文件读写最怕的不是报错而是报错后连接没关、句柄泄漏。尤其是写Excel报表、写日志文件、连接数据库这类场景如果open()成功之后、write()阶段出错文件对象没有被关闭轻则文件占用无法删除重则句柄数量到上限后程序彻底崩溃。标准解法是withtry-except-finally结合使用def write_report(path, data): file_ok False try: with open(path, w, encodingutf-8) as f: f.write(format_data(data)) file_ok True except OSError as e: log.error(写入报表失败: %s, e) raise finally: log.info(报表写入完成文件句柄已由 with 自动关闭) if not file_ok: send_alert(f报表写入异常请检查: {path})这里with的作用是保证无论write成功还是抛异常文件都会关闭。finally则负责收尾性的通知工作。注意这里我在finally里没有动文件对象本身只做了日志和告警避免在资源清理阶段再引入新错误。finally里越简单越安全这是个重要的经验。6. 这些坑我替你们踩过了异常处理的反面清单写到这里该上硬菜了。异常处理本身并不复杂但用好它很难因为坑全在细节里。我把自己实际踩过的坑整理成一份清单每一条都是拿线上事故换来的。6.1 裸except与吞异常前面提过裸except:会捕获KeyboardInterrupt和SystemExit这已经很危险了。更危险的是很多人捕获异常后什么都不做try: do_something() except Exception: pass这段代码执行完你完全不知道发生了什么。程序倒是没崩但功能呢数据呢静默吞异常是代码异味里最严重的一条。它让排错变得几乎不可能——你甚至不知道哪里出了问题。如果确实觉得某个异常不重要、可忽略至少得写一行日志log.debug(已忽略异常%s, e)留个案底。判断标准很简单一个except块里让你写不出三行有意义的处理逻辑那这个except就不该存在或者就应该让异常直接抛出去。6.2 finally里的隐藏陷阱finally里我踩过两个坑都是血的教训。第一个是在finally里写returndef divide(a, b): try: return a / b except ZeroDivisionError: return 除零错误 finally: return finally的return print(divide(10, 2)) # 输出finally的return而不是5.0原因在于finally一定会在函数返回前执行如果finally里有return它会直接覆盖前期所有return的结果。这个坑极其隐蔽代码审查都看不出来只有跑测试时才露馅。铁律finally块里不做return不做函数返回值相关的操作。第二个是在finally里做可能出错的操作。比如finally里关闭资源时关连接本身也可能抛异常一旦在finally里抛出新异常它会覆盖原始异常导致原始错误信息丢失。稳妥做法是用try-except包住finally里的清理操作finally: try: conn.close() except Exception: log.warning(连接关闭时发生异常已忽略, exc_infoTrue)6.3 异常处理的性能账异常处理的性能开销经常被夸大但也确实存在。关键认知是Python抛出并捕获一个异常开销比普通的if-else分支要高得多。官方文档和社区实测都显示异常的开销主要花在Traceback的构建、异常对象的创建和栈帧的展开上。所以不要把异常当成通用的流程控制工具。比如判断字典里有没有某个键两套写法# 写法A异常驱动不推荐KV模式常见但性能差 try: value d[key] except KeyError: value None # 写法B先检查推荐 value d.get(key)d.get()的语义和写法A完全等价但性能好很多。有人说Python的EAFP风格就是优先用try-except这没错但EAFP针对的是真正的异常场景不是可以用一个方法参数解决的常规判断。性能优化的原则归纳起来就一句话用异常处理意外情况用条件判断处理预期分支。6.4 assert不是用来做校验的最后一个坑很多初学者喜欢写assert total 0, total必须大于0 total 100 / total看着像校验但这个校验有个致命问题assert在Python被优化模式运行python -O时会被直接移除你的校验瞬间消失。真正的生产环境里如果你要校验入参请用条件判断加异常抛出if total 0: raise ValueError(ftotal必须为正数当前值: {total}) total 100 / totalassert是给开发和测试期用的调试工具不适合作为生产环境入口参数的防御手段。别指望它能守住你的系统底线。关于异常处理我看到过很多种学习路径有人刷题有人抄代码但真正能让人进步最快的永远是扔一个不听话的真实项目过去让它反复崩几回。我到现在写代码第一个版本里还是会习惯性地用try把危险操作包住但经过这么多趟坑之后我更清楚的是异常处理不是在代码里画一个防崩结界而是让程序在出错时能按你的剧本走完该走的路——重试的能重试降级的能降级该记录的记录该通知的通知。如果让我给一条最朴素的建议那就是捕获异常前先问自己一句——我接下来要怎么处理它答不上来的时候就让异常往上抛。一个会泼辣地抛异常、会诚实地记录日志的程序比一个安静吞掉所有错误的程序靠谱太多了。这套思路可以在几乎所有语言里迁移Python只是让你写起来最顺手的那一个而已。
返回列表