一个接口加了三个装饰器:@retry、@log、@cache。上线后发现"重试了三次但只打印一次日志",而"缓存把失败结果也缓存了"——因为装饰器的顺序决定了"谁包裹谁",这个"掩盖"是语法的功劳:并列的 @ 行看起来像"三个并列的属性",而实际是"三层包裹"——于是"顺序"这个语义被"排版"藏起来了。
而"看起来并列"的写法掩盖了这层嵌套。
装饰器是"函数套函数",于是"顺序"就是"嵌套顺序"。而它的三种常见误解(顺序无关、只影响性能、可以随便调)都会在"错误路径"上暴露。
1. 顺序的机制:谁在外面
| 写法 | 等价嵌套 |
|---|---|
@a @b def f |
a(b(f)) |
调用 f() |
先入 a,再入 b,最后到 f |
于是"最上面的装饰器"是"最外层"——它最先看到调用,也最后看到返回。这个"最先入、最后出"的规则能解释大部分困惑:要"记录最终结果"就放外层,要"记录每次尝试"就放内层。
// 两种顺序的等价写法(读法:从下往上套)
@log
@retry
def call(): ...
// 等价于 call = log(retry(call))
@retry
@log
def call(): ...
// 等价于 call = retry(log(call))
// 差别:重试的是"包含日志的调用"还是"只重试原函数"
2. 顺序影响的三种行为
| 行为 | 受顺序影响 |
|---|---|
| 重试几次、每次是否记日志 | 是 |
| 缓存的是"成功结果"还是"含异常的结果" | 是 |
| 事务边界是否包含"重试" | 是 |
第一项的"用途"很实际:"只记一次"适合"审计"(一次业务动作一条),"记每次"适合"排障"(看重试了几次、每次错在什么)——于是"顺序"其实是"你在意什么"的表达。
第一项是开头那个现象:@log 在 @retry 外面 → 只记一次;在里面 → 每次重试都记一次。
第二项更严重:@cache 在 @retry 外面 → 缓存了"重试后仍失败"的结果,于是"下次直接拿到失败"。第二项的"变体"是"缓存了'瞬时错误'":一次网络抖动被缓存 10 分钟,于是"看起来一直失败"——而重试本来能救它。
"顺序"可以用一个"两次调用的实验"验证,而不是靠读代码猜:
// 加一行打印,看"嵌套顺序"是否如你所想
@log
@retry(times=2)
def call(): raise RuntimeError("x")
call()
// 若日志只出现一次 → log 在外层(只看到最终失败)
// 若日志出现三次 → log 在内层(每次重试都记)
3. "该谁在外"的三条判据
| 判据 | 结论 |
|---|---|
| 想"记录每次尝试" | 日志在内层 |
| 想"缓存成功结果" | 缓存在最内层(或缓存内层结果) |
| 想"事务覆盖重试" | 事务在外层 |
这个"冲突"的解法是"分开两层":外层负责"重试与记录",内层负责"计算与缓存"——于是"重试时每次都走内层",而"内层命中缓存就不真算"。
第一项与第二项常冲突:"想记录每次尝试"与"想缓存成功结果"不能靠"同一个包裹顺序"同时满足——于是要"分层":日志在内、缓存在更内、重试在外。
4. "状态类装饰器"的三种坑
| 坑 | 表现 |
|---|---|
functools.wraps 忘了加 |
函数名/文档丢失 |
| 装饰器带参数时写错 | @dec() 与 @dec 混淆 |
| 单例/类装饰器共享状态 | 多实例串味 |
第一项的表现很具体:**f.__name__ 变成 wrapper**第一项的"连带影响"在"框架"上:Flask/FastAPI 的路由按函数名注册,函数名重复会"覆盖前一个路由"——于是"两个接口只剩一个",而报错信息毫不相关。
,于是"日志里全是 wrapper"、"路由注册失败"。
第二项是"语法坑":@dec 与 @dec() 的区别是"是否调用工厂"——写错会得到"dec 当成被装饰函数"的诡异错误。
// 三种写法的差别
@dec # dec 是装饰器本身:dec(f)
@dec() # dec 是工厂:dec()(f)
@dec(arg) # 工厂带参数
// 反例:把"工厂"直接当装饰器用 → f 被当成 arg 传进工厂
"缓存 key"要"包含全部影响结果的输入",否则会"串味":
// 反例:key 只含第一个参数
@cache(key=lambda a, b: a)
def calc(a, b): ...
calc(1, 2); calc(1, 3) # 第二次命中"1,2"的缓存 → 结果错
// 正解:用"全部参数 + 关键配置"
@cache(key=lambda *a, **kw: (a, sorted(kw.items())))
def calc(*a, **kw): ...
5. "缓存装饰器"的三种失效
| 失效 | 原因 |
|---|---|
| 缓存了异常 | 异常未被区分 |
| 缓存了可变对象 | 返回同一个 list,调用方改了就污染 |
| key 不含全部参数 | 参数不同却命中同一缓存 |
第二项的"修法"要么"返回副本"(成本),要么"约定不可变"(纪律)——而在 Python 里"约定"经常被打破,于是"返回副本"更稳。
第二项在"返回 list/dict"时很常见:缓存返回同一个对象,调用方 append 后,下次拿到的是被改过的。
6. 排错:五种"装饰器相关问题"
| 现象 | 成因 | 处理方向 |
|---|---|---|
| 日志只打一次 | 日志在重试外层 | 调顺序 |
| 失败被缓存 | 缓存在重试外层 | 缓存放内层 |
| 函数名变成 wrapper | 缺 functools.wraps |
补上 |
| 装饰器"没生效" | @dec 与 @dec() 写错 |
检查工厂 |
| 并发下结果串味 | 共享状态 | 用 contextvars/无状态 |
第五项在"限流/计数"装饰器上常见:装饰器里存了一个 counter,而多个请求共用它第五项的"正确写法"是"把状态放在函数参数或上下文里"(如 contextvars),而不是"装饰器闭包里"——闭包里的状态是"每个进程一份",于是"多进程部署时限流翻倍"。
——于是"限流按进程算,而不是按用户"。
"装饰器顺序"在框架里往往被固定,而"你的装饰器"要跟它对齐:
// 一个"可用"的顺序约定(业务接口)
1 路由(框架固定,最外)
2 限流/鉴权(要在"业务逻辑之前")
3 事务(要包住"重试")
4 重试
5 日志(要看"每次尝试")
6 缓存(最内层,只缓存成功结果)
// 判据:从外到内,"越靠外越像'协议层',越靠内越像'计算层'"
7. 边界:什么情况不必纠结顺序
| 场景 | 为什么 | 做法 |
|---|---|---|
| 只有一个装饰器 | 无嵌套 | 不管 |
| 装饰器"纯观察"(只计数) | 顺序无所谓 | 不管 |
| 互相独立(日志 + 计时) | 无冲突 | 不管 |
| 框架已固定顺序(如路由) | 由框架决定 | 跟随 |
最后一条与"可读性"有关的边界:"顺序敏感的装饰器"应当"在代码里写出理由"——一行注释"log 在内层,为了记录每次重试",比"靠读的人推断"可靠得多。
判断口径一句话:"这两个装饰器会不会互相影响结果"。会(重试与缓存、事务与重试),顺序就是语义;不会(日志与计时),顺序只是"记录时机的细微差别"。
再补一个"易错"的事实:@retry 若把"异常类型"写成了"宽泛的 Exception",那它会"重试业务校验失败"(本来不该重试的)——于是"用户看到的错误变成'稍后重试'"。这类"重试范围"的问题与"顺序"叠加时最难查:日志显示"重试三次后成功",而业务上那次失败本不该重试。
8. 自测
@a @b def f等价于什么嵌套?为什么"最上面的装饰器最后看到返回"?- 为什么"
@log在@retry外面"会导致"只打印一次"? - "缓存了失败结果"是哪种顺序造成的?正确顺序是什么?
@dec与@dec()的区别是什么?写错会出现什么诡异错误?- "缓存返回可变对象"为什么会污染调用方?
- 判断"是否需要纠结顺序"的那个问题是什么?