"""缓存工具 - 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()