量化研究数据怎么长期保存?从 PostgreSQL 备份到 R2 归档的取舍
一年后想重新运行今天的一次量化研究,究竟应该恢复什么?
如果当时的代码还在 GitHub、研究结果还留着一个 run_id,但行情提供商已经修订了历史数据、原始响应被清理,那么“重新运行成功”也可能算出另一份结果。对有 Python/SQL 研究任务、长期积累信号与因子快照的开发者来说,我更在意的是:一次决策所用的数据、代码和可用时间,能否被再次准确地组合起来。
我在早期的方案中打算让 PostgreSQL 保存研究索引、信号和 outcome,把较大的原始载荷归档到 Cloudflare R2,再裁剪数据库里旧的 raw_json。后来发现,最难做决定的地方并不是 gzip 压缩,也不是 S3 SDK 怎么调用,而是三个问题:这个复杂度现在值不值得付;为什么选先建 manifest 的归档顺序;以及裁剪后还能不能证明同一次研究读到的就是同一份历史。
我为状态转换写过一套小型 Python 离线实验,使用 SQLite 内存数据库和模拟对象存储。它验证了一些防误删条件,但没有验证 PostgreSQL/R2 跨系统故障或多实例并发。下面涉及这套实验时会明确说它证明了什么;其他部分是基于官方文档和当前问题得出的架构判断。
先确定什么时候只用 PostgreSQL 就够了
对数据量还不大、回放频率不高的个人研究项目,我并不建议一开始就维护 PostgreSQL + R2 + manifest + 对账 worker 四件套。只要研究输入仍完整保存在 PG,先做好定期备份、恢复演练、运行参数与代码版本记录,往往已经能解决大部分实际问题。
这里不能跳过 PostgreSQL 的 PITR(时间点恢复)。它用 base backup 配合连续归档的 WAL,把数据库集群恢复到备份覆盖范围内的指定时间点。这是成熟的灾难恢复手段。只要备份和 WAL 保留范围足够长,而且所需材料当时都在 PG 中,理论上确实可以从恢复后的独立数据库找出某次研究记录。
但 PITR 的操作单位主要是数据库集群,而我想解决的是“拿到 run_id=35 对应的原始行情版本,重算那一次决策”。为这一个 run 恢复整个旧数据库、维护漫长的 WAL 保留期,并让旧数据库版本与今天的研究代码重新配合,成本通常比取回一个不可变数据包高。更重要的是,PITR 不会替你冻结当时的 Python 依赖、因子代码、交易所日历,也不会保存当年没有写入 PG 的外部 API 响应。
所以这两种技术不是二选一:PITR 负责数据库灾难恢复;按 run 固定的数据归档负责研究追溯。 一个不能自动代替另一个。不过如果业务根本不需要长期追溯到单次原始输入,后者也可能是不必要的建设。
我会先看实际增长,而不是先猜一个“到了 100GB 必须上 R2”的门槛。PostgreSQL 提供了能直接观察表、TOAST 和索引总体占用的函数。例如下面是拟用于真实 PG 的只读检查 SQL,尚未在本项目的 PostgreSQL 实例上执行:
SELECT
pg_size_pretty(pg_relation_size('quant_research.signal_snapshot')) AS heap,
pg_size_pretty(pg_table_size('quant_research.signal_snapshot')) AS table_including_toast,
pg_size_pretty(pg_total_relation_size('quant_research.signal_snapshot')) AS total_with_indexes;
从这些数据再结合每天新增的原始 payload 字节、保留天数、查询频率、备份/WAL 增速、恢复时间目标去评估。只有当冷数据占用了不成比例的数据库维护成本,而且真的需要逐 run 恢复时,我才会增加对象存储层。门槛由测量决定,不由某篇架构文章决定。
TOAST 已经把大字段放到行外,为何还要考虑冷热分表?
这个反问很关键。
PostgreSQL 的 TOAST 本来就会处理大字段。默认情况下,当整行超过通常约 2KB 的阈值,系统可能压缩某些可变长字段,必要时再把内容移到行外的 TOAST 表,主行保留外部指针。对于行外值,官方文档描述的磁盘指针长度为 18 字节。
但“超过 2KB 的 JSONB 一定会行外存储”并不准确。TOAST 看的是行、列存储策略和压缩效果;EXTENDED 通常先尝试压缩。如果大部分查询不读取 raw_json,TOAST 确实已经帮主表减轻了很多负担。不能把“字段大”直接当作分表的理由。
旧方案真正让我重新犹豫的是裁剪的写入代价:在原表把 raw_json 改成摘要,本质还是一个 UPDATE。PostgreSQL 的 MVCC 会保留旧行版本,变更产生 WAL,需要后续清理已失效版本;如果大字段内容发生变化,相应 TOAST 数据也可能需要回收。大批量历史行反复更新,有机会带来膨胀、维护负担和业务锁竞争。但这不等于每次更新都会“产生严重 bloat”,也不能宣称“一字节磁盘永远收不回来”。
PostgreSQL VACUUM 文档把区别说得很清楚:普通 VACUUM 一般把死元组空间变成表内可复用空间;VACUUM FULL 会改写表并可以归还更多磁盘空间,但需要更重的排他锁。文件尾部可截断等情况也存在,不能把物理占用简化成“裁剪后必定不变”。
如果现在是全新设计,我会更认真地考虑垂直分表:
signal_snapshot
snapshot_id | run_id | signal_time | score | raw_hash | archive_key
signal_raw_payload
snapshot_id | payload_version | raw_json | captured_at
高频查询只碰紧凑的 signal_snapshot;真正需要原始请求时再查 signal_raw_payload。归档验证后,只清理 payload 表,不重写主表的信号记录。普通分表能隔离写入压力,却不保证 DELETE 以后立刻收回文件系统空间——子表仍受 MVCC/VACUUM 约束。
如果历史 payload 天然按月份或批次过期,还可以进一步分区。PostgreSQL 官方的 分区文档指出,删除整个符合条件的分区通常比逐行删除快得多,而且避开大规模 DELETE 引出的常规清理成本。但这里有一条硬前提:该分区所有记录都满足归档校验、留存期限和删除授权,否则不能因为“本月大部分已经归档”就 DROP 整月。
这给我的设计选择增加了顺序感。已有大表不应为了一篇文章立刻迁移;先测 PG 的 heap/TOAST、WAL 和查询成本,之后再判断是继续依靠 TOAST、采用垂直分表,还是按删除生命周期建立分区。也不能默认 pg_repack、VACUUM FULL 或分区删除都没有锁、额外磁盘或运维代价。
Manifest-first:我为什么先记录归档意图
有两条都成立的流程:
上传优先:
打包 → PUT 对象 → GET 验证 → 写 PG manifest
清单优先:
在 PG 写 PENDING + 内容摘要 → PUT 对象 → GET 验证 → PG 标记 VERIFIED
上传优先比较直观,也不会产生“PENDING 清单指向一个尚不存在的对象”。可它会留下另一类问题:PUT 成功后进程崩溃,PG 里还没有任何记录。对象可能成为 orphan;如果靠扫 bucket 来补,得有明确的对象命名、归属与回收规则。
我在离线实验里选了 manifest-first,理由是希望先有一个可查询、带唯一身份的归档意图。任务启动时就固定 run_id / revision / expected_sha256;之后即使上传失败,对账程序也知道原本承诺保存哪个版本,可以在 PG 查询失败次数和恢复状态。这个选择是工程取舍,并非分布式事务定理。只写一个 PENDING,绝不自动获得并发互斥能力。
离线代码最初就暴露过它的代价。上传被拒绝后,PENDING 留了下来,而对象不存在。第一版对账只会检查远端是否有对象,于是永远修复不了这条记录。后来我改成:如果源 payload 还完整,就重新用原始内容打包;只有重新生成的 SHA-256 和字节数都与 manifest 一致才允许重传。源内容漂移则直接拒绝。这是实验中真实修过的局部逻辑,不是线上故障记录:
recovered = pack(self._source_rows(manifest["run_id"]))
if digest(recovered) != manifest["sha256"] or len(recovered) != manifest["byte_size"]:
raise ArchiveError("pending manifest source changed; cannot re-upload")
self.store.put_if_absent(manifest["object_key"], recovered)
再往生产走,审稿意见说的租约确实必须补。但“给 PENDING 加一把锁”还不够。两个 worker 可能先后获得同一个任务:A 拿到租约后网络卡住,租约到期;B 接管并完成写入;A 醒来如果还能提交,就会覆盖 B 的状态。
我会让 PG 通过单条条件 UPDATE ... RETURNING 抢占任务,同时增加递增的 lease_epoch(fencing token)。示意 SQL 如下,它是待验证的生产设计,不是前面 28 项测试执行过的代码:
UPDATE archive_manifest
SET lease_owner = $2,
lease_epoch = lease_epoch + 1,
locked_until = clock_timestamp() + interval '5 minutes',
status = 'RECONCILING'
WHERE identity = $1
AND status IN ('PENDING', 'RECONCILING')
AND (locked_until IS NULL OR locked_until < clock_timestamp())
RETURNING lease_epoch, expected_sha256, object_key;
外部 PUT/GET 不应持有一个漫长的 PG 事务。完成校验以后,worker 只能用它领到的 lease_owner + lease_epoch 进行条件更新;旧 epoch 提交失败,就让新持有者对账。五分钟只是说明租约字段的示例值,需要结合实际包大小、心跳和超时策略决定。数据库租约只能控制 PG 的状态更新,不能阻止过期 worker 已经发出的对象存储写入。所以远端还需要内容寻址 key、禁止不同字节覆盖相同 key 的条件写入,以及冲突时重新校验。
此外,恢复程序要分别处理“对象已有但 PG 未确认”“manifest 有记录但对象缺失”“上传正在进行”“重复提交同一内容”这几类结果,而不是一律重试到成功;当源数据已裁剪或哈希不符,宁可进入人工修复,也不能用最新行情补一份旧快照。没有真实并发和故障注入的集成测试之前,我只把这套方案称为设计。
远端对象的身份与校验
Cloudflare 当前的 R2 S3 兼容表列出 PutObject、HeadObject、GetObject 和单次 PUT 的条件请求支持;但 GetBucketVersioning 和 PutBucketVersioning 仍列在未实现的 API 中。不能假设打开 AWS S3 的 bucket versioning 就能在 R2 自动保留并任意读取旧版本。R2 Workers 对象里有一个单次上传的 version 字段,也不等于可依赖的 S3 历史版本访问机制。
因此我仍会采用显式的不可变版本 key,例如 raw/v1/run=35/revision=2/sha256=...jsonl.gz。身份里保留 schema、run、revision 和实际压缩字节的 SHA-256;同一身份要求同样的字节。如果出现不同哈希,创建新 revision,不覆盖旧对象。结合 R2 的 Bucket Locks 和 生命周期规则 制定保留政策,但别把 R2 Bucket Locks 当成 AWS S3 Object Lock 请求头的完整兼容实现。
上传验收要分两层。我会在对象的自定义元数据里重复保存压缩包摘要和行数,让 HEAD 能便宜地判断“清单和对象标注是否一致”。R2 的 S3 扩展文档说明 x-amz-meta-* 可以映射到对象的自定义 metadata。
下段是 单次 PutObject 的 SDK 接入示意,没有在真实 R2 上执行。用 boto3 时,Metadata 对应远端的 x-amz-meta-*,上传前由应用计算摘要:
# gzip_bytes 和 expected_sha256 必须来自同一份不可变输入
s3.put_object(
Bucket=bucket,
Key=object_key,
Body=gzip_bytes,
Metadata={
"sha256": expected_sha256,
"row-count": str(row_count),
"schema-version": "1",
},
IfNoneMatch="*",
)
head = s3.head_object(Bucket=bucket, Key=object_key)
assert head["Metadata"]["sha256"] == expected_sha256
这只能作为“快速对账通过”的信号。元数据本身是上传方提供的声明,HEAD 返回的 SHA 字符串不是服务端替你验证了文件内容。允许裁剪之前,至少应当实际 GET 取回完整压缩字节,重算 SHA-256,解压并核对 schema、行数和记录身份;以后还要做抽样恢复演练。离线实验专门注入过“HEAD 可见、GET 内容损坏”,裁剪被挡住了。
Multipart 要单独测试:不能用 multipart 的 ETag 冒充完整 SHA-256,也不能假设单次 PutObject 的条件参数自动适用于 CompleteMultipartUpload。SDK 何时切换 multipart、失败后如何 abort、重试时怎样防覆盖,应该在授权的 R2 测试桶上验收,不能靠上面的代码片段宣称已经实现。
一根 K 线出现修订,旧研究究竟该看到哪个版本
对象存储负责把旧字节留住;研究回放还需要知道哪些字节在当时已经可用。
这属于双时态(bitemporal)问题的一部分。市场事件时间(valid/event time)描述 K 线属于哪一个交易时段;observed_at 或 ingested_at 描述研究系统什么时候真正拿到了某一修订。数据商自己的发布时间(source_published_at)最好另存:它可能早于你成功抓到的时间,不能拿来假装你的策略早就知道。
我在合成 fixture 里给同一交易日期放了两条版本:10 月 2 日首次收到 close=100,10 月 5 日才收到修订值 104。按读取截止时间得到的是:
as_of(UTC) | 可见 close |
|---|---|
| 2026-10-01 09:00 | 尚无记录 |
| 2026-10-03 00:00 | 100 |
| 2026-10-06 00:00 | 104 |
这个小测试确认了离线 as_of 选择器不会让后来那条修订跑回较早的观察时点。它还没有验证真实供应商的迟到消息、撤销、更正优先级、复权因子或股票池成分历史。
真正接到策略查询层时,我不想留一个默认走“最新行情”的隐式选项。研究 API 应明确传入 as_of_utc、dataset_revision 或冻结的 snapshot_id;缺少研究截止时间就拒绝历史回放请求。最新视图可以单独提供给实时看盘,但不应悄悄成为回测默认数据源。遇到相同事件和观测时间的冲突版本,先按数据商版本/更正序号及质量规则判定或隔离,别简单以“最后写入的一行”为准。
时区细节也不只是“全部转 UTC”就结束。比如美股常规交易时段的 16:00 是交易所当地时钟,America/New_York 会随夏令时从 EDT 切到 EST;把收盘简单写死为每天同一个 UTC 时刻会出错。研究系统应该用带 IANA 时区的交易所日历生成具体的 trading_session 与决策截止事件,再转换成有时区的 UTC 时间戳,按实际数据可用性加上 ingestion watermark。timestamptz 精度要能区分供应商消息先后;微秒可以作为一致的存储精度,但精度高不自动意味着不会前视。
交易所提前收盘、停牌、盘后修订都可能改变“这根数据什么时候允许进入策略”的答案。举个边界:如果策略在 16:00 收盘事件触发,但正式收盘价 16:00:02 才到达,那么 16:00:00 的回测就不该提前使用它。真正决定是否可用的,是决策 cutoff 与所绑定数据 revision 的实际可见时间。
类似地,拆股复权、退市标的、指数成分调整和财务重述最好各自有独立的版本链;否则保存了一份原始价格,却在今天用新的成分股名单回放去年,仍然不算精确复现。之前那篇 因子验证文章 讨论过 signal-time 快照,和这里的数据版本有同一条时间边界。
最小实验的结论,以及尚未实现的“一年后重跑”
我的离线实验一共运行了 28 个测试用例,28 个通过,覆盖重复归档、模拟上传失败、对象缺失或损坏时拒绝裁剪、PENDING 修复、保留期和确认开关、as_of 修订等。准确说,它是对我自己实现的状态机和模拟故障分支做回归检查,不是分布式正确性证明。网络分区、真实 multipart 故障、跨 worker 租约竞争、PostgreSQL 事务与锁、真实 R2 读写路径,测试一次都没有覆盖。
曾经展示的 13.0 → 13.0 → 13.0 结果也应降到合适的位置。合成记录只有两个值 10.5、15.5;裁剪前、从归档取回和恢复以后,计算平均值都是 13.0。这只是 gzip/哈希/简单恢复路径的 smoke test,谈不上复现复杂回测,更无法推断实际存储节省。配套的 replay-mini.py 是一个可以独立运行的小练习,不值得把这个数字反复放进标题和大表里。
原始实验的运行命令与输出如下,环境为 Python 3.13.5、内存 SQLite 和模拟对象存储:
$ python3 verify.py --output results
Ran 28 tests in 0.017s
OK
CASE_SUMMARY {"ran": 28, "passes": 28, "failures": 0, "errors": 0}
完整测试源码和生成结果保存在 Content Lab 研究档案里,仓库目前是私有的。我不会给公开读者贴一个实际上无法访问的 GitHub 地址,也不会把它叫做公开的“一键复现项目”。文中留下少量真实代码片段足以说明验证过程;需要公众复现整套 fixture 时,必须先整理并公开经过检查的资产。
更大的缺口是 reproduction_manifest。这次测试的 manifest 确实记录了压缩对象摘要、schema、示例计算函数、参数、Python 版本和 UTC,但没有演练“在另一台机器上按清单检出当年的策略 commit、恢复真实数据库数据、重建依赖、加载交易日历,再重跑整条因子/outcome 流程”。所以一年后完整重跑,还没有被证明。
下一步真正值得做的验收,不应该再给内存 fixture 加十个看上去漂亮的用例,而是拿一个实际完成的研究 run 做隔离恢复:
- 固定原始数据的版本、对象 SHA-256、行情供应商修订/
as_of,以及那次研究的 signal snapshot。 - 锁定策略 Git commit、配置与参数哈希、依赖锁文件或容器 digest、交易所日历版本、时区和 outcome 定义;缺其中一项就说明该次实验无法做到哪一级别的复现。
- 在独立 PostgreSQL 环境验证真实 SQL 事务、TOAST、并发租约和受控删除;在授权 R2 测试桶覆盖单次与 multipart、失败中断、条件写和完整 GET 校验。
- 从已裁剪的归档恢复数据,用冻结环境重新运行研究代码,与当年保存的信号、决策和结果逐项比较。遇到差异时记录是数据修订、代码漂移还是环境变化,而不是把两次命令都返回 0 当作成功。
这才是本文开头问题的验收标准。现在的方案已足以指导存储边界和风险设计,但我不会在“只验证了离线状态机”的阶段开放生产 raw_json 自动裁剪。研究数据不怕一时多占一点空间,真正无法接受的是清理成功以后,发现旧决策的输入再也解释不清了。