一次没有响应的预留
订单节点向库存节点发起预留,等了 300ms 没有响应。此时至少有五种完全不同的现实:
- 库存进程被
kill -9,或处理到一半崩溃后从持久化日志恢复; - 请求或响应在网络上丢了,或两台机器之间的链路断了但两边都还活着;
- 库存节点返回了一个格式正确、内容错误的预占单号。
前两种属于非拜占庭故障,第三种是拜占庭故障。调用方看到的都是“没有可用响应”,但能用来恢复的手段完全不同:崩溃-恢复可以重放日志,丢包可以重传,分区要等网络恢复或走多数派,拜占庭需要签名、交叉验证或法定人数投票。故障模型不是学术分类,它决定你愿意为正确性付多少成本;从一次失败反推故障类型是不可能的,把异常都当网络抖动、重试就完了,是订单系统最常见的误判。
五种故障的可观测差异
| 故障 | 节点状态 | 消息 | 调用方能否区分 | 典型恢复手段 |
|---|---|---|---|---|
| 崩溃停止 | 进程终止,不再恢复 | 在途消息丢失 | 只能看到超时/连接拒绝 | 重启、主备切换 |
| 崩溃恢复 | 进程终止后重启 | 在途消息丢失 | 与崩溃停止相同 | stable storage + 日志重放 |
| 消息丢失 | 节点正常 | 单条消息静默消失 | 不能,只能靠超时推断 | 重传 + 幂等 |
| 网络分区 | 节点都正常 | 两个方向都不通 | 不能区分“对端挂了”和“网络断了” | 多数派、租约、分区恢复 |
| 拜占庭 | 节点可能作恶 | 可伪造、篡改、选择性发送 | 单条消息层面不能 | 签名、交叉验证、BFT |
确定性网络:让故障可复现
真实网络故障无法按需复现。线上偶发的丢包、分区和重启,在测试环境里要么不出现,要么以不可控的方式出现。arch-lab 的做法是把网络抽象成一个显式的消息队列,故障注入变成队列上的确定性操作:
DropNext(n):接下来 n 条消息在投递时丢弃;Partition(a, b):切断双向链路,消息在投递时被丢弃;Crash(node)/Recover(node):节点停止/重启,在途消息随投递失败;CorruptNext(n):改写接下来 n 条消息的载荷,模拟拜占庭行为。
所有操作都同步、无睡眠、无随机数,因此测试可以精确断言“第几条消息被丢、统计计数是多少”。这比 time.Sleep 加真实 socket 可靠得多,也让失败用例能稳定复现。
// file: internal/platform/faultnet/faultnet.go
// tier: L1
// verify: go test ./internal/platform/faultnet/ -count=1
package faultnet
import (
"errors"
"fmt"
)
// Errors returned by the simulated network and replicas.
var (
ErrCrashed = errors.New("faultnet: node crashed")
ErrUnknownNode = errors.New("faultnet: unknown node")
)
// Message is one in-flight request or reply.
type Message struct {
From int
To int
Payload string
}
// Stats records what happened to messages, never what they meant.
type Stats struct {
Sent int
Delivered int
Dropped int
Corrupted int
}
// Network delivers messages in FIFO order with injectable faults.
type Network struct {
nodes int
crashed map[int]bool
cut map[[2]int]bool
queue []Message
dropN int
corruptN int
Stats Stats
}
// New creates a fully connected network of n nodes.
func New(n int) *Network {
return &Network{
nodes: n,
crashed: make(map[int]bool),
cut: make(map[[2]int]bool),
}
}
func (nw *Network) valid(node int) bool { return node >= 0 && node < nw.nodes }
// Send enqueues a message. A crashed sender cannot send.
func (nw *Network) Send(from, to int, payload string) error {
if !nw.valid(from) || !nw.valid(to) {
return fmt.Errorf("send %d->%d: %w", from, to, ErrUnknownNode)
}
if nw.crashed[from] {
return fmt.Errorf("send %d->%d: %w", from, to, ErrCrashed)
}
nw.Stats.Sent++
nw.queue = append(nw.queue, Message{From: from, To: to, Payload: payload})
return nil
}
// DropNext loses the next n messages at delivery time.
func (nw *Network) DropNext(n int) { nw.dropN += n }
// CorruptNext rewrites the payload of the next n delivered messages.
func (nw *Network) CorruptNext(n int) { nw.corruptN += n }
// Partition cuts both directions between a and b.
func (nw *Network) Partition(a, b int) {
nw.cut[[2]int{a, b}] = true
nw.cut[[2]int{b, a}] = true
}
// Heal restores both directions between a and b.
func (nw *Network) Heal(a, b int) {
delete(nw.cut, [2]int{a, b})
delete(nw.cut, [2]int{b, a})
}
// Crash stops a node; its queued messages are dropped on delivery.
func (nw *Network) Crash(node int) { nw.crashed[node] = true }
// Recover restarts a node with an empty volatile state.
func (nw *Network) Recover(node int) { delete(nw.crashed, node) }
// Pending reports how many messages are still queued.
func (nw *Network) Pending() int { return len(nw.queue) }
// Step removes and returns the next deliverable message.
func (nw *Network) Step() (Message, bool) {
for len(nw.queue) > 0 {
m := nw.queue[0]
nw.queue = nw.queue[1:]
if nw.dropN > 0 {
nw.dropN--
nw.Stats.Dropped++
continue
}
if nw.crashed[m.From] || nw.crashed[m.To] {
nw.Stats.Dropped++
continue
}
if nw.cut[[2]int{m.From, m.To}] {
nw.Stats.Dropped++
continue
}
if nw.corruptN > 0 {
nw.corruptN--
m.Payload = "corrupt:" + m.Payload
nw.Stats.Corrupted++
}
nw.Stats.Delivered++
return m, true
}
return Message{}, false
}
// Replica is one inventory state machine with a durable applied set.
type Replica struct {
stable map[string]bool
live map[string]bool
up bool
}
// NewReplica starts an empty, running replica.
func NewReplica() *Replica {
return &Replica{stable: make(map[string]bool), live: make(map[string]bool), up: true}
}
// Apply reserves exactly once per order ID and reports whether it was new.
func (r *Replica) Apply(orderID string) (bool, error) {
if !r.up {
return false, fmt.Errorf("apply %s: %w", orderID, ErrCrashed)
}
if r.stable[orderID] {
r.live[orderID] = true
return false, nil
}
r.stable[orderID] = true
r.live[orderID] = true
return true, nil
}
// Crash discards volatile state but keeps stable state.
func (r *Replica) Crash() {
r.up = false
r.live = make(map[string]bool)
}
// Recover reloads the working set from stable storage.
func (r *Replica) Recover() {
r.up = true
r.live = make(map[string]bool)
for id := range r.stable {
r.live[id] = true
}
}
// Applied reports whether the order is present after the last recovery.
func (r *Replica) Applied(orderID string) bool { return r.live[orderID] }
Stats 只记录消息层面的结果,不记录业务含义,这是有意为之:网络层能告诉你“丢了 3 条”,不能告诉你“丢了 3 条预留请求,其中 2 条已经在服务端生效”。业务语义必须由上层协议自己维护。
用表驱动测试逐项注入
// file: internal/platform/faultnet/faultnet_test.go
// tier: L1
// verify: go test ./internal/platform/faultnet/ -count=1
package faultnet
import (
"errors"
"testing"
)
func TestFaultInjectionTable(t *testing.T) {
cases := []struct {
name string
inject func(*Network)
wantOK bool
wantDrop int
wantCorr int
wantPayload string
}{
{
name: "clean link",
inject: func(*Network) {},
wantOK: true,
wantPayload: "reserve:ord-1",
},
{
name: "one message lost",
inject: func(nw *Network) { nw.DropNext(1) },
wantOK: false,
wantDrop: 1,
wantPayload: "",
},
{
name: "partition blocks delivery",
inject: func(nw *Network) { nw.Partition(0, 1) },
wantOK: false,
wantDrop: 1,
wantPayload: "",
},
{
name: "receiver crashed",
inject: func(nw *Network) { nw.Crash(1) },
wantOK: false,
wantDrop: 1,
wantPayload: "",
},
{
name: "byzantine payload rewrite",
inject: func(nw *Network) { nw.CorruptNext(1) },
wantOK: true,
wantCorr: 1,
wantPayload: "corrupt:reserve:ord-1",
},
{
name: "healed partition delivers",
inject: func(nw *Network) { nw.Partition(0, 1); nw.Heal(0, 1) },
wantOK: true,
wantPayload: "reserve:ord-1",
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
nw := New(3)
tc.inject(nw)
if err := nw.Send(0, 1, "reserve:ord-1"); err != nil {
t.Fatalf("send: %v", err)
}
msg, ok := nw.Step()
if ok != tc.wantOK {
t.Fatalf("delivered = %v, want %v", ok, tc.wantOK)
}
if tc.wantOK && msg.Payload != tc.wantPayload {
t.Fatalf("payload = %q, want %q", msg.Payload, tc.wantPayload)
}
if nw.Stats.Dropped != tc.wantDrop {
t.Fatalf("dropped = %d, want %d", nw.Stats.Dropped, tc.wantDrop)
}
if nw.Stats.Corrupted != tc.wantCorr {
t.Fatalf("corrupted = %d, want %d", nw.Stats.Corrupted, tc.wantCorr)
}
})
}
}
func TestCrashRecoveryKeepsStableState(t *testing.T) {
r := NewReplica()
applied, err := r.Apply("ord-1")
if err != nil || !applied {
t.Fatalf("first apply = (%v, %v), want (true, nil)", applied, err)
}
r.Crash()
if _, err := r.Apply("ord-1"); !errors.Is(err, ErrCrashed) {
t.Fatalf("apply while crashed: err = %v, want ErrCrashed", err)
}
if r.Applied("ord-1") {
t.Fatal("volatile state survived crash")
}
r.Recover()
if !r.Applied("ord-1") {
t.Fatal("stable state missing after recovery")
}
applied, err = r.Apply("ord-1")
if err != nil || applied {
t.Fatalf("replayed apply = (%v, %v), want (false, nil)", applied, err)
}
}
本机实际运行结果(Go 1.26.0,darwin/arm64):
=== RUN TestFaultInjectionTable
=== RUN TestFaultInjectionTable/clean_link
=== RUN TestFaultInjectionTable/one_message_lost
=== RUN TestFaultInjectionTable/partition_blocks_delivery
=== RUN TestFaultInjectionTable/receiver_crashed
=== RUN TestFaultInjectionTable/byzantine_payload_rewrite
=== RUN TestFaultInjectionTable/healed_partition_delivers
--- PASS: TestFaultInjectionTable (0.00s)
--- PASS: TestFaultInjectionTable/clean_link (0.00s)
--- PASS: TestFaultInjectionTable/one_message_lost (0.00s)
--- PASS: TestFaultInjectionTable/partition_blocks_delivery (0.00s)
--- PASS: TestFaultInjectionTable/receiver_crashed (0.00s)
--- PASS: TestFaultInjectionTable/byzantine_payload_rewrite (0.00s)
--- PASS: TestFaultInjectionTable/healed_partition_delivers (0.00s)
=== RUN TestCrashRecoveryKeepsStableState
--- PASS: TestCrashRecoveryKeepsStableState (0.00s)
PASS
ok archlab/internal/platform/faultnet 0.470s
两个读数值得注意。第一,byzantine payload rewrite 的 wantOK 是 true:消息照样投递成功,只是内容被改成了 corrupt:reserve:ord-1,Stats.Corrupted 增加 1。网络层没有能力阻止或识别篡改,它只能计数。第二,崩溃恢复测试里,Crash 之后 Apply 返回 ErrCrashed,Applied 为 false;Recover 之后 Applied 又变回 true,再次 Apply 同一个 orderID 返回 (false, nil)。稳定存储保住了已提交事实,重放没有产生第二次预占。
为什么订单系统默认按崩溃-恢复建模
arch-lab 的内部服务选择“崩溃-恢复 + 消息可能丢失 + 可能分区”,不选择拜占庭,理由不是“拜占庭太学术”,而是三条工程判据:
- 故障占主导。 订单系统的真实事故绝大多数是进程崩溃、机器宕机、发布中断、网络抖动和机房链路故障。这些都属于非拜占庭故障。
- 恢复语义可落地。 崩溃-恢复模型允许用 stable storage、WAL、复制日志和幂等重放把状态收敛回来;拜占庭模型要求每条消息可验证、每个节点不可信,协议消息轮次和签名成本会显著上升。
- 信任边界在集群外部。 内部节点由同一套发布、监控和权限体系管理,默认不恶意;真正的不可信输入来自公网请求,应该在入口做鉴权、校验和限流,而不是让整个内部协议承担 BFT 成本。
| 判据 | 选崩溃-恢复 | 选拜占庭容错 |
|---|---|---|
| 故障来源 | 硬件、进程、网络 | 节点可能被攻陷或作恶 |
| 节点信任 | 同一信任域 | 跨组织、无共同管理员 |
| 典型代价 | 日志、快照、重放 | 3f+1 节点、额外通信轮次 |
需要强调的是:选择崩溃-恢复不等于忽略数据损坏。磁盘位翻转、软件 bug 产生的错误写入仍然可能发生,工程上用校验和、备份、对账来兜底,而不是把整个协议升级成 BFT。
与站内文章的分工
Raft 实现 展示了崩溃-恢复模型下如何用任期、日志匹配和多数派提交保证共识。本节不重复 Raft 的算法,只说明它为什么可以假定节点“要么按日志执行,要么崩溃后按日志恢复”,而不能假定节点会撒谎。
常见错误与边界
第一,把网络分区当成节点崩溃。分区时两边都活着,各自可能继续服务;如果两边都认为自己可以写,就会产生双写。分区恢复后必须先解决冲突,不能简单让后写覆盖。
第二,崩溃恢复后直接重放所有请求而不做去重。稳定存储保住了“已应用”的标记,但重放路径必须真的读取这个标记;否则同一个 orderID 会被扣两次库存。
第三,用应用层日志代替 stable storage。进程崩溃时,内存缓冲和未 fsync 的日志都可能丢失;崩溃-恢复模型的前提是“已确认的事实已经持久化”,不是“我调用了写日志函数”。
第四,在测试里只注入“请求失败”,不注入“请求成功但响应丢失”。前者只覆盖崩溃停止的一部分,后者才会暴露幂等和查询的必要性;反过来,内部订单系统也不该为了“以防万一”承担 BFT 的通信成本,模型要匹配威胁。
小结
故障模型决定了系统能做什么假设,也决定了恢复手段的成本。崩溃停止与崩溃恢复的差别在于状态是否持久化,丢包与分区的差别在于链路是否整段失效,拜占庭则是唯一需要把“节点可能撒谎”写进协议的模型。arch-lab 选择崩溃-恢复,是因为订单系统的真实故障以进程和网络为主,稳定存储加幂等重放足以收敛。下一节转向调用方最常犯的错误:把网络当成可靠、零延迟、带宽无限的管道,并验证重试如何在多层之间放大。
阅读导航:上一节:从单机到分布式:哪些问题真的变了 · 下一节:远程调用的八个假设与端到端原则 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。