表撑到一亿行之前:vibe 项目的数据归档与冷热分离实战
当只增不减的表撑过内存,备份和查询会一起衰减。vibe 项目归档实战:四个动手信号、三种策略对照(原生分区+pg_partman vs 归档表 vs Parquet 冷导出)、幂等可重入的归档作业状态机、归档后查询路由、回查解冻,以及归档与备份、合规保留期的关系。

在动手之前,先把三件事的边界划清楚——站内这三篇指南各管一摊,别走错片场:
- vs 《数据库备份与灾难恢复指南》:那篇讲"丢了怎么恢复"——RPO/RTO、备份策略、灾难演练;本篇讲"日常治理"——表太大怎么拆、冷热数据怎么分家。备份是救命的,归档是减肥的。
- vs 《慢查询与索引优化指南》:那篇讲"查得快"——怎么建索引、怎么读执行计划;本篇讲"表别撑爆"——归档策略。归档之后索引自然变小、查询自然变快,那是副作用,不是目的。别指望靠加索引解决"表里有 2 亿行日志"的问题。
- vs 《Schema 迁移指南》:那篇讲"结构变更"——加列、改类型、零停机迁移;本篇讲"数据生命周期"——数据老了去哪。迁移管的是表的形状,归档管的是表里的数据去哪养老。
一句话:备份管"丢",索引管"慢",迁移管"变",归档管"胖"。本篇只讲"胖"。
vibe 项目的表是怎么撑爆的
vibe coding 项目有一种经典死法:上线三个月,一切岁月静好。某天早上你收到三件事——Supabase/Neon 的账单翻倍了,pg_dump 跑了 4 小时还没结束,后台一个查近 7 天订单的接口把库打挂了。你打开数据库一看:request_logs 8000 万行,events 2 亿行,audit_logs 从项目第一天起一行没删过。
这不是你的错,这是 vibe coding 的结构性问题:AI 帮你写代码时,从不考虑"这张表一年后有多大"。日志表、事件表、审计表、消息表——这些只增不减的表,是每个 vibe 项目的标配。而归档,恰恰是 AI 最不会主动提醒你做的事:它不属于任何一个 feature,不产生任何用户可见的价值,只在你不做的时候惩罚你。
先纠正一个观念:归档不是删除。删除是"扔了",归档是"搬到便宜的地方放着,还能查"。一个健康的归档体系回答三个问题:什么数据算"老"(信号指标)、老数据搬去哪(策略选型)、搬的过程不出岔子(作业设计)。本篇按这个顺序,用 PostgreSQL 原生分区 + pg_partman 给你一套真实可跑的方案。
一、什么时候该归档:四个信号,别凭感觉
"表有点大"不是动手理由,"指标超标"才是。以下四个信号,中了两个就该排期了。
信号 1:表大小——单表超过实例内存,或占库 60% 以上
PostgreSQL 官方分区文档里有一条经验法则:当表的大小超过数据库服务器的内存大小时,分区带来的收益才值得。对 vibe 项目可以更激进一点:单表超过 50GB,或者一张表占了全库 60% 以上的体积,就是红灯。先跑这条 SQL 看看你家最大的表:
SELECT tablename,
pg_size_pretty(pg_total_relation_size('public.' || tablename)) AS total_size,
pg_size_pretty(pg_relation_size('public.' || tablename)) AS heap_size
FROM pg_tables
WHERE schemaname = 'public'
ORDER BY pg_total_relation_size('public.' || tablename) DESC
LIMIT 10;
注意看 total_size 和 heap_size 的差值——差的那部分是索引。一张 8000 万行的日志表,索引体积经常是堆表的 1.5 倍。这就是为什么"加索引"治不了"表胖":索引本身就是胖的一部分。
信号 2:查询衰减——同一个查询,p95 逐月上涨
表胖的第一个临床症状不是报错,是"越来越慢"。用 pg_stat_statements 看同一类查询的 mean_exec_time 趋势,如果连续三个月上涨,且 shared_blks_hit 命中率在掉,说明热数据已经装不进内存了:
SELECT queryid,
calls,
round(mean_exec_time::numeric, 2) AS mean_ms,
round((100.0 * shared_blks_hit
/ nullif(shared_blks_hit + shared_blks_read, 0))::numeric, 1) AS hit_pct
FROM pg_stat_statements
WHERE query LIKE '%request_logs%'
ORDER BY mean_exec_time DESC
LIMIT 5;
需要 CREATE EXTENSION pg_stat_statements,Supabase/RDS/Neon 都支持。这个信号和《慢查询与索引优化指南》的区别在于:索引指南解决的是"查询写法/索引缺失"导致的慢;这里解决的是"数据量本身"导致的慢——执行计划再漂亮,2 亿行全表扫一次就是 2 亿行。
信号 3:备份时长——超过你的维护窗口
跑一次全量 pg_dump 计时。如果备份时长超过你的维护窗口(比如超过 1 小时),或者恢复演练的 RTO 已经超标,归档是最直接的减肥药:把 2 年的冷数据搬走,备份体积直接砍掉 70%,备份时长和恢复时长同比例下降。备份策略本身怎么做,去看《数据库备份与灾难恢复指南》;这里只强调一点:归档是让备份重新变便宜的最快手段。
信号 4:业务信号——冷热分界线是查出来的,不是拍脑袋定的
"90 天前的数据算冷数据"——这个 90 天从哪来?查。先看数据分布,再看查询日志里时间过滤条件的分布:
-- 数据按月分布:冷数据到底占多少
SELECT date_trunc('month', created_at) AS m, count(*)
FROM request_logs
GROUP BY 1 ORDER BY 1 DESC
LIMIT 12;
如果 90 天前的数据占了 85%,而你的查询日志里 99% 的请求都带 created_at > now() - interval '90 days',那分界线就是 90 天——有数据支撑,谁也别争。四个信号里中了两个,动手;等到第五个信号(磁盘满了)才动手,那就不是归档,是救火。
二、三种策略对照:没有银弹,只有取舍
归档业界有三种主流做法,我按 vibe 项目的体量从轻到重排:
- 策略 A:PostgreSQL 原生分区表 + pg_partman——按时间 RANGE 分区,pg_partman 自动建未来分区、按保留期 detach 旧分区。对应用几乎透明:逻辑上还是一张表,
SELECT * FROM request_logs WHERE created_at > ...照写,分区裁剪自动跳过冷分区。 - 策略 B:归档表 + UNION 视图——定期
INSERT INTO archive.request_logs_2025_01 SELECT ...再DELETE FROM request_logs;全量查询走UNION ALL视图。不用装任何扩展,最土,但每一步都是你亲手写的 SQL,出问题好查。 - 策略 C:冷存储导出 Parquet——
COPY出 Parquet 文件扔 S3/R2,库里只留热数据。存储成本最低(对象存储比云数据库便宜一个数量级),但回查要走 DuckDB/Athena 或回灌,链路最长。

图 1:三种归档策略定性对照(1–10 分,越高越好;成本类维度已取反)。分数是选型讨论用的示意,不是基准测试。
| 维度 | A 原生分区 + pg_partman | B 归档表 + 视图 | C Parquet 冷存储 |
|---|---|---|---|
| 查询方式 | 一张逻辑表,分区裁剪自动生效 | 应用层按时间路由,或 UNION ALL 视图 | 热查走库,冷查走对象存储/DuckDB |
| 冷数据回查 | ATTACH 回来,分钟级 | 视图里直接有,秒级 | 下载回灌,小时级 |
| 存储成本 | 还在 PG 里,不省钱 | 还在 PG 里,不省钱 | S3/R2,便宜一个数量级 |
| 实施成本 | 中:装扩展 + 迁移表结构 | 低:纯 SQL 定时任务 | 中高:导出链路 + 回查链路 |
| 归档剥离效率 | DETACH 毫秒级,业务无感 | INSERT+DELETE,有锁和 VACUUM 开销 | COPY 导出快,但链路长 |
| 适合谁 | 绝大多数 vibe 项目 | 装不了扩展的托管环境过渡 | 每天 GB 级日志、合规允许离库 |
我的选型建议很直接:90% 的 vibe 项目选 A。B 适合那种"连扩展都装不了"的极简托管环境做过渡;C 是"每天产生 GB 级日志"的重度选手,或者云数据库账单已经肉疼到必须离库的。注意 A 和 C 不互斥——成熟的做法是 A+C:先用分区表做在线冷热分离(温数据),再把 detach 下来的分区导出 Parquet 进对象存储(冷数据)。本篇后面的实战就是按这个组合拳写的。
三、分区键选择与迁移实战
策略 A 的第一步不是装扩展,是选分区键。分区键选错,后面全白干。
3.1 分区键三原则
一个好的归档分区键必须同时满足三条:
- 查询都带它——分区裁剪(partition pruning)只对 WHERE 里出现分区键的查询生效。如果你的查询从不按时间过滤,分了区也白分。
- 单调递增——新数据永远只进最新的分区,旧分区一旦写满就再也不变,"切时间片"才干净。
- 边界清晰——归档的本质是"扔掉/搬走一段时间的数据",分区边界必须和归档边界对齐(按月归档就按月分区)。
对 request_logs 来说,created_at 三条全中。反面教材:用 user_id 做分区键——查询确实常带 user_id,但归档是按时间淘汰的,user_id 分区里新老数据混在一起,detach 的时候根本下不去手。记住:归档分区键几乎永远是时间列,这是由"归档=按时间淘汰"这个本质决定的。
分区键定了之后是分区粒度:按天、按周还是按月?看两个数——单个分区的数据量,和总分区数。经验值:单个分区 1GB–10GB 最舒服,太小则分区数爆炸(规划器要逐个过分区约束),太大则 detach 的粒度太粗、一次归档搬运太久。对大多数 vibe 项目,按月起步:一个月几 GB,一个分区就是一个归档单元,detach、导出、校验、销毁都以"月"为单位,心智负担最低。等单月数据超过 50GB 再考虑按周。别一上来就按天——三年就是 1095 个分区,\d+ 看一眼都翻页,更别说规划器了。
3.2 安装 pg_partman
pg_partman 是 PGXN 上的分区管理扩展(pgxn.org/dist/pg_partman),5.x 只支持 PG 原生声明式分区。它的核心价值就一句话:自动化的分区维护——提前建好未来的分区、按保留期 detach/drop 旧分区,不用你写 cron 去拼 DDL。
CREATE SCHEMA partman; CREATE EXTENSION pg_partman WITH SCHEMA partman;
托管环境注意两点:RDS(12.5+)、Supabase、Neon 等主流托管 PG 都提供 pg_partman,直接在控制台/ SQL 里启用即可;pg_partman 自带的后台 worker(BGW)需要写 shared_preload_libraries 并重启实例——托管环境一般动不了,那就用 pg_cron 定时调 partman.run_maintenance(),效果一样。
3.3 把现有大表迁成分区表
PostgreSQL 有个硬限制:普通表不能直接"变成"分区表,必须重建。但 pg_partman 把这个过程做成了标准动作——旧表改名、用原名建分区父表、搬运数据、删旧表:
-- 1. 旧表改名(应用短暂停写,或在低峰期做)
ALTER TABLE request_logs RENAME TO request_logs_legacy;
-- 2. 用原名建分区父表:按 created_at 做 RANGE 分区,按月,提前建 2 个未来分区
SELECT partman.create_parent(
p_parent_table => 'public.request_logs',
p_control => 'created_at',
p_type => 'native',
p_interval => 'monthly',
p_premake => 2
);
-- 3. 分批搬运:数据从旧表 MOVE 进分区(不是复制,旧表会被搬空)
CALL partman.partition_data_proc(
'public.request_logs',
p_interval := '1 month',
p_batch := 200,
p_source_table := 'public.request_logs_legacy'
);
-- 4. 校验:旧表应被搬空,新表行数应与迁移前一致
SELECT count(*) FROM request_logs_legacy; -- 期望 0
SELECT count(*) FROM request_logs; -- 期望 = 迁移前行数
-- 5. 确认无误后下线旧表
DROP TABLE request_logs_legacy;
第 3 步的 partition_data_proc 是 pg_partman 专门为大表迁移准备的过程:按 p_interval 切片、按 p_batch 分批提交,避免一个超长事务把库锁死。搬完记得 VACUUM ANALYZE request_logs 更新统计信息,否则规划器对新分区的行数估计是瞎的。
3.4 索引处理:vibe 项目最常见的翻车点
分区表的索引规则和普通表不一样,AI 生成的迁移代码在这里翻车率最高:
第一,在父表上建索引,会自动应用到所有分区(已有的和未来新建的):
CREATE INDEX ON request_logs (user_id, created_at); CREATE INDEX ON request_logs (created_at, status_code);
第二,主键/唯一约束必须包含分区键。这是 PG 的硬限制:唯一性只能在单个分区内保证,跨分区的唯一性 PG 做不到。所以下面这行在分区表上会直接报错:
-- 错误示范:AI 最爱这么写,分区表上必报错 -- ALTER TABLE request_logs ADD PRIMARY KEY (id); -- 正确做法:主键必须带上分区键 ALTER TABLE request_logs ADD PRIMARY KEY (id, created_at);
如果你的业务真的需要"全局唯一的 id",做法是:id 保持唯一性由应用层保证(比如用 uuid,碰撞概率忽略不计),数据库层面的主键写成 (id, created_at)。别跟数据库较劲,这是官方文档里白纸黑字写的限制。
第三,大表加索引怕锁表时的绕法。在分区父表上 CREATE INDEX 不能用 CONCURRENTLY(会锁)。官方推荐的绕法是:先在父表上 CREATE INDEX ... ON ONLY(只建壳,标记为 invalid),再到每个分区上单独 CREATE INDEX CONCURRENTLY,最后 ALTER INDEX ... ATTACH PARTITION 把它们拼起来。vibe 项目表还没大到需要这套时,记住有这个选项就行。
第四,确认分区裁剪开着。SHOW enable_partition_pruning; 默认是 on,别手贱关了。验证方法:EXPLAIN SELECT * FROM request_logs WHERE created_at > now() - interval '7 days';,看执行计划里是不是只扫描了最近的分区。
四、归档作业设计:幂等、可重入、可观测
分区表建好了,数据会按月自动切片。但"旧分区 detach 下来之后去哪",PG 不会替你决定——这就是归档作业的活。先定分工,这是整个设计里最重要的一句话:
pg_partman 只负责"按保留期 detach",归档作业只负责"搬走 → 校验 → 销毁"。两边各干各的,通过"已 detach 的独立表"这个交接物衔接,绝不抢活。
-- pg_partman 的保留策略:6 个月前的分区自动 detach,但保留成独立表(不删除)
UPDATE partman.part_config
SET retention = '6 months',
retention_keep_table = true, -- 关键:只分离,不删除
retention_keep_index = true
WHERE parent_table = 'public.request_logs';
配好之后,每次维护(BGW 或 partman.run_maintenance())会自动把超期的分区(比如 request_logs_p2025_03_01)从父表 detach,变成一张普通独立表,躺在那等归档作业来认领。应用查询不受任何影响——因为查询本来就只查最近 90 天。
4.1 pg_cron 还是应用层 job?
| pg_cron(库内定时) | 应用层 job(BullMQ / node-cron) | |
|---|---|---|
| 优点 | 离数据最近,SQL 一把梭;不依赖应用进程 | 能调 S3 SDK、发告警、写日志,表达力强 |
| 缺点 | 做不了对象存储上传、HTTP 告警这些"出库"的事 | 应用挂了 job 就停了,要另做高可用 |
| 适合 | 纯库内动作:detach、建视图、删分区 | 完整归档链路:导出 Parquet → 上传 S3 → 校验 → 告警 |
本篇的完整链路(导出 Parquet + 上传对象存储)必须出库,所以推荐应用层 job 做主链路,pg_cron 只负责两件小事:调 partman.run_maintenance()(如果 BGW 不可用),以及定时 VACUUM ANALYZE。别把 S3 上传塞进数据库函数里,那是给自己挖坑。
4.2 归档作业状态机
归档作业最怕两件事:跑到一半崩了不知道哪些分区搬了没搬;两个实例同时跑把同一个分区搬两遍。解法是状态机 + 排他锁:

图 2:归档作业状态机。每个状态转移先写库再执行,崩溃重启从 status 继续;同一分区同一时刻只跑一个。
状态落库用一张 archive_jobs 表,一行对应一个分区的一次归档:
CREATE TABLE archive_jobs (
job_id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
partition_name text NOT NULL UNIQUE,
status text NOT NULL DEFAULT 'pending'
CHECK (status IN ('pending','detaching','exporting','verifying',
'retained','destroyed','failed')),
row_count bigint,
sha256 text,
s3_key text,
attempt int NOT NULL DEFAULT 0,
last_error text,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now()
);
幂等三件套,缺一不可:
- 状态先行:每个状态转移都是"先
UPDATE archive_jobs SET status=...再执行动作"。崩溃重启后,worker 按status继续,没做完的重做,做完的跳过。 - 分区级排他锁:开工先
SELECT pg_advisory_xact_lock(hashtext('request_logs_p2025_03_01')),同一分区同一时刻只有一个 worker 能拿到锁,第二个直接等待或跳过。事务级锁随事务结束自动释放,不怕 worker 崩了留死锁。 - 交接物检查:detach 前先查这个分区还在不在父表上(可能 pg_partman 已经 detach 过了),导出的 Parquet 是否已存在且 sha256 对得上——对得上就直接跳到登记,保证重跑不重复干活。
worker 骨架(TypeScript + BullMQ 风格,核心是状态机,可直接照着实现):
async function archivePartition(partition: string) {
await db.query(`SELECT pg_advisory_xact_lock(hashtext($1))`, [partition]);
const job = await upsertJob(partition, 'detaching'); // 状态先行
try {
// 1. detach(若 pg_partman 已 detach 则跳过)
await ensureDetached(partition);
await setStatus(partition, 'exporting');
// 2. COPY 导出 -> 转 Parquet -> 上传 S3,返回行数与 sha256
const { rows, sha256, s3Key } = await exportToParquet(partition);
await setStatus(partition, 'verifying', { rows, sha256, s3Key });
// 3. 校验:分区行数 == Parquet 行数,sha256 对上
await verifyExport(partition, rows, sha256);
await setStatus(partition, 'retained');
// 4. 合规保留期满后才销毁(见第八节),销毁前最后确认
// await destroyPartition(partition); // -> 'destroyed'
} catch (err) {
await setStatus(partition, 'failed', { error: String(err) });
await alert(`归档失败: ${partition}`, err); // 告警必须有,否则 failed 躺表里没人知道
throw err;
}
}
注意 catch 里的告警:归档是后台任务,失败了不会 500,不会有人尖叫,只会安静地躺在 failed 状态里,然后三个月后你发现磁盘又满了。没有告警的归档作业等于没有归档作业。
五、从 detach 到 Parquet:一次完整归档的 SQL
把状态机里的关键步骤翻译成 SQL。假设 pg_partman 已经把 2025 年 3 月的分区 detach 成了独立表 request_logs_p2025_03_01:
-- 0. 确认它真的被 detach 了(还在父表分区列表里就别动手)
SELECT inhparent::regclass AS parent
FROM pg_inherits WHERE inhrelid = 'request_logs_p2025_03_01'::regclass;
-- 期望:0 行(已是独立表)
-- 1. 导出:COPY 出 CSV(托管 PG 没有服务器文件权限时,用 COPY TO STDOUT 走应用层)
COPY (SELECT * FROM request_logs_p2025_03_01)
TO '/archive/staging/request_logs_2025_03.csv' WITH (FORMAT csv, HEADER);
-- 2. 转 Parquet 并上传(应用层做,示例用 DuckDB;生产用 SDK 直传 S3/R2)
-- import duckdb
-- duckdb.sql("""COPY (SELECT * FROM read_csv('/archive/staging/request_logs_2025_03.csv', header=true))
-- TO '/archive/staging/request_logs_2025_03.parquet' (FORMAT parquet)""")
-- s3_key = upload('/archive/staging/request_logs_2025_03.parquet',
-- 's3://myapp-archive/request_logs/2025_03.parquet')
-- 3. 校验:库里行数 == Parquet 行数,且 sha256 对上(应用层比对)
SELECT count(*) FROM request_logs_p2025_03_01;
-- 4. 登记归档元数据(校验通过后才写 retained)
UPDATE archive_jobs
SET status = 'retained', row_count = 48210391,
sha256 = 'e3b0c44...', s3_key = 's3://myapp-archive/request_logs/2025_03.parquet',
updated_at = now()
WHERE partition_name = 'request_logs_p2025_03_01';
-- 5. 合规保留期满后(见第八节),销毁分区表释放空间
-- DROP TABLE request_logs_p2025_03_01;
-- UPDATE archive_jobs SET status = 'destroyed' WHERE partition_name = 'request_logs_p2025_03_01';
几个实操细节:
- detach 下来的表自带索引(因为
retention_keep_index = true),导出前可以DROP INDEX掉用不上的,导出更快;反正导出完表就销毁了。 - Parquet 按"年月"一个文件,别按天切碎——对象存储里 10 万个小文件比 36 个大文件难管得多,回查时列裁剪也更有效。
- sha256 是算在 Parquet 文件上的,不是 CSV 中间文件。校验链是:库行数 == Parquet 行数,Parquet sha256 == 上传后下载验算的 sha256。中间文件校验通过就删,别留着占 staging 盘。
- 如果某个月的数据量小到不值得走 S3(比如几 MB),直接把 detach 下来的表移到
archiveschema 放着就行,别为了"架构优雅"硬上对象存储。
六、归档后查询路由:热查走分区表,冷查走归档
归档之后,查询要分流。原则只有一条:在线接口永远只查热数据,冷数据查询走专门的慢通道。
6.1 应用层按时间路由(推荐)
// HOT_DAYS 与 retention_policies.hot_days 保持一致,配在配置中心别写死
const HOT_DAYS = 90;
function resolveLogsSource(from: Date, to: Date) {
const cutoff = new Date(Date.now() - HOT_DAYS * 864e5);
if (to < cutoff) return 'archive.request_logs_cold'; // 全冷:只查归档
if (from < cutoff) return 'both'; // 跨界:热表 + 归档合并
return 'public.request_logs'; // 全热:走分区表,分区裁剪生效
}
"跨界查询"(时间范围横跨冷热分界线)在后台管理/审计场景很常见,实现时用两次查询 + 应用层合并,别为了省一次查询去造一个大视图——冷热两边的数据量级差了两个数量级,合并逻辑放在应用层最可控。
6.2 UNION ALL 视图(给后台/审计的兜底)
CREATE SCHEMA IF NOT EXISTS archive; CREATE VIEW archive.request_logs_all AS SELECT * FROM public.request_logs UNION ALL SELECT * FROM archive.request_logs_p2025_02_01 UNION ALL SELECT * FROM archive.request_logs_p2025_01_01;
丑话说前头:普通视图没有分区裁剪,查这个视图会把所有分支扫一遍。所以它只给后台管理、审计导出这种"本来就慢、一天跑几次"的场景用,绝不给在线接口用。每次新增/销毁归档分区时,记得同步更新这个视图的定义——可以写进归档作业的 retained/destroyed 状态转移里自动维护。
6.3 ORM 映射提醒
Prisma/Drizzle 用户注意两点:分区父表在 ORM 里就是一张普通表,读写照常用,零改动;archive schema 下的归档表单独映射成只读 model,并且写操作永远只走热表 model。见过最蠢的 bug 是归档视图被 ORM 当成普通表做 UPDATE——视图又不可写,凌晨三点 oncall 被叫醒。
七、回查与解冻:归档的数据还能回来
"归档了还能查回来吗"——这是老板唯一关心的问题。答案是能,但分三级,每级的成本差一个数量级。把 SLA 写进文档,免得运营以为"归档=秒查":
| 级别 | 数据在哪 | 回查成本 | 典型场景 |
|---|---|---|---|
| 热(0–90 天) | 分区表在线分区 | 毫秒级,正常查询 | 日常业务、用户自助查询 |
| 温(detach 未销毁) | archive schema 独立表 | 分钟级:ATTACH 回分区表 | 客诉追查、审计抽查 |
| 冷(已导出 Parquet) | S3/R2 对象存储 | 小时级:下载 → 建表 → 回灌 → ATTACH | 法务取证、历史数据分析 |
7.1 温数据恢复:ATTACH 回来
-- 把归档分区重新挂回分区表,查询立刻又能查到它
ALTER TABLE request_logs ATTACH PARTITION archive.request_logs_p2025_02_01
FOR VALUES FROM ('2025-02-01') TO ('2025-03-01');
注意一个性能细节:ATTACH PARTITION 会全表扫描验证数据确实落在边界内,期间持有 ACCESS EXCLUSIVE 锁。如果表是几千万行,这个扫描不便宜。绕法是先加一个与边界完全匹配的 CHECK 约束,PG 发现约束和分区边界一致时会跳过扫描:
ALTER TABLE archive.request_logs_p2025_02_01 ADD CONSTRAINT chk_2025_02
CHECK (created_at >= DATE '2025-02-01' AND created_at < DATE '2025-03-01');
-- 然后再 ATTACH,验证扫描会被跳过
ALTER TABLE request_logs ATTACH PARTITION archive.request_logs_p2025_02_01
FOR VALUES FROM ('2025-02-01') TO ('2025-03-01');
查完记得再 DETACH 回去——温数据区的表是"借调"不是"调岗",别让它赖在热表里。
7.2 冷数据恢复:从 Parquet 回灌
-- 1. 从 S3 下载 Parquet -- aws s3 cp s3://myapp-archive/request_logs/2025_01.parquet /archive/restore/ -- 2. 建空表(结构与分区一致,LIKE 父表最省事) CREATE TABLE archive.request_logs_p2025_01_01 (LIKE public.request_logs INCLUDING ALL); -- 3. 灌数据(DuckDB / COPY 二选一) -- 4. 校验行数后 ATTACH(同 7.1,加 CHECK 约束跳扫描)
冷恢复一年可能用不了一次,但每年至少演练一次。Parquet 文件静静躺在 S3 里三年,某天你发现 pyarrow 升级后读不出来,或者 S3 生命周期策略误删了——这种时候才发现,比数据丢了更难受。演练很简单:随机抽一个月,下载、回灌、行数对上、删掉。
八、归档、备份与合规保留期:三者的关系
归档、备份、合规保留期是三个东西,但 vibe 项目里经常被混成一团。先分清楚:
- 归档:把冷数据搬离在线库,目的是让在线库变瘦变快。本篇讲的就是它。
- 备份:数据丢了能恢复,目的是救命。《数据库备份与灾难恢复指南》专讲这个。
- 合规保留期:法律要求某些数据至少存多久(等保 2.0 要求日志留存不少于 6 个月),目的是别违法。
8.1 归档让备份重新变便宜
归档对备份是纯利好:在线库从 2TB 瘦到 300GB,pg_dump 从 4 小时变 20 分钟,恢复演练的 RTO 同比例下降。而归档出去的 Parquet 是不可变文件——写一次、校验一次,之后只用做完整性抽查,不用每天增量备份。备份策略从"每天全量 2TB"变成"每天全量 300GB + 每月新增几个 Parquet",账单和时长双降。
8.2 保留期是三层的,别混成一层
| 层 | 管什么 | 本篇的落地 | 示例 |
|---|---|---|---|
| 在线保留(热) | 在线可查多久 | 应用层路由的 HOT_DAYS | 90 天 |
| 分区保留(温) | detach 后在库里留多久 | pg_partman 的 retention | 6 个月 |
| 合规保留(冷) | 法律要求至少存多久 | Parquet 在 S3 的生命周期 + 下面的策略表 | 2 年 |
保留期谁来定?法务和产品定,工程只负责实现。把结论落到一张表里,别散落在某个人的脑子里:
CREATE TABLE retention_policies (
data_type text PRIMARY KEY, -- 'request_logs'
hot_days int NOT NULL, -- 在线保留天数
warm_months int NOT NULL, -- detach 后在库保留月数
cold_years int NOT NULL, -- Parquet 在对象存储保留年数
legal_basis text NOT NULL -- 依据:等保2.0 / GDPR / 业务需要
);
INSERT INTO retention_policies VALUES
('request_logs', 90, 6, 2, '等保2.0日志留存≥6个月;GDPR数据最小化');
归档作业的"销毁"步骤,必须读这张表:cold_years 没到,Parquet 不许删;warm_months 没到,detach 下来的表不许 DROP。把"能不能删"变成查表,而不是看运维的心情。
8.3 归档不是 GDPR 的免死金牌
最容易踩的合规坑:用户行使删除权(GDPR 第 17 条/《个人信息保护法》),你把在线库删了,以为完事了——但 S3 里躺着三年前的 Parquet,里面还有他的 user_id 和行为日志。归档数据同样在删除义务范围内。
实操做法:删除请求进来时,热表按 user_id 删;温表(detach 未销毁)同样删;冷 Parquet 两种处理——数据量小时重写文件(读出来、过滤掉该用户、写回去、更新 sha256),数据量大时记 tombstone,下次归档/压缩时过滤。无论哪种,archive_jobs 里记一笔,审计时拿得出来。别等法务来问"你们归档里还有没有这个用户的数据"时答不上来。
九、落地检查清单与常见坑
动手前逐项打勾:
- [ ] 第一节的四个信号,中了两个再动手,别为归档而归档
- [ ] 分区键 = 查询必带 + 单调递增 + 时间列,三条全满足
- [ ] 主键/唯一约束包含分区键(
(id, created_at)不是(id)) - [ ]
enable_partition_pruning = on,并用 EXPLAIN 验证裁剪生效 - [ ] pg_partman 的
retention_keep_table = true——让它只 detach 不删除,删除是归档作业的事 - [ ]
archive_jobs状态机 + 排他锁 + 失败告警,三件套齐了再上线 - [ ] 先在 staging 完整跑一轮:detach → 导出 → 校验 → 销毁 → 回查
- [ ] 冷恢复演练进年度计划,一年至少一次
常见坑Top 5:
- 分区数量失控:按天分区 × 保留 3 年 = 1095 个分区,规划器每次都要过一遍分区列表,查询计划变慢。从按月起步,真不够再细化。
- default 分区悄悄膨胀:分区键为 NULL 或落在所有边界外的数据会进 default 分区。定期检查 default 分区的行数,用
partition_data_proc把误入的数据清到正确分区——否则 default 就是下一个"撑爆的表"。 - DETACH/ATTACH 的锁:DETACH 本身毫秒级,但要拿锁;如果有长事务占着表,DETACH 会排队。归档作业永远在低峰期跑,拿锁加
lock_timeout,拿不到就下一轮。 - 归档作业和 pg_partman 抢活:分工写死——partman 只 detach,作业只搬运。如果两边都能删表,某天你会发现一张表被删了两次(一次报错,一次成功),或者该删的没删。
- 忘了 ORM:分区父表对 ORM 透明,但
archiveschema 的表和 UNION 视图要单独映射。写操作永远只走热表 model,归档 model 只读。
结语
归档是 vibe 项目从"玩具"到"产品"的成人礼之一。表会胖,查询会慢,账单会涨——区别只在于,你是主动给它办健身卡(分区 + 归档作业 + 保留期),还是等它心梗进 ICU(磁盘满了、库挂了、备份跑不完了)再抢救。
从最小闭环开始:今晚跑一下第一节那条表大小 SQL,找到你最大的那张只增不减的表;这周末给它加上按月分区和 pg_partman;下个月让第一个 detach 的分区自动导出 Parquet。三个月后,你的备份时长、查询 p95 和云账单会告诉你,这事做得值。数据治理没有银弹,但"按月分区、按期归档、按表保留"这十二个字,够一个 vibe 项目用到上市。
评论 (0)
相关文章

AI 生成的表格代码是按 demo 数据规模写的:全量拉取卡死页面、假分页、前端排序悄悄错位。本篇讲一张表的交互工程:offset vs cursor 分页选型、排序列白名单、筛选同步 URL、JSONB 存列偏好、跨页批量选中、虚拟滚动测试方法,以及 12 项上线验收清单。

prompt 是建议,不是约束。AI 结构化输出可靠性的四层防线:模型原生结构化输出(OpenAI vs Anthropic,官方文档核实)、zod 校验的严格/宽松取舍、把报错原文喂回的修复重试、按成本选的降级兜底。

功能开关把部署和发布解耦:代码先上,功能后开。四种开关类型与选型矩阵、一张表的最小实现、兜底铁律(新功能默认关、止血开关冻结)、开关腐烂治理,以及开关与灰度发布如何配合。