97 lines
3.9 KiB
Python
97 lines
3.9 KiB
Python
"""限流计数后端:Redis 固定窗口计数,**Redis 不可用时一律放行**。
|
||||
|
|
|
|||
|
|
取舍(为什么是固定窗口,而不是 ZSET 滑动窗口或令牌桶):
|
|||
|
|
|
|||
|
|
- 固定窗口用一条 `INCR` + `TTL` 就能原子地完成"计数 + 取重试等待时间",不需要 Lua
|
|||
|
|
或事务,故障面最小;滑动窗口要 `ZREMRANGEBYSCORE`/`ZADD`/`ZCARD`/`EXPIRE` 四条命令
|
|||
|
|
才能近似原子,令牌桶还要在服务端保存补充速率的状态。本平台的限流目的是**保护**
|
|||
|
|
底座不被单个客户端打爆,不是做精确计费,窗口边界最多放过一个窗口的量可以接受。
|
|||
|
|
- 计数键带 TTL,Redis 自己回收,不需要额外的清理任务,也不会留下永久脏键。
|
|||
|
|
|
|||
|
|
降级语义(用户要求,也是 `MemoryCacheAdapter` 的同一原则):限流是保护措施,不能因为
|
|||
|
|
Redis 故障把正常请求全部拒掉。因此后端**只返回 `None` 表示"无法判定"**,由调用方放行;
|
|||
|
|
这里绝不抛异常、绝不返回"计数超限"。
|
|||
|
|
|
|||
|
|
Redis 客户端是**懒建**的:进程启动时 Redis 不可用不应该让应用起不来,第一次真正需要
|
|||
|
|
限流判定时才建连(与 `bootstrap.py` 的记忆缓存适配器一致)。
|
|||
|
|
"""
|
|||
|
|
|
|||
|
|
import logging
|
|||
|
|
from typing import Any, Protocol
|
|||
|
|
|
|||
|
|
from app.core.config import get_settings
|
|||
|
|
|
|||
|
|
logger = logging.getLogger(__name__)
|
|||
|
|
|
|||
|
|
|
|||
|
|
class CounterBackend(Protocol):
|
|||
|
|
"""限流计数后端。
|
|||
|
|
|
|||
|
|
`increment` 返回 `(窗口内计数, 剩余秒数)`;返回 `None` 表示后端不可用,
|
|||
|
|
调用方必须**放行**(fail-open)。
|
|||
|
|
"""
|
|||
|
|
|
|||
|
|
async def increment(self, key: str, window_seconds: int) -> tuple[int, int] | None: ...
|
|||
|
|
|
|||
|
|
|
|||
|
|
class RedisCounterBackend:
|
|||
|
|
def __init__(
|
|||
|
|
self,
|
|||
|
|
redis_url: str,
|
|||
|
|
*,
|
|||
|
|
connect_timeout_seconds: float,
|
|||
|
|
client_factory: Any | None = None,
|
|||
|
|
) -> None:
|
|||
|
|
self._redis_url = redis_url
|
|||
|
|
self._connect_timeout_seconds = connect_timeout_seconds
|
|||
|
|
# 注入点仅用于测试:默认走 `redis.asyncio.Redis.from_url`。
|
|||
|
|
self._client_factory = client_factory
|
|||
|
|
self._client: Any | None = None
|
|||
|
|
|
|||
|
|
async def _client_or_none(self) -> Any | None:
|
|||
|
|
if self._client is not None:
|
|||
|
|
return self._client
|
|||
|
|
try:
|
|||
|
|
if self._client_factory is not None:
|
|||
|
|
client = self._client_factory()
|
|||
|
|
else:
|
|||
|
|
from redis.asyncio import Redis
|
|||
|
|
|
|||
|
|
client = Redis.from_url(
|
|||
|
|
self._redis_url,
|
|||
|
|
socket_connect_timeout=self._connect_timeout_seconds,
|
|||
|
|
socket_timeout=self._connect_timeout_seconds,
|
|||
|
|
decode_responses=True,
|
|||
|
|
)
|
|||
|
|
self._client = client
|
|||
|
|
except Exception:
|
|||
|
|
logger.warning("限流后端不可用:Redis 客户端构造失败,本次降级放行", exc_info=True)
|
|||
|
|
return None
|
|||
|
|
return self._client
|
|||
|
|
|
|||
|
|
async def increment(self, key: str, window_seconds: int) -> tuple[int, int] | None:
|
|||
|
|
client = await self._client_or_none()
|
|||
|
|
if client is None:
|
|||
|
|
return None
|
|||
|
|
try:
|
|||
|
|
async with client.pipeline(transaction=False) as pipe:
|
|||
|
|
pipe.incr(key)
|
|||
|
|
pipe.ttl(key)
|
|||
|
|
count, ttl = await pipe.execute()
|
|||
|
|
remaining = int(ttl)
|
|||
|
|
if remaining < 0:
|
|||
|
|
# 键首次创建(或历史上丢过 TTL):补一次过期为窗口长度。
|
|||
|
|
await client.expire(key, window_seconds)
|
|||
|
|
remaining = window_seconds
|
|||
|
|
return int(count), max(1, remaining)
|
|||
|
|
except Exception:
|
|||
|
|
logger.warning("限流后端不可用:Redis 计数失败,本次降级放行", exc_info=True)
|
|||
|
|
return None
|
|||
|
|
|
|||
|
|
|
|||
|
|
def default_counter_backend() -> CounterBackend:
|
|||
|
|
settings = get_settings()
|
|||
|
|
return RedisCounterBackend(
|
|||
|
|
settings.redis_url, connect_timeout_seconds=settings.redis_connect_timeout_seconds
|
|||
|
|
)
|