一次滚动重启丢掉的支付回调
arch-lab 的 order-api 曾经用进程内的 map[orderID]callback 关联支付回调:用户支付后,渠道回调到任意一个实例,实例查本地 map 找到订单再推进状态。滚动发布时流量切到新实例,三十七笔已支付回调落到了没有 map 条目的进程,订单停在 CREATED,用户却已经扣款。代码没有并发 bug,问题在于这份“临时”状态其实是回调关联的事实来源,却没有被当成状态对待。
有状态服务不是“用了数据库的服务”,而是任何持有不可随手丢弃的数据的运行时单元。本节要把“哪些状态、放在哪、怎么恢复、怎么迁移”写成清单和测试。11.1 决定了部署单元,11.2 决定了配置从哪来,本节决定这些单元重建时,数据能不能跟着回来。
状态不是二选一
把状态分成“有状态/无状态”两类太粗,实际至少要分四类。分类的判据不是存储介质,而是丢失后的后果:能不能重算、要多久、会不会改变业务事实。
| 类别 | 丢失后果 | 判据 | arch-lab 例子 |
|---|---|---|---|
| 事实来源 | 不可逆,除非有备份 | 业务不能从别处推导出来 | 订单、库存预留、目录数据 |
| 派生状态 | 可重算,但有时间成本 | 能从某个事实来源重建 | Kafka 事件流、搜索索引 |
| 缓存 | 只影响延迟,不影响正确性 | 重建来源存在,且丢失可容忍 | 幂等键缓存、价格快照 |
| 临时状态 | 无影响 | 生命周期不超过一次请求或一个实例 | 临时文件、请求上下文 |
一个部署单元是否“无状态”,取决于它把哪一类状态放在进程内。order-api 不持有事实来源,所以可以随时替换;但它曾经把回调关联放进内存,这份状态既不是派生也不是缓存,而是没有被命名的事实来源,替换时就会丢。
订单系统的状态清单
arch-lab 的状态清单有六项:三个 PostgreSQL 事实来源、一个 Redis 缓存、一个 Kafka 派生日志、一个本地临时目录。每一项都要写 owner、恢复路径和目标时长,否则“有状态”只是一句形容词。
| 状态 | 介质 | 类别 | Owner | 恢复路径 | 部署影响 |
|---|---|---|---|---|---|
| orders-db | PostgreSQL | 事实来源 | team-order | WAL PITR,RTO 90s,RPO 5min | 主库不能滚动重启,走故障转移 |
| inventory-db | PostgreSQL | 事实来源 | team-inventory | WAL PITR,RTO 90s,RPO 5min | 同上,预留数据不可丢 |
| catalog-db | PostgreSQL | 事实来源 | team-catalog | 快照恢复,RTO 15min,RPO 24h | 可降级读缓存,但不可清空 |
| idempotency-redis | Redis | 缓存 | team-order | 从 orders-db 重建,2min | 可随时清空,数据库唯一索引兜底 |
| kafka-events | Kafka | 派生状态 | team-platform | 从 outbox 重放,30min | 可重置 offset,按顺序重放 |
| order-api-tmp | 本地盘 | 临时状态 | team-order | 无需恢复 | 随实例删除 |
幂等键这一行是关键边界。arch-lab 把幂等键同时写入 PostgreSQL 的 idempotency_keys 唯一索引和 Redis;Redis 只加速重复请求的拒绝,真正的判据在数据库。这样 Redis 清空只会让一小段窗口内的重复请求多走一次数据库,而不会产生重复订单。如果反过来把 Redis 当唯一存储,一次故障转移就可能让同一笔支付被处理两次。
“有状态”不等于“不能部署”
一个单元能否被替换,取决于状态满足三个条件之一:已经外置(进程不持有事实来源)、有复制与接管(主库故障时副本能提升)、可重建(派生状态能在目标时间内从事实来源重算)。三个条件都没有,才真的“不能部署”。
order-api 满足第一条:订单状态在 PostgreSQL,进程内只有请求上下文,滚动发布随时可以做。orders-db 是典型的有状态服务,但它仍然可以部署:托管故障转移、连接重试、副本提升都是部署流程的一部分;不能做的是把主库当无状态进程直接 kill。真正不能部署的是把待处理回调放在本地 map 或本地盘、又没有 drain 和迁移步骤的单元。
“有状态服务不能上容器/不能滚动发布”的说法通常省略了后半句:不能的是没有写接管步骤的替换。有状态单元的发布流程必须包含三件事:先停止新写入或切到副本,等待检查点完成,再提升新主并恢复流量。Kubernetes 的 StatefulSet 只是把这些步骤的一部分自动化,它不替你决定 RPO、备份和迁移顺序。
恢复不是一句“有备份”
备份存在不等于能恢复。一个可用的恢复方案至少包含四件事:备份策略、恢复步骤、恢复后的校验步骤、RTO/RPO 目标。只写“每天备份”而没有实际演练,等于把恢复能力寄托在运气上。arch-lab 的校验器要求每个事实来源同时具备 Backup、RestoreStep、VerifyStep 和非零 RTO,缺一项就报错。
派生状态和缓存的要求不同:必须写清从哪个事实来源重建、重建步骤是什么、重建需要多久。重建时间不是运维细节,它直接决定降级窗口有多长。Kafka 重放要排在数据库恢复之后,因为 outbox 在 orders-db 里;顺序错了,重放会读到不完整的数据。下面 RecoveryMinutes 的输出分别是 90、2、30、0 分钟,测试会逐项断言。
迁移也是状态边界变更
schema 变更和跨存储迁移都会改变状态的边界,不能当成普通代码发布。删除一列、改类型、把幂等键从 Redis 搬到 PostgreSQL,都需要扩展-收缩:先加新结构并双写,回填并校验,再切读,最后删除旧结构。破坏性变更如果没有扩展-收缩,滚动发布期间新旧实例会同时读写不兼容的结构。
校验器把迁移分成两个硬约束:Destructive 必须搭配 ExpandContract,否则报 DESTRUCTIVE_MIGRATION;不可逆迁移必须至少有回滚路径或扩展-收缩,否则报 NO_ROLLBACK_PATH。跨存储迁移还多一步对账:双写期间两边都可能失败,切换前要比较条数与关键字段,不能只看“新库有数据”。
把清单写成校验器
下面的 stateinv 包把状态分类、恢复路径和迁移门槛写成结构。Validate 检查无主状态、事实来源缺少备份/恢复/校验/RTO、派生状态没有重建来源、本地盘承载持久状态、临时状态丢失有影响;CheckMigrations 单独检查破坏性迁移。
// file: internal/stateinv/inventory.go
// tier: L1
// verify: go test ./internal/stateinv/
package stateinv
// DataClass 决定一份状态丢失后的代价,而不是它存在哪种数据库里。
type DataClass string
const (
Source DataClass = "SOURCE_OF_TRUTH"
Derived DataClass = "DERIVED"
Cache DataClass = "CACHE"
Ephemeral DataClass = "EPHEMERAL"
)
// Store 是状态清单的一项。Source 必须有备份、恢复步骤和校验步骤;
// Derived/Cache 必须能说清从哪个 Source 重建;Ephemeral 必须明确丢失无影响。
type Store struct {
Name string
Kind string
Class DataClass
Owner string
Backup string
RestoreStep string
VerifyStep string
RTOMinutes int
RPOMinutes int
RebuildFrom string
RebuildStep string
RebuildMinutes int
LossImpact string
}
type Inventory struct {
Stores []Store
}
type Finding struct {
Code string
Subject string
Detail string
}
// Validate 检查状态清单是否自洽:无主、来源无恢复、派生无重建、
// 本地盘承载持久状态、临时状态丢失有影响。
func (inv Inventory) Validate() []Finding {
var out []Finding
seen := make(map[string]bool, 8)
add := func(code, subject, detail string) {
key := code + "\x00" + subject
if !seen[key] {
seen[key] = true
out = append(out, Finding{Code: code, Subject: subject, Detail: detail})
}
}
byName := make(map[string]Store, len(inv.Stores))
for _, s := range inv.Stores {
byName[s.Name] = s
}
for _, s := range inv.Stores {
if s.Owner == "" {
add("UNOWNED_STORE", s.Name, "no owning team")
}
// 本地盘只能放临时数据;持久状态放本地盘就无法随实例替换。
if s.Kind == "local-disk" && s.Class != Ephemeral {
add("LOCAL_DISK_STATE", s.Name, "persistent state on local disk")
}
switch s.Class {
case Source:
if s.Backup == "" {
add("SOURCE_WITHOUT_BACKUP", s.Name, "no backup policy")
}
if s.RestoreStep == "" {
add("SOURCE_WITHOUT_RESTORE", s.Name, "no restore procedure")
}
if s.VerifyStep == "" {
add("SOURCE_WITHOUT_VERIFY", s.Name, "no post-restore check")
}
if s.RTOMinutes <= 0 {
add("SOURCE_WITHOUT_RTO", s.Name, "no recovery time target")
}
case Derived, Cache:
if s.RebuildFrom == "" || s.RebuildStep == "" || s.RebuildMinutes <= 0 {
add("DERIVED_WITHOUT_REBUILD", s.Name, "no rebuild source or step")
continue
}
src, ok := byName[s.RebuildFrom]
if !ok {
add("UNKNOWN_REBUILD_SOURCE", s.Name, s.RebuildFrom)
} else if src.Class != Source {
add("REBUILD_NOT_FROM_SOURCE", s.Name, s.RebuildFrom+" is not SOURCE_OF_TRUTH")
}
case Ephemeral:
if s.LossImpact != "none" {
add("EPHEMERAL_STATE_LOSS", s.Name, s.LossImpact)
}
}
}
return out
}
// RecoveryMinutes 返回该状态丢失后的目标恢复时长:Source 用 RTO,
// 派生状态用重建时长,临时状态为 0。未知名字返回 -1。
func (inv Inventory) RecoveryMinutes(name string) int {
for _, s := range inv.Stores {
if s.Name != name {
continue
}
switch s.Class {
case Source:
return s.RTOMinutes
case Derived, Cache:
return s.RebuildMinutes
default:
return 0
}
}
return -1
}
// Migration 描述一次 schema 变更。破坏性变更必须走扩展-收缩,
// 不可逆变更必须至少保留回滚路径,否则发布就变成单向门。
type Migration struct {
ID string
Destructive bool
ExpandContract bool
Reversible bool
}
func CheckMigrations(ms []Migration) []Finding {
var out []Finding
for _, m := range ms {
if m.Destructive && !m.ExpandContract {
out = append(out, Finding{Code: "DESTRUCTIVE_MIGRATION", Subject: m.ID, Detail: "requires expand-contract"})
}
if !m.Reversible && !m.ExpandContract {
out = append(out, Finding{Code: "NO_ROLLBACK_PATH", Subject: m.ID})
}
}
return out
}
校验器只检查声明是否自洽:它不会连接数据库确认备份真的存在,也不会执行一次 PITR。真实项目里要把 RestoreStep 接到演练脚本,把 VerifyStep 接到恢复后的自动化校验;本节保持 L1,只保证清单不会漏项。
基线可恢复,五类漂移要报警
测试先构造六项状态齐全的清单,断言零违规,并打印事实来源与派生状态的数量;再逐条变异,覆盖无主、来源无备份、派生无重建、本地盘持久化、临时状态丢失有影响;最后单独测试恢复时长和迁移门槛。
// file: internal/stateinv/inventory_test.go
// tier: L1
// verify: go test ./internal/stateinv/
package stateinv
import "testing"
// baseline 是 arch-lab 在 2026-10 的状态清单:三个事实来源、一个缓存、
// 一个可重放事件日志、一个只放临时文件的本地盘。
func baseline() Inventory {
return Inventory{Stores: []Store{
{Name: "orders-db", Kind: "postgres", Class: Source, Owner: "team-order", Backup: "continuous WAL archive", RestoreStep: "PITR to timestamp", VerifyStep: "row count + last order id", RTOMinutes: 90, RPOMinutes: 5},
{Name: "inventory-db", Kind: "postgres", Class: Source, Owner: "team-inventory", Backup: "continuous WAL archive", RestoreStep: "PITR to timestamp", VerifyStep: "reservation sum per SKU", RTOMinutes: 90, RPOMinutes: 5},
{Name: "catalog-db", Kind: "postgres", Class: Source, Owner: "team-catalog", Backup: "nightly snapshot", RestoreStep: "restore latest snapshot", VerifyStep: "checksum + SKU count", RTOMinutes: 15, RPOMinutes: 1440},
{Name: "idempotency-redis", Kind: "redis", Class: Cache, Owner: "team-order", RebuildFrom: "orders-db", RebuildStep: "reload keys from idempotency_keys", RebuildMinutes: 2},
{Name: "kafka-events", Kind: "kafka", Class: Derived, Owner: "team-platform", RebuildFrom: "orders-db", RebuildStep: "replay outbox with original offsets", RebuildMinutes: 30},
{Name: "order-api-tmp", Kind: "local-disk", Class: Ephemeral, Owner: "team-order", LossImpact: "none"},
}}
}
func TestBaselineInventoryIsRecoverable(t *testing.T) {
inv := baseline()
findings := inv.Validate()
sources := 0
for _, s := range inv.Stores {
if s.Class == Source {
sources++
}
}
t.Logf("stores=%d sources=%d findings=%d", len(inv.Stores), sources, len(findings))
if len(findings) != 0 {
t.Fatalf("baseline should be recoverable, got %v", findings)
}
}
func TestRecoveryMinutesMatchClass(t *testing.T) {
inv := baseline()
cases := map[string]int{
"orders-db": 90,
"idempotency-redis": 2,
"kafka-events": 30,
"order-api-tmp": 0,
"missing": -1,
}
for name, want := range cases {
if got := inv.RecoveryMinutes(name); got != want {
t.Fatalf("%s: want %d, got %d", name, want, got)
}
}
}
func TestValidateCatchesStateDrift(t *testing.T) {
cases := []struct {
name string
mutate func(*Inventory)
code string
}{
{"unowned store", func(inv *Inventory) {
inv.Stores[storeIndex(inv, "catalog-db")].Owner = ""
}, "UNOWNED_STORE"},
{"source without backup", func(inv *Inventory) {
inv.Stores[storeIndex(inv, "orders-db")].Backup = ""
}, "SOURCE_WITHOUT_BACKUP"},
{"derived without rebuild", func(inv *Inventory) {
inv.Stores[storeIndex(inv, "kafka-events")].RebuildFrom = ""
}, "DERIVED_WITHOUT_REBUILD"},
{"persistent local disk", func(inv *Inventory) {
inv.Stores[storeIndex(inv, "order-api-tmp")].Class = Cache
}, "LOCAL_DISK_STATE"},
{"ephemeral loss has impact", func(inv *Inventory) {
inv.Stores[storeIndex(inv, "order-api-tmp")].LossImpact = "pending callback lost"
}, "EPHEMERAL_STATE_LOSS"},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
inv := baseline()
tc.mutate(&inv)
if !hasFinding(inv.Validate(), tc.code) {
t.Fatalf("want %s, got %v", tc.code, inv.Validate())
}
})
}
}
func TestMigrationGate(t *testing.T) {
cases := []struct {
name string
m Migration
code string
}{
{"destructive without expand-contract", Migration{ID: "0042", Destructive: true, Reversible: false}, "DESTRUCTIVE_MIGRATION"},
{"irreversible without rollback", Migration{ID: "0043", Reversible: false}, "NO_ROLLBACK_PATH"},
{"expand-contract destructive", Migration{ID: "0044", Destructive: true, ExpandContract: true, Reversible: false}, ""},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
findings := CheckMigrations([]Migration{tc.m})
if tc.code == "" {
if len(findings) != 0 {
t.Fatalf("want clean, got %v", findings)
}
return
}
if !hasFinding(findings, tc.code) {
t.Fatalf("want %s, got %v", tc.code, findings)
}
})
}
}
func storeIndex(inv *Inventory, name string) int {
for i := range inv.Stores {
if inv.Stores[i].Name == name {
return i
}
}
panic(name)
}
func hasFinding(findings []Finding, code string) bool {
for _, f := range findings {
if f.Code == code {
return true
}
}
return false
}
实测输出
在本机 Go 1.26.0 下执行 go test ./...,全部通过;gofmt -l . 无输出,go vet ./... 无告警。下面是基线用例和完整套件的真实输出。
$ go test ./internal/stateinv/ -run 'TestBaselineInventoryIsRecoverable|TestRecoveryMinutesMatchClass' -v
=== RUN TestBaselineInventoryIsRecoverable
inventory_test.go:30: stores=6 sources=3 findings=0
--- PASS: TestBaselineInventoryIsRecoverable (0.00s)
=== RUN TestRecoveryMinutesMatchClass
--- PASS: TestRecoveryMinutesMatchClass (0.00s)
PASS
ok archlab/internal/stateinv 0.254s
$ go test -count=1 ./...
ok archlab/internal/stateinv 0.249s
基线是六项状态、三个事实来源、两个可重建状态、零违规。findings=0 只说明清单自洽,真正的恢复能力还要靠演练;但至少每个事实来源都被迫写下了备份、恢复、校验和 RTO,每个派生状态都被迫写下了重建来源和重建时长。
常见错误与边界
第一,把缓存当事实来源。Redis 里的会话、幂等键、购物车一旦没有持久化来源,故障转移就会变成数据丢失。第二,有备份但没有恢复步骤和校验,真出事时才发现备份不可用。第三,把待处理任务放在本地内存或本地盘,滚动发布时随实例消失。第四,破坏性 schema 变更一步到位,滚动窗口里新旧实例互相写坏。第五,走向另一个极端,认为有状态服务完全不能部署,于是长期不敢升级数据库。
边界也要说清楚:本节清单只覆盖 arch-lab 自己拥有的状态,外部支付渠道的账本是它的事实来源,不在我们的恢复范围内,只能通过对账衔接;跨地域容灾、对象存储生命周期和密钥托管也不在本节展开。stateinv 验证的是声明,不是备份真实可用;恢复演练必须单独执行。本节保持 L1,不声称跑过真实故障转移或多节点集群。
小结
有状态服务的边界不在“有没有数据库”,而在四类状态的划分和三条可替换条件:外置、可接管、可重建。arch-lab 用六项状态清单把订单、库存、目录、缓存、事件流和临时文件分开,stateinv 强制每个事实来源写清备份、恢复、校验和 RTO,每个派生状态写清重建来源和时长,CheckMigrations 则把破坏性变更挡在发布之前。有状态不是不能部署,不能的是没有接管和恢复步骤的替换。
阅读导航:上一节:11.2 配置、密钥与环境差异 · 下一节:12.1 需求、约束与非功能目标 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。