《后端架构设计·分布式与数据一致性》1.2 故障模型:崩溃、丢包、分区与拜占庭

崩溃停止、崩溃恢复、消息丢失、网络分区与拜占庭故障,对调用方暴露的信号并不相同。本文用可复现的确定性网络逐项注入五类故障,配合持久化副本测试说明:订单系统默认按崩溃-恢复建模,是因为稳定存储加幂等重放能够收敛,而拜占庭需要另一套成本高得多的协议。

一次没有响应的预留

订单节点向库存节点发起预留,等了 300ms 没有响应。此时至少有五种完全不同的现实:

  1. 库存进程被 kill -9,或处理到一半崩溃后从持久化日志恢复;
  2. 请求或响应在网络上丢了,或两台机器之间的链路断了但两边都还活着;
  3. 库存节点返回了一个格式正确、内容错误的预占单号。

前两种属于非拜占庭故障,第三种是拜占庭故障。调用方看到的都是“没有可用响应”,但能用来恢复的手段完全不同:崩溃-恢复可以重放日志,丢包可以重传,分区要等网络恢复或走多数派,拜占庭需要签名、交叉验证或法定人数投票。故障模型不是学术分类,它决定你愿意为正确性付多少成本;从一次失败反推故障类型是不可能的,把异常都当网络抖动、重试就完了,是订单系统最常见的误判。

五种故障的可观测差异

故障节点状态消息调用方能否区分典型恢复手段
崩溃停止进程终止,不再恢复在途消息丢失只能看到超时/连接拒绝重启、主备切换
崩溃恢复进程终止后重启在途消息丢失与崩溃停止相同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 的内部服务选择“崩溃-恢复 + 消息可能丢失 + 可能分区”,不选择拜占庭,理由不是“拜占庭太学术”,而是三条工程判据:

  1. 故障占主导。 订单系统的真实事故绝大多数是进程崩溃、机器宕机、发布中断、网络抖动和机房链路故障。这些都属于非拜占庭故障。
  2. 恢复语义可落地。 崩溃-恢复模型允许用 stable storage、WAL、复制日志和幂等重放把状态收敛回来;拜占庭模型要求每条消息可验证、每个节点不可信,协议消息轮次和签名成本会显著上升。
  3. 信任边界在集群外部。 内部节点由同一套发布、监控和权限体系管理,默认不恶意;真正的不可信输入来自公网请求,应该在入口做鉴权、校验和限流,而不是让整个内部协议承担 BFT 成本。
判据选崩溃-恢复选拜占庭容错
故障来源硬件、进程、网络节点可能被攻陷或作恶
节点信任同一信任域跨组织、无共同管理员
典型代价日志、快照、重放3f+1 节点、额外通信轮次

需要强调的是:选择崩溃-恢复不等于忽略数据损坏。磁盘位翻转、软件 bug 产生的错误写入仍然可能发生,工程上用校验和、备份、对账来兜底,而不是把整个协议升级成 BFT。

与站内文章的分工

Raft 实现 展示了崩溃-恢复模型下如何用任期、日志匹配和多数派提交保证共识。本节不重复 Raft 的算法,只说明它为什么可以假定节点“要么按日志执行,要么崩溃后按日志恢复”,而不能假定节点会撒谎。

常见错误与边界

第一,把网络分区当成节点崩溃。分区时两边都活着,各自可能继续服务;如果两边都认为自己可以写,就会产生双写。分区恢复后必须先解决冲突,不能简单让后写覆盖。

第二,崩溃恢复后直接重放所有请求而不做去重。稳定存储保住了“已应用”的标记,但重放路径必须真的读取这个标记;否则同一个 orderID 会被扣两次库存。

第三,用应用层日志代替 stable storage。进程崩溃时,内存缓冲和未 fsync 的日志都可能丢失;崩溃-恢复模型的前提是“已确认的事实已经持久化”,不是“我调用了写日志函数”。

第四,在测试里只注入“请求失败”,不注入“请求成功但响应丢失”。前者只覆盖崩溃停止的一部分,后者才会暴露幂等和查询的必要性;反过来,内部订单系统也不该为了“以防万一”承担 BFT 的通信成本,模型要匹配威胁。

小结

故障模型决定了系统能做什么假设,也决定了恢复手段的成本。崩溃停止与崩溃恢复的差别在于状态是否持久化,丢包与分区的差别在于链路是否整段失效,拜占庭则是唯一需要把“节点可能撒谎”写进协议的模型。arch-lab 选择崩溃-恢复,是因为订单系统的真实故障以进程和网络为主,稳定存储加幂等重放足以收敛。下一节转向调用方最常犯的错误:把网络当成可靠、零延迟、带宽无限的管道,并验证重试如何在多层之间放大。

阅读导航:上一节:从单机到分布式:哪些问题真的变了 · 下一节:远程调用的八个假设与端到端原则 。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「books」更多文章

  1. 《互联网产品的 AI 架构·平台化与治理》附录 C 平台化评审清单
  2. 《互联网产品的 AI 架构·平台化与治理》附录 B 能力目录与配额字段速查
  3. 《互联网产品的 AI 架构·平台化与治理》附录 A 治理角色与职责速查