从零理解——确保每一步操作都可撤回的 5 层防线
在 SaaS 产品中,数据是一切的根基。一次误操作可能导致:
每一种情况需要不同的恢复手段,这就是为什么需要多层防线。
WAL(Write-Ahead Log,预写式日志)是 PostgreSQL 的核心机制:
当你执行任何数据变更(INSERT / UPDATE / DELETE)时,PostgreSQL 不是直接修改磁盘上的数据文件,而是:
这就是"Write-Ahead"的含义——先写日志,后改数据。
为什么要这样设计?
PITR(Point-in-Time Recovery)就是利用"基础备份 + WAL 归档"实现的"时间机器"。
如果你只有每天凌晨 3 点的 pg_dump 逻辑备份:
对于 SaaS 业务,这可能意味着丢失了几十个预约、多笔交易、新注册的客户……
| 工具 | 原理 | 核心优势 | 适合场景 |
|---|---|---|---|
| pgBackRest | 独立的备份管理工具,支持全量/增量/差异备份 + WAL 归档 | 增量备份(只备份变化的部分)、并行压缩、S3 原生支持、自动验证备份完整性 | 生产环境首选,功能最完善 |
| WAL-G | Uber 开源的 WAL 归档工具,轻量级 | 体积小、配置简单、支持 S3/GCS/Azure、适合容器化部署 | Docker/ECS 环境(你们的场景) |
| pg_basebackup | PostgreSQL 内置命令,做全量物理备份 | 无需安装额外工具、PostgreSQL 原生支持 | 小规模数据库、简单场景、入门学习 |
# postgresql.conf(通常在 /etc/postgresql/15/main/ 或数据目录下) # 1. 设置 WAL 级别为 replica(记录足够的信息用于恢复和复制) wal_level = replica # 2. 开启归档模式(将 WAL 文件自动存档到指定位置) archive_mode = on # 3. 归档命令(将 WAL 文件复制到归档目录) # %p = WAL 文件的完整路径 # %f = WAL 文件名 archive_command = 'cp %p /var/lib/postgresql/wal-archive/%f' # 4. 允许流式复制连接(用于热备份) max_wal_senders = 3
wal_level = replica:让 WAL 记录更详细的信息(默认的 minimal 级别不够恢复用)archive_mode = on:告诉 PostgreSQL "写满的 WAL 文件不要删掉,要存起来"archive_command:指定怎么存。可以是简单的 cp,也可以用 aws s3 cp 传到云端max_wal_senders:允许几个并发的备份连接(pg_basebackup 和 WAL 流式复制都需要)# 使用 pg_basebackup 创建一个完整的物理备份 pg_basebackup \ -h localhost \ -p 5433 \ -U postgres \ -D /var/backups/pg-base-$(date +%Y%m%d) \ -Ft \ # tar 格式输出 -z \ # gzip 压缩 -P # 显示进度
# 1. 停止 PostgreSQL sudo systemctl stop postgresql # 2. 清空当前数据目录(危险操作!确保有备份) rm -rf /var/lib/postgresql/15/main/* # 3. 从基础备份恢复 tar xzf /var/backups/pg-base-20260216/base.tar.gz -C /var/lib/postgresql/15/main/ # 4. 创建恢复配置文件 cat > /var/lib/postgresql/15/main/recovery.signal # 5. 在 postgresql.conf 中指定恢复目标 # restore_command = 'cp /var/lib/postgresql/wal-archive/%f %p' # recovery_target_time = '2026-02-16 10:15:00+00' ← 恢复到这个时间点 # 6. 启动 PostgreSQL(会自动开始恢复流程) sudo systemctl start postgresql
上面的手工步骤会被简化为一条命令:
pgbackrest restore \ --stanza=celoria \ --type=time \ --target="2026-02-16 10:15:00+00"
pgBackRest 自动处理:定位最近的基础备份 → 复制数据文件 → 重放 WAL → 停在目标时间点。
| 对比维度 | 物理备份 (pg_basebackup) | 逻辑备份 (pg_dump) |
|---|---|---|
| 备份内容 | 二进制数据文件(磁盘级别) | SQL 语句 / 数据格式文件 |
| 备份速度 | 较快(直接复制文件) | 较慢(需要查询每张表的数据) |
| 恢复速度 | 很快(直接放回数据文件) | 较慢(需要重新执行 SQL / 重建索引) |
| 支持 PITR | ✅ 是(配合 WAL) | ❌ 否(只是快照) |
| 粒度控制 | 只能恢复整个数据库 | 可以恢复单张表、单个 Schema |
| 跨版本 | ❌ 必须同版本 PG | ✅ 可跨版本恢复 |
| 适合场景 | 全库灾难恢复、主从复制 | 单租户恢复、数据迁移、开发环境同步 |
结论:两者不是替代关系,而是互补关系。物理备份做全局兜底,逻辑备份做精细恢复。
位于 backend/services/tenant/backup-service.js,做了这些事情:
| 功能 | 实现方式 | 状态 |
|---|---|---|
| 租户 Schema 备份 | pg_dump -Fc -n tenant_xxx | ✅ 已实现 |
| S3 远程存储 | AWS SDK PutObject | ✅ 已实现 |
| SHA-256 校验 | 备份文件 checksum 验证 | ✅ 已实现 |
| 备份验证 | 恢复到临时 Schema 对比行数 | ✅ 已实现 |
| 过期清理 | 按天数 + 最大数量保留 | ✅ 已实现 |
| 审计追踪 | backup_started/completed/failed | ✅ 已实现 |
目前只备份单个租户的 Schema(如 tenant_spa001),但 public schema 里存放着:
tenants 表 — 所有租户的注册信息platform_settings — 平台级配置tenant_admins — 平台管理员features / plan_features — 功能权限矩阵如果 public schema 损坏,即使租户备份完好也无法知道要恢复哪些租户。
修复:增加全库 pg_dump 定时任务,每天至少一次。
当前代码中,executePgRestore() 的第一步是:
psql -c "DROP SCHEMA IF EXISTS tenant_xxx CASCADE"
这意味着:先把当前数据全部删掉,再用备份恢复。如果恢复过程中 pg_restore 失败了(网络中断、磁盘满了),你会同时丢失当前数据和恢复——两边都没了。
修复:恢复前自动创建一份"pre_restore"备份。
如果 Node.js 服务崩溃了,backup-job.js 的 cron 调度也会停止,意味着服务挂了的时候恰好也没有备份——而服务挂了往往正是你最需要备份的时候。
修复:使用系统级 cron 或独立的备份工具(pgBackRest 内置调度)。
改造 #001:砌一面墙把客厅分出书房改造 #002:在厨房装了水槽改造 #003:把书房的墙拆了改成开放式在 Celoria 项目中,我们使用 node-pg-migrate 管理数据库迁移。每个迁移文件包含两个函数:
// 一个典型的迁移文件结构 // exports.up = 向前迁移(应用变更) exports.up = (pgm) => { pgm.createTable('appointments', { id: { type: 'uuid', primaryKey: true }, guest_name: { type: 'varchar(100)', notNull: true }, start_time: { type: 'timestamptz', notNull: true }, }); }; // exports.down = 回退迁移(撤销变更) exports.down = (pgm) => { pgm.dropTable('appointments'); };
exports.down(回退函数) ✅有些数据库变更天然是不可逆的。比如:
| 操作 | 是否可逆 | 为什么 |
|---|---|---|
CREATE TABLE |
✅ 可逆 | 回退时 DROP TABLE 就行(空表没数据可丢) |
ADD COLUMN |
✅ 可逆 | 回退时 DROP COLUMN(如果新列还没写入数据) |
DROP COLUMN |
❌ 不可逆 | 列被删除后,里面的数据就没了,回退也恢复不了数据 |
ALTER COLUMN TYPE |
⚠️ 可能不可逆 | 如果从 varchar 改成 integer,非数字的值会丢失 |
DROP TABLE |
❌ 不可逆 | 表里所有数据都没了 |
UPDATE / DELETE 数据 |
❌ 不可逆 | 旧值被覆盖或删除,迁移回退无法恢复原始数据 |
生产环境删除一个列,不应该一步完成,而应该分三次迁移:
ALTER TABLE employees RENAME COLUMN old_email TO _deprecated_old_email;ALTER TABLE employees DROP COLUMN _deprecated_old_email;在 CI/CD 流程中,执行迁移前应该自动触发一次备份:
# 在部署脚本中 # 第一步:迁移前备份 pg_dump -Fc -f pre_migration_$(date +%Y%m%d_%H%M%S).dump # 第二步:执行迁移 npm run migrate:up # 第三步:验证迁移成功 npm run test:integration # 如果验证失败,可以: # 方案 A: npm run migrate:down(回退迁移) # 方案 B: pg_restore pre_migration_xxx.dump(从备份恢复)
前三层都是数据库级别的保护,但有些场景用数据库恢复是"杀鸡用牛刀":
这就是应用级保护要解决的问题。
-- ❌ 硬删除:数据直接消失,无法恢复 DELETE FROM guests WHERE id = 'abc-123'; -- ✅ 软删除:只是标记为"已删除",数据还在 UPDATE guests SET deleted_at = NOW(), deleted_by = 'employee-456' WHERE id = 'abc-123'; -- 恢复?直接把标记清掉 UPDATE guests SET deleted_at = NULL, deleted_by = NULL WHERE id = 'abc-123';
| 要素 | 说明 |
|---|---|
| 新增列 | deleted_at TIMESTAMPTZ DEFAULT NULL — NULL 表示未删除,有值表示删除时间deleted_by VARCHAR(255) DEFAULT NULL — 记录是谁删除的 |
| 查询过滤 | 所有常规查询都要加 WHERE deleted_at IS NULL,只返回"没被删除"的记录 |
| 唯一约束 | 如果表有唯一约束(如 email),需要把 deleted_at 加入约束条件,否则删除后同 email 无法再注册 |
| 定期清理 | 软删除的数据不是永远留着。通常保留 30-90 天后真正清理(GDPR 合规要求) |
| 适用表 | 高价值业务数据:guests(客户)、appointments(预约)、invoices(发票)、transactions(交易) |
-- 创建变更日志表 CREATE TABLE data_change_log ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), table_name TEXT NOT NULL, -- 哪张表 record_id TEXT NOT NULL, -- 哪条记录 operation TEXT NOT NULL, -- INSERT / UPDATE / DELETE old_data JSONB, -- 修改前的完整行(JSON 格式) new_data JSONB, -- 修改后的完整行 changed_by VARCHAR(255), -- 谁改的 changed_at TIMESTAMPTZ DEFAULT NOW() -- 什么时候改的 ); -- 用触发器自动记录(以 guests 表为例) CREATE OR REPLACE FUNCTION log_guest_changes() RETURNS TRIGGER AS $$ BEGIN IF TG_OP = 'UPDATE' THEN INSERT INTO data_change_log (table_name, record_id, operation, old_data, new_data, changed_by) VALUES ('guests', OLD.id, 'UPDATE', row_to_json(OLD)::jsonb, row_to_json(NEW)::jsonb, current_setting('app.current_user', true)); ELSIF TG_OP = 'DELETE' THEN INSERT INTO data_change_log (table_name, record_id, operation, old_data, changed_by) VALUES ('guests', OLD.id, 'DELETE', row_to_json(OLD)::jsonb, current_setting('app.current_user', true)); END IF; RETURN NEW; END; $$ LANGUAGE plpgsql; CREATE TRIGGER guests_change_trigger AFTER UPDATE OR DELETE ON guests FOR EACH ROW EXECUTE FUNCTION log_guest_changes();
-- 场景:有人误改了 guest abc-123 的手机号,要恢复 -- 1. 查看变更历史 SELECT operation, old_data->>'phone' AS old_phone, new_data->>'phone' AS new_phone, changed_by, changed_at FROM data_change_log WHERE table_name = 'guests' AND record_id = 'abc-123' ORDER BY changed_at DESC LIMIT 5; -- 结果: -- UPDATE | 1234567890 | 0000000000 | employee-456 | 2026-02-16 10:15:00 -- 2. 从 old_data 中恢复 UPDATE guests SET phone = '1234567890' -- 从 old_data 中读取原始值 WHERE id = 'abc-123';
不需要恢复整个数据库,不需要停机,秒级完成。
// 对高风险批量操作使用事务 + 预览确认模式 async function batchUpdatePrices(req, services) { // 开启事务 return await tenantDb.transaction(req, async (client) => { // 第一步:执行变更(还没提交) for (const svc of services) { await client.query( 'UPDATE services SET price = $1 WHERE id = $2', [svc.newPrice, svc.id] ); } // 第二步:查询变更后的结果 const preview = await client.query( 'SELECT id, name, price FROM services WHERE id = ANY($1)', [services.map(s => s.id)] ); // 第三步:返回预览(事务尚未提交) // 如果用户确认,事务自动提交 // 如果代码抛出异常或用户取消,事务自动回滚 return preview.rows; }); // ← 事务在这里自动提交或回滚 }
| 特性 | 英文 | 白话解释 |
|---|---|---|
| A | Atomicity(原子性) | 事务里的操作"全做或全不做",不存在做了一半的情况 |
| C | Consistency(一致性) | 事务前后,数据库从一个合法状态转到另一个合法状态(如余额不能为负) |
| I | Isolation(隔离性) | 并发的事务互不干扰,就好像在排队一个一个执行 |
| D | Durability(持久性) | 一旦事务提交成功,数据就持久保存,即使断电也不会丢失(靠 WAL 保证) |
DR Plan 的核心是两个指标:
| 指标 | 全称 | 含义 | 白话 |
|---|---|---|---|
| RPO | Recovery Point Objective | 可容忍的最大数据丢失量 | "我能接受丢失过去多长时间的数据?" RPO = 1小时 意味着最多丢 1 小时的数据 |
| RTO | Recovery Time Objective | 恢复所需的最长时间 | "从出事到恢复正常,最多能等多久?" RTO = 2小时 意味着 2 小时内必须恢复 |
遇到问题时,不同场景应该使用不同的恢复方式。以下是速查表:
| 场景 | 首选恢复方式 | 使用的防线层 | RPO | RTO |
|---|---|---|---|---|
| 误删单条记录(客户/预约) | 软删除恢复 SET deleted_at = NULL |
第 4 层 | 0 | 秒级 |
| 误改单条记录 | 从审计日志 old_data 恢复 | 第 4 层 | 0 | 秒级 |
| 误执行了错误的 UPDATE/DELETE | PITR 恢复到临时库 → 提取正确数据 | 第 1 层 | 分钟级 | 30min |
| 迁移脚本出错(DDL 变更) | npm run migrate:down |
第 3 层 | 0 | 分钟级 |
| 迁移脚本出错(含数据修改) | 迁移前备份恢复 | 第 2+3 层 | 0(迁移前备份) | 10-30min |
| 单租户数据损坏 | restoreBackup() Schema 级恢复 |
第 2 层 | 取决于备份频率 | 10min |
| 整库数据损坏(所有租户) | PITR 全量恢复 | 第 1 层 | WAL 归档延迟 (通常 <1min) |
1-2h |
| 服务器磁盘完全损坏 | 从 S3 基础备份 + WAL 重建 | 第 1+5 层 | 同上 | 2-4h |
| 整个 AWS 区域不可用 | 跨区域 S3 备份 + 新区域重建 | 第 5 层 | 取决于跨区复制延迟 | 4-8h |
原因:这是唯一能防止"备份间隔内数据丢失"的手段。没有 PITR,一旦出事,凌晨备份到出事时间之间的数据全部不可恢复。
工作量:修改 postgresql.conf + 配置 pgBackRest/WAL-G + 测试恢复流程,约 1-2 天
原因:补上 public schema 的备份缺口。目前如果 public schema 损坏,租户信息丢失,无法恢复。
工作量:在现有 backup-job.js 中增加全库 pg_dump,约半天
原因:restoreBackup() 中 DROP CASCADE 前不备份当前状态,恢复失败则两边数据都丢。
工作量:在 restoreBackup() 方法中增加一步 createBackup,约 2 小时
适用表:guests, appointments, invoices, transactions
工作量:迁移脚本 + 查询过滤器 + API 层 "恢复" 端点,约 2-3 天
适用表:guests, services, employees(修改频率高的表)
工作量:创建 data_change_log 表 + 触发器 + 查询界面,约 3-4 天
每月至少做一次恢复演练,记录 RPO/RTO 实际值,与目标对比。
没有一种方案能解决所有问题。WAL+PITR 兜底全局,逻辑备份做精细恢复,迁移系统管 DDL 变更,软删除 + 审计日志保护日常操作——层层叠加,才能做到真正的"每一步都可撤回"。