Files
2026-06-25 17:41:06 +08:00

163 lines
4.8 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# core/cache.py
import json as _stdlib_json
from contextlib import asynccontextmanager
from typing import Any, AsyncGenerator, Optional, Union
import logging
logger = logging.getLogger(__name__)
from redis.asyncio import Redis
# ===== 尝试使用你的 fff,否则回退到 stdlib =====
try:
import orjson as json
except ImportError:
json = _stdlib_json # type: ignore
# ===== Redis 客户端单例(可替换为你自己的)=====
class RedisClient:
_instance: Optional["RedisClient"] = None
_redis: Optional[Redis] = None
def __new__(cls):
if cls._instance is None:
cls._instance = super().__new__(cls)
return cls._instance
async def init_redis(self, url: str = "redis://localhost:6379/0") -> None:
if self._redis is None:
self._redis = Redis.from_url(url, decode_responses=False)
@property
def client(self) -> Redis:
if self._redis is None:
raise RuntimeError("Redis not initialized. Call init_redis() first.")
return self._redis
async def close(self) -> None:
if self._redis:
await self._redis.close()
self._redis = None
redis_client = RedisClient()
# ===== FastAPI 依赖注入 =====
async def get_redis() -> Redis:
"""FastAPI 依赖:获取 Redis 客户端"""
return redis_client.client
async def invalidate_cache(key: str) -> bool:
"""
主动删除缓存键。
返回是否成功删除(Redis delete 返回被删除的 key 数量)。
"""
try:
result = await redis_client.client.delete(key)
return result > 0
except Exception as e:
logger.exception(f"Failed to invalidate cache key: {key}", exc_info=e)
return False
# ===== 缓存上下文管理器 =====
class CacheResult:
__slots__ = ("key", "hit", "value", "_to_set", "_set_called")
def __init__(self, key: str) -> None:
self.key = key
self.hit = False
self.value: Any = None
self._to_set: Any = None
self._set_called = False
def set(self, value: Any) -> None:
"""标记要缓存的值(可为 None"""
self._to_set = value
self._set_called = True
@asynccontextmanager
async def cache_if(
key: str,
ttl: int = 3600,
redis: Optional[Redis] = None,
) -> AsyncGenerator[CacheResult, None]:
"""
异步缓存上下文管理器,支持 None 值缓存(防穿透)。
Args:
key: 缓存键
ttl: 正常值缓存时间(秒)
redis: 可选 Redis 客户端(用于测试或自定义)
Usage:
async with cache_if("report:123") as cache:
if cache.hit:
return cache.value
result = await compute()
cache.set(result) # result 可为 None
"""
result = CacheResult(key)
client = redis or redis_client.client
# 尝试读缓存
try:
cached_val = await client.get(key)
if cached_val is not None:
# 解码
if cached_val == b"__NULL__":
result.hit = True
result.value = None
else:
result.hit = True
result.value = json.loads(cached_val)
except Exception:
# Redis 不可用,降级(不中断主流程)
logger.exception(f"Redis get error for key: {key}")
pass
yield result
# 写缓存(仅当调用了 set()
if result._set_called:
try:
if result._to_set is None:
val = b"__NULL__"
ex = 60 # 空值短 TTL
else:
# 注意:orjson.dumps 返回 bytesstdlib 返回 str → 统一转 bytes
serialized = json.dumps(result._to_set)
val = serialized if isinstance(serialized, bytes) else serialized.encode("utf-8")
ex = ttl
await client.setex(key, ex, val)
except Exception:
# 写缓存失败,不影响主流程
pass
# ===== 装饰器版(可选补充)=====
from functools import wraps
import asyncio
import hashlib
def cached(ttl: int = 3600):
"""函数缓存装饰器(使用 cache_if)"""
def decorator(func):
@wraps(func)
async def wrapper(*args, **kwargs):
# 生成 key(简单版,可替换为更 robust 的)
key_data = str(args) + str(sorted(kwargs.items()))
key = f"cached:{func.__name__}:{hashlib.md5(key_data.encode()).hexdigest()}"
logger.debug(f"Cache key: {key}")
async with cache_if(key, ttl) as cache:
if cache.hit:
logger.debug(f"Cache hit for key: {key}")
return cache.value
result = await func(*args, **kwargs)
cache.set(result)
return result
return wrapper
return decorator