半夜两点被连续报警吵醒,爬起来一看:生产环境的订单回调批处理全部失败。打开日志平台,只找到一行——Exception: request failed。没有堆栈、没有业务参数、没有函数名,这个异常到底从哪冒出来的,完全看不出来。搞 Python 异常处理的人都知道,这种日志比没有日志更让人抓狂:你知道出了问题,但你不知道问题在哪,只能对着代码逐行猜。后来我到过不少团队,发现大家踩的坑惊人一致——不是没写 try/except,而是把异常处理用错了地方。
这篇文章不讲教科书定义,只讲实战经验:异常怎么捕获、怎么读、怎么记日志、怎么设计异常体系、资源清理的边界在哪里。我尽量把这些年实际踩过的坑、定过的规范、看过的代码都摊开来写,适合所有想把代码从“能跑”改成“能查”的 Python 开发者,也适合准备给团队定一套异常处理规范的同学。
1. 异常处理最大的误区:except不是安全网,而是分流器
1.1 三层误区:包太多、吞太狠、print完事
我见过最多的三层误区,每层都是渐进式的,越往后越危险。
第一层是把整个函数体塞进一个 try 里,仿佛包住了就安全。很多从 Java 转过来的开发者尤其喜欢这样写:
def get_user_config(user_id): try: config = db.query("SELECT * FROM config WHERE user_id = ?", user_id) return parse_config(config) except Exception: pass return default_config这段代码的问题在于:db.query如果因为数据库密码错误挂了,程序依然会默默返回默认配置。用户行为异常,但没有任何日志、没有任何报警,可能等业务方发现“为什么所有用户都拿到了默认配置”时,已经过去好几天了。
第二层是except Exception: pass。这个写法的杀伤力比不写还大。异常被吞掉之后,程序会继续往下执行,状态可能已经半残,后续的结果没有一次是可信的。最致命的是,这类问题往往不会立刻暴露,而是像定时炸弹一样藏在某个角落。
第三层是print(e)。表面上看比 pass 好一点,至少打印了异常信息,但实际作用非常有限。生产环境里 stdout 经常不落盘,即使落盘了,没有堆栈、没有上下文、没有发生位置的 print,本质上只是把一个错误变成了一行无法定位的提示文字。这一层在第 3 章我会展开讲一次真实排障,当时就是被这种写法坑到凌晨四点。
1.2 异常的本质:它也是一种控制流
想要跳出具象的坑,先要理解 Python 的异常机制到底是什么。
当某行代码触发异常,Python 解释器会立刻中断当前代码的执行,沿着调用栈一层一层往回找except,直到找到能匹配的捕获语句,异常才被“接住”;如果一路找到程序入口都没有except,解释器就会打印完整的 traceback 并退出进程。
所以异常本质上不是“程序崩溃前的挽救动作”,而是程序从正常路径切换到错误处理路径的一个跳转机制。你可以把它理解成开车时的导航:正常路线走不通,系统重新规划出一条备用路线。try/except就是这条备用路线的入口——它不是在阻止“断路”发生,而是在“断路”发生之后,决定接下来往哪走。
理解了这一点,很多问题就迎刃而解:你希望处理什么异常,就捕获什么异常;不希望处理的,就让它继续往上抛。最怕的就是把except当成一块万能膏药,什么地方都贴一下,结果把正常路径和错误路径搅成一锅粥,最后连自己都分不清程序到底在走哪条路。
1.3 一个实例:从“包住一切”到“清晰分流”
拿读取配置文件这个场景来说,几乎每个项目都会有。糟糕的版本长这样:
try: content = open("config.json").read() config = json.loads(content) if config["debug"]: logger.info("debug mode") except Exception as e: logger.error("load config failed: %s", e) config = default_config这段代码把三种完全不同的异常混在一起处理:文件不存在、JSON 格式错误、字段缺失,统一打成一条 error,统一返回默认配置。问题就来了:文件不存在可能是首次启动,走默认配置完全可以接受;但如果是运维把 JSON 格式写错了,这属于配置错误,应该立刻告警;而config["debug"]抛 KeyError 则纯粹是开发阶段写错了字段名,属于 bug,应该直接炸出来让开发者看见。
改成“分流”的写法之后,每种异常都有自己明确的去处:
try: content = open("config.json").read() except FileNotFoundError: logger.warning("config.json not found, use default config") return default_config try: config = json.loads(content) except JSONDecodeError: logger.error("config.json is not valid JSON") raise if "debug" not in config: raise ConfigFieldError("missing 'debug' field in config.json")同样的功能,代码量差不多,但行为完全不同:文件不存在时安静降级,格式错误时立刻报警并中断启动,字段缺失时直接把问题暴露在开发环境。这才是异常处理该有的样子——它不是安全网,而是分流器,每一种异常应该流向它该去的地方。
2. 捕获顺序与try块粒度:从凭感觉到有标准
2.1 捕获顺序为什么先精确后宽泛
except的匹配规则是自上而下,命中第一个匹配类型就结束,后面的except不再执行。这个特性本身很简单,但很多人写代码时完全没当回事。
看这段代码:
try: data = payload["items"] except Exception: handle_all_errors() except KeyError: handle_missing_items()看起来好像很完整,实际上第二个except是死代码。因为KeyError是Exception的子类,第一个except Exception会先把所有异常接住,handle_missing_items()永远不会执行。
正确的写法是把精确异常放在前面,宽泛异常放在最后兜底:
try: data = payload["items"] except KeyError: handle_missing_items() except (TypeError, ValueError): handle_bad_payload() except Exception: handle_unknown_error()这里还有一个细节:多个类型写在同一个元组里,比写多行except更清晰。如果你判断这些异常的处理逻辑完全一样,就放进同一个元组,避免重复代码。
关于except Exception,我的建议是:除非你在顶层做兜底记录并重新抛出,否则不要轻易使用。它的问题在于会把KeyboardInterrupt、SystemExit之外的几乎所有异常都拦截下来,导致很多你没预料到的 bug 被“按下不表”。真正的兜底写法应该是:
except Exception: logger.exception("unexpected error") raise记完日志立刻往上抛,让上层框架或者进程接手,而不是自己硬扛。
2.2 try要包多小:最小作用域原则
我发现一个规律:一个 try 块如果超过半个屏幕,大概率是没拆好。异常处理最核心的原则就是“最小作用域”——尽量让每个 try 只覆盖一个操作,这样异常来源清晰,处理策略也能各自独立。
举个例子,读取文件并解析然后写入数据库,三个步骤的风险完全不同:
try: with open(file_path) as f: data = json.load(f) insert_rows(data) except Exception: logger.exception("load failed")如果这个 try 报错,你只能知道“load failed”,但你不知道是文件打不开、JSON 解析失败,还是数据库写入失败。这三种错误的处理方式完全不同:文件打不开可能要跳过这个文件继续跑,JSON 解析失败说明数据源有问题,数据库写入失败则很可能需要重试。混在一起,所有策略都无从谈起。
拆开之后,逻辑就清楚多了:
try: with open(file_path) as f: raw = f.read() except FileNotFoundError: logger.warning("skip missing file: %s", file_path) return try: data = json.loads(raw) except JSONDecodeError: logger.exception("bad json in %s", file_path) raise try: insert_rows(data) except DatabaseError: logger.exception("insert failed for %s", file_path) raise我在实际项目里见过一个反面教材:一个开发者把“拉取订单、解析回调报文、更新数据库”整个流程包在一个大 try 里,某天任务静默失败,日志只有一行Order update failed。三个人查了三个小时,最后发现是回调报文的时间字段格式处理错了。如果每一步独立 try,十分钟就能定位到问题。异常处理的意义不只是“让程序不崩溃”,更是“让问题能被快速找到”。
2.3 except块里的四种出路:记录、抛出、降级、重试
一个except块里,实际上只有四种出路可以选择,每种都有它适用的场景。我习惯把它们列成一张表,写代码之前先对号入座:
| 出路 | 适用场景 | 注意点 |
|---|---|---|
| 记录后抛出 | 未知异常、核心流程、不适合静默处理 | 尽量用logger.exception,不要只记录 message |
| 记录后降级 | 非核心依赖、有默认值可替代 | 确保调用方知道结果是“降级”的 |
| 记录并吞掉 | 已知且无害的异常,如文件不存在 | 必须有日志,禁止裸pass |
| 重试 | 网络抖动、连接超等瞬态错误 | 必须设上限,配合退避策略,不能无限循环 |
其中“重试”这块最容易被写坏。我见过有人给整个函数套一个for i in range(10),不管什么异常都重试,结果数据库字段超长的错误也被反复重试十次,把慢查询日志直接刷爆。
瞬态错误才值得重试,比如网络超时、连接断开、对端限流。一个相对可靠的重试函数长这样:
import time import random def retry(func, max_attempts=3, base_delay=0.5): for attempt in range(1, max_attempts + 1): try: return func() except TransientError: if attempt == max_attempts: raise delay = base_delay * (2 ** (attempt - 1)) + random.uniform(0, 0.2) time.sleep(delay)三个关键点:只捕获瞬态错误、重试必须有上限、退避时间加上随机抖动,不然多个任务失败后同时重试,会造成“重试风暴”把下游系统打挂。指数退避加抖动是分布式系统里的通用做法,在业务代码里同样适用。
3. 异常对象不是黑盒:教你真正读懂traceback
3.1 e里到底有什么:args与异常文本
写except ValueError as e:之后,e这个对象里到底有什么,很多人其实没仔细想过。常见操作是str(e)拿到一条错误文本,但e.args才是构造异常时传入的原始参数。
大多数内置异常的args只有一个字符串,所以str(e)看起来没问题。但异常文本只是冰山一角——同一个错误文本完全可能来自不同的位置。比如ValueError: invalid literal for int() with base 10: 'abc',既可能是配置解析传错了参数,也可能是某个接口返回了非预期格式。
所以在日志里,我不建议只记str(e)。至少要看repr(e),它包含了异常类型名和构造参数;更稳妥的是用traceback.format_exc()把整个调用链都打出来。到了排障的时候,一行文本和一段完整堆栈的差别,可能是三小时和三十秒的差别。
3.2 traceback的读法:从下往上,最后一帧才是案发现场
Python 的 traceback 排版方式经常误导新人:最上面是最外层调用,最下面是异常真正被抛出的位置。很多人第一眼习惯看最上面,然后顺着往下找,这是反的。
看这段典型的 traceback:
File "/app/worker.py", line 42, in process_order save_order(order) File "/app/db.py", line 88, in save_order conn.execute(sql, params) sqlite3.IntegrityError: UNIQUE constraint failed正确读法是先看最后两行——db.py第 88 行执行 SQL 时触发唯一约束冲突,这是案发现场;再往上看,是worker.py第 42 行的process_order调用了save_order。所以排查路径是从下往上:先确认“在哪里炸的”,再确认“它是被谁调进来的”。
这里有个实用技巧:当你组织的代码日志里出现多行 traceback 时,别整段贴,先把最后一行异常类型和 message 摘出来,再用中间的文件路径和行号去定位代码位置。大多数人看到sqlite3.IntegrityError: UNIQUE constraint failed就已经知道问题大概是数据重复了,堆栈只是用来确认是在哪一步产生的重复。
3.3 异常链:raise from 保留原始现场
在except块里重新抛出新异常时,Python 提供了一种保留原始异常的方式,很多人不知道或者用错了。
默认情况下,如果你在except里直接raise NewError("..."),Python 会把原来的异常挂在新异常的__context__上,traceback 里会显示两段信息。但可读性取决于上下文,有时候会被During handling of the above exception, another exception occurred这种提示干扰。
更清晰的方式是显式指定异常链:
def load_user(user_id): try: return database.fetch_user(user_id) except DatabaseError as e: raise UserLoadError(f"failed to load user {user_id}") from e这样上层捕获UserLoadError时,traceback 里同时能看到最底层的DatabaseError完整链路。from e把两层异常绑定在一起,既不会丢失原始信息,又能保留业务层面的语义。
反过来,如果你有意隐藏内部细节,比如“这个内部异常只是触发了一个外部兜底,不想让调用方看到”,可以用from None切断异常链。但坦率说,这个操作要非常谨慎,切断之后排障难度会上升。我自己的原则是:默认保留,除非有明确的安全或封装需求,否则不要切断链条。
3.4 一次只有一个message的排障实录
第 1.1 节提到的“print式日志”,我实际排过一次很典型的故障。
当时某个线上模块的日志持续报出Exception: pow must be >= 0,但没有任何堆栈。整个日志只有这一行,连它来自哪个函数都不知道。我当时的排查链路是这样的:
第一步,用日志关键词在整个代码库里搜索,找到了唯一一处抛出这个文本的地方,是在某个指标计算函数里,一行raise ValueError("pow must be >= 0")。
第二步,看这个函数的调用方,追踪到它是在事件循环里被异步调用的。函数内部出错后,异常被异步框架捕获,进了统一兜底:
except Exception as e: logger.error("Error: %s", e)这一行就是罪魁祸首。它把异常对象变成了一个字符串,丢掉了所有 traceback 信息,导致上下文在框架层就被截断了。
第三步,修复。把那行日志改成logger.exception(...),同时往上抛或者至少记录完整堆栈。改完之后,报警信息里直接带了文件和行号,问题当晚上线,第二天就定位到了数据源里一个负数参数。
那次排障之后,我给团队定了一条硬规矩:任何except块里,严禁只输出str(e),要么输出完整 traceback,要么就重新抛出。一个只带 message 的异常日志,本质上是把排障的活儿留给了下一个人。
4. 日志里最该记录的异常信息:让凌晨两点的报警能被看懂
4.1 logging.exception的正确打开方式
Python 的logging模块提供了一个非常顺手但经常被忽略的方法:logger.exception。
它的特点是只能在except块里调用,因为它依赖当前线程正在处理的异常上下文。调用之后,日志会同时包含异常类型、message 和完整 traceback:
try: result = calculate(data) except ValueError: logger.exception("calculate failed, data=%s", data) raise这条日志输出之后,排障的人能直接看到错误发生的位置、调用的参数、完整的调用链。我第一次用的时候最大的感受是:以前要写traceback.format_exc()再拼进日志,现在一行就全搞定了。
一个很小的细节:logger.exception(...)在底层等价于logger.error(..., exc_info=True),所以日志级别是 ERROR。如果你只是想留一条 WARNING 级别的异常记录,就不能用logger.exception,而要写logger.warning("...", exc_info=True)。
4.2 日志该记哪些字段:时间、模块、业务参数、完整堆栈
一条合格的异常日志,至少应该包含这几类内容:时间戳、日志级别、模块名、文件名和行号、异常信息,以及当时的关键业务上下文。
我第一次处理生产故障时,看到的日志是“空手道”式的:只有request failed。后来我给自己定了一个格式模板,直接用 logging 的 Formatter 控制:
import logging import sys logging.basicConfig( level=logging.INFO, format="%(asctime)s %(levelname)s %(name)s %(filename)s:%(lineno)d %(message)s", handlers=[ logging.StreamHandler(sys.stderr), logging.FileHandler("app.log", encoding="utf-8"), ], )message里我会明确写出业务上下文,比如订单号、用户 ID、请求 ID。看日志的时候,“时间 + 模块 + 行号 + 业务字段 + 完整堆栈”五点齐全,基本不需要再去翻代码就能判断是什么问题。
这里有一个特别要提醒的安全红线:记录业务上下文时,不要把明文密码、token、手机号、身份证号写进日志。异常日志本来就是要长期留存的,一旦带上敏感信息,后面想清理都无从下手。我在日志规范里明确写过一句话:宁可少记一个参数,也不要多记一个密钥。
4.3 用exc_info=True控制细节,避免噪音
不是所有except都需要完整堆栈。如果你明确知道这个异常已经被妥善处理了,比如配置文件不存在、循环里跳过一条脏数据,那记录一行简短说明就够了;但如果异常意味着系统进入异常状态,则必须带上完整堆栈。
我常用的写法是两种区分开:
except FileNotFoundError: logger.info("config file not found, fallback to default") except ValueError as e: logger.error("bad value: %s, context=%s", e, ctx, exc_info=True)把FileNotFoundError当成已知的、预期内的分支处理,不打堆栈,避免日志刷屏;把ValueError当成异常状态,保留堆栈方便排查。
很多团队的问题恰恰相反:高频出现、预期内的异常总是带一堆堆栈刷屏,真正偶发、严重的问题反而被淹没。判断标准其实很简单——这个异常发生之后,需不需要人去看代码?需要,就带堆栈;不需要,就说明原因即可。
4.4 报警刷屏与去重:别让日志平台失去价值
如果同一个异常每小时刷几千条,人的注意力会被消耗殆尽,等真正的严重问题出现时,反而没人关注了。这就是日志平台“狼来了”效应。
我见过最夸张的场景:凌晨两点开始,某个循环任务里的异常每小时产生上万条 error,邮件告警直接被打爆,第二天早上看日志,全是一模一样的 traceback。这种情况下,排障的人要在上万条重复里找那一条真正有用的记录。
工程上至少要做的动作是“归类去重”。同一类型、同一堆栈的异常,合并成一条摘要,记录第一次出现时间、最后一次出现时间、总次数:
recent_errors = {} def report_error(key, message): if key not in recent_errors: recent_errors[key] = 0 send_alert(key, message) recent_errors[key] += 1key可以用异常类型的类名加堆栈第一行来生成。生产环境里很多错误追踪平台已经内置了这套逻辑,但即便不接平台,这个思路在团队内部也完全可以用脚本实现。告警的价值在于“让人注意到”,而不是“淹没所有人”。日志记录得再全,如果人已经看麻了,那和没记也没区别。
5. 自定义异常与业务语义:让调用方可以定向处理
5.1 全项目raise Exception("xx")的灾难
很多项目里能看到这样的代码:
def get_user(user_id): user = repo.find(user_id) if not user: raise Exception("user not found") return user顶层调用方为了接住这个异常,只能写except Exception:,于是所有错误都被兜在一起。最典型的结果是:用户不存在时返回None,数据库连接挂了时也返回None。上层逻辑根本分不清“没有这个用户”和“系统出了故障”,两个完全不同的问题被当成同一种情况处理。
这就是异常语义缺失的典型症状。异常类型本身应该携带语义:调用方看到NotFoundError,就知道这是业务上“数据不存在”,可以走空分支;看到DatabaseError,就知道是基础设施故障,应该报警和重试,而不是当作正常业务分支。
用异常类型表达语义,本质上是在给调用方提供“定向捕获”的能力。没有这一层,所有except都会退化回except Exception,最后又变成第 1 章说的吞异常。
5.2 设计一套可用的自定义异常体系
自定义异常不是越复杂越好,关键在于层级清晰、命名一致。我比较推荐的三层结构长这样:
class AppError(Exception): """业务错误基类,所有业务异常都继承它""" error_code = "APP_ERROR" class NotFoundError(AppError): error_code = "NOT_FOUND" class PermissionDeniedError(AppError): error_code = "PERMISSION_DENIED" class ExternalServiceError(AppError): error_code = "EXTERNAL_SERVICE_ERROR"为什么要继承Exception而不是直接继承BaseException?因为BaseException底下还挂着KeyboardInterrupt、SystemExit这些特殊对象,它们负责的是进程级控制流,不应该被业务代码捕获。自定义业务异常的根类放在Exception下面,才是正确的层级。
异常层级也不要太深,两层到三层就够用。我见过五层继承的异常体系,几乎没有人能在写except时记得清每一层该捕获什么。保持扁平,让每个异常类型的职责一眼可见,比炫技重要得多。
5.3 自定义异常携带业务上下文
自定义异常真正区别于内置异常的地方,不只是“多了一个类名”,而是可以携带结构化信息。比如订单状态流转失败,异常里直接带上订单号和当前状态:
class OrderStateError(StateError): def __init__(self, order_id, current_state, message=None): self.order_id = order_id self.current_state = current_state super().__init__(message or f"order {order_id} cannot transit from {current_state}")上层捕获之后,可以直接读取e.order_id做定向处理,比如更新状态、人工介入,而不是从字符串里用正则去抠订单号。这个区别在写监控、写重试任务、写补偿逻辑时尤其明显。
需要提醒的是,异常类里不要放密码、token 这类敏感字段。异常对象在被捕获、记录、甚至序列化的整个生命周期里,都有可能进入日志或消息队列。业务上下文只放那些“排障时需要、但不敏感”的信息,才是一个长期安全的习惯。
5.4 避免过度设计:什么时候用内置异常就够了
自定义异常虽然好用,但也不意味着所有场景都要新建异常类。很多情况下内置异常已经完全够用,硬造一套反而加重负担。
我的判断标准是这样的:参数校验失败用ValueError,类型不对用TypeError,索引和键不存在用IndexError/KeyError,文件相关用FileNotFoundError/PermissionError。这些内置异常语义明确,Python 生态里的人一看就懂,不需要额外学习成本。
自定义异常的引入时机只有一个:当“调用方需要按类型区分处理”或者“异常需要携带额外的业务字段”时。否则不要为了“看起来规范”而设计一套异常体系。代码是给人读的,多一个层级就多一份理解成本。把内置异常用到位,再谈自定义,顺序才不会反。
6. finally、with与上下文管理器:把资源清理做对
6.1 finally的意义:try块不一定以except结尾
提到异常处理,很多人想到的是try/except,却忽略了try/finally这个同样重要的组合。finally块的意义在于:无论try里是否抛出异常,finally里的代码都保证执行。
handle = open("output.txt", "w") try: handle.write(payload) finally: handle.close()如果write抛出异常,handle.close()依然会执行,异常会继续向上传播。这种写法适合“我不打算处理这个异常,但我必须保证资源被清理”的场景。新手很容易陷入一个误区:觉得想清理资源就必须捕获异常。实际上,try/finally才是更精确的工具,它能区分“清理”和“捕获”这两件完全不同的事。
6.2 with语句的魔法:enter__和__exit
大多数场景下,try/finally手写资源清理会被with更优雅地替代:
with open("output.txt", "w") as f: f.write(payload)这段代码和上一小节的try/finally几乎等价,但不可见的部分更值得注意。with会调用对象的__enter__和__exit__方法,即使块内抛出异常,__exit__也会被执行。
理解__exit__的签名是你用好with的关键:
def __exit__(self, exc_type, exc_val, exc_tb):三个参数在无异常时全是None;有异常时分别对应异常类型、异常实例和 traceback。如果__exit__返回True,异常就会被吞掉;返回False或什么都不返回,异常继续向外传播。
我自己写上下文管理器时,默认在__exit__里做两件事:完成清理,返回False。举个例子,一个最简单的锁管理器:
class LockGuard: def __init__(self, lock): self.lock = lock def __enter__(self): self.lock.acquire() return self.lock def __exit__(self, exc_type, exc_val, exc_tb): self.lock.release() return False这样with LockGuard(lock):保证锁一定会被释放,而锁竞争等待中抛出的异常照样向上抛,不会因为进入with就被静默吞掉。这个“返回 False”的行为至关重要——曾经有人在__exit__里直接return True,导致所有块内异常全部消失,排查了很久才发现是这里的问题。
6.3 多资源清理与"异常被吞"的边界
with很好用,但多个资源嵌套时,缩进会变得很难受,而且每个资源对应一个上下文,表达能力其实有限。
with open("a.log", "w") as f1: with open("b.log", "w") as f2: ...这种写法语法上完全正确,但层级一多,代码的可读性就崩了。如果你有多个资源需要统一管理,我更推荐contextlib.ExitStack:
from contextlib import ExitStack with ExitStack() as stack: f1 = stack.enter_context(open("a.log", "w")) f2 = stack.enter_context(open("b.log", "w"))无论块内发生了什么,两个文件的__exit__都会被调用,并且按登记的逆序清理。遇到“资源数量不固定”的场景,比如遍历一个目录后打开所有文件,ExitStack几乎是唯一优雅的解法。
这里还有一个容易踩的坑:手写多个try/finally时,如果第一个close()本身抛出异常,第二个close()可能根本执行不到。虽然close()抛异常不常见,但这正是with和ExitStack存在的价值——让清理动作在异常情况下也保持确定性和顺序。
6.4 上下文管理器的进阶用法:临时目录、计时器或事务
上下文管理器不只是资源清理的工具,它还能抽象出很多常见的“进入-执行-退出”逻辑。用contextlib.contextmanager装饰器,代码能写得非常简洁:
from contextlib import contextmanager import time @contextmanager def timed(name): start = time.perf_counter() try: yield finally: cost = time.perf_counter() - start logger.info("%s cost %.3fs", name, cost)用的时候就是:
with timed("fetch_order"): order = fetch_order(order_id)比手写start = time.time()加logger.info清爽太多了,而且自动覆盖异常路径。
更进阶的用法是事务控制。很多业务代码里,提交和回滚逻辑最容易漏掉异常分支:
@contextmanager def transaction(conn): try: yield conn conn.commit() except Exception: conn.rollback() raise这个模式的语义非常清晰:块内正常完成就提交,块内抛出任何异常就回滚并且继续向上抛。业务代码里只需要with transaction(conn):包住几个数据库操作,事务边界自然成立。写异常处理的时候,把握住一个核心:with不是魔法,它是在__exit__里做你原本应该在finally里做的事,只是写得更好看、更不容易漏。
最后分享一条我自己沉淀下来的规矩。每次写一个except之前,我都会先问自己一个问题:这个异常发生时,程序下一步应该做什么?如果答不上来,就不要写这个except,让异常继续往上抛,抛到能回答这个问题的地方。这些年靠这条规矩,我避开了很多次凌晨两点的电话。你可以数一数自己项目里现有的except块,看看有多少其实是在吞异常——那个数字通常会让你重新思考异常处理的写法。