On the Discipline of Catching Carefully异常不是失败 · 是分级信号
2026 年 4 月底的一个周日, 我盯着监控面板看了 24 小时.
事情的起因平平无奇 — 我们做了一次 ban 解除的小改动, 周五 deploy 上去, 表面无事. 周一早上回过头看 metrics, 发现一个数字不对: "账号解封" 通知应该有 80 多条, 实际只有 0 条. 没有任何 user 收到这条通知.
诡异的是, 服务没报错, log 里全是 INFO 级别的"成功", 数据库也没看出啥. 表面看起来一切正常.
把链路扒到底才发现: 我们插一条新的 notification type 的时候, 漏了同步数据库的 CHECK 约束. INSERT 报 IntegrityError, 但代码里有一个 try/except, 把这个 error logger.error 之后默默吞掉, 主流程接着 commit. 用户的 ban 在 DB 里是真解了, 但通知永远没发出去.
24 小时, 80 多个用户, 没一个收到提示. 全靠他们自己刷新页面发现 "咦怎么我能登录了".
事后我想了一会儿. 这次的根因当然是漏改 CHECK 约束 — 那是个独立的 PITFALL. 但更深的根因, 是那段 try/except 的存在. 它不该存在.
异常不是失败, 是分级信号
很多代码里的 try/except 写法是这样的:
try:
do_something()
except Exception as e:
logger.error(f"failed: {e}")
# continue
看起来是 "防御式编程" — 我不让任何 exception 把进程搞挂, 全部 catch 起来记下来. 看起来是 "健壮".
但我现在认为这是 最危险的代码模式之一. 因为它把所有异常当同一种东西处理 — "失败了, 记一下, 继续走". 异常不是同一种东西.
异常是 分级信号:
· 业务级异常: 用户输入不对、第三方 API 返回 400、配额超限 — 这些是预期内的"不顺", catch + 给用户友好提示是对的.
· 系统级异常: 数据库连接断了、Redis 超时、磁盘满 — 这些是基础设施问题, catch + retry 或者 fail-fast 都行, 但要 alert.
· 不变量异常 (invariant): IntegrityError、NOT NULL violation、CHECK 约束失败、外键失败 — 这些是 代码假设和数据库现实对不上. 它们不是"失败", 是"代码错了".
第三类的处理只有一种合法做法: logger.critical + raise. 让它炸. 让监控收到. 让我半夜被 page.
因为如果你 catch 了一个不变量异常然后继续走, 你就是在告诉系统: "我知道这一步没成功, 但我假装它成了, 然后接着用这个假状态做后面的事." 那 24 小时静默故障就是这种思路的下游产物.
为什么 DB 不变量异常特别危险
三类异常里, 第三类最容易写错也最容易吞.
原因有三:
一, 它长得像普通失败. IntegrityError 对调用方来说就是一个 SQLAlchemy / asyncpg 抛出的 Exception, 跟连接超时长一个样. 一句通用 except 就能把它一起捞了.
二, 它通常发生在事务的中间. 你已经做了三步, 第四步报 invariant violation. 如果你 except 掉, 前三步还在事务里, 接着 commit — 你 commit 了一个"中间状态"的事务. 这个状态不属于任何业务定义的状态机, 它就是个数据空洞.
三, 它的表现是"逻辑上半成品". 进程没挂, 接口没 500, 用户没收到错误. 但 事情没办完. 这种半成品最难发现, 因为没有任何信号告诉你出错了.
这次的事件里, 我们的 try 块大致是这样:
# lift_expired_bans.py
async with session.begin():
user.ban_status = 'lifted'
user.ban_lifted_at = now()
try:
await session.execute(insert(Notification).values(
user_id=user.id,
type='account_unbanned', # ← CHECK 不允许的新值
...
))
except Exception as e:
logger.error(f"failed to insert notification: {e}")
# 这里就 swallow 了 IntegrityError
# commit 接着发生 — ban_lifted_at 写进去了, notification 没有
读这段代码的人当时大概想的是: "通知失败别影响主流程, 用户的 ban 还是要解开的." 这个直觉是对的, 但实现错了 — 错在用同一个 except 捞了所有异常, 包括 invariant.
怎么写不会吞 invariant 的 try/except
正确的写法是 具体捕获 + 分流:
from sqlalchemy.exc import IntegrityError
async with session.begin():
user.ban_status = 'lifted'
user.ban_lifted_at = now()
try:
await session.execute(insert(Notification).values(...))
except IntegrityError:
# invariant 异常: 我假设的 schema 和 DB 现实对不上
# 这是 critical bug, 不能吞
logger.critical(
"BUG: notification CHECK violation on lift_expired_bans, "
"type='account_unbanned' missing from DB enum?"
)
raise # ← 让事务 rollback, 让监控收到, 让我被 page
except (httpx.TimeoutException, redis.ConnectionError) as e:
# 系统级异常: 基础设施问题
# 通知降级到 queue retry
logger.warning(f"notification delivery degraded: {e}")
await enqueue_for_retry(...)
注意几件事:
· 具体捕获. 不写 except Exception, 写明确的异常类. 这逼你想清楚每一类异常该怎么处理.
· logger.critical, 不是 logger.error. 因为大部分团队的告警阈值是 critical 起跳. error 级别只会被 grep 出来, 不会 page 人.
· raise, 不是 continue. invariant 异常必须让事务 rollback. 半成品状态比 "什么都没做" 更糟.
· 注释写明白 这一类异常为什么必须炸. 半年后另一个工程师 (或者 AI) 想 "善意地" 加 except 兜底时, 能看到这个解释.
把异常分级编入 CI
事故之后我做了三件事.
第一, 在 CI 里加了一条规则: 所有 INSERT 到带 CHECK 约束的表的代码路径, 必须有对应的枚举测试. 这条规则把 "代码假设的枚举值" 跟 "DB 实际允许的枚举值" 自动 diff, 任何不一致 fail build.
具体实现是一个 pytest 测试:
# tests/test_notification_types_align.py
CODE_TYPES = { # 从代码里 grep 出来的所有 type 字符串
'rental_ended', 'account_unbanned', 'order_completed', ...
}
def test_notification_types_match_db_check():
db_types = await fetch_check_constraint_values('notifications', 'type')
missing = CODE_TYPES - db_types
assert not missing, (
f"code uses these types but DB CHECK forbids: {missing}"
)
第二, 把 lib 里所有的 except Exception 标记成需要 review — 通过一个简单的 grep + comment annotation 系统. 不是禁止, 是要求显式说明 为什么这里宽 catch.
第三, 在 CLAUDE.md 里加了一条 feedback memory:
规则: 不变量异常 (IntegrityError / NOT NULL / CHECK / FK violation)
严禁吞掉, 必须 logger.critical + raise.
Why: 2026-04-27 一次 try/except 吞 IntegrityError, 主流程
commit 但通知永不落库, 24h 80+ 用户失通知, 全靠
自己刷页面发现.
How: 适用所有 SQLAlchemy / asyncpg / psycopg 路径.
不适用业务 ValidationError / 第三方 API 4xx
(那些是合法的 "业务失败", 该 catch 该转用户提示).
有了这条 memory, 下次任何 agent 改类似代码, 都会被这条规则提醒. 事故档案永远比代码本身更重要.
异常处理是 design, 不是 fix
这次事故让我重新思考了一件事 — 我们大部分时候把异常处理当 "fix": 出错了, 加个 try/except 兜一下, 完工.
但好的异常处理是 设计. 它在你写代码之前就该决定:
· 这个函数会面对几类异常?
· 每一类异常意味着什么? (业务不顺 / 基础设施抖动 / 代码假设错了)
· 每一类异常该 谁 看到? (用户 / oncall / 半夜的我)
· 每一类异常该 多严重? (info / warning / error / critical)
· 异常之后系统该处于什么状态? (rollback / 部分提交 + 队列重试 / fail-fast)
这五个问题答完了, try/except 才有合理的形状. 没答的话, 它就是 "先写一个 try 兜底, 万一出错呢" 这种条件反射.
条件反射是反应, 不是设计. 设计是 预先决定每一类异常的命运.
收尾 — silent failure 是最贵的故障
如果一个系统出问题然后 500 了, 那是 便宜的故障. 你立刻知道, 立刻修, 一小时之内复盘.
如果一个系统出问题然后 silent 24 小时, 那是 昂贵的故障. 你不知道它出了问题, 用户不知道它出了问题, 等发现时已经累积了 80 个事件, 复盘要看 24 小时的 log, 修完还要回滚补偿.
而这两种故障的区别, 往往就是一个 try/except 里的一行代码.
所以下次写 try/except, 想清楚: 我捕获的是哪一类异常? 我的捕获会不会让一个本该响的警报哑了?
如果会, 不要 catch. 让它炸.
炸是最好的 alert.
「天 网 恢 恢, 疏 而 不 失」
老 子 · 第 七 十 三 章