2026-10-28 11:14:17 +08:00

241 lines
8.4 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.

"""缓存工具 - RefreshableCache + 全局缓存注册
支持文件持久化,所有 Gunicorn worker 共享缓存。
"""
import os
import pickle
import hashlib
import tempfile
import time
from functools import wraps
_registry = []
CACHE_DIR = None
DATA_VERSION_FILE = '.data-version'
def _get_cache_dir():
"""获取文件缓存目录(项目根目录/tmp_cache)"""
global CACHE_DIR
if CACHE_DIR is not None:
return CACHE_DIR
# 本项目在 services/ 下,项目根是 services/..
base = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
CACHE_DIR = os.path.join(base, 'tmp_cache')
os.makedirs(CACHE_DIR, exist_ok=True)
return CACHE_DIR
def _data_version_signature():
"""返回业务数据版本文件签名,供所有进程识别导入后的数据变化。"""
path = os.path.join(_get_cache_dir(), DATA_VERSION_FILE)
try:
stat = os.stat(path)
# 同一纳秒时间戳下连续写入仍可能发生,加入内容摘要避免漏判。
with open(path, 'rb') as f:
content = f.read()
return (stat.st_ino, stat.st_mtime_ns, stat.st_size,
hashlib.sha256(content).digest())
except OSError:
return None
def bump_data_version():
"""发布一次业务数据变更,使所有 worker 的旧缓存立即失效。"""
cache_dir = _get_cache_dir()
path = os.path.join(cache_dir, DATA_VERSION_FILE)
fd, tmp_path = tempfile.mkstemp(dir=cache_dir, suffix='.version.tmp')
try:
with os.fdopen(fd, 'w', encoding='utf-8') as f:
f.write(str(time.time_ns()))
os.replace(tmp_path, path)
except Exception:
try:
os.unlink(tmp_path)
except OSError:
pass
raise
return _data_version_signature()
class RefreshableCache:
"""可刷新的缓存(内存 + 文件持久化)
所有 Gunicorn worker 共享文件缓存。
数据写入 auto_update.py → 删除文件缓存 → 各 worker 下次请求自动重建。
"""
def __init__(self, maxsize=1, cache_key=None):
self.maxsize = maxsize
self._cache = None
self._file_signature = None
self._data_signature = None
self._cache_key = cache_key
self._file_path = None
self._wrapped_func = None
def _current_file_signature(self):
"""返回共享缓存文件签名,用于识别其他进程的刷新/重建。"""
fpath = self._file_path
if not fpath:
return None
try:
stat = os.stat(fpath)
# os.replace 会生成新 inode;即使新旧内容大小相同、写入时间极近也能识别。
return (stat.st_ino, stat.st_mtime_ns, stat.st_size)
except OSError:
return None
def _get_file_path(self, func):
"""获取缓存文件路径(懒计算)"""
if self._file_path:
return self._file_path
key = self._cache_key
if not key:
key = f"{func.__module__}.{func.__qualname__}"
fname = hashlib.md5(key.encode()).hexdigest() + '.cache'
self._file_path = os.path.join(_get_cache_dir(), fname)
return self._file_path
def _load_from_file(self):
"""从文件加载缓存;数据版本晚于缓存文件时拒绝旧结果。"""
fpath = self._file_path
if not fpath or not os.path.exists(fpath):
return False
try:
version_path = os.path.join(_get_cache_dir(), DATA_VERSION_FILE)
if os.path.exists(version_path):
cache_mtime = os.stat(fpath).st_mtime_ns
version_mtime = os.stat(version_path).st_mtime_ns
# 版本文件与缓存文件可能落在同一纳秒时间戳;版本不早于缓存时,
# 按旧缓存处理,避免导入后重新加载更新前结果。
if version_mtime >= cache_mtime:
try:
os.remove(fpath)
except OSError:
pass
return False
with open(fpath, 'rb') as f:
self._cache = pickle.load(f)
self._file_signature = self._current_file_signature()
self._data_signature = _data_version_signature()
return True
except Exception:
return False
def _save_to_file(self, data):
"""保存缓存到文件(原子写入)"""
fpath = self._file_path
if not fpath:
return
try:
cache_dir = os.path.dirname(fpath)
os.makedirs(cache_dir, exist_ok=True)
fd, tmp_path = tempfile.mkstemp(dir=cache_dir, suffix='.tmp')
try:
with os.fdopen(fd, 'wb') as f:
pickle.dump(data, f)
os.replace(tmp_path, fpath)
self._file_signature = self._current_file_signature()
self._data_signature = _data_version_signature()
except:
try:
os.unlink(tmp_path)
except OSError:
pass
except Exception:
pass
def wrap(self, func):
"""装饰器:缓存函数返回值,支持内存+文件双缓存"""
self._wrapped_func = func
self._get_file_path(func)
@wraps(func)
def wrapper(*args, **kwargs):
# 1. 内存缓存命中前核对共享文件。数据更新脚本运行在独立进程中,
# 删除/重建缓存文件后,各 Gunicorn worker 必须放弃自己的旧内存副本。
if self._cache is not None:
current_signature = self._current_file_signature()
current_data_signature = _data_version_signature()
if (current_signature == self._file_signature and current_signature is not None
and current_data_signature == self._data_signature):
return self._cache
self._cache = None
self._file_signature = None
self._data_signature = None
# 2. 文件缓存命中 → 加载到内存
if self._load_from_file():
return self._cache
# 3. 执行函数
result = func(*args, **kwargs)
self._cache = result
# 4. 写入文件缓存(异步感觉不到延迟)
self._save_to_file(result)
return result
return wrapper
def refresh(self):
"""清除缓存(内存 + 文件)"""
self._cache = None
self._file_signature = None
self._data_signature = None
fpath = self._file_path
if fpath and os.path.exists(fpath):
try:
os.remove(fpath)
except OSError:
pass
# 尝试用 cache_key 匹配更多文件(当 key 在 __init__.py 中有变化时兜底)
# 但如果 _file_path 从未初始化过则跳过
if not fpath and self._cache_key:
# 尝试从 cache_key 推导文件路径
fname = hashlib.md5(self._cache_key.encode()).hexdigest() + '.cache'
fallback = os.path.join(_get_cache_dir(), fname)
if os.path.exists(fallback):
try:
os.remove(fallback)
except OSError:
pass
def clear(self):
"""清除缓存"""
self.refresh()
def register_cache(cache):
"""注册缓存实例"""
_registry.append(cache)
def refresh_all_caches():
"""刷新所有注册的缓存(内存 + 文件),返回已刷新的缓存名称。"""
refreshed = []
for c in _registry:
c.refresh()
refreshed.append(c._cache_key or (
f"{c._wrapped_func.__module__}.{c._wrapped_func.__qualname__}"
if c._wrapped_func else c.__class__.__name__))
# 兜底:确保文件缓存目录中所有 .cache 文件被清除
cache_dir = _get_cache_dir()
try:
for fname in os.listdir(cache_dir):
if fname.endswith('.cache'):
os.remove(os.path.join(cache_dir, fname))
except Exception:
pass
# 发布数据版本。即使旧缓存文件因权限或时序问题未删除,其他 worker
# 也会在下一次请求时发现版本变化并重新计算。
bump_data_version()
return refreshed
def clear_all_caches():
"""清除所有注册的缓存"""
refresh_all_caches()