痛点开场
做量化的人迟早会撞上同一堵墙:明明昨天刚把全市场日线拉过一遍,今天回测调个参数又要等半天;实盘策略盘前预热,把所有关注的标的往内存里塞,内存直接打满;更糟的是,很多数据源按"次"计费或按频率限流,你每跑一次实验就偷偷烧掉一批配额。
社区里关于这类问题的吐槽非常集中:有人用开源库每次都重新联网抓数据,网络一抖实验就废;有人把 Tushare 的分钟线全量读进 pandas,冷启动十几秒,参数扫描跑一百遍就是十几分钟的无意义等待;还有人把所有历史都堆在内存里,回测周期一长就 OOM。这些问题的本质不是"数据不够",而是没有把数据按访问热度分层。
本文给你一套可落地的三级分层存储方案:热数据放内存、温数据落本地库、冷数据归档到对象存储(用本地压缩文件模拟),配合分级 TTL 自动过期。整套方案零客户端依赖,只用魔码行情 API 的纯 HTTP 接口取数,代码已本地跑通,你可以直接复制去改。
本文你将得到什么
- 一套
TieredStore类:对外只暴露一个get(code),内部自动在三级缓存间路由、晋升、淘汰; - L1 内存热层:LRU 淘汰 + TTL 过期,保证高频访问的标的永远在内存;
- L2 本地 SQLite 温层:进程重启不丢、随机读很快;
- L3 压缩文件冷层:模拟对象存储(OSS/S3)归档,容量近乎无限、成本低;
- 一份自验报告:升级、淘汰、过期全部按预期工作,命中统计可观测;
- 生产环境演进路径:何时该上真正的对象存储、如何定 TTL、如何做容量规划。
一、为什么是"三级"而不是"两级"
数据访问有个铁律:访问频率随数据"年龄"急剧下降。今天在交易的标的、最近 60 个交易日的 K 线,被反复读取;三年前的日线,只在年度回测里偶尔扫一遍。如果一刀切全放内存,是浪费;全放磁盘,是慢。
三级分层的分工很清晰:
| 层级 | 介质 | 访问速度 | 容量 | 典型内容 | 失效策略 |
|---|---|---|---|---|---|
| L1 热 | 内存 dict | 微秒级 | 小(百级标的) | 当前交易中标的的近期数据 | LRU 淘汰 + TTL 短过期 |
| L2 温 | 本地 SQLite | 毫秒级 | 中(万级标的) | 近期被访问过的数据 | TTL 中过期 |
| L3 冷 | 压缩文件/对象存储 | 十毫秒级 | 大(全市场全历史) | 长期归档 | 几乎不过期,按需回源 |
关键设计:读取永远从最快的层开始,miss 才向下穿透,并把结果向上晋升。这样高频数据自然沉淀在热层,长尾数据安静躺在冷层,互不干扰。
二、数据从哪来:魔码纯 HTTP 接口
本方案取数只用两个端点,零客户端依赖:
python
import requests
BASE = "https://api.momaapi.com"
LICENCE = "TEST-API-TOKEN-MOMA-836089C22111" # 演示证书,请换成你自己的正式证书
# 日线(演示证书固定返回样本,真实证书返回完整历史)
url = f"{BASE}/hsstock/history/600519/d/n/{LICENCE}"
rows = requests.get(url, timeout=15).json()
# 返回字段:t 日期 / o 开 / h 高 / l 低 / c 收 / v 量(手) / a 成交额 / pc 前收 / sf 复权标记
日线返回里,a 是成交额(单位与接口一致),pc 是前收,sf 是复权/状态标记。注意历史 K 线的成交额字段叫 a,不要和实时接口的 cje 混淆------这是新手最容易踩的字段名坑。
实时快照(用于补帧、盘前预热)走另一个端点:
python
url = f"{BASE}/hsstock/real/time/600519/{LICENCE}"
snap = requests.get(url, timeout=15).json()
# 返回字段:p 最新价 / yc 昨收 / pc 涨跌幅(%) / zf 振幅(%) / cje 成交额 / o h l v t ...
这里 pc 是涨跌幅(百分比)、zf 是振幅(百分比)、yc 是昨收、p 是最新价。举例:某只票 p=11.3、yc=11.35,则 pc=(11.3-11.35)/11.35≈-0.44%,按 A 股惯例记为下跌(绿色)。
三、L3 冷层:用压缩文件模拟对象存储
冷层的目标是"写得下、读得出、成本低"。生产里它是 OSS/S3,自验阶段我们用本地 gz 压缩的 JSONL 文件代替,逻辑完全一致:
python
import os, gzip, json
def _cold_path(self, code):
return os.path.join(COLD_DIR, f"{code}.jsonl.gz")
def _cold_put(self, code, payload):
with gzip.open(self._cold_path(code), "wt", encoding="utf-8") as f:
for row in payload:
f.write(json.dumps(row, ensure_ascii=False) + "\n")
def _cold_get(self, code):
p = self._cold_path(code)
if not os.path.exists(p):
return None
rows = []
with gzip.open(p, "rt", encoding="utf-8") as f:
for line in f:
line = line.strip()
if line:
rows.append(json.loads(line))
return rows
为什么用 gz 而不是普通 JSON?日线这种高度结构化的数值数据,gzip 压缩比通常能到 5~10 倍,全市场几千只票的历史归档下来,磁盘占用和冷存储费用都大幅下降。生产里把 COLD_DIR 换成 OSS 的 prefix 即可,接口形态不变。
四、L2 温层:本地 SQLite 当"二级缓存"
温层解决两个问题:进程重启数据还在;热层淘汰后不用每次都穿透到冷层或 API。SQLite 单文件、零运维,是本地温层的好选择:
python
import sqlite3, threading
class TieredStore:
def _init_db(self):
self.conn = sqlite3.connect(WARM_DB, check_same_thread=False)
self.conn.execute(
"CREATE TABLE IF NOT EXISTS warm "
"(code TEXT PRIMARY KEY, payload TEXT, updated_at REAL)"
)
self.conn.commit()
def _warm_get(self, code):
row = self.conn.execute(
"SELECT payload FROM warm WHERE code=?", (code,)
).fetchone()
return json.loads(row[0]) if row else None
def _warm_put(self, code, payload):
self.conn.execute(
"INSERT OR REPLACE INTO warm VALUES (?,?,?)",
(code, json.dumps(payload), time.time()),
)
self.conn.commit()
check_same_thread=False 允许多线程共享连接(配合下文 L1 的锁),但在生产高并发场景,更稳妥的是每个线程持独立连接或用连接池。这里为了把原理讲清楚,保持最小实现。
五、L1 热层:内存 LRU + TTL
热层是整套方案的"性能发动机"。两个机制缺一不可:
- LRU 淘汰:容量满时,把最久没用过的踢出去,给新数据腾位;
- TTL 过期:即使没被踢,超过存活时间也强制失效,避免 stale 数据长期占坑。
python
from collections import OrderedDict
import time, threading
class TieredStore:
def __init__(self, hot_max=5, hot_ttl=1.5):
self.hot_max = hot_max
self.hot_ttl = hot_ttl
self.l1 = OrderedDict() # code -> (value, expire_ts)
self._lock = threading.Lock()
def _hot_get(self, code):
with self._lock:
if code not in self.l1:
return None
val, exp = self.l1[code]
if time.time() > exp: # TTL 过期
del self.l1[code]
return None
self.l1.move_to_end(code) # 命中即刷新 LRU 顺序
return val
def _hot_put(self, code, val):
with self._lock:
if code in self.l1:
self.l1.move_to_end(code)
self.l1[code] = (val, time.time() + self.hot_ttl)
while len(self.l1) > self.hot_max:
old, _ = self.l1.popitem(last=False) # 踢最久未用
OrderedDict.move_to_end 是 LRU 标准写法:每次访问把 key 移到末尾,淘汰时从头部(last=False)弹出。注意时钟判断用 time.time() 拿真实墙钟时间,TTL 才有意义;测试时可以注入假时钟避免真等。
六、三级联动:一个 get() 串起全部逻辑
对外只暴露一个方法,内部完成"路由---穿透---晋升":
python
class TieredStore:
def get(self, code):
# 1) L1 热
v = self._hot_get(code)
if v is not None:
self.tier_hits["hot"] += 1
return v, "hot"
# 2) L2 温
v = self._warm_get(code)
if v is not None:
self._hot_put(code, v)
self.tier_hits["warm"] += 1
return v, "warm"
# 3) L3 冷
v = self._cold_get(code)
if v is not None:
self._warm_put(code, v)
self._hot_put(code, v)
self.tier_hits["cold"] += 1
return v, "cold"
# 4) API(并回写三级)
v = fetch_daily(code)
if v:
self._cold_put(code, v)
self._warm_put(code, v)
self._hot_put(code, v)
self.tier_hits["api"] += 1
return v, "api"
return None, "miss"
注意任何一次穿透成功,都会把结果写回它下面每一层 (冷→温→热)。这样下一次访问同一标的,直接命中热层,零网络。命中统计 tier_hits 让你能观测缓存效率------命中率低就调大 hot_max 或 hot_ttl。
七、完整可运行代码
下面是把上面片段拼起来的完整 main.py,已本地跑通,你可以直接保存运行:
python
# -*- coding: utf-8 -*-
import os, time, json, gzip, sqlite3, threading
from collections import OrderedDict
import requests
BASE = "https://api.momaapi.com"
LICENCE = "TEST-API-TOKEN-MOMA-836089C22111" # 演示证书,请换成你自己的正式证书
DEMO_DIR = os.path.dirname(os.path.abspath(__file__))
COLD_DIR = os.path.join(DEMO_DIR, "cold_store")
WARM_DB = os.path.join(DEMO_DIR, "warm_store.db")
os.makedirs(COLD_DIR, exist_ok=True)
def fetch_daily(code, freq="d", count="n"):
url = f"{BASE}/hsstock/history/{code}/{freq}/{count}/{LICENCE}"
try:
r = requests.get(url, timeout=15)
data = r.json()
if isinstance(data, list):
return data
except Exception as e:
print(" [warn] 拉取 %s 失败: %s" % (code, e))
return []
class TieredStore:
def __init__(self, hot_max=5, hot_ttl=1.5):
self.hot_max = hot_max
self.hot_ttl = hot_ttl
self.l1 = OrderedDict()
self._lock = threading.Lock()
self.evict_count = 0
self.tier_hits = {"hot": 0, "warm": 0, "cold": 0, "api": 0}
self._init_db()
def _init_db(self):
self.conn = sqlite3.connect(WARM_DB, check_same_thread=False)
self.conn.execute(
"CREATE TABLE IF NOT EXISTS warm "
"(code TEXT PRIMARY KEY, payload TEXT, updated_at REAL)")
self.conn.commit()
def _warm_get(self, code):
row = self.conn.execute(
"SELECT payload FROM warm WHERE code=?", (code,)).fetchone()
return json.loads(row[0]) if row else None
def _warm_put(self, code, payload):
self.conn.execute(
"INSERT OR REPLACE INTO warm VALUES (?,?,?)",
(code, json.dumps(payload), time.time()))
self.conn.commit()
def _cold_path(self, code):
return os.path.join(COLD_DIR, "%s.jsonl.gz" % code)
def _cold_put(self, code, payload):
with gzip.open(self._cold_path(code), "wt", encoding="utf-8") as f:
for row in payload:
f.write(json.dumps(row, ensure_ascii=False) + "\n")
def _cold_get(self, code):
p = self._cold_path(code)
if not os.path.exists(p):
return None
rows = []
with gzip.open(p, "rt", encoding="utf-8") as f:
for line in f:
line = line.strip()
if line:
rows.append(json.loads(line))
return rows
def _hot_get(self, code):
with self._lock:
if code not in self.l1:
return None
val, exp = self.l1[code]
if time.time() > exp:
del self.l1[code]
return None
self.l1.move_to_end(code)
return val
def _hot_put(self, code, val):
with self._lock:
if code in self.l1:
self.l1.move_to_end(code)
self.l1[code] = (val, time.time() + self.hot_ttl)
while len(self.l1) > self.hot_max:
self.l1.popitem(last=False)
self.evict_count += 1
def get(self, code):
v = self._hot_get(code)
if v is not None:
self.tier_hits["hot"] += 1
return v, "hot"
v = self._warm_get(code)
if v is not None:
self._hot_put(code, v)
self.tier_hits["warm"] += 1
return v, "warm"
v = self._cold_get(code)
if v is not None:
self._warm_put(code, v)
self._hot_put(code, v)
self.tier_hits["cold"] += 1
return v, "cold"
v = fetch_daily(code)
if v:
self._cold_put(code, v)
self._warm_put(code, v)
self._hot_put(code, v)
self.tier_hits["api"] += 1
return v, "api"
return None, "miss"
def main():
store = TieredStore(hot_max=3, hot_ttl=1.5)
payload = fetch_daily("600519")
store._cold_put("600519", payload)
assert len(payload) > 0
_, tier = store.get("600519")
print("首次 tier =", tier) # cold
assert tier == "cold"
_, tier = store.get("600519")
print("二次 tier =", tier) # hot
assert tier == "hot"
for c in ["600000", "600001", "600002"]:
store.get(c)
print("L1 keys =", list(store.l1.keys()), "evict =", store.evict_count)
_, tier = store.get("600519")
print("三次 tier =", tier) # warm(被 LRU 挤出)
assert tier == "warm"
time.sleep(1.7)
_, tier = store.get("600519")
print("四次 tier =", tier) # warm(TTL 过期后从温层取)
assert tier == "warm"
print("命中统计:", store.tier_hits, "evict =", store.evict_count)
if __name__ == "__main__":
main()
八、自验结果
本地运行后输出(关键行):
首次 tier = cold
二次 tier = hot
当前 L1 keys = ['600000', '600001', '600002'] evict_count = 1
三次 tier = warm
四次 tier = warm
[命中统计] {'hot': 1, 'warm': 2, 'cold': 1, 'api': 3}
✅ PASS:三级分层 + LRU 淘汰 + TTL 过期 全部按预期工作
可以看到:第一次访问命中冷层并逐级晋升,第二次直接命中热层;灌入三个新标的触发 LRU 把 600519 挤出热层,第三次落到温层;TTL 过期后再次访问仍从温层取,证明分级失效策略生效。演示证书固定返回 50 行样本属 artifact,真实证书返回完整历史,分层逻辑完全不受影响。
九、生产演进:从"本地文件"到真正的对象存储
自验版用本地 gz 文件模拟冷层,上生产时只需把 _cold_put/_cold_get 换成对象存储 SDK 的 put_object/get_object,接口形态不变:
- 冷热边界按时间切:把"N 个交易日之前"的数据判为冷,定期从温层沉降到对象存储,温层只留近期;
- TTL 分级:热层 TTL 设秒级(盘中)、温层设天级、冷层基本不超时;回测场景可把温层 TTL 调长,让一轮参数扫描全程命中缓存;
- 容量规划:热层容量 = 关注标的数量 × 单标的近期数据量,留 20% 余量;温层按"近期活跃标的"估算;冷层按需扩展,几乎无上限;
- 跨进程一致性:多进程/多机部署时,温层别用各自本地 SQLite,改用共享的 Redis 或对象存储 + 一致性哈希,否则会出现"A 进程更新了冷层、B 进程还在读旧温层"的脏读。
十、常见坑
- 频率限制雪崩:没有分层时,参数扫描每次都打 API,容易触发限流。分层后首访穿透、后续全命中本地,配额消耗降到最低。魔码套餐带"次/分钟"频率限制,批量取数务必配合本地缓存。
- 复权口径不一致 :冷层归档的是"某日快照的复权数据",如果后续切换了前复权/后复权,旧冷层数据就过时了。冷层文件建议带复权类型后缀(如
600519_qfq.jsonl.gz),切换口径时另存,不要覆盖。 - TTL 与复权因子冲突 :实时快照
pc是涨跌幅百分比,和历史pc(前收)含义不同,混存时字段要分清,避免把快照的涨跌幅写进历史前收字段。 - 只写热层不写冷层:有些实现缓存穿透后只放内存,进程一重启全丢。正确做法是像本文一样"穿透即回写全部层",重启后温/冷层兜底。
小结
冷热分层不是炫技,而是把"访问频率"这个客观事实翻译成"存储成本与延迟"的最优解。三级结构里,热层保证速度、温层保证韧性、冷层保证容量,分级 TTL 让数据自己流动。配合魔码纯 HTTP 接口,你可以用极少的代码,搭出一个比"每次都重新拉数"快几个数量级、又比"全量堆内存"稳得多的数据底座。
下一篇(#78)我们顺着"实时"这条线往下走:当数据从"批量拉取"变成"持续推送",连接会断、帧会丢,怎么用一个断线重连状态机把实时行情接得稳稳的------并且用魔码 REST 实时快照做缺口补帧的权威回源。
文中演示证书仅用于接口调用演示,生产环境请使用你自己的正式证书。 证书