返回探索
指南VibeFix 编辑部更新于 2026年10月17日

表撑到一亿行之前: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 或回灌,链路最长。

三种归档策略对照条形图:A 原生分区表 + pg_partman、B 归档表 + UNION 视图、C Parquet 冷存储导出,六个维度 1–10 分定性评分

图 1:三种归档策略定性对照(1–10 分,越高越好;成本类维度已取反)。分数是选型讨论用的示意,不是基准测试。

维度A 原生分区 + pg_partmanB 归档表 + 视图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 分区键三原则

一个好的归档分区键必须同时满足三条:

  1. 查询都带它——分区裁剪(partition pruning)只对 WHERE 里出现分区键的查询生效。如果你的查询从不按时间过滤,分了区也白分。
  2. 单调递增——新数据永远只进最新的分区,旧分区一旦写满就再也不变,"切时间片"才干净。
  3. 边界清晰——归档的本质是"扔掉/搬走一段时间的数据",分区边界必须和归档边界对齐(按月归档就按月分区)。

对 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 归档作业状态机

归档作业最怕两件事:跑到一半崩了不知道哪些分区搬了没搬;两个实例同时跑把同一个分区搬两遍。解法是状态机 + 排他锁:

归档作业状态机流程图:一次分区归档的完整生命周期(定时触发 → 前置检查 → 排他锁 → DETACH → 导出 Parquet → 校验 → 登记元数据 → 合规保留期 → 销毁分区表)

图 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()
);

幂等三件套,缺一不可:

  1. 状态先行:每个状态转移都是"先 UPDATE archive_jobs SET status=... 再执行动作"。崩溃重启后,worker 按 status 继续,没做完的重做,做完的跳过。
  2. 分区级排他锁:开工先 SELECT pg_advisory_xact_lock(hashtext('request_logs_p2025_03_01')),同一分区同一时刻只有一个 worker 能拿到锁,第二个直接等待或跳过。事务级锁随事务结束自动释放,不怕 worker 崩了留死锁。
  3. 交接物检查: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 下来的表移到 archive schema 放着就行,别为了"架构优雅"硬上对象存储。

六、归档后查询路由:热查走分区表,冷查走归档

归档之后,查询要分流。原则只有一条:在线接口永远只查热数据,冷数据查询走专门的慢通道。

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_DAYS90 天
分区保留(温)detach 后在库里留多久pg_partman 的 retention6 个月
合规保留(冷)法律要求至少存多久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:

  1. 分区数量失控:按天分区 × 保留 3 年 = 1095 个分区,规划器每次都要过一遍分区列表,查询计划变慢。从按月起步,真不够再细化。
  2. default 分区悄悄膨胀:分区键为 NULL 或落在所有边界外的数据会进 default 分区。定期检查 default 分区的行数,用 partition_data_proc 把误入的数据清到正确分区——否则 default 就是下一个"撑爆的表"。
  3. DETACH/ATTACH 的锁:DETACH 本身毫秒级,但要拿锁;如果有长事务占着表,DETACH 会排队。归档作业永远在低峰期跑,拿锁加 lock_timeout,拿不到就下一轮。
  4. 归档作业和 pg_partman 抢活:分工写死——partman 只 detach,作业只搬运。如果两边都能删表,某天你会发现一张表被删了两次(一次报错,一次成功),或者该删的没删。
  5. 忘了 ORM:分区父表对 ORM 透明,但 archive schema 的表和 UNION 视图要单独映射。写操作永远只走热表 model,归档 model 只读。

结语

归档是 vibe 项目从"玩具"到"产品"的成人礼之一。表会胖,查询会慢,账单会涨——区别只在于,你是主动给它办健身卡(分区 + 归档作业 + 保留期),还是等它心梗进 ICU(磁盘满了、库挂了、备份跑不完了)再抢救。

从最小闭环开始:今晚跑一下第一节那条表大小 SQL,找到你最大的那张只增不减的表;这周末给它加上按月分区和 pg_partman;下个月让第一个 detach 的分区自动导出 Parquet。三个月后,你的备份时长、查询 p95 和云账单会告诉你,这事做得值。数据治理没有银弹,但"按月分区、按期归档、按表保留"这十二个字,够一个 vibe 项目用到上市。

阅读 0评论 0

评论 (0)

ME
0/1000
评论加载中...
浏览项目广场发布你的项目

相关文章

深色后台数据表格实拍:带复选框选择、可排序列、搜索与刷新控件的用户列表
指南
别再一次性全拉出来:vibe 项目的数据表格实战

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

数据工作流性能优化后端工程
实拍:程序员紧盯显示器上的代码逐行检查
指南
别再 JSON.parse 裸奔:AI 结构化输出的校验与修复实战

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

后端工程项目创作独立开发
实拍:工业控制面板上的红色急停按钮
指南
上线不靠祈祷:vibe 项目的功能开关与应急关停实战

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

部署上线后端工程项目创作