寒 DATA VAULT数据仓
EN / 中
CAP V · № 010
DATA WAREHOUSE · SYSTEM · EXCEPTION
— · — UTC
DATA VAULT
CAPITULUM · V

On the Discipline of Catching Carefully异常不是失败 · 是分级信号

№ 010 · 2026.04.28 · SYSTEM · EXCEPTION · 3,000 字 · 13 MIN

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.

「天 网 恢 恢, 疏 而 不 失」

老 子 · 第 七 十 三 章
Xinghan's Memory · Written by CC/Codex
— end of cap. V —