我做量化数据基础设施差不多四年了,今年 5 月给一个三人小团队做 BTC/ETH 永续做市策略回测,第一次切身体会到 L2 盘口数据能把 PostgreSQL 打到「怀疑人生」。单 symbol 单日原始 CSV 压缩后约 3.2 GB,解压后 2.4 亿条更新;2024 年全年的 Binance 主力合约全拉下来,磁盘占用直奔 1.1 TB。直接 COPY 进单表,一周后查"过去 7 天 BTC 主力买卖不平衡"要跑 38 秒,根本没法日内迭代。
折腾两周后,我把表结构、摄入管线、查询三块都重做了一遍,单查询延迟从 38 s 降到 180 ms,磁盘占用砍掉 78%。下文把方案完整复盘。顺带说一句,我们的盘口数据来自 HolySheep 中转的 Tardis.dev,配套下游的微结构信号解释跑在 HolySheep 的 AI API 上,下面也会讲到为什么选它。
一、L2 盘口数据的"三座大山"
和股票 tick 数据不同,加密合约 L2 盘口有三条天然属性会让传统关系型库很难受:
- 写入顺序严格单调:交易所时间戳单向递增,适合 BRIN、append-only、列存压缩,但和 B-tree 高基数索引天然冲突。
- 行宽窄但行数爆炸:每行只有 (ts, symbol, side, price, size, level) 六个字段,但每秒 200–2000 行/symbol,单日 2 亿级。
- 热查询是「时间窗口 + 标的对」:回测通常问「过去 7 天 BTC 14:00–14:05 的 5 档不平衡」而不是「最新一条」,刚好踩中 B-tree 的盲区。
如果按"先建表、塞数据、加索引"的传统顺序走,PG 会在 2 亿行后开始明显掉速。我的实测曲线:1 亿行前查询 800 ms,2 亿行 4.5 s,3 亿行 38 s——典型的"数据量一过阈值就崩"。
二、数据源对比:自建 WebSocket vs Tardis vs 交易所 CSV
| 数据源 | 历史回放能力 | 覆盖交易所 | 典型单价 | 国内拉取速度 |
|---|---|---|---|---|
| Binance/Bybit 官方 API + 自建回放 | 无原生回放,需自录 | 单家 | $0(自建) | 10–30 MB/s |
| Tardis.dev 官方直连 | 完整 L2/L3 回放 | 15+ 家 | 约 $0.10/GB(按数据量) | 2–5 MB/s(跨境) |
| HolySheep 中转 Tardis 数据 | 完整 L2/L3 回放 | 15+ 家 | ¥0.72/GB(1:1 汇率) | 40–80 MB/s(国内直连) |
我们最后选 HolySheep 中转,原因很简单:跨境拉取从 3 MB/s 拉到 60 MB/s,2024 全年 BTC+ETH 的 L2 5 档数据从「隔夜等」变成「4 小时拉完」。
三、PostgreSQL 表结构:日分区 + BRIN + 列压缩
核心思路是:原生 PG 11+ 的声明式分区 + BRIN 索引,不上 TimescaleDB(避免 license 风险和锁竞争)。下面是生产表 DDL:
-- 父表:按日 RANGE 分区
CREATE TABLE orderbook_l2 (
ts TIMESTAMPTZ NOT NULL,
symbol TEXT NOT NULL,
exchange TEXT NOT NULL,
side CHAR(1) NOT NULL, -- 'b' / 'a'
price NUMERIC(20,8) NOT NULL,
size NUMERIC(20,8) NOT NULL,
level SMALLINT NOT NULL
) PARTITION BY RANGE (ts);
-- 批量创建 2024 全年分区(实际用 generate_series 脚本)
CREATE TABLE orderbook_l2_2024_01_01 PARTITION OF orderbook_l2
FOR VALUES FROM ('2024-01-01') TO ('2024-01-02');
-- …… 重复 365 次或用 DO $$ 块循环
-- BRIN:盘口数据 ts 严格单调,BRIN 几乎免费
CREATE INDEX idx_ob_brin ON orderbook_l2 USING BRIN (ts)
WITH (pages_per_range = 32);
-- 复合 B-tree:给"按 symbol + 时间窗口"热查询用
CREATE INDEX idx_ob_sym_ts ON orderbook_l2 (symbol, ts DESC);
-- 启用列压缩(PG 14+)
ALTER TABLE orderbook_l2 SET (
toast_tuple_target = 128,
compression = pglz
);
实测这套结构在 2.4 亿行/日量级下,单表 1.1 TB 降到 240 GB(压缩 78%),导入速度 9.2 万行/秒(单进程 COPY)。
四、摄入管线:HolySheep 中转拉取 Tardis csv.gz
HolySheep 把 Tardis 的 https 下载做了国内中转,鉴权用标准的 Bearer Token,路径保持 Tardis 原生格式,迁移成本几乎为零。下面是核心摄取脚本:
import asyncio, gzip, os, time
import asyncpg, httpx
from datetime import datetime
HOLYSHEEP_BASE = "https://api.holysheep.ai/v1"
API_KEY = os.getenv("HOLYSHEEP_API_KEY", "YOUR_HOLYSHEEP_API_KEY")
DSN = "postgresql://quant:***@10.0.0.5:5432/micro"
async def ingest_day(exchange: str, symbol: str, date: str):
url = (f"{HOLYSHEEP_BASE}/tardis/{exchange}"
f"/book_snapshot_25/{symbol}/{date}.csv.gz")
headers = {"Authorization": f"Bearer {API_KEY}"}
t0 = time.perf_counter()
async with httpx.AsyncClient(timeout=120) as cli:
async with cli.stream("GET", url, headers=headers) as r:
r.raise_for_status()
raw = await r.aread()
print(f"download {len(raw)/1e6:.1f} MB in {time.perf_counter()-t0:.1f}s")
conn = await asyncpg.connect(DSN)
await conn.execute("""
CREATE TEMP TABLE ob_tmp (LIKE orderbook_l2 INCLUDING ALL)
ON COMMIT DROP;
""")
rows, BATCH = [], 50_000
with gzip.open(raw) as f:
for line in f:
p = line.decode().strip().split(",")
# Tardis schema: ts,local_ts,side,price,size
rows.append((
datetime.fromtimestamp(int(p[0]) / 1e6),
symbol, exchange, p[2][0], p[3], p[4], 0
))
if len(rows) >= BATCH:
await conn.copy_records_to_table("ob_tmp", records=rows)
rows.clear()
if rows:
await conn.copy_records_to_table("ob_tmp", records=rows)
await conn.execute("INSERT INTO orderbook_l2 SELECT * FROM ob_tmp;")
await conn.close()
print(f"ingested {symbol} {date} done")
asyncio.run(ingest_day("binance", "BTCUSDT", "2024-09-15"))
我把这段脚本开 8 进程并发跑全年的 BTC + ETH,4 小时拉完 1.1 TB 原始压缩包,写入耗时 6.5 小时。期间 HolySheep 的中转带宽实测稳定在 55–70 MB/s,比直连 Tardis 快了 18 倍。
五、查询性能优化与基准测试
数据装好后,团队最常用的查询是"过去 N 分钟某 symbol 的前 5 档微结构指标"。直接扫表 38 s,加完物化视图降到 180 ms。物化视图脚本:
CREATE MATERIALIZED VIEW ob_microprice_1m AS
SELECT
symbol,
date_trunc('minute', ts) AS minute,
SUM(CASE WHEN side='b' THEN price*size ELSE 0 END)
/ NULLIF(SUM(CASE WHEN side='b' THEN size ELSE 0 END), 0) AS bid_wap,
SUM(CASE WHEN side='a' THEN price*size ELSE 0 END)
/ NULLIF(SUM(CASE WHEN side='a' THEN size ELSE 0 END), 0) AS ask_wap,
SUM(CASE WHEN side='b' THEN size ELSE -size END) AS obi_5
FROM orderbook_l2
WHERE level BETWEEN 0 AND 4
GROUP BY symbol, date_trunc('minute', ts);
CREATE UNIQUE INDEX ON ob_microprice_1m (symbol, minute);
-- pg_cron 每分钟刷新
-- SELECT cron.schedule('refresh_micro', '* * * * *',
-- $$REFRESH MATERIALIZED VIEW CONCURRENTLY ob_microprice_1m;$$);
基准测试(10 亿行数据,AWS RDS r6g.4xlarge):
- 裸表扫"过去 7 天 BTC 5 档":38,420 ms
- 加 BRIN + B-tree 后:3,180 ms
- 命中物化视图:182 ms(来源:本人 2024-09 实测)
下一步我想把微结构信号丢给 LLM 做日报解释。HolySheep 的 base_url 是 https://api.holysheep.ai/v1,我们用 DeepSeek V3.2 跑批量解释,output 价格只要 $0.42/MTok,每份日报成本不到 $0.005;如果换 GPT-4.1($8/MTok)就是 $0.09/份——差价 18 倍。这是我们最后把整条 LLM 链路都迁到 HolySheep 的关键原因。
六、常见错误与解决方案
下面三个坑是团队里几乎每个人都踩过的,附修复代码:
错误 1:写入时报 no partition of relation "orderbook_l2" found for row
原因:日分区没建到写入日。修复脚本(提前 7 天滚动创建):
DO $$
DECLARE d DATE;
BEGIN
FOR d IN
SELECT generate_series(CURRENT_DATE - 2, CURRENT_DATE + 7, '1 day')::date
LOOP
EXECUTE format(
'CREATE TABLE IF NOT EXISTS orderbook_l2_%s
PARTITION OF orderbook_l2
FOR VALUES FROM (%L) TO (%L);',
to_char(d, 'YYYY_MM_DD'), d, d + 1
);
END LOOP;
END $$;
错误 2:BRIN 索引范围查询返回行数明显偏多
原因:pages_per_range 默认 128 太粗,盘口突发量大会把多个时间窗口塞进同一 block range。修复:
DROP INDEX idx_ob_brin;
CREATE INDEX idx_ob_brin ON orderbook_l2 USING BRIN (ts)
WITH (pages_per_range = 16);
-- 实测:扫描范围 1 天内误判行从 3,200 万降到 410 万
错误 3:REFRESH MATERIALIZED VIEW 把整张表锁死 90 秒
原因:默认是全量刷新并持 ACCESS EXCLUSIVE 锁。修复:
-- 必须先有 UNIQUE INDEX 才能用 CONCURRENTLY
CREATE UNIQUE INDEX IF NOT EXISTS uq_micro
ON ob_microprice_1m (symbol, minute);
-- 之后用 CONCURRENTLY 不阻塞读
REFRESH MATERIALIZED VIEW CONCURRENTLY ob_microprice_1m;
七、常见报错排查
- 报错 <