← 返回目录
p1.5动手⏱ 约 14 分钟

数据落库与增量更新

重跑一次就多一份数据,是最常见的静默灾难

🔎 最后验证 2026-07📚 来源:三种存储与幂等性实测于 2026-07-31 在 ECS 完成(5 个宽基指数,24876 行),输出见 code/outputs/stdout.txt🧰 duckdb 1.5.5、pyarrow 25.0.0、pandas 3.0.5
为什么学这个

你的定时任务昨晚跑了两遍——网络超时重试了一次。

今天你打开数据库,行数比预期多了 7960 行。但你不会发现,因为没人会去数行数。你只会在三周后发现某个回测结果诡异,然后花两天时间才想到去查数据。

这一节解决两件事:数据存在哪,以及怎么保证同一批数据灌一百遍,结果都一样

后者有个名字叫幂等性。它不是"最佳实践",它是重跑在现实里必然发生之后唯一的活路。

💡 打个比方

往通讯录里加联系人,如果按"姓名+手机号"判重,你加一百遍也只有一条记录。

如果直接往后追加,加一百遍就有一百条——而且你打电话时永远不知道该用哪条。

数据入库的区别,就在于你有没有主键。

一、存哪:CSV / Parquet / DuckDB 实测

数据:5 个宽基指数,2005-01-04 ~ 2026-07-29,合计 24876 行

格式文件大小写入全量读条件查询
CSV2.32 MB0.117s0.033s0.032s
Parquet1.03 MB0.064s0.062s0.006s
DuckDB1.01 MB0.040s0.013s0.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(...)

这样任何一行数据,你都能回答"它是谁给的、什么时候拿的"。当两个源的数据混在同一张表里时(而这迟早会发生),这两列是你唯一的救命稻草。

❓ 测验
你的日更任务用 append 写库,昨晚因超时重试跑了两遍。今天数据库里会发生什么?
✏️ 填空
增量更新要做到重跑一百遍结果都一样,这个性质叫 ___ 性。

📌 免责:本课为技术教学,文中指数仅用于数据工程演示,不构成投资建议。

✅ 小结

落库三件事:

  • 选型:DuckDB 作主库(体积 44%、条件查询快 7 倍、零服务),Parquet 作交换,CSV 只给人看
  • 幂等:主键 + ANTI JOIN。实测同一批数据第二次灌入新增 0 行,重叠 7 个月的批次只进新数据,重复主键 0
  • 原子:临时文件 + os.replace,或直接用 DuckDB 事务

外加一条:血缘跟着数据进库(source / fetched_at 两列)。

下一节做数据体检流水线:把 p1.3、p1.4 讲过的那些坑——除权跳空、停牌占位、日历错位——变成一套自动跑的断言。数据进库之前先过一遍体检,不合格的批次直接拒收。

下一节 → 数据质量流水线
🔎 来源与核验· 3 条,点开核对
本节每个关键论断都对应一个可追溯的来源 —— 这是本课程"靠谱、不过时"的底线。
「5 个宽基指数 24876 行:CSV 2.32MB/写 0.117s/全量读 0.033s/条件查 0.032s;Parquet 1.03MB/0.064s/0.062s/0.006s;DuckDB 1.01MB/0.040s/0.013s/0.005s。Parquet 与 DuckDB 体积均为 CSV 的 44%,DuckDB 条件查询比 CSV 快 7 倍」
📚 本节 code/storage_bench.py,2026-07-31 于 ECS 实测,输出见 code/outputs/stdout.txt✓ 已核验 2026-07
「主键(symbol,date)+ANTI JOIN 增量:第 1 次新增 16916 行,第 2 次灌入相同数据新增 0 行,第 3 次(重叠 7 个月)新增 7960 行,总计 24876 行与去重期望一致,重复主键 0 行」
📚 同上实测✓ 已核验 2026-07
「原子写入应使用临时文件 + os.replace(同文件系统内为原子操作);DuckDB 可用事务回滚替代」
📚 POSIX rename 语义 / DuckDB 官方文档✓ 已核验 2026-07
智图软件的赞赏码
都看到这了,打个赏呗!
接下来 · p1.6
数据质量流水线
继续读下一节 →