凌晨三点的生产事故
那天晚上,我刚躺下,手机就炸了。
运维老张在群里连着发了十几条消息:"支付接口超时率飙升到30%了,订单全卡住了,你在吗?"
我瞬间清醒。上个月刚上线的支付模块,出了问题。
远程连上服务器,翻日志。
奇怪的事情发生了——日志里什么都没有。没有报错,没有异常堆栈,只有一条条"支付失败"的业务记录。
我把代码翻来覆去看了三遍,逻辑没问题啊。
再往下翻,看到了这段:
try:
result = third_party_pay(amount, card_info)
save_order(result)
except:
log.error("支付失败")
return False
就这么一段。看起来还挺"安全"的——出错了就记日志、返回失败,挺规范吧?
但就是这个看似"安全"的写法,把我坑得通宵没睡。
因为 except: 这个裸捕获,把所有异常都吞掉了。包括 KeyboardInterrupt、SystemExit,也包括最要命的——**内存不足时的 MemoryError**。
支付接口调用失败了,但失败的真实原因——对方接口返回了一个特殊格式的异常,我根本没捕获到。那个异常里面,其实带着"银行卡已过期"的具体原因。
但 except: 不管三七二十一,全吞了。我只看到"支付失败"四个字,然后去查日志,日志里什么详细信息都没有。
那个通宵,我什么都没干成。就因为多写了三个字符——: 前面没有指定异常类型。
天亮的时候,我把那段代码改成了这样:
try:
result = third_party_pay(amount, card_info)
save_order(result)
except PaymentTimeoutError:
log.error("支付超时,稍后重试")
except InvalidCardError as e:
log.error(f"卡号无效:{e.detail}")
return False
except Exception as e:
log.error(f"未知支付异常:{e}")
log.exception(e)
return False
问题当场定位:卡号格式不对。用户输错了一个数字。前端校验没拦住,后端裸 except 又给吞了。
从那天起,我发誓再也不写裸 except 了。
裸 except 到底错在哪里?
来认识一下Python里三种最常见的异常捕获写法:
# 写法一:裸 except(最危险)
try:
do_something()
except:
print("出错了")
# 写法二:捕获 Exception(比较安全)
try:
do_something()
except Exception:
print("出错了")
# 写法三:捕获具体异常(最推荐)
try:
do_something()
except ValueError:
print("值错了")
表面上看,写法一和写法二好像差不多——都是"出错了就执行"。
但差太多了。
关键在于:Python的异常体系是分层的。
BaseException
├── SystemExit
├── KeyboardInterrupt
├── GeneratorExit
└── Exception
├── ValueError
├── TypeError
├── AttributeError
├── KeyError
├── RuntimeError
└── ... (几十种内置异常)
except: 捕获的是所有继承自 BaseException 的异常。
而 except Exception: 捕获的是所有继承自 Exception 的异常。
区别在哪?SystemExit、KeyboardInterrupt、GeneratorExit 这三个直接继承自 BaseException,**不继承自 Exception**。
什么意思?
KeyboardInterrupt是你按Ctrl+C时抛出的异常SystemExit是sys.exit()时抛出的异常
如果你的代码里用了裸 except,按 Ctrl+C 都退不出去——因为异常被吞了,程序会继续执行。
这不是开玩笑。我曾经见过一个脚本,因为写了个裸 except,运维按了半天 Ctrl+C 都关不掉,最后只能 kill -9 强杀进程。
吞掉异常的真实代价
裸 except 最大的问题不是捕获了什么,而是你根本不知道捕获了什么。
看这段代码:
try:
result = json.loads(response.text)
except:
result = {}
看起来挺合理——解析JSON,失败了就用空字典。
但如果 response 是 None,response.text 会抛出 AttributeError。被 except 吞了。
如果 response.text 是 "",json.loads("") 会抛出 JSONDecodeError。又被吞了。
如果内存不够,抛出 MemoryError。照样被吞。
你拿到一个空字典,然后继续往下执行。后续的业务逻辑就建立在"这是个空字典"的假设上。
你调试的时候,看到的不是 JSONDecodeError,而是一堆莫名其妙的 KeyError 和 TypeError。
真正的异常被吞掉了,吐出来的是一个面目全非的"症状"。
这就是为什么我那个通宵什么都没查到——真实异常被吞了,日志里只有四个字"支付失败"。
三种正确的写法
写法一:捕获具体异常
try:
with open('config.json') as f:
config = json.load(f)
except FileNotFoundError:
print("配置文件不存在,使用默认配置")
config = DEFAULT_CONFIG
except json.JSONDecodeError as e:
print(f"配置文件格式错误:{e}")
raise
这是最推荐的写法——你明确知道可能出什么错,而且每种错误都有对应的处理方式。
写法二:捕获 Exception 并记录详细信息
当你确实需要捕获"所有可能的异常"时(比如在框架的顶层),用 except Exception 而不是裸 except:
try:
result = call_external_service()
except Exception as e:
# 记录完整的堆栈信息
log.error(f"调用外部服务失败:{e}")
log.exception(e) # 这行会把完整堆栈打出来
# 返回一个安全的默认值,或者降级处理
return DEFAULT_RESULT
关键点:捕获了,就一定要记录。 而且要用 log.exception 或者 traceback.format_exc(),把堆栈信息留下来。
写法三:捕获特定异常并重新抛出
有时候你捕获异常不是为了处理它,而是为了在抛出去之前做点事情——比如记录日志、清理资源:
try:
result = process_data()
except ValueError as e:
log.warning(f"数据异常:{e}")
# 做完清理工作后,重新抛出
raise
except RuntimeError as e:
log.error(f"运行时错误:{e}")
# 这个太严重了,直接转成业务异常
raise BusinessError("系统繁忙,请稍后重试") from e
注意 raise 不加参数表示重新抛出当前异常,不改变堆栈。
还有两个容易被忽略的细节
细节一:except 是有顺序的
try:
do_something()
except Exception:
print("兜底")
except ValueError:
print("值错误") # 这行永远不会执行
因为 Exception 是 ValueError 的父类,except Exception 在前面就把所有 ValueError 都截胡了。
正确的顺序是:具体的在前,通用的在后。
try:
do_something()
except ValueError:
print("值错误")
except TypeError:
print("类型错误")
except Exception:
print("其他错误")
细节二:else 和 finally 的妙用
try 后面可以跟 else 和 finally:
try:
result = risky_operation()
except ValueError:
log.error("值异常")
else:
# 只有没有异常时才会执行
log.info("操作成功,结果是:{result}")
finally:
# 不管有没有异常,都会执行
cleanup_resources()
else 适合放"一切正常时才做"的事情,finally 适合放"无论如何都要做"的事情——比如关闭文件、释放锁。
注意:如果你在 except 里 return 了,finally 还是会执行。 finally 是真正意义上的"无论如何都会执行"。
实战建议:什么时候用什么
写工具函数、库代码时:抛出具体的异常,不要捕获。让调用者决定怎么处理。
def parse_config(path):
with open(path) as f:
return json.load(f)
# 不捕获异常,让调用者自己去处理
写业务逻辑时:捕获具体异常,做针对性处理。实在需要兜底,用 except Exception 并记录日志。
写框架、中间件、顶层入口时:可以有一个 except Exception 兜底,但必须记录完整堆栈。
def main():
try:
run_app()
except Exception as e:
log.critical(f"程序崩溃:{e}")
log.exception(e)
sys.exit(1)
现在你再看那三种写法
回过头来,这三种写法差距到底有多大?
# 写法一:裸 except
except:
# 捕获所有异常,包括 Ctrl+C
# 你知道捕获了什么吗?不知道。
# 等于在代码里埋了一颗地雷。
# 写法二:except Exception
except Exception:
# 捕获普通异常,但不影响 Ctrl+C
# 至少不会让你关不掉程序
# 但还是太宽泛,建议配合日志使用
# 写法三:except ValueError
except ValueError:
# 只捕获特定异常
# 你知道发生了什么,也知道该怎么处理
# 其他异常会正常上抛,不会遗漏
从"埋雷"到"专业",差的只是一个异常类型名。
那天之后
那个通宵之后,我给自己定了一条铁律:
永远不写裸 except。
如果实在想写,先停一下,问自己三个问题:
- 这里可能会抛出哪些异常?
- 每种异常我打算怎么处理?
- 万一有我没想到的异常,我怎么保证不丢失信息?
想清楚了再下笔。
异常捕获不是为了"让程序不崩溃"——而是让程序在崩溃的时候,你能立刻知道为什么崩溃,并且用最快的速度修好它。
那个通宵,我损失了睡眠,但换来了一个教训。
值了。