数据落库与增量更新
重跑一次就多一份数据,是最常见的静默灾难
你的定时任务昨晚跑了两遍——网络超时重试了一次。
今天你打开数据库,行数比预期多了 7960 行。但你不会发现,因为没人会去数行数。你只会在三周后发现某个回测结果诡异,然后花两天时间才想到去查数据。
这一节解决两件事:数据存在哪,以及怎么保证同一批数据灌一百遍,结果都一样。
后者有个名字叫幂等性。它不是"最佳实践",它是重跑在现实里必然发生之后唯一的活路。
往通讯录里加联系人,如果按"姓名+手机号"判重,你加一百遍也只有一条记录。
如果直接往后追加,加一百遍就有一百条——而且你打电话时永远不知道该用哪条。
数据入库的区别,就在于你有没有主键。
一、存哪:CSV / Parquet / DuckDB 实测
数据:5 个宽基指数,2005-01-04 ~ 2026-07-29,合计 24876 行。
| 格式 | 文件大小 | 写入 | 全量读 | 条件查询 |
|---|---|---|---|---|
| CSV | 2.32 MB | 0.117s | 0.033s | 0.032s |
| Parquet | 1.03 MB | 0.064s | 0.062s | 0.006s |
| DuckDB | 1.01 MB | 0.040s | 0.013s | 0.005s |
- 体积:Parquet 与 DuckDB 都只有 CSV 的 44%(列式存储 + 压缩)
- 条件查询:DuckDB 比 CSV 快 7 倍——而且这还只是 2.4 万行。数据量上到千万行,差距是几百倍,因为 CSV 每次都得整个读一遍再过滤
本课选型:
- DuckDB 作主库:单文件、零服务、支持 SQL 与事务,适合本地研究
- Parquet 作交换格式:跨语言、跨工具,给别人一份数据就发 parquet
- CSV 只用于人眼查看和小样本调试
别小看"零服务"这一点。装 PostgreSQL 需要起服务、配账号、管备份;DuckDB 就是一个文件,拷走就能用——研究阶段的运维成本应该接近零。
二、幂等:同一批数据灌两遍会怎样
关键在于用主键做 ANTI JOIN,而不是 append:
CREATE TABLE bars (
symbol VARCHAR, date DATE, open DOUBLE, high DOUBLE, low DOUBLE,
close DOUBLE, volume DOUBLE, source VARCHAR, fetched_at TIMESTAMP,
PRIMARY KEY (symbol, date) -- 主键写在 schema 里,不是写在注释里
);
INSERT INTO bars
SELECT b.* FROM batch b
LEFT JOIN bars t ON t.symbol = b.symbol AND t.date = b.date
WHERE t.symbol IS NULL; -- 只插库里没有的行
实测三次灌入:
第 1 次灌入(2005~2019 末):新增 16916 行,库里共 16916 行
第 2 次灌入(完全相同的数据):新增 0 行 ← 幂等的话必须是 0
第 3 次灌入(2019-06 起,与前段重叠 7 个月):新增 7960 行,库里共 24876 行
去重后应有 24876 行,实际 24876 行 → ✓ 一致
重复主键行数:0 ← 必须是 0
第 3 次是关键:新批次和已有数据重叠了 7 个月。这正是现实中最常见的情形——你不会记得上次更新到哪天,索性多取一段。ANTI JOIN 让重叠部分自动被忽略,只有真正的新数据进库。
df.to_sql(..., if_exists="append") 是这一节要根除的写法。
它在第一次跑的时候完全正确,所以你会以为它没问题。灾难发生在:
- 定时任务因为超时重跑了一次
- 你手动补了一次数据,忘了上次补过
- 上游任务重试机制触发
这些都不是异常情况,而是长期运行的系统里必然发生的事。而 append 的后果是静默的:没有报错,只是某些日期有了两行。之后所有基于这张表的统计——收益率、波动率、换手——全部偏移,而且偏得毫无规律。
唯一的解法是让重复插入变成无操作,即主键 + ANTI JOIN(或 INSERT ... ON CONFLICT DO NOTHING)。
🔧 动手做:同一批数据灌两遍,看它翻不翻倍(5 分钟)
跑 python storage_bench.py,看幂等那一段:同一批数据连灌三次。
你会看到(实测):用"主键(symbol,date)+ANTI JOIN"增量入库——第 1 次新增 16916 行,第 2 次灌入相同数据新增 0 行,第 3 次(与前段重叠 7 个月)只新增 7960 行,总计与去重期望完全一致、重复主键 0 行。而朴素的 append,灌两遍就是两份。
想明白:"重跑一次就多一份数据"是最常见的静默灾难——脚本崩了你重跑,数据悄悄翻倍,回测结果全错还不报错。入库必须幂等:靠主键去重,让"灌几遍结果都一样"。这样你才敢放心重跑。
三、原子性:写到一半崩了
# ❌ 直接写目标文件:崩了 → 目标文件是半截的,下次读取静默出错
df.to_parquet(path)
# ✅ 写临时文件 + 原子重命名:崩了 → 目标文件仍是上一版完整数据
tmp = path + ".tmp"
df.to_parquet(tmp)
os.replace(tmp, path) # 同一文件系统内,原子操作
DuckDB 更省事:用事务 BEGIN / COMMIT,崩了自动回滚,不需要手写临时文件。
四、血缘跟着数据进库
p1.1 那个血缘 json,现在应该变成表里的两列:
df["source"] = "baostock"
df["fetched_at"] = pd.Timestamp(...)
这样任何一行数据,你都能回答"它是谁给的、什么时候拿的"。当两个源的数据混在同一张表里时(而这迟早会发生),这两列是你唯一的救命稻草。
📌 免责:本课为技术教学,文中指数仅用于数据工程演示,不构成投资建议。
落库三件事:
- 选型:DuckDB 作主库(体积 44%、条件查询快 7 倍、零服务),Parquet 作交换,CSV 只给人看
- 幂等:主键 + ANTI JOIN。实测同一批数据第二次灌入新增 0 行,重叠 7 个月的批次只进新数据,重复主键 0
- 原子:临时文件 +
os.replace,或直接用 DuckDB 事务
外加一条:血缘跟着数据进库(source / fetched_at 两列)。
下一节做数据体检流水线:把 p1.3、p1.4 讲过的那些坑——除权跳空、停牌占位、日历错位——变成一套自动跑的断言。数据进库之前先过一遍体检,不合格的批次直接拒收。
🔎 来源与核验· 3 条,点开核对
