我做量化数据基础设施差不多四年了,今年 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 盘口有三条天然属性会让传统关系型库很难受:

如果按"先建表、塞数据、加索引"的传统顺序走,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):

下一步我想把微结构信号丢给 LLM 做日报解释。HolySheep 的 base_urlhttps://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;

七、常见报错排查