主题
容灾:副本能接班,备份能回到过去,恢复还需要演练
数据库有三个副本,并不意味着一次误删后还有一份好数据。如果删除也是正常复制的一部分,三个副本会先后变成同样的错误状态。反过来,备份文件完整,也不意味着业务马上能恢复:还要找到合适的版本、恢复依赖、核对数据,再把流量切回来。
容灾要从具体故障和业务目标出发。这篇文章依次讲多副本、故障转移、同城双活、异地多活、备份恢复与混沌实验,重点是每种手段保护什么,以及恢复过程中如何避免制造第二次事故。
先定目标:允许停多久,允许丢多久的数据
| 术语 | 约束什么 | 需要明确的口径 |
|---|---|---|
| RTO,恢复时间目标 | 故障后允许业务中断多久 | 从哪个故障时点计时,到哪个用户操作恢复达标结束 |
| RPO,恢复点目标 | 恢复后允许缺失多长时间范围的数据 | 最后可恢复的数据时间点,与故障时点相差多少 |
| 实际恢复时间 | 某次事件或演练真正花了多久 | 包含检测、决策、资源准备、恢复、验证及切流 |
| 实际数据损失 | 真正未恢复的写入和时间范围 | 不能只看“最近一次备份任务成功” |
RTO/RPO 是目标,演练结果才是证据。RPO=5 分钟通常表示业务最多接受约五分钟的数据回退窗口,并不等于“每五分钟启动备份”就一定达标:任务失败、备份完成时间、日志缺口和恢复工具问题都可能拉大间隔。定义和范围应由业务与技术负责人共同确认。[1]
文末用人为设定的时间线演示计算:12:00 的快照,12:02 发生事故,12:13 验证服务恢复。程序输出:
text
scenario data gap: 2m0s scenario recovery interval: 11m0s这是场景算术,不是本机实际恢复花了 11 分钟,更不是测得某数据库 RPO 为两分钟。数据间隔说明最后快照与事故差两分钟,实际丢了几条业务记录还需要写入日志核对。
多副本先回答“故障域在哪里”
三个进程放在一台机器上,可以缓解单进程退出,却一起受制于主机故障;三台机器在同一机架或可用区,还可能共享电源、网络和区域级依赖。故障域是可能一起失效的资源集合,不只有地理位置,也包括同一个账号、权限、配置发布和软件缺陷。
| 副本布局 | 主要覆盖 | 剩余风险与成本 |
|---|---|---|
| 同机多进程 | 部分进程级失败 | 主机、磁盘、内核仍共享 |
| 跨主机同区 | 单主机失效 | 区域网络、电力、共同发布 |
| 跨可用区 | 一部分机房级故障 | 同区域依赖、跨区延迟和费用 |
| 跨区域 | 更大地域故障 | 更高复制延迟、切流、数据冲突与运营复杂度 |
| 跨账号或权限边界保存历史版本 | 账号误操作或部分攻击面 | 恢复凭据、密钥和权限也必须独立可用 |
副本之间怎么同步,决定故障时可能剩下什么。异步复制通常先确认主节点提交,再把变化传到副本,因此主节点突然丢失时,最近已确认写入可能尚未到备用节点。同步复制把某种副本确认放进提交路径,降低指定故障条件下的数据损失风险,同时增加延迟和对副本可用性的依赖。确认的是接收、写盘还是回放,要查具体数据库配置,不能只看“同步”二字。[2]
例如 PostgreSQL 的同步复制设置区分等待 WAL 写入与回放等语义。只有满足选定确认条件和故障假设,才可以讨论相应的数据保护;它不保证避免应用把错误值同步到所有副本。[2]
故障转移不是把备用节点改名
一次完整的主备切换至少包含故障判断、阻止旧主继续写、选择可用恢复点、提升新主、更新路由、处理旧连接以及验证业务。PostgreSQL 官方 Failover 文档特别讨论了避免旧主重新出现后形成双主的问题;外部系统需要承担检测和协调职责。[3]
正在绘制图示…
查看图源代码
flowchart TD
A[发现主节点异常] --> B[核实影响及切换条件]
B --> C[隔离旧主写入权限]
C --> D[确认候选副本状态与可能数据缺口]
D --> E[提升新主并更新路由]
E --> F[重建连接并核对关键业务]
F --> G[逐步恢复流量]
G --> H[旧主清理并重新加入复制]
网络分区中,旧主可能还在为一部分客户端服务。仅靠“备用没收到心跳”无法证明旧主已经死亡。应利用可靠的选主协议、fencing、存储权限或基础设施隔离,确保过期执行者不能继续写。否则新旧两边都接受订单,恢复连接以后还要面对冲突合并。
切换前还要确认备用容量。平时只承担 10% 流量的备站,在事故时不一定能立刻吃下 100%。DNS、代理、连接池和客户端缓存也不会必然同时切换;现有长连接可能继续指向旧节点。切换计划需要重试与幂等边界,不能靠无限重试掩盖不确定提交。
回切同样是一次风险操作。先恢复复制、核对数据、明确当前权威写入方,再选择受控时间窗口。为了“尽快恢复原样”直接把流量切回旧主,可能重新暴露过期数据或再次形成双写。
同城双活和异地多活,先说明谁能写什么
“双活”可以仅指两个站点都接流量,也可以指两边都处理独立分片的写,还可能指同一条记录两边都可写。这些架构的一致性难度不同。
| 方式 | 写入归属 | 好处 | 主要代价 |
|---|---|---|---|
| 双站接流量、单写中心 | 写入最终汇聚到一个主写方 | 应用层冗余较容易理解 | 写中心和到它的网络仍关键 |
| 按租户或分片分属写入 | 一个键在任一时刻有明确写入归属 | 两地可同时处理业务,减少同键冲突 | 路由、跨分片操作和归属迁移更复杂 |
| 同键多地写入 | 多个站点可改同一记录 | 网络局部故障时可能继续本地写 | 冲突解决、顺序、唯一约束和业务不变量最难 |
同城一般更容易获得较低网络延迟,却可能共享城市级故障;异地扩大地理隔离,也增加复制往返和运营成本。距离并不自动决定一致性,真正决定行为的是复制、选主和冲突协议。
如果两地各看到库存为 1,都允许卖出 1 件,最后把计数合并不能自动撤回已经承诺的订单。可以限制每个库存键只有一个写入归属,预分配可售额度,或采用业务接受的冲突处理;每种方法都在可用性、延迟和资源利用之间做取舍。多活不是免除不变量的理由。AWS 灾备架构资料也把多站点 active/active 列为复杂度更高的选择,而不是所有工作负载的默认答案。[4]
备份的价值在于保留可选择的历史
多副本通常跟随当前状态,备份保留历史恢复点。二者可以互补,不能互相代替。文末模型先把两条记录保存为快照,再让主副本都接受一次逻辑删除:
go
replica := live
live = Snapshot{Version: 1}
replica = live实际输出:
text
after replicated logical delete, live rows: 0 replica rows: 0
restored rows: 2 total: 30第一部分只是一次赋值模拟“错误传播”,没有运行复制系统。第二部分则真实读取本地临时目录的备份文件、验证并恢复了两条记录。历史快照没有被同步删除,因而还能回到删除前。
| 备份内容或机制 | 解决的问题 | 必须保留或核对 |
|---|---|---|
| 全量备份 | 提供某个基线 | 完整性、版本、创建完成时间 |
| 增量、差异备份 | 减少后续复制量 | 完整的依赖链与恢复顺序 |
| 持续日志归档、PITR | 恢复到基线之后某个时间点 | 基线、连续日志、时间线和目标点 |
| 版本保留、独立权限或不可变存储 | 减少误删和攻击扩散 | 保留期限、删除权限、密钥可用性 |
| 配置、代码、schema、基础设施定义 | 数据之外也能重建服务 | 兼容版本、网络、证书和依赖 |
PostgreSQL PITR 依赖基础备份与从基线开始连续可用的 WAL;只有某一天的 SQL dump,不能因此声称具备任意时点恢复。数据目录以外的配置也要按官方要求单独考虑。[5]
恢复点必须在错误发生之前。如果逻辑错误持续三天才被发现,而所有备份只保留两天,再多副本也可能找不到干净版本。保留策略应覆盖实际发现延迟与合规要求,成本包括存储、恢复时间和访问控制。
恢复演练要验证业务,不能只看文件能解压
文末 Restore 按顺序执行四类检查:文件长度和 SHA-256、JSON 结构、schema 版本、业务记录总额与唯一 ID。全部通过后,才写同目录临时文件并替换目标文件。校验失败时不会改动原目标。
go
if len(data) != m.Bytes || sha256.Sum256(data) != m.Hash {
return Snapshot{}, errors.New("checksum mismatch")
}
if err = Validate(s); err != nil {
return Snapshot{}, err
}实际输出:
text
corrupt backup rejected: true负例还构造了“校验和正确但总额错误”“重复 ID”“未知 schema 版本”。它们同样被拒绝,说明校验和只保证与可信清单一致,不保证业务数据正确。若攻击者能同时修改快照与清单,普通哈希也不能证明来源可信;清单、密钥和备份权限必须另有可信边界。[6]
文件恢复成功只是第一步。真实演练还要启动兼容版本的应用,查询关键订单、核对账目或计数、检查索引和权限,再跑代表性的读写路径。演练报告记录事故起点、发现时间、批准切换时间、数据恢复完成时间和用户旅程恢复时间;不要只把数据库导入耗时记作 RTO。[1][6]
建议在隔离环境中按以下顺序演练:
- 选定一份真实策略产生的备份,确认来源和权限。
- 在独立目标中恢复,禁止连向生产写入口。
- 先验证格式和数据库完整性,再验证业务不变量。
- 使用隔离流量验证应用、依赖与关键用户操作。
- 核对数据缺口与完整恢复时间是否满足目标。
- 保存结果和失败项,清理演练资源,修正后重试。
本地示例使用 CreateTemp、Sync 和 Rename 组织验证后写入,但没有实现生产备份的一致性快照、目录 fsync、签名、加密、保留管理或断电恢复协议;不能把单次运行成功升级为任意崩溃下的持久化保证。[7]
混沌工程从一个可否证的假设开始
混沌实验不是随机杀进程。先描述正常指标和预期行为,再注入一个有边界的故障,观察假设是否仍成立。原始原则强调稳态假设、现实故障、自动化及控制爆炸半径;是否在生产执行还必须满足组织授权与风险控制。[8]
| 实验项 | 一个清楚的描述示例 |
|---|---|
| 假设 | 测试环境一个应用实例退出后,剩余实例仍能在预设成功率和时延阈值内处理隔离请求 |
| 范围 | 指定测试实例、限定流量、限定持续时间 |
| 观测 | 用户成功率、尾延迟、队列、就绪副本和依赖负载 |
| 停止条件 | 错误率、积压或资源超过预设阈值立即停止注入 |
| 恢复动作 | 撤销故障、验证实例与依赖恢复,再清理测试数据 |
| 禁止扩散 | 不触碰共享生产数据库、真实客户和未授权目标 |
从本地故障注入、预生产演练,再按风险审批决定是否扩大范围。不要一次同时改网络、主库和权限,否则很难知道哪条机制失效,也更难退出。没有可用观测、停止开关或恢复负责人时,先补这些条件。[9]
本次只损坏自己创建的临时文件,并在退出时删除整个实验目录;没有停服务、断网络、操作集群或真实数据。go test、go test -race、go vet 在 Go 1.27.2 / Linux amd64 下通过。真实多副本切换、跨区多活、数据库 PITR 和生产恢复目标均未在本环境验证。
一次演练通过,证明的是特定版本、故障和操作路径曾经成功。下一次依赖变化、密钥过期、数据规模增长后仍需要重新验证;历史可用性和一次恢复记录都不是未来不会故障的保证。
依据
- AWS Well-Architected:Define recovery objectives for downtime and data loss:RTO/RPO 与业务恢复目标。
- PostgreSQL 18:Synchronous Replication:同步/异步复制与确认条件。
- PostgreSQL 18:Failover:切换、检测及避免旧主造成双主。
- AWS:Disaster recovery options in the cloud:备份恢复、预备容量及多站点架构的取舍。
- PostgreSQL 18:Continuous Archiving and PITR:基础备份、连续 WAL 和恢复步骤。
- Google SRE:Data Integrity:数据完整性、防止错误传播与恢复验证。
- Go 1.27.2 os 文件操作、bytes、encoding/json:本地恢复程序使用的 I/O 与解码接口;持久化范围按正文限定。
- Principles of Chaos Engineering:稳态假设、注入故障和限制影响范围。
- AWS Well-Architected:Test resiliency using chaos engineering:实验护栏与恢复准备。
完整本地恢复实验
只使用标准库;所有文件位于程序自己创建的临时目录,不接受任意目标路径。整数总额只是小型测试数据,不是可用于金融金额计算的生产数据模型。Manifest 在演示进程中保留,生产还需可靠存储与保护清单。
sh
go run .
go test -v ./...
go test -race ./...
go vet ./...go.mod
go
module example.com/disaster
go 1.27.0main.go
代码块 · 142 行 · 点击收起 / 展开
go
package main
import (
"bytes"
"crypto/sha256"
"encoding/json"
"errors"
"fmt"
"io"
"os"
"path/filepath"
"time"
)
type Record struct {
ID string
Amount int
}
type Snapshot struct {
Version int
Rows []Record
Total int
}
type Manifest struct {
Hash [32]byte
Bytes int
CapturedAt time.Time
}
func Validate(s Snapshot) error {
if s.Version != 1 {
return errors.New("unsupported schema")
}
seen := map[string]bool{}
sum := 0
for _, r := range s.Rows {
if r.ID == "" || seen[r.ID] || r.Amount < 0 {
return errors.New("invalid row")
}
seen[r.ID] = true
sum += r.Amount
}
if sum != s.Total {
return errors.New("business total mismatch")
}
return nil
}
func Backup(path string, s Snapshot, at time.Time) (Manifest, error) {
if err := Validate(s); err != nil {
return Manifest{}, err
}
data, err := json.Marshal(s)
if err != nil {
return Manifest{}, err
}
if err = os.WriteFile(path, data, 0600); err != nil {
return Manifest{}, err
}
return Manifest{sha256.Sum256(data), len(data), at}, nil
}
func Restore(src, dst string, m Manifest) (Snapshot, error) {
data, err := os.ReadFile(src)
if err != nil {
return Snapshot{}, err
}
if len(data) != m.Bytes || sha256.Sum256(data) != m.Hash {
return Snapshot{}, errors.New("checksum mismatch")
}
var s Snapshot
dec := json.NewDecoder(bytes.NewReader(data))
dec.DisallowUnknownFields()
if err = dec.Decode(&s); err != nil {
return Snapshot{}, err
}
var extra any
if err = dec.Decode(&extra); err != io.EOF {
return Snapshot{}, errors.New("trailing data")
}
if err = Validate(s); err != nil {
return Snapshot{}, err
}
f, err := os.CreateTemp(filepath.Dir(dst), "restore-*")
if err != nil {
return Snapshot{}, err
}
tmp := f.Name()
defer os.Remove(tmp)
if _, err = f.Write(data); err != nil {
f.Close()
return Snapshot{}, err
}
if err = f.Sync(); err != nil {
f.Close()
return Snapshot{}, err
}
if err = f.Close(); err != nil {
return Snapshot{}, err
}
if err = os.Rename(tmp, dst); err != nil {
return Snapshot{}, err
}
return s, nil
}
func main() {
if err := run(); err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
}
func run() error {
dir, err := os.MkdirTemp("", "restore-lab-*")
if err != nil {
return err
}
defer os.RemoveAll(dir)
backup := filepath.Join(dir, "snapshot.json")
dst := filepath.Join(dir, "restored.json")
at := time.Date(2026, 10, 13, 12, 0, 0, 0, time.UTC)
live := Snapshot{1, []Record{{"a", 10}, {"b", 20}}, 30}
m, err := Backup(backup, live, at)
if err != nil {
return err
}
replica := live
live = Snapshot{Version: 1}
replica = live
fmt.Println("after replicated logical delete, live rows:", len(live.Rows), "replica rows:", len(replica.Rows))
restored, err := Restore(backup, dst, m)
if err != nil {
return err
}
fmt.Println("restored rows:", len(restored.Rows), "total:", restored.Total)
if err = os.WriteFile(backup, []byte("corrupted fixture"), 0600); err != nil {
return err
}
_, err = Restore(backup, filepath.Join(dir, "must-not-exist.json"), m)
fmt.Println("corrupt backup rejected:", err != nil)
incident := at.Add(2 * time.Minute)
verified := incident.Add(11 * time.Minute)
fmt.Println("scenario data gap:", incident.Sub(m.CapturedAt), "scenario recovery interval:", verified.Sub(incident))
return nil
}main_test.go
代码块 · 56 行 · 点击收起 / 展开
go
package main
import (
"crypto/sha256"
"encoding/json"
"os"
"path/filepath"
"testing"
"time"
)
func TestRestoreAndCorruptionDoesNotReplaceTarget(t *testing.T) {
dir := t.TempDir()
src := filepath.Join(dir, "backup")
dst := filepath.Join(dir, "target")
s := Snapshot{1, []Record{{"a", 7}}, 7}
m, err := Backup(src, s, time.Time{})
if err != nil {
t.Fatal(err)
}
if _, err = Restore(src, dst, m); err != nil {
t.Fatal(err)
}
before, _ := os.ReadFile(dst)
if err = os.WriteFile(src, []byte("bad"), 0600); err != nil {
t.Fatal(err)
}
if _, err = Restore(src, dst, m); err == nil {
t.Fatal("accepted corruption")
}
after, _ := os.ReadFile(dst)
if string(before) != string(after) {
t.Fatal("target modified on failed validation")
}
}
func TestChecksumIsNotBusinessValidation(t *testing.T) {
for _, s := range []Snapshot{{1, []Record{{"a", 10}}, 99}, {1, []Record{{"a", 1}, {"a", 2}}, 3}, {2, nil, 0}} {
dir := t.TempDir()
src := filepath.Join(dir, "source")
dst := filepath.Join(dir, "target")
b, err := json.Marshal(s)
if err != nil {
t.Fatal(err)
}
if err = os.WriteFile(src, b, 0600); err != nil {
t.Fatal(err)
}
m := Manifest{sha256.Sum256(b), len(b), time.Time{}}
if _, err = Restore(src, dst, m); err == nil {
t.Fatal("accepted invalid snapshot", s)
}
if _, err = os.Stat(dst); !os.IsNotExist(err) {
t.Fatal("target created", err)
}
}
}