当你想让代码"慢下来":一个 Python 装饰器的踩坑实录
缘起:老板说系统太快了
某天,老板突发奇想:"我们的系统太快了,用户体验不好。能不能让某些操作故意慢一点?"
作为一名有尊严的工程师,我的第一反应是:这是什么离谱的需求?但既然是老板的要求,那就做吧。
需求很简单:
- 实现一个类装饰器
@relax - 调用被装饰类中的某些方法/属性前先 sleep 一会儿
- 支持白名单控制哪些需要延迟
- 延迟时间可配置
听起来挺简单的,对吧?
"这个需求很简单,怎么实现我不管。" —— 每一个产品经理的口头禅
第一版实现:__getattribute__ 方案
作为一个有经验的 Python 开发者,我首先想到的是拦截属性访问。__getattribute__ 是 Python 中最底层的属性访问拦截点,所有属性访问都会经过它。
看起来很优雅,一个继承类搞定一切。但老板说:"不要用 __getattribute__,换其他方案。"
为什么?__getattribute__ 太底层了,拦截范围太广,连 __dict__、__class__ 这些特殊属性都会被拦截,容易出问题。而且它会影响所有属性访问,性能开销也不小。
好吧,换个方案。
第二版实现:包装方法/属性方案
既然 __getattribute__ 不行,那就精准打击——只包装需要延迟的属性和方法。
这个方案更精准,只影响指定的属性和方法。
老板又提需求了:"__init__ 中创建的实例属性也要支持延迟。"
比如:
这就有点麻烦了。实例属性是在 __init__ 执行时才创建的,装饰器执行时(类定义时)这些属性还不存在。
第三版实现:支持实例属性
我的方案是:
- 在装饰器执行阶段,为白名单中的实例属性创建 property(即使属性还不存在)
- 包装
__init__,执行完原__init__后,把实例属性移动到_relax_{name}这个隐藏位置 - property 的 getter/setter 从隐藏位置读写,并添加延迟
看起来没问题,跑一下测试:
一切正常。提交代码,下班!
深夜惊魂:pytest 重跑的诡异 bug
第二天,QA 同事跑来:"你的代码有问题,pytest 重跑时日志变多了!"
我一脸懵逼:"什么意思?"
他展示了 pytest 的输出:
每次重跑,日志都会多出一些。这太诡异了!
"这就好比你去餐厅点菜,第二次点同样的菜,厨师却多给你做了几道。" —— 一个不太恰当的比喻
问题定位:闭包变量的幽灵
经过仔细分析,我发现了问题所在:
wrapped_instance_attrs = set() 这个变量是装饰器闭包中的局部变量!
这意味着:
- 第一次运行:
wrapped_instance_attrs是空的,实例化时属性名被添加进去 - pytest 重跑时(
--reruns在同一进程中):模块被重新导入,装饰器重新执行 - 装饰器重新执行 →
wrapped_instance_attrs被重置为空 set - 但类上的
_relax_prop_{name}标记和 property 已经存在(从上一次运行遗留) - 状态不一致!
更糟糕的是,wrapped_instance_attrs 在实例间共享。第一个实例处理后,第二个实例会跳过这些属性(因为 if name in wrapped_instance_attrs: continue)。
这就像一个餐厅:
- 第一次:厨师记录了"已做过的菜"
- 第二次:厨师换人了,记录本被清空,但厨房里的菜还在
- 结果:混乱
第一轮修复:把 property 创建移到装饰器执行阶段
我的修复思路:
- 把实例属性的 property 创建移到类定义时(装饰器执行阶段)
- 移除
wrapped_instance_attrs这个共享状态 __init__只负责移动属性值,不创建 property
这样 property 只创建一次,而且是在类级别管理,应该没问题了。
跑一下测试:
又出问题了!实例化过程中触发了延迟。
第二轮踩坑:__init__ 中的赋值触发了 property setter
问题分析:
在 __init__ 中执行 self.slow_attr = "value" 时:
slow_attr已经是一个 property(装饰器执行时创建的)- property 有 setter → 赋值操作触发 setter → setter 中有
time.sleep() - 结果:实例化过程中就延迟了!
这违背了用户意图——用户只想在访问属性时延迟,而不是在初始化时延迟。
"你点菜时厨师才开始做,而不是你进门时厨师就开始做。" —— 又一个不太恰当的比喻
最终修复:区分初始化和访问
解决方案:setter 中判断是否是初始化阶段。
如果 _relax_{name} 还不存在 → 是初始化赋值 → 不延迟
如果 _relax_{name} 已存在 → 是后续赋值 → 要延迟
最终测试:
完美!
再跑 pytest 重跑:
终于稳定了!
技术总结
这次踩坑让我学到了几个重要教训:
1. 装饰器闭包变量的陷阱
装饰器中的局部变量会在多次调用间共享,这在 pytest 重跑场景下会出问题。
解决方案:避免在装饰器闭包中维护实例级别的状态,改用类级别标记或实例级别属性。
2. property 与实例属性的时机问题
property 在类定义时创建,实例属性在 __init__ 时创建。两者的时机不同步,需要小心处理。
3. 初始化 vs 访问的区别
用户可能只想延迟"访问",不想延迟"初始化"。setter 需要区分这两种场景。
4. pytest 重跑的特殊性
pytest --reruns 在同一进程中运行,模块会被重新导入,但之前的类定义可能遗留。这与每次启动新进程的行为不同。
最终代码
使用示例
结语
这个需求看似简单,却让我踩了三个坑:
__getattribute__太底层,不能用- 装饰器闭包变量在 pytest 重跑时状态不一致
- property setter 在初始化时也被触发
最终通过:
- 把 property 创建移到类定义时
- 移除共享状态
- setter 区分初始化和访问
才彻底解决问题。
"简单的需求背后,往往藏着复杂的坑。" —— 每一个工程师的血泪总结
希望这篇博客能帮助遇到类似问题的朋友。如果你的老板也提出类似离谱需求,至少你知道怎么正确实现了。
本文首发于技术博客,转载请注明出处。
