Files
backend/backend/app/core/cache.py
T
34047007@qq.com 090938fa2b fix: 第11轮深度审计修复 — P0 keyset翻页崩溃 + P1布尔符语义/语法误报等16项修复
P0 (1项):
- keyset cursor: lit.title or "__NULL__" 空字符串误判为NULL导致后续翻页全空

P1 (6项):
- boolean_operator: 仅扫描depth=0的token,括号内AND/OR不再影响顶层操作符判定
- is_pubmed_syntax: 去除AND/OR/NOT检测的IGNORECASE(PubMed仅识别大写布尔符)
- _relevance_query: MeSH-only查询(如breast[MAJR])补充原始回退,避免退化到日期排序
- pmid_terms: 缺少try/except int(),补充ValueError+DOI兜底
- 部分日期展开: 2024-01[DP]等YYYY-MM partial date展开为整月范围(DP/EDAT/CRDT)
- 前端#N去重: resolveQuery/expandQuery dedup refs防重复引用误判为循环

P2 (9项):
- cache: filter-options加入invalidate_search_cache清理
- worker: daily_ftp_update/daily_citation_update异常时finally保证缓存清理
- cursor_val: 空字符串''通过is None检查,补充not cursor_val
- keyset UI: 模板条件sort==='date'改为KEYSET_SORTS.has(sort)
- resetAllFilters: 补充showCustomYear=false
2026-07-28 12:08:17 +08:00

205 lines
7.6 KiB
Python

"""缓存服务:Redis → 内存回退(Redis 不可用时自动降级,无连接等待)"""
import json
import logging
from collections import OrderedDict
logger = logging.getLogger(__name__)
class CacheService:
"""内存缓存实现(生产环境可替换为 Redis),内存模式最多保留 1000 条"""
MAX_MEMORY_ITEMS = 1000
def __init__(self):
self._store: OrderedDict[str, dict] = OrderedDict()
self._redis_failed = False
self._redis = None
async def _get_redis(self):
"""尝试获取 Redis 连接,失败即降级(每 60 秒重试一次)"""
if self._redis_failed:
import time as _time
if getattr(self, '_redis_retry_at', 0) > _time.monotonic():
return None
self._redis_failed = False
if self._redis is not None:
return self._redis
try:
from redis.asyncio import Redis
from app.config import settings
r = Redis.from_url(settings.REDIS_URL, decode_responses=True, socket_connect_timeout=1)
await r.ping()
self._redis = r
return r
except Exception:
self._redis_failed = True
self._redis = None
import time as _time
self._redis_retry_at = _time.monotonic() + 60
logger.info("Redis 不可用,使用内存缓存(60 秒后重试)")
return None
async def get(self, key: str) -> dict | None:
r = await self._get_redis()
if r:
try:
val = await r.get(key)
return json.loads(val) if val else None
except Exception:
logger.exception("Redis GET failed: %s", key)
return None
val = self._store.get(key)
if val is not None:
self._store.move_to_end(key)
return val
async def set(self, key: str, value: dict, ttl: int = 300) -> bool:
r = await self._get_redis()
if r:
try:
await r.set(key, json.dumps(value, default=str), ex=ttl)
return True
except Exception:
logger.exception("Redis SET failed: %s", key)
return False
# 内存模式:自动淘汰最旧条目,避免无限增长
# 注:OrderedDict.__setitem__ 在 Python 3.9+ 已自动移到末尾,无需 move_to_end
self._store[key] = value
if len(self._store) > self.MAX_MEMORY_ITEMS:
self._store.popitem(last=False)
return True
async def mget(self, keys: list[str]) -> list[dict | None]:
"""批量获取,顺序对应 keys 列表。Redis 不可用时降级到内存模式。"""
r = await self._get_redis()
if r:
try:
vals = await r.mget(*keys)
return [json.loads(v) if v else None for v in vals]
except Exception:
logger.exception("Redis MGET failed")
return [None] * len(keys)
return [self._store.get(k) for k in keys]
async def delete(self, key: str):
r = await self._get_redis()
if r:
import contextlib
with contextlib.suppress(Exception):
await r.delete(key)
else:
self._store.pop(key, None)
async def pop(self, key: str) -> dict | None:
r = await self._get_redis()
if r:
try:
val = await r.get(key)
if val is not None:
await r.delete(key)
return json.loads(val) if val else None
except Exception:
logger.exception("Redis POP failed: %s", key)
return None
return self._store.pop(key, None)
async def get_or_set(self, key: str, loader, ttl: int = 300) -> dict | None:
"""原子化 get-then-set-or-fallback:先查缓存,命中即返回;未命中调用 loader() 加载、写缓存后返回。
防缓存雪崩(cache stampede):Redis 模式用 SETNX 互斥锁,内存模式用 asyncio.Lock。
未抢到锁的请求会短暂自旋等待,避免重复计算。
"""
cached = await self.get(key)
if cached is not None:
return cached
lock_key = f"lock:{key}"
if r := await self._get_redis():
# Redis 模式:SETNX 互斥锁
try:
locked = await r.set(lock_key, "1", nx=True, ex=10)
except Exception:
locked = True # 锁操作失败时直接执行 loader,不做等待
if locked:
try:
value = await loader()
if value is not None:
await r.set(key, json.dumps(value, default=str), ex=ttl)
return value
finally:
await r.delete(lock_key)
# 未抢到锁:自旋等待(最多 3 次,每次 100ms)
import asyncio
for _ in range(3):
await asyncio.sleep(0.1)
cached = await self.get(key)
if cached is not None:
return cached
# 超时仍未获缓存:直接执行(保险)
value = await loader()
if value is not None:
await self.set(key, value, ttl=ttl)
return value
else:
# 内存模式:asyncio.Lock 互斥
import asyncio
if not hasattr(self, '_mem_locks'):
self._mem_locks: dict[str, asyncio.Lock] = {}
if key not in self._mem_locks:
self._mem_locks[key] = asyncio.Lock()
async with self._mem_locks[key]:
# 双检锁(double-check):获取锁后可能已被其他协程写入
double_check = await self.get(key)
if double_check is not None:
return double_check
value = await loader()
if value is not None:
self._store[key] = value
self._store.move_to_end(key)
return value
async def invalidate_user(self, user_id: str):
await self.delete(f"user:{user_id}:profile")
async def invalidate_search_cache(self):
"""管道运行后失效所有搜索相关缓存。"""
await self.delete_pattern("search:advanced:*")
await self.delete_pattern("search:year_counts:*")
await self.delete("search:year_counts:all")
await self.delete("journals:map")
await self.delete_pattern("atm:*")
await self.delete("filter-options")
async def invalidate_tenant(self, tenant_id: str):
await self.delete(f"tenant:{tenant_id}:plan")
await self.delete(f"tenant:{tenant_id}:settings")
async def delete_pattern(self, pattern: str):
"""Delete all keys matching a glob pattern.
Redis 模式用 SCAN 0 MATCH pattern 迭代删除。
内存模式用 OrderedDict key 前缀匹配删除。
"""
r = await self._get_redis()
if r:
try:
cursor = 0
while True:
cursor, keys = await r.scan(cursor, match=pattern, count=100)
if keys:
await r.delete(*keys)
if cursor == 0:
break
except Exception:
logger.exception("Redis SCAN/DEL failed for pattern: %s", pattern)
else:
import fnmatch
to_delete = [k for k in self._store if fnmatch.fnmatch(k, pattern)]
for k in to_delete:
self._store.pop(k, None)
cache = CacheService()