缓存策略
三层缓存体系
L1: 内存缓存(cache_manager)
基于 TTL 的进程内缓存,交易时段短缓存保证数据新鲜度(market_data_service/infra.py 的 TTL_TRADING/TTL_IDLE 代理,数值来自 core/config.py,可经环境变量覆盖):
_CACHE_TTL_TRADING = {
"sentiment": 10, # 指数/涨跌家数(秒级变化)
"distribution": 10, # 涨跌幅分布直方图
"fund_flow": 15, # 资金流概况(中频变化)
"industry_rank": 30, # 行业排名(低频变化)
"hot_stocks": 10, # 热门股票排行
"sector_treemap": 15, # 板块热力图
}
# 休市:统一 CACHE_TTL_IDLE = 3600(1 小时)
交易时间判定
def _is_trading_hours() -> bool:
"""A股交易时间:周一至周五 9:30-11:30, 13:00-15:00"""
now = datetime.now()
if now.weekday() >= 5:
return False
hm = now.hour * 100 + now.minute
return (930 <= hm <= 1130) or (1300 <= hm <= 1500)
分钟线轮询缓存(IntradayCache)
分钟 K 线(/api/v1/stocks/{code}/intraday)独立于 L1,使用 IntradayCache(market_data_service/intraday_cache.py)应对前端 30s 高频轮询,避免并发请求穿透到数据源:
- TTL 映射:高频周期
1m/5m= 30s(与前端轮询对齐),15m=900s、30m=1800s、60m=3600s;动态 TTL——盘中用周期基准,盘后统一放宽 ≥3600s(数据不再变化,长时间可复用) - 新鲜度语义:
get()返回(records, fresh)——fresh 命中直接返回;过期(stale)仍返回旧数据供前端展示,同时后台线程触发回源刷新并写回(stocks.py::_spawn_intraday_refresh按 key 防重入),保证轮询窗口内无空窗、且陈旧数据不会"陈旧永驻" - 跨 TTL 去重合并:
set()按 datetime 合并新老 bar,同时间点新数据覆盖旧数据,防止追加轮询数据时重复 - 响应头:
stocks.py对 intraday 响应统一返回Cache-Control: public, max-age=30(盘中)/max-age=3600(盘后,is_trading_hours()动态判定)+ 对应的X-Cache-TTL,source字段区分cache(新鲜命中)/cache_stale(过期复用)/live(实时回源) - 失效:
invalidate()支持精确(code+period)/按 code /全清,供后台同步任务主动清缓存
L2: DB 快照持久化
成功获取的数据通过 fox 门面写入持久层(market_snapshot 通用快照表已于 2026-08-22 退役迁移):
- fund_flow:UPSERT 到
fox_market_daily(盘后定型权威数据) - 其余 5 类(sentiment/distribution/industry_rank/hot_stocks/sector_treemap):UPSERT 到
fox_market_snapshot(date+data_type 双主键,当日每类一行,幂等) - 样本估算数据不落库(
sample_based标记),避免休市时回退到不具代表性的样本 - 当日数据快速读取(休市时避免无谓外部调用);历史数据兜底取最近一条有效记录(带
_snapshot_date/_source: "db_history"标注) - 过期数据保护(
fox_market_data._snapshot_stale):历史兜底快照须不早于「最近已定型交易日 −STALE_MAX_AGE(3 天)」,否则返回None由上层输出空结构——按交易日而非自然日衡量,长假不误杀,同步停更超窗才拦截
L3: 静态数据 lru_cache
参考 AkShare @lru_cache() 模式,进程内永不变的参考数据使用 functools.lru_cache(market_data_service/infra.py):
@lru_cache(maxsize=1)
def index_code_map() -> dict[str, str]:
"""主要指数代码→名称映射(18 条:A股 14 + 港美 4)"""
return {"000001": "上证指数", "399001": "深证成指", ...}
@lru_cache(maxsize=1)
def index_tencent_codes() -> dict[str, str]:
"""指数代码→腾讯前缀代码(sh/sz/bj/hk/us,显式市场不可猜测)"""
@lru_cache(maxsize=1)
def breadth_sample_codes() -> tuple[str, ...]:
"""涨跌分布抽样50只代表性股票"""
return ("600519", "601318", ...)
@lru_cache(maxsize=1)
def em_all_market_fs() -> str:
"""东财全市场A股筛选条件"""
return "m:0+t:6,m:0+t:80,m:1+t:2,m:1+t:23,m:0+t:81+s:2048"
融合层缓存(fox_engine)
融合层读路径(fox_engine/engine.py,FoxDataEngine)内置分级 TTL 进程缓存,按数据变化频率分档:
| 缓存 | TTL | 数据 |
|---|---|---|
_VALUATION_TTL | 300s | 估值快照(随日行情变化) |
_PROFILE_TTL / _FINANCE_TTL | 600s | 股票主数据 / 财务指标(低频变化) |
_EVENT_TTL | 1800s | 龙虎榜 / 大宗 / 两融 / 公告 / 新闻 |
_STATIC_TTL | 3600s | 行业 / 指数日线 / K线 / 分红 / 股东户数序列 |
- 失效:
fox.invalidate(code=None)全清 / 指定 code 精确清除;ETL 写路径(writersync_*)完成后调用,保证读路径不返回脏数据 - 市场级:
fox_market_data.py盘后数据(fox_market_daily)另设 4h 进程缓存(_EOD_CACHE_TTL=14400),persist_daily()写入成功即失效 - 消费方统一经
fox_engine门面单例(fox)访问,避免各模块自建引擎实例导致缓存分叉
聚合端点缓存(services/abnormal_moves.py)
/realtime/abnormal-moves 聚合三个变化频率差异极大的子源,而前端以
tier="realtime"(15s)轮询。若三个子源用同一节奏重算,绝大多数算出来的是同一个值 ——
实测单次 3.3s、20 次 TDX TCP 往返,15s 轮询 = 4800 次往返/小时/客户端。
故按「子源真实变化频率」分档,并配 single-flight(同 key 并发只跑一次 producer):
| 子源 | 变化频率 | TTL |
|---|---|---|
auction(竞价窗口 09:15–09:30 内) | 实时 | 20s |
auction(窗口外) | 全字段冻结(见下) | 1800s |
intraday(东财 push2ex) | 真·实时 | 20s |
deviation(龙虎榜) | 盘后发布,日级 | 1800s |
| 任一子源的空结果 | — | 20s(_TTL_EMPTY) |
三条设计约束(都是踩过坑才加的):
- 空结果用独立短 TTL:既避免一次瞬时失败污染长窗口(龙虎榜窗口 30min, 会表现为「异动一直是空的」且不报错),也避免「本来就没数据」的正常态 每轮重打数据源(实测空态仍耗 1.9s/次)。
- 窗口状态进 key:否则 09:14 写入的长 TTL 条目会被顶进 09:15–09:30
这段最需要实时的窗口。非「交易日且 ≥ 09:15」时另用独立 key,与
live/frozen完全隔离。 - 不用
core.cache_manager:那是文件级缓存,为「重启后仍可用」而设计; 此处 TTL 最长 30min 且是盘中易变数据,落盘只带来磁盘 churn 无收益。
auction窗口外能给到 1800s,是因为池子取昨日涨停池(当日不再变化)+ 撮合 09:25 落定 + 昨收固定 —— 三项都固定,重算结果必然相同。 另:非交易日 / 交易日 09:15 前根本不触 TDX(见source-tdx.md的get_auction注意事项),故实际往返降到约 40 次/小时。
缓存失效策略
| 场景 | 行为 |
|---|---|
| TTL 到期 | 自动失效,下次请求重新获取 |
| 数据无效 | 不写入缓存(builders.py::is_valid_data 校验) |
| 东财被封 | 不缓存空数据,触发降级(TDX → 腾讯 → 新浪 → DB 历史) |
| 样本估算数据 | 不写入 DB 快照(sample_based 标记) |
| 进程重启 | L1 全部失效,从 L2/L3 恢复 |
设计原则
- 不缓存失败:外部 API 返回空/异常时不写入缓存,确保下次可重试
- 不缓存过期:DB 历史兜底快照超出陈旧窗口(
_snapshot_stale,最近交易日 − 3 天)直接判空,不冒充当日数据 - 交易时段敏感:指数/涨跌家数/热门榜 10s、资金流/热力图 15s、行业排名 30s