主题
数据库运维手册
创建:2026-08-13 覆盖:日常节律 · 重大变更 · 事故分级与处置 · 演练周期 不重复已有文档,只做总纲与指路:
- 备份/恢复的操作步骤 →
supabase-backup-restore-runbook.md- 表结构真相源 →
database-schema.md+supabase/sql/sync-tables.sql- 迁移纪律(append-only / schema.sql 冻结)→
CLAUDE.md§5- 跨端协调协议 →
CLAUDE.md§9- 事故的执行流程 →
/db-incidentskill(.claude/skills/db-incident/)
1. 这套系统的数据长什么样
| 层 | 位置 | 丢失后果 |
|---|---|---|
| 客户端本地 SQLite | 每台设备各一份(lampio.db) | 单设备损失,可从云端 pull 回来 |
| Supabase Postgres | jdtbyteiwnciqnfppztz | 🔴 全部用户的学习数据,不可逆 |
| Supabase Storage | reading-snapshots/{user_id}/… | 🔴 阅读快照正文,不在 pg_dump 里 |
| 本机备份 | ~/Backups/lampio/ | 上面两者的唯一副本 |
🔴 一条必须刻在脑子里的事实:supabase backups list 实测 walg=true / pitr=false / earliest=0 / latest=0 —— 这个 project 没有任何可恢复的平台自动备份。 「免费版有 7 天兜底」不成立。我们自己那份就是全部。
2. 🔴 这个项目的「误删」有两种,处理方式相反
搞混这两种会做出恰好相反的动作,所以放在最前面。
类型 A:应用层软删(占绝大多数)
用户在 app 里删了笔记本 / 生词 / 来源页。10 张同步表全部是软删 —— 数据行还在,只是 deleted_at 被写了值。
数据没丢,不需要备份。 但恢复它有一个非显然的陷阱:
⚠️ 只在云端把
deleted_at清成 NULL 是无效的,而且会被自动撤销。
sync/pull/的三分支里有一条贯穿全部 10 张表的规则: 「remote 活 + 本地已删 → skip。本地墓碑靠 push 传播,绝不被远端活行复活。」所以流程会是:你清了云端墓碑 → 客户端下一轮 pull 跳过这条远端活行(本地墓碑优先) → 紧接着 push 又把本地墓碑推上云 → 你的恢复被自动抹掉,而且 60 秒内就发生。
正确做法二选一:
- 让客户端先离场:目标设备登出(
clear_learning_data_if_user_changed会清本地学习数据), 清云端墓碑,再登录做一次全量 pull。代价是该设备的本地未同步改动会丢。 - 两端同时清:云端清
deleted_at的同时,直接改客户端本地 SQLite 的同一行 (deleted_at = NULL,且synced_at要小于updated_at让它变脏推上去)。 适合只有一两台设备的情况。
类型 B:物理删除(罕见但致命)
DROP TABLE / DELETE FROM 忘了 WHERE / project 被删 / 迁移写错。 数据真的没了,只能靠备份。走 runbook §4(部分数据)或 §5(整库)。
判定方法
sql
-- 行还在吗?(软删的行 count(*) 仍能数到)
select count(*) filter (where deleted_at is null) as 活,
count(*) filter (where deleted_at is not null) as 墓碑,
count(*) as 总计
from user_learning_entries where user_id = '<uid>';总计不变 = 类型 A;总计变少 = 类型 B。
2b. 触发应急的场景清单
⚠️ 本节的核心认知:这套系统的事故绝大多数是静默的 —— 不报错、不崩溃, 数据只是悄悄不一致。等到"看起来出事了",往往已经跑了很久。 所以下表的**「症状」列比「处置」列更重要** —— 认不出症状,流程再好也用不上。
🟥 = 真实发生过 · 🟨 = 有红线专门防它(说明踩过或差点踩) · ⬜ = 理论风险
A. 同步引擎类(本架构最独特,也最静默)
| 场景 | 症状(用户会怎么说) | 判据 | 处置要点 | |
|---|---|---|---|---|
| 🟥 | watermark 跑超 → 永久漏数据 | 「我在手机上存的词,电脑上没有」 没有任何报错 | 查 settings.last_sync_at,与云端 MAX(server_updated_at) 比 | 🔴 回退 watermark 重拉,不是去修数据。数据在云端好好的,是客户端跳过了它。红线 #5/#5a/#5d |
| 🟥 | 墓碑回声环 | 没人报障。表现为配额异常消耗 + 对端每轮重复拉同一批 | 某些行 deleted_at > synced_at 恒真,每 60s 被重推 | 按红线 #6d 四条子规则逐条查(W 写侧同值 / P1 禁本端时钟 / P2 取 MAX 且操作数非 NULL / P3 覆盖 push 侧 mark)—— 只查「有没有写 MAX」会漏掉直接写本端 now 的那一类(2026-08-03 实测 57 行每分钟重推;对端同款缺陷见 cross-end/37·38) |
| 🟨 | 跨用户数据泄漏 | 🔴 无人会报障,除非用户看到别人的词 | push 的 dirty-check 漏 user_id 过滤或漏外层括号 | 见下方「§2c 隐私事故」——处置逻辑完全不同 |
| 🟨 | 换用户/登出未清理 | 「登录后看到上个账号的东西」 | 本地表残留他人 user_id 行 | 红线 #5c。清本地 + 确认没 push 上去 |
| 🟨 | 软删被 sync 复活 | 「删掉的又回来了」 | 墓碑列缺失或 payload 恒写 null | 红线 #6/#6a/#6b |
B. 发版 / 变更类(可预防,靠流程不靠救火)
| 场景 | 症状 | 处置要点 | |
|---|---|---|---|
| 🟨 | Supabase DDL 晚于客户端上线 | 🔴 用户完全无感(本地照常用),但云端停止更新,换设备才发现全丢 | PostgREST 对未知列返回 PGRST204,整批 push 失败(不是跳过那一列)。立刻补 DDL 即恢复。预防见 §4.2 |
| 🟨 | 迁移链断裂 VersionMismatch | 「更新后某功能没了/打不开」 | 改了已发布迁移 → sqlx checksum 不符 → 整条链中止,pending 迁移永不执行。红线 #11 |
| ⬜ | 发版后发现严重 bug | —— | Updater 有人工闸门 promote-updater;已更新的用户只能靠再发一版修 |
C. 凭据 / 账号类(花钱或泄密)
| 场景 | 后果 | 处置要点 | |
|---|---|---|---|
| ⬜ | service_role key 泄漏 | 🔴 绕过全部 RLS,任意读写所有用户数据 | 立刻轮换 → 更新 admin 部署 env。不需要发客户端版(客户端不用它) |
| ⬜ | anon key 泄漏/需轮换 | RLS 仍在,危害有限,但项目身份暴露 | 🔴 轮换 = 必须重新发版(编译期常量),且 RVH 要过商店审核。已知:旧 key 仍在 git 历史与已发版二进制里 |
| ⬜ | LLM API key 泄漏 | 账单被刷 | 立刻在服务商侧吊销 → supabase secrets set 换新 → 查 llm_call_log 估损失 |
D. 容量 / 平台类(当前余量充足,列出供将来对照)
2026-08-13 实测:数据库 17 MB / 500 MB(3.4%)、Storage 434 KB / 1 GB、 daily-analyze-articles + weekly-discover-sites 两个 cron 在跑 (→ 项目不会因"不活跃"被免费版暂停)。
唯一持续增长且没有保留策略的是 llm_call_log:16 天 2572 行 / 1272 kB, 外推约 30 MB/年。短期无威胁,但它是将来第一个撞配额的表,值得先加个保留策略。
E. 运维自身类(最讽刺,也最常见)
| 场景 | 症状 | 判据 | |
|---|---|---|---|
| ⬜ | 备份悄悄停了 | 🔴 完全无症状,直到你需要它 | Keychain 被锁 / 数据库密码轮换 / 磁盘满 / launchd 掉了。唯一防线是 --check 巡检 |
| ⬜ | 备份恢复不回去 | 出事时才发现 | schema 演进后备份失效。唯一防线是定期演练 |
2c. 隐私事故(跨用户泄漏)—— 处置逻辑与数据丢失相反
数据丢失是「怎么把东西找回来」;隐私事故是「东西已经到了不该去的地方,找回来也不算完」。
红线 #5i 的场景:push 的 dirty-check 若漏 user_id 过滤(或漏外层括号,使 AND 结合优先级让"改过""软删"两支绕开过滤),任何遗留在本地、不属于当前登录用户的行, 都会被以当前用户身份 upsert 到云端。静默、无报错。
处置顺序与数据丢失不同:
- 止血(同 §5)
- 划定影响范围:哪些
user_id的数据出现在了哪些其他账号下、时间窗多长 - 清理:删除错误归属的行 —— 注意这些行在云端可能已被对方设备 pull 下去, 清云端不等于清干净
- 评估告知义务:真实外部用户受影响时,这是合规问题不是技术问题
- 修根因 + 回归测试:⚠️ 红线明说「只测新增行抓不到」—— 必须造「已同步过、之后又被改/被软删的外来行」才能复现
3. 日常节律
| 频率 | 动作 | 命令 |
|---|---|---|
| 每天 04:15 | 服务端巡检 + 心跳检查 + 凭据到期(自动,Supabase pg_cron) | daily-db-audit → 异常发 Sentry |
| 每 30 分钟 | 站点可达性探测(自动,pg_cron 两步法) | probe-endpoints → 连续失败发 Sentry |
| 想看一眼时 | 打开 /admin/ops(看板,非告警) | 备份 / 巡检存活 / 容量 / 发布链路 / 凭据,见 §3.3 |
| 每周日 03:30 | 全量备份 + 本机巡检 + 上报心跳(自动,已装) | launchd app.lampio.supabase-backup |
| 每月 | 巡检备份新鲜度 | scripts/backup-supabase.sh --check |
| 动 schema 前 | 手动备份一次 | scripts/backup-supabase.sh |
| 随时(只读,想跑就跑) | 数据一致性巡检 | scripts/db-audit.sh |
| 每次 schema 变更后 / 发版前 | 恢复演练 | runbook §5-§6,约 20 分钟 |
| 季度 | 复核 supabase backups list 与配额 | 见 §5 |
3.1 一致性巡检 scripts/db-audit.sh
为什么它比应急流程更重要:§2b 里 A、B 两类事故全部是静默的,应急流程只能在 「你已经知道出事了」之后起作用。这个脚本负责的是让你知道 —— 把「几周后偶然发现」 变成「下周日自动告诉你」。
全部是 SELECT,不写任何一行,随时可跑。已挂在每周备份之后(&& 串联), 异常时弹系统通知。各组检查对应 §2b 的场景:
- 归属完整性(
user_id→auth.users)—— 主要防恢复事故:稳态下 FK 挡着, 但pg_restore --disable-triggers或恢复顺序错时 FK 不生效,孤儿行会真的出现 - 父子归属一致 —— 🔴 跨用户泄漏(红线 #5i)唯一可机器检测的特征
- 悬挂 FK(子行指向不存在的父行) 3b. 活子行挂在墓碑父行下(红线 #6a)—— 与上一条不是同一件事:读侧查询都带
deleted_at IS NULL,这种孤儿在两端 UI 里都不可见,2026-08-26 补这组之前 没有任何自动检查会说话 - 墓碑回声环(红线 #6d)—— 服务端没有
synced_at,用「墓碑行updated_at很旧但server_updated_at很新」这个特征代替 - push 活跃度 —— 停摆多半是
PGRST204让整批 push 挂了 - Storage 指针完整性 —— 指针在但字节没了 = 用户点「回原文」空白
- 备份新鲜度 —— 顺带查,避免「以为有备份」
2026-08-13 自检:往演练库注入三类真实异常(跨用户归属、悬挂 FK、回声环), 三条全部报红、退出码 1。一个永远返回 0 的检查比没有检查更危险, 改动这个脚本后请照样注入验证一次。
3.2 服务端监控 supabase/sql/monitoring.sql(2026-08-13 上线)
同一套判据也跑在 Supabase 上(pg_cron 每天 04:15),异常聚合成一条发 Sentry。 本机脚本没有废弃,降级为「手动 / 事故时用」—— 事故时你需要一个不依赖被怀疑组件 的独立诊断工具。
🔴 为什么必须有服务端这一份:心跳倒置(dead man's switch)
「备份新鲜度」这条检查在本机跑是逻辑上无效的 ——「我的电脑检查我的电脑有没有 备份」,而电脑关机时既不备份也不检查,所以永远不会告警。你出去休假两周,回来 才发现备份早停了。
修法是把检查方向倒过来:
本机备份成功 → POST 一条心跳到 ops_heartbeat
↓
服务端 pg_cron 每天检查心跳是否陈旧(> backup_max_age_hours,默认 192h)
↓
陈旧 → Sentry 告警这样「电脑没开机 → 没备份 → 没心跳 → 服务端告警」,恰好在本地方案失效的场景下生效。
心跳只在完整成功的备份后上报(--db-only / --storage-only 不报), 否则心跳会掩盖「其中一半一直在失败」。
组成
| 对象 | 作用 |
|---|---|
ops_heartbeat | 外部任务报平安(local-backup) |
ops_audit_findings | 巡检留痕(保留 90 天),便于回看「这问题存在多久了」 |
ops_monitor_config | 告警开关 / 阈值 / Sentry DSN 三段 |
run_db_audit() | 巡检本体,与 db-audit.sh 同源同判据 —— 改一处请两处都改 |
ops_daily_monitor() | 巡检 + 心跳检查 + 聚合成一条告警(不是每条一封) |
ops_notify() | pg_net → Sentry store 端点 |
cron daily-db-audit | 每天 04:15 |
cron weekly-prune-findings | 每周一清理 90 天前的留痕 |
三张 ops_* 表都开了 RLS 且不给任何 policy = 除 service_role 外全拒。 实测 anon key 读三张表均返回空。
告警通道
复用已接入的 Sentry,不新引入 Resend/SendGrid(那意味着新账号、新 key、 新的会过期会失效的东西)。实测 pg_net → Sentry store 端点 HTTP 200。
DSN 的 public key 本就随客户端二进制分发,不算密钥,但仍不写进仓库 —— monitoring.sql 只建配置表,DSN 三段单独 seed(该文件 §6 有命令)。
手动操作
sql
select ops_daily_monitor(); -- 立刻跑一次
select * from ops_audit_findings order by id desc; -- 看历史
update ops_monitor_config set value='false' where key='alert_enabled'; -- 临时静音⚠️ 已知边界
监控自己会挂。→ 2026-08-14 已补,见下 §3.3。原文保留以说明补法的由来: pg_cron job 失败了谁告诉你?巡检里有一条查cron.job_run_details近 3 天失败 (覆盖 analyze-articles / discover-sites),但它查不了自己。- 告警会重复。 问题不修就每天一封。这是刻意的(还坏着就该继续吵), 但真遇到长期不修的项,用限时静音(§3.3)而不是删检查。
3.3 admin /admin/ops —— 第二观察者(2026-08-14)
上面那套的唯一出口是 Sentry 告警(push)。缺的是 pull:想知道「现在健不健康」 时能看一眼。对本项目这个缺口尤其贵 —— 事故绝大多数是静默的,于是「没收到告警」有两种 无法区分的含义:一切正常,或者监控自己死了。
🔴 admin 恰好能补上 SQL 侧补不了的那一环:它是独立于 pg_cron 的进程, 读一眼 ops_heartbeat['db-audit'] 的 last_seen_at 就知道巡检是不是几天没跑了。 超过 36h 没有心跳 = daily-db-audit 已停。
⚠️ 不要改回
max(ops_audit_findings.checked_at)(2026-08-17 用过,已废弃)。run_db_audit()只在where c > 0时写 findings 行 —— 全绿的一次巡检不留任何痕迹, 于是「跑了、很健康」与「根本没跑」在数据上完全同形,这张卡在健康系统里永远只能报 unknown。现在run_db_audit()每次运行都无条件 upsert 一条心跳: **发现可以为空,心跳不可以。**不需要 Sentry cron check-in (其免费额度仍未评估,但现在也不必评估了)。
页面读的全是既有产物,不新增第三份检查判据(检查判据仍只有 run_db_audit() ↔ db-audit.sh 那一对)。
⚠️ 要分清两种「判据」,它们各有各的唯一真相源,别互相冒充。
- 检查判据(什么算异常)=
run_db_audit()↔db-audit.sh那一对。admin 只读它的产物。- 呈现判据(这盏灯该是什么颜色、多旧的数据还算数)=
admin/lib/ops-status-rules.ts。 它同样只能有一份:卡片、总览条、首页横幅调同一个函数。2026-08-26 之前它散在各个 组件的 JSX 里,于是同一件事在卡片和总览上判出两个颜色(admin 红线 9)。
| 卡片 | 数据源 |
|---|---|
| 本机备份心跳 | ops_heartbeat + backup_max_age_hours |
| 巡检自身还活着吗 | ops_heartbeat['db-audit'].last_seen_at(不是 findings 的 max —— 见上方警告) |
| 巡检发现 | ops_audit_findings 近 30 天,按 check_name 聚合 + 首次出现时间 |
| 容量水位 / 定时任务 | ops_platform_stats()(新增 RPC) |
| 站点可达性 | ops_probe_state ← ops_probe_endpoints()(新增)。🔴 判据含 last_checked_at 的新鲜度:超过 3 个探测周期没有新结果即 unknown —— 探测停了这张表会冻在 200 / failures=0,只看失败计数会永远绿 |
| 凭据到期 | ops_credentials(新增表) |
| 发布链路一致性 | GitHub 公开仓 + updater latest.json + landing /api/health |
另有两格判的不是某张表,而是「监控自己通不通」:
| 格 | 判据 |
|---|---|
| 定时任务 | ops_platform_stats() 的 cron_jobs 对照声明表核对集合 —— 被 unschedule 的任务会直接从 cron.job 消失,只看返回列表的话它「不存在」= 不会有任何一行变红。容差从各自的 schedule 现推 |
| 告警通道 | ops_monitor_config 的 sentry_host / sentry_project_id / sentry_public_key + alert_enabled —— 即 ops_notify() 真正读的那几个值,外加与本站 DSN 的指向核对。⚠️ 不是 NEXT_PUBLIC_SENTRY_DSN(那是 admin 浏览器 SDK 的,与 DB 告警毫无关系;2026-08-26 前这盏灯一直接在它上面,三段全改烂也照样绿) |
写入面只开三样:白名单阈值、限时静音、手动跑一次只读巡检。 🔒 Sentry 通道三段(host / project_id / public_key)刻意不给界面入口 —— 改错它等于静默失去全部告警,而后果恰恰是收不到告警。界面上只有只读的健康显示。
⚠️ 「手动跑一次」只调 run_db_audit()。backup_heartbeat / credential_expiry / credential_review 住在 ops_daily_monitor() 里(它会发告警,故不给按钮), 那个按钮永远不会刷新这三项 —— 界面文案已写明,别再据它判断备份和凭据刚查过。
静音必须带时长(alert_muted_until,到点自动恢复,UI 只给 1/8/24h): 忘记恢复的静音比没有告警更危险,因为你以为有告警。静音只挡「发」不挡「查」, 巡检照常留痕。
🔒 为什么这一页的数据走鉴权 Server Action 而不是 Server Component 直读
2026-08-14 实测确认:admin 的鉴权是纯客户端的(AuthGuard 是 "use client", session 存 localStorage)。Server Component 在渲染时就把数据序列化进 RSC flight payload —— 未登录请求同样拿得到(curl /admin/observability 无任何凭据即可取到运营数据)。
ops 页含凭据台账(每个密钥存在哪里 = 给攻击者的地图),故改成 「空壳页 + loadOpsSnapshot(token) 走 requireAdmin 验签」。 回归守门:admin/tests/e2e/auth-gate.spec.ts 断言未登录响应里不含任何运维数据。
⚠️ 其余 dashboard 页仍是老写法(analytics / observability / vocabulary / settings), 且 app/actions/{recommendations,recommend-config,vocabulary}.ts 同样没有服务端鉴权 —— 存量缺口,根治要把 session 从 localStorage 迁到 cookie(@supabase/ssr)。
为什么「动 schema 前必须手动备一次」:迁移是本仓唯一会批量、不可逆改动生产数据的 动作。上一次自动备份可能已是 6 天前,中间的用户数据没有任何保护。这一条成本 70 秒。
为什么演练要跟着 schema 走:备份能否恢复会随 schema 演进失效。2026-08-13 的演练结论 只对当时的 schema 有效 —— 新增一张表、改一个约束,恢复路径就可能出现新的失败点。
4. 重大变更流程
4.1 本地迁移(migrations.rs)
schema.sql(v1) 自 2026-07-26 永久冻结(红线 #11)。新表新列只进新编号迁移 (下一个 v33),用 CREATE TABLE IF NOT EXISTS / ADD COLUMN 幂等加法式。 守门:scripts/check-schema-frozen.mjs + migrations.rs::flattened_baseline_matches_terminal。
4.2 Supabase 侧 DDL
🔴 顺序不能反:Dashboard 执行 DDL 必须先于带新列 payload 的客户端上线。 PostgREST 对未知列返回 PGRST204,会让整批 push 失败(不是跳过那一列)。
历史教训:v23(deleted_at)、v29(recommended_articles 正文列)、v30 都踩过这条顺序。
4.3 涉及 10 张同步表时
按 CLAUDE.md §9「共享表变更检查清单」走: migrations.rs → supabase/sql/sync-tables.sql → database-schema.md → sync.rs push/pull → RVH 新会话对齐。
变更后跑 /cross-end-check 查三端漂移。
4.4 变更前后的备份闸
备份(手动跑一次)→ 变更 → 验证 → 若失败:按 §2 判定类型 → 走 /db-incident5. 事故分级
| 级别 | 判据 | 首要动作 |
|---|---|---|
| P0 | 数据物理丢失或正在扩大:表被 DROP、DELETE 无 WHERE 已提交、project 不可用、迁移改错数据 | 立刻止血(见下),然后走 /db-incident |
| P1 | 数据还在但可见性/一致性异常:软删误传播、sync 复活、watermark 漏数据、跨端数据对不上 | 先只读诊断,别急着写 |
| P2 | 单用户问题、配额、性能、Edge Function 报错 | 正常排查 |
🔴 P0/P1 的第一动作永远是止血
这个架构里 sync 是双向的、60 秒一轮、跑在每台登录设备上。一个错误状态不会静静待着 —— 它会在一分钟内被推到云端,再被拉到其他设备。先切断传播,再谈修复。
止血手段(按影响面从小到大):
- 让受影响设备登出(停掉该端的自动同步循环)
- 若是云端数据被污染:临时把相关表的 RLS policy 改成拒绝写入,阻止所有客户端 push
- 最坏情况:Supabase Dashboard 暂停 project
在止血之前,不要做任何"我先修一下看看"的写操作。
6. 处置总原则(P0/P1)
止血 → 取证 → 只读诊断 → 定点修复 → 验证 → 复盘- 取证先于修复:救援动作本身会破坏现场。动手前先跑一次
scripts/backup-supabase.sh(哪怕数据已经不全)—— 这份"事故现场快照"能让你在修错时 还有退路,也是判断"到底丢了多少"的基准。 - 只读诊断:所有
select,一条update/delete都不要。产出应该是一句能说清的话: 「X 张表的 Y 行,在 T 时刻被 Z 动作影响」。 - 定点修复:用
INSERT ... ON CONFLICT DO UPDATE,永远不要DELETE + INSERT—— 后者会把其他设备刚 push 上来的更新一起抹掉。 - 验证:修完必须验跨表完整性(孤儿 / 悬挂 FK),不是只看行数。
- 复盘:写进本文件 §8。
具体执行步骤见 /db-incident skill。
7. 恢复能力的现状与缺口
✅ 有:每周全量备份(Postgres + Storage 两份)、经实测验证可恢复、8 份滚动。
⬜ 缺口 1 — 异地副本:本机是唯一副本,硬盘坏了一起没。见 §9 建议。
⬜ 缺口 2 — RPO 一周:周日备份、周六出事 = 丢 6 天用户数据。 要缩短只能提高频率,或开 Supabase Pro 的 daily backup / PITR。
⚠️ RTO 的瓶颈不是数据库是发版:supabase_url/anon_key 是 build.rs 注入的编译期 常量,换 project-ref 意味着存量客户端连的还是死掉的旧 project。灾难恢复应优先 保住原 project-ref。详见 runbook §5.5。
8. 事故记录
每次 P0/P1 事故在此追加一条:时间 / 现象 / 根因 / 处置 / 防复发动作。
(暂无。2026-08-13 的恢复演练不算事故,记录在 runbook §6。)
9. 备份策略演进路线
按成本/收益排序,不必一次做完:
| 阶段 | 动作 | 成本 | 解决什么 |
|---|---|---|---|
| ✅ 已做 | 本机 launchd 每周备份 + 一致性巡检 + 演练验证 | 0 | 平台侧不可逆丢失 |
| ✅ 已做(待你设口令启用) | gpg AES-256 加密后推一份到 iCloud Drive | 0,无新信任方 | 异地副本 |
| 有营收后 | Supabase Pro 开 daily backup / PITR | ~$25/mo | RPO 从一周降到一天甚至分钟级 |
| 用户量上来后 | 常开设备(NAS/VPS)跑备份 | 硬件或 ~$5/mo | 彻底去掉本机开机依赖 |
异地副本怎么启用
bash
security add-generic-password -U -a "$USER" -s lampio-backup-passphrase -w设完口令,下次备份就会自动多产出一个 ~/Library/Mobile Documents/com~apple~CloudDocs/LampioBackups/lampio-<时间戳>.tar.gz.gpg (保留 4 份,可用 BACKUP_OFFSITE_DIR / BACKUP_OFFSITE_KEEP 改)。 没设口令 = 静默跳过,不算失败。
🔴 口令丢了 = 异地副本永久打不开。存进密码管理器,别只放 Keychain (Keychain 跟着这台机器;而异地副本存在的意义正是这台机器没了)。
解密还原:
bash
gpg --decrypt lampio-<时间戳>.tar.gz.gpg | tar xzf - -C <目标目录>实测:112 KB 密文、PGP symmetric key encrypted data - AES 256、 密文中零可读关键词、完整解密还原后与本机备份逐字节一致。
关于「本机要开机」这条:没有想象中严重 —— launchd 的 StartCalendarInterval 在错过 执行时刻后会在机器下次唤醒时补跑,所以短期休眠只是延迟,不是跳过。真正会漏的是 「整周关机」(如长假)。--check 巡检就是为这种情况准备的。
为什么不建议 GitHub Actions(重新评估后仍不推荐,但理由要说准):它确实能解决开机依赖, 但要把生产连接串 + service_role key 放进 GitHub Secrets,且每周把全部用户数据拉进托管 runner 解密处理。对一个隐私敏感产品,这是引入一个新的信任方去解决一个「加密推云盘」 就能解决的问题(异地),而开机依赖本身又没那么严重。若将来真要上,务必先建一个只读 Postgres 角色专用于备份,别把 postgres 超级用户的连接串放进任何 CI。