PAYATHON 2026

Python 中一次与多次 async lock 调用,哪种性能更好且更安全?

技术老齐

建议每个用户的每次限流判断只获取一次 asyncio.Lock,在同一个临界区内完成窗口重置、额度检查、间隔检查和计数更新。这样可以保证“检查后占用额度”的原子性,也能减少反复获取和释放锁的开销。

每个用户使用各自的锁。发送 Telegram 消息和执行业务 handler 都应放在锁外。

为什么这些操作应共用一次加锁

检查通过后,需要及时更新计数,否则后续请求可能读到旧状态。

假设每日额度只剩一个名额,检查与更新之间如果发生任务切换,就可能出现:

请求 A:检查额度,还有一个名额
请求 B:检查额度,还有一个名额
请求 A:增加计数,放行
请求 B:增加计数,放行

检查和更新分别加锁,无法保证整个流程的原子性。将它们放在同一个临界区内,同一用户的其他请求就必须等本次状态更新完成后,才能检查额度。

但题目中的代码有一个前提需要说明:await 不一定会让出执行权。在单线程事件循环中,没有竞争的锁可以立即获取,而这些方法内部也没有其他会挂起的操作。因此,仅凭代码中有多个 await,不能断定原样运行就一定会出现上述交错。

将整个操作放在一次加锁范围内,可以明确保护边界。以后加入真正会挂起的调用时,也更容易检查是否影响并发安全。

先修正原代码中的计数问题

原代码还有两处与锁无关的逻辑错误。

total_requests_start_time 没有初始化。它为 None 时,reset_day_limit() 直接返回,后续的 add_reuqest() 也没有为它赋值,因此每日额度不会按预期重置。

另外,下面的条件会少放行一次:

self.total_requests >= self.max_requests_per_day - 1

上限为 5 时,计数达到 4 就会开始拒绝请求。如果采用“先检查,通过后加一”的方式,条件应改为:

self.total_requests >= self.max_requests_per_day

还需要明确“一天”如何计算。下面的实现使用按需重置的 24 小时窗口:首次请求开始计时,窗口到期后,在下一次请求时重置。它既不按自然日零点重置,也不是统计任意过去 24 小时的滑动窗口。

将检查和更新合并为一个方法

可以保留 RequestsData 和 RateLimiter 的分工,由 RequestsData 对外提供一个方法,完成整次限流判断:

import asyncio
import time
from collections import defaultdict


class RequestsData:
    def __init__(
        self,
        seconds_interval: float = 1,
        max_requests_per_day: int = 5,
    ):
        if seconds_interval < 0:
            raise ValueError("seconds_interval must be non-negative")
        if max_requests_per_day <= 0:
            raise ValueError("max_requests_per_day must be positive")

        self.seconds_interval = seconds_interval
        self.max_requests_per_day = max_requests_per_day
        self.day_len_seconds = 24 * 60 * 60

        self.last_time: float | None = None
        self.total_requests = 0
        self.total_requests_start_time: float | None = None

        self._notified = False
        self._async_lock = asyncio.Lock()

    async def try_acquire(self) -> tuple[bool, bool]:
        # 返回值:(是否需要发送限流提示, 是否允许请求)
        async with self._async_lock:
            now = time.monotonic()

            if (
                self.total_requests_start_time is None
                or now - self.total_requests_start_time
                >= self.day_len_seconds
            ):
                self.total_requests_start_time = now
                self.total_requests = 0
                self._notified = False

            day_limited = (
                self.total_requests >= self.max_requests_per_day
            )
            interval_limited = (
                self.last_time is not None
                and now - self.last_time < self.seconds_interval
            )

            if day_limited or interval_limited:
                should_notify = not self._notified
                self._notified = True
                return should_notify, False

            self.last_time = now
            self.total_requests += 1
            self._notified = False
            return False, True


class RateLimiter:
    def __init__(
        self,
        seconds_interval: float = 1,
        max_requests_per_day: int = 5,
    ):
        self.requests: defaultdict[int, RequestsData] = defaultdict(
            lambda: RequestsData(
                seconds_interval=seconds_interval,
                max_requests_per_day=max_requests_per_day,
            )
        )

    async def on_request(self, uid: int) -> tuple[bool, bool]:
        return await self.requests[uid].try_acquire()

这里用 time.monotonic() 计算经过的时间,避免系统时钟校准影响间隔判断。它适合进程内计时;如果需要按指定时区的自然日重置,就要另外计算日期边界。

_notified 保留了原代码的提示策略:连续拒绝请求期间,只产生一次通知信号;成功放行或窗口重置后,才允许再次通知。被拒绝的请求不会增加计数,也不会更新 last_time。

不要在外层获取 _async_lock 后,再调用原来那些也会获取同一把锁的方法。asyncio.Lock 不可重入,同一个任务再次获取自己尚未释放的锁,会一直等待。

middleware 中只处理判断结果

完成原有的事件类型、聊天类型和用户检查后,限流部分可以写成:

should_notify, allowed = await self.rate_limiter.on_request(user_id)

if should_notify:
    text = get_text(texts.rate_limit_exited_text, lang)
    await event.answer(text)

if not allowed:
    return None

return await handler(event, data)

on_request() 返回时,锁已经释放,event.answer() 的网络等待和后续业务处理都不会占用该用户的限流锁。

题目给出的 middleware 片段还缺少放行后的 handler(event, data) 调用。如果完整代码中也没有这句,通过限流检查的事件仍然不会进入后续处理。

性能和适用范围

这里只涉及几个字段的读取、比较和更新,临界区很短,合并加锁也减少了锁操作次数。具体能快多少,需要在实际负载下测量,不能根据获取锁的次数直接推算性能倍数。

如果所有操作都在同一个事件循环中执行,而且整段检查和更新始终没有挂起点,也可以将逻辑写成不加锁的同步方法,依靠协作式调度避免任务交错。不过,后续修改必须一直遵守“内部不能挂起”的约束。保留一次加锁通常更容易维护。

使用这份实现时,还需注意:

  • 所有访问同一用户限流状态的代码都应使用同一把锁。从其他地方直接修改字段会绕过保护。
  • asyncio.Lock 只协调同一事件循环中的任务,不提供跨线程或跨进程保护。多个 worker 各自保存状态时,无法共同执行同一个全局额度;使用共享存储时,检查和更新也需要保持原子性。
  • 同一进程中,需要共享额度的处理路径应使用同一个 RateLimiter 实例,避免各自保存一份计数。
  • requests 会随用户数量增长。清理记录时,要避免旧对象仍被使用,就为同一用户创建新对象,否则可能有两份额度同时生效。
  • 这里统计的是通过限流检查的请求。后续发送消息或执行 handler 失败时,已经占用的额度不会自动退回。

备注:内容仅供参考。