Python 装饰器深度实战:从 functools.wraps 到异步装饰器,5 个生产级场景与性能实测(2026)

📝 372 字 · ☕ 1 分钟阅读

对装饰器背后的机制感兴趣的,推荐读这篇 Python 描述符深度实战——property、slots、cached_property 全是一套协议的不同应用,跟装饰器正好凑成元编程双雄。

前言:凌晨三点,一个装饰器把我坑了

先说个真实的事。上个月有个服务半夜报警,接口全挂。我爬起来一看日志:函数签名对不上、文档全丢了、连函数名都变成了 wrapper。排查半天,罪魁祸首是一行漏掉的 functools.wraps——同事给核心函数套了个装饰器,没保留元数据,框架按名字找函数直接找错对象。

那晚之后我发誓把装饰器彻底搞明白。这篇文章就是我复盘后的产物:从语法糖本质到 functools.wraps 为什么必须用,从带参装饰器到异步装饰器,最后用 200 万次调用的实测数据告诉你:装饰器到底贵不贵。

装饰器本质:不就是个语法糖吗

很多人背了无数遍「装饰器就是闭包」,但写起来还是懵。其实记住一件事就够了:@deco 就是 f = deco(f) 的简写。函数在 Python 里是一等公民,能当参数传、能当返回值。

def deco(fn):
    def wrapper(x):
        print("before")
        result = fn(x)
        print("after")
        return result
    return wrapper

@deco
def double(x):
    return x * 2

# 等价于:double = deco(double)

就这么点事。装饰器返回的新函数替换掉旧函数,之后调用 double 实际走的是 wrapper。如果你之前写过闭包、写过 生成器,这套「函数工厂」的思维应该不陌生。

functools.wraps:99% 的人漏掉的那一行

上面那个装饰器有个隐患:double.__name__ 变成了 wrapper。平时没事,一旦你的代码靠函数名做事——Flask 路由注册、日志过滤、序列化、调试器——就全乱了。我那次线上事故就是这么来的。

import functools

def deco(fn):
    @functools.wraps(fn)   # 这行不能省
    def wrapper(x):
        return fn(x)
    return wrapper

@deco
def double(x):
    """把数字翻倍"""
    return x * 2

print(double.__name__)  # double(没有 wraps 会输出 wrapper)
print(double.__doc__)   # 把数字翻倍(没有 wraps 会输出 None)

wraps 的原理是拷贝 __name____doc____module__ 等属性,并设置 __wrapped__ 指向原函数,让 inspect 能穿透装饰器看到真相。从今天起,你写的每个装饰器第一行必须是它。

带参装饰器:三层嵌套,别慌

有时候装饰器需要参数,比如「缓存 60 秒」「重试 3 次」。写法就是外面再包一层函数,接收参数:

def ttl_cache(seconds=60):
    cache = {}
    def deco(fn):
        @functools.wraps(fn)
        def wrapper(*args):
            now = time.monotonic()
            if args in cache:
                val, ts = cache[args]
                if now - ts < seconds:
                    return val
            val = fn(*args)
            cache[args] = (val, now)
            return val
        return wrapper
    return deco

@ttl_cache(60)
def get_price(code):
    return fetch_price(code)  # 模拟昂贵调用

三层结构记一个口诀:外层收参数,中层收函数,内层收调用。很多新手把第二层和第三层合并,结果参数跑到函数参数里,运行时报错一脸懵。

装饰器叠加顺序:从下往上执行

多个装饰器叠一起,顺序经常被搞反。记住:执行时从下往上,越靠近函数的装饰器越先执行。

@auth        # 后执行(外层)
@cache       # 先执行(内层)
def api():
    pass

# 等价于:api = auth(cache(api))

所以「先缓存再鉴权」和「先鉴权再缓存」是不同的。缓存命中就直接返回,根本走不到 auth;如果你希望所有请求都过鉴权,就得把 auth 放外面。这个顺序问题我在生产环境见过不止一次——缓存把没权限的数据发出去了。

性能实测一:装饰器调用到底贵不贵

总有人担心「加了装饰器性能就崩了」。我用 200 万次调用实测了一把(Python 3.12,裸函数 vs 各种装饰器):

Python 装饰器调用开销实测:无装饰器44.9ns vs 简单装饰器74.1ns

结论很干脆:简单装饰器单次调用只多花约 29 纳秒(74.1 vs 44.9 ns)。哪怕你的接口每秒被调 1 万次,装饰器开销也就 0.3ms 的零头。真正吃性能的是装饰器里干的活——打日志、查缓存、跑网络请求,跟那几纳秒完全不是一个量级。所以:装饰器性能不是问题,装饰器里写慢代码才是问题

顺带一提,异步装饰器单次调用 7 微秒的开销,大头是事件循环调度,不是装饰器本身。想深挖异步调度的,可以看我之前写的 asyncio.TaskGroup 结构化并发实战

性能实测二:缓存装饰器能快多少

装饰器最值钱的用法之一是缓存。我用斐波那契递归(经典重复计算场景)做了个对比:fib(30) 连算 5 次,朴素递归每次重算,缓存装饰器只算一次。

缓存装饰器效果对比:朴素递归547.9ms vs TTL缓存0.044ms vs lru_cache 0.03ms

方案 耗时 加速比
朴素递归(每次重算) 547.9 ms 1x
自写 TTL 缓存装饰器 0.044 ms 约 1.2 万倍
functools.lru_cache 0.030 ms 约 1.8 万倍

但别急着给所有函数套缓存——@lru_cache 的坑我单独写过一篇 《Python @lru_cache 不是银弹》:可变参数、内存爆炸、状态不一致,每个都能在线上给你上课。缓存只该用在「反复调用 + 结果不变 + 参数可哈希」的函数上。

异步装饰器:async def 怎么包装

写异步代码的人经常卡在装饰器上:包装函数是普通 def,return 的是 coroutine 而不是结果,await 不了。解法很简单——wrapper 也写成 async:

def async_retry(times=3):
    def deco(fn):
        @functools.wraps(fn)
        async def wrapper(*args, **kwargs):
            for i in range(times):
                try:
                    return await fn(*args, **kwargs)
                except Exception:
                    if i == times - 1:
                        raise
                    await asyncio.sleep(0.5 * (i + 1))  # 退避
        return wrapper
    return deco

@async_retry(3)
async def fetch_order(order_id):
    async with aiohttp.ClientSession() as s:
        async with s.get(f"/orders/{order_id}") as r:
            return await r.json()

注意两个细节:wrapper 内部必须 await fn(...),否则返回的是协程对象而不是结果;重试之间用 asyncio.sleep 而不是 time.sleep,后者会阻塞整个事件循环。这个错误我在 code review 里抓过太多次了。

生产级案例:限流装饰器

最后给一个完整的实战例子。给第三方 API 调用加限流,用装饰器比在业务代码里到处写判断干净得多:

import time
import functools

def rate_limit(max_per_second=5):
    interval = 1.0 / max_per_second
    last = [0.0]  # 用列表存可变状态
    def deco(fn):
        @functools.wraps(fn)
        def wrapper(*args, **kwargs):
            now = time.monotonic()
            wait = interval - (now - last[0])
            if wait > 0:
                time.sleep(wait)
            last[0] = time.monotonic()
            return fn(*args, **kwargs)
        return wrapper
    return deco

@rate_limit(5)
def call_quota_api():
    return requests.post("https://api.example.com/v1/check")

为什么 last 用列表?因为闭包里的普通变量只能读不能写——直接 last = now 会报 UnboundLocalError,这是闭包装饰器最经典的翻车点。用可变对象绕过去,或者用 nonlocal 声明。

避坑清单

  • 漏掉 functools.wraps:元数据丢失,调试和框架集成全乱(我踩过,凌晨三点的那种)
  • 闭包变量只读:需要修改外层变量时用 nonlocal 或可变容器
  • 装饰器顺序:从下往上执行,鉴权/缓存顺序别搞反
  • 异步函数用普通装饰器包装:返回协程对象而非结果,记得 wrapper 也 async
  • 缓存装饰器无脑套:可变参数、超大结果集、状态依赖的函数不适合

常见问题(FAQ)

Q: functools.wraps 不加会怎样?

函数名变成 wrapper,__doc__ 变 None。依赖函数名的框架(路由、日志、序列化)会行为异常,调试时栈信息也全是 wrapper。线上事故的常见来源。

Q: 装饰器会影响性能吗?

实测简单装饰器单次调用多花约 29 纳秒(200 万次调用 44.9ns vs 74.1ns),相对业务逻辑可忽略。真正影响性能的是装饰器内部执行的操作。

Q: 多个装饰器的执行顺序?

从下往上执行:@a @b def f() 等价于 f = a(b(f)),b 先执行,a 后执行。需要控制访问顺序时把外层装饰器写上面。

Q: 装饰器能用在异步函数上吗?

能,但 wrapper 必须写成 async def 并在内部 await 原函数,否则返回的是协程对象。异步重试、限流、超时都适合用装饰器封装。

总结

装饰器是 Python 里性价比最高的抽象之一:一个装饰器就能给一堆函数统一加缓存、重试、限流、日志。但它的坑也藏在细节里——wraps、闭包变量、叠加顺序、异步包装,每一个都值得花十分钟记牢。

想继续深挖的,这几篇跟本文是连着的:上下文管理器深度实战(with 和装饰器是 Python 两大「切面」武器)、Python 性能翻车现场(看看哪些看似很快的写法其实很慢)、类型注解进阶(装饰器配合类型标注才能写出可维护的代码)。

如果你也被某个装饰器坑过,欢迎留言——说不定下次复盘就是你贡献的案例。

📤 分享这篇文章