《后端架构设计·分布式与数据一致性》1.1 从单机到分布式:哪些问题真的变了

同一段订单预留逻辑,在进程内调用与跨节点调用下会暴露部分失败、响应丢失与墙上时钟错序。本文用可运行的 Go 双节点模拟器把调用结果分成 APPLIED、NOT_APPLIED、UNKNOWN 三类,并用测试说明未知状态下重试为什么必须依赖按订单号的幂等,而不是调用方的猜测。

一段看起来没变的调用

arch-lab 的下单流程里,订单节点要调用库存节点预留库存。单机版本就是一行方法调用:

reservationID, err := inventory.Reserve(orderID, "sku-a", 2)
if err != nil {
    return fmt.Errorf("place order %s: %w", orderID, err)
}

这段代码把返回的 err 当成事实:有错就是没预留,没错就是预留了。只要 Reserve 和调用方在同一个进程里,这个假设成立,因为一次函数调用要么正常返回,要么带着错误返回,不存在“函数已经执行完,但调用方永远收不到返回值”的中间态。

把库存节点搬到另一台机器后,调用点几乎没变,语义却变了。订单节点在 200ms 后超时,库存节点可能在 210ms 才完成扣减;订单节点收到连接重置,库存节点可能早已提交;订单节点看到 500,库存节点可能是在写响应时崩溃。调用方拿到的错误,描述的是通信结果,不是对端状态。

这是从单机到分布式真正变化的第一件事:失败不再是二值的。单机代码只有“成功”和“失败”,跨节点代码必须承认第三种结果——不知道。

三处只在跨节点出现的差异

部分失败:一半成功不是异常,是常态

单机事务里,Reserve 和状态更新共享一个失败边界:要么都发生,要么都不发生。跨节点后,“预留成功、订单状态未更新”“订单已创建、预留请求没发出”“预留完成、响应丢失”都是合法中间态。系统不会停在一致状态等你处理,它会带着这些半成品继续接受流量。

部分失败之所以危险,不是因为它复杂,而是因为它不可判定。调用方无法仅凭超时判断对端做没做,只能通过额外的查询、幂等键或对账来收敛。这个结论会贯穿整卷。

延迟不是常数,重试会放大它

本地方法调用延迟通常在纳秒到微秒级,跨节点调用要经过序列化、排队、网络往返和对端调度。更关键的是尾部延迟:平均值 20ms 不代表没有 2s 的请求。一次下单请求如果串行调用库存、支付、优惠券三个服务,每个服务 p99 都是 200ms,那么整体 p99 不是 200ms,而是三个尾部同时出现的概率问题;调用方再套一层重试,下游看到的是成倍的流量。

延迟还改变了超时的含义。超时设置得比对端处理时间短,就会制造大量“实际成功但调用方认为失败”的请求;设置得太长,又会拖垮线程和连接池。超时值不是配置项,而是对失败语义的声明。

没有共享时钟,顺序要自己编码

订单节点和库存节点各自读自己的墙上时钟。订单节点在本地 1000ms 记录 reserve,库存节点在本地 995ms 记录 confirm,按时间戳排序会得到 confirm 在前。可是因果上,confirm 是收到 reserve 之后才发生的。

A(reserve) @1000ms  ──发送──▶  B(confirm) @995ms
按墙上时钟排序:B 在前,A 在后(错误)
按因果排序:    A 在前,B 在后(正确)

这不是时钟“不准”那么简单:NTP 校正、虚拟机暂停、闰秒处理都会让两台机器的读数在短时间内反向。跨节点排序必须依赖逻辑时钟、版本号或显式的因果标记,不能直接把 time.Now() 塞进排序键。第 2 章会展开时钟,本节只需要记住:分布式系统里没有全局的“现在”。

双节点模拟器:把三种结果写进类型

下面这段代码把订单节点和库存节点拆成两个对象,用 Link 模拟跨节点调用,并把故障注入放在链路上。关键设计是 Outcome 类型:它不描述“成功/失败”,而描述“调用方知道什么”。

// file: internal/platform/simcall/simcall.go
// tier: L1
// verify: go test ./internal/platform/simcall/ -count=1
package simcall

import (
	"errors"
	"fmt"
	"sort"
)

// Sentinel errors returned by the simulated inventory node.
var (
	ErrRejected          = errors.New("simcall: inventory rejected")
	ErrInsufficientStock = errors.New("simcall: insufficient stock")
	ErrTimeout           = errors.New("simcall: deadline exceeded")
)

// Outcome is what the caller knows after the call returns.
type Outcome string

const (
	OutcomeApplied    Outcome = "APPLIED"
	OutcomeNotApplied Outcome = "NOT_APPLIED"
	OutcomeUnknown    Outcome = "UNKNOWN"
)

// Fault injects one failure mode into the call path.
type Fault int

const (
	FaultNone Fault = iota
	FaultRejectBeforeApply
	FaultTimeoutAfterApply
)

// Mode selects an in-process call or a simulated cross-node call.
type Mode int

const (
	ModeLocal Mode = iota
	ModeRemote
)

// Inventory is the reservation aggregate.
type Inventory struct {
	stock    map[string]int
	reserved map[string]string // orderID -> reservationID
	seq      int
}

// NewInventory copies the initial stock so the caller keeps ownership.
func NewInventory(stock map[string]int) *Inventory {
	cp := make(map[string]int, len(stock))
	for k, v := range stock {
		cp[k] = v
	}
	return &Inventory{stock: cp, reserved: make(map[string]string)}
}

// Reserve applies the reservation idempotently by order ID.
func (inv *Inventory) Reserve(orderID, sku string, qty int) (string, error) {
	if id, ok := inv.reserved[orderID]; ok {
		return id, nil
	}
	if qty <= 0 {
		return "", fmt.Errorf("reserve %s: %w", orderID, ErrRejected)
	}
	if inv.stock[sku] < qty {
		return "", fmt.Errorf("reserve %s: %w", orderID, ErrInsufficientStock)
	}
	inv.stock[sku] -= qty
	inv.seq++
	id := fmt.Sprintf("rsv-%d", inv.seq)
	inv.reserved[orderID] = id
	return id, nil
}

// Stock reports the remaining quantity for a SKU.
func (inv *Inventory) Stock(sku string) int { return inv.stock[sku] }

// ReservationID returns the stored reservation for an order.
func (inv *Inventory) ReservationID(orderID string) (string, bool) {
	id, ok := inv.reserved[orderID]
	return id, ok
}

// Link is the order node's view of the inventory node.
type Link struct {
	Mode      Mode
	Fault     Fault
	Inventory *Inventory
}

// Reserve returns the outcome visible to the caller. UNKNOWN means the
// request may or may not have taken effect.
func (l *Link) Reserve(orderID, sku string, qty int) (Outcome, string, error) {
	if l.Mode == ModeLocal {
		id, err := l.Inventory.Reserve(orderID, sku, qty)
		if err != nil {
			return OutcomeNotApplied, "", err
		}
		return OutcomeApplied, id, nil
	}
	if l.Fault == FaultRejectBeforeApply {
		return OutcomeNotApplied, "", ErrRejected
	}
	id, err := l.Inventory.Reserve(orderID, sku, qty)
	if err != nil {
		return OutcomeNotApplied, "", err
	}
	if l.Fault == FaultTimeoutAfterApply {
		return OutcomeUnknown, "", ErrTimeout
	}
	return OutcomeApplied, id, nil
}

// Event is an observation stamped by a node's wall clock.
type Event struct {
	Node   string
	Name   string
	WallMS int64
}

// ByWallClock sorts a copy of events by wall-clock timestamps.
func ByWallClock(events []Event) []Event {
	out := append([]Event(nil), events...)
	sort.Slice(out, func(i, j int) bool { return out[i].WallMS < out[j].WallMS })
	return out
}

注意 Inventory.Reserve 里的第一段:如果 orderID 已有预占单,直接返回原来的单号。这不是分布式锁,也不是事务,而是一个最小幂等契约——同一个订单重复预留,库存只扣一次。这个契约在下面会被测试反复验证。

用表驱动测试固定错误分类

测试不只验证“成功路径”,更要把每种故障对应的调用方可见结果钉死。表里五行的期望值就是本节的核心结论。

// file: internal/platform/simcall/simcall_test.go
// tier: L1
// verify: go test ./internal/platform/simcall/ -count=1
package simcall

import (
	"errors"
	"testing"
)

func TestReserveOutcomes(t *testing.T) {
	cases := []struct {
		name        string
		mode        Mode
		fault       Fault
		qty         int
		want        Outcome
		wantErr     error
		wantStock   int
		wantReserve bool
	}{
		{"local success", ModeLocal, FaultNone, 2, OutcomeApplied, nil, 8, true},
		{"remote success", ModeRemote, FaultNone, 2, OutcomeApplied, nil, 8, true},
		{"remote reject before apply", ModeRemote, FaultRejectBeforeApply, 2, OutcomeNotApplied, ErrRejected, 10, false},
		{"remote ack lost after apply", ModeRemote, FaultTimeoutAfterApply, 2, OutcomeUnknown, ErrTimeout, 8, true},
		{"remote out of stock", ModeRemote, FaultNone, 11, OutcomeNotApplied, ErrInsufficientStock, 10, false},
	}

	for _, tc := range cases {
		t.Run(tc.name, func(t *testing.T) {
			inv := NewInventory(map[string]int{"sku-a": 10})
			link := &Link{Mode: tc.mode, Fault: tc.fault, Inventory: inv}

			got, _, err := link.Reserve("ord-1", "sku-a", tc.qty)
			if got != tc.want {
				t.Fatalf("outcome = %s, want %s", got, tc.want)
			}
			if tc.wantErr == nil && err != nil {
				t.Fatalf("unexpected error: %v", err)
			}
			if tc.wantErr != nil && !errors.Is(err, tc.wantErr) {
				t.Fatalf("error = %v, want %v", err, tc.wantErr)
			}
			if stock := inv.Stock("sku-a"); stock != tc.wantStock {
				t.Fatalf("stock = %d, want %d", stock, tc.wantStock)
			}
			_, reserved := inv.ReservationID("ord-1")
			if reserved != tc.wantReserve {
				t.Fatalf("reserved = %v, want %v", reserved, tc.wantReserve)
			}
		})
	}
}

func TestRetryAfterUnknownDoesNotDoubleReserve(t *testing.T) {
	inv := NewInventory(map[string]int{"sku-a": 5})

	lost := &Link{Mode: ModeRemote, Fault: FaultTimeoutAfterApply, Inventory: inv}
	out, _, err := lost.Reserve("ord-1", "sku-a", 2)
	if out != OutcomeUnknown || !errors.Is(err, ErrTimeout) {
		t.Fatalf("first call = (%s, %v), want (UNKNOWN, timeout)", out, err)
	}
	if stock := inv.Stock("sku-a"); stock != 3 {
		t.Fatalf("stock after unknown = %d, want 3", stock)
	}

	retry := &Link{Mode: ModeRemote, Inventory: inv}
	out, id, err := retry.Reserve("ord-1", "sku-a", 2)
	if out != OutcomeApplied || err != nil {
		t.Fatalf("retry = (%s, %v), want APPLIED", out, err)
	}
	if stock := inv.Stock("sku-a"); stock != 3 {
		t.Fatalf("stock after retry = %d, want 3 (no double reserve)", stock)
	}
	stored, ok := inv.ReservationID("ord-1")
	if !ok || stored != id {
		t.Fatalf("reservation = %q, want %q", stored, id)
	}
}

func TestWallClockCanInvertCausalOrder(t *testing.T) {
	// A reserves at its local 1000ms; B confirms at its local 995ms because
	// B's clock is 10ms behind. Causality says reserve happened first.
	events := []Event{
		{Node: "A", Name: "reserve", WallMS: 1000},
		{Node: "B", Name: "confirm", WallMS: 995},
	}
	got := ByWallClock(events)
	if got[0].Name != "confirm" {
		t.Fatalf("sorted order = %s first, want confirm (demonstrating inversion)", got[0].Name)
	}
}

本机实际运行结果(Go 1.26.0,darwin/arm64):

=== RUN   TestReserveOutcomes
=== RUN   TestReserveOutcomes/local_success
=== RUN   TestReserveOutcomes/remote_success
=== RUN   TestReserveOutcomes/remote_reject_before_apply
=== RUN   TestReserveOutcomes/remote_ack_lost_after_apply
=== RUN   TestReserveOutcomes/remote_out_of_stock
--- PASS: TestReserveOutcomes (0.00s)
    --- PASS: TestReserveOutcomes/local_success (0.00s)
    --- PASS: TestReserveOutcomes/remote_success (0.00s)
    --- PASS: TestReserveOutcomes/remote_reject_before_apply (0.00s)
    --- PASS: TestReserveOutcomes/remote_ack_lost_after_apply (0.00s)
    --- PASS: TestReserveOutcomes/remote_out_of_stock (0.00s)
=== RUN   TestRetryAfterUnknownDoesNotDoubleReserve
--- PASS: TestRetryAfterUnknownDoesNotDoubleReserve (0.00s)
=== RUN   TestWallClockCanInvertCausalOrder
--- PASS: TestWallClockCanInvertCausalOrder (0.00s)
PASS
ok  	archlab/internal/platform/simcall	0.961s

读数里最重要的一行是 remote ack lost after apply:期望结果是 UNKNOWN,但 wantStock 是 8、wantReserve 是 true——也就是说,调用方明确收到了超时错误,库存却已经扣减、预占单也已经存在。如果订单节点按“超时即失败”回滚订单,就会得到一张已取消但库存仍被占用的订单。

第二个测试把这个边界再推一步:在 UNKNOWN 之后重试同一个 orderID,库存仍然是 3,预占单号也没有变。这不是因为调用方聪明,而是因为服务端把 orderID 当成了幂等键。没有这一层,重试就是重复扣减。

第三个测试故意让 B 的时钟慢 10ms,排序结果把 confirm 排在 reserve 前面。它说明墙上时钟只能表示“哪台机器觉得现在几点”,不能表示“谁先发生”。

调用方应该怎么处理三类结果

调用方看到对端是否可能已生效不能做什么应该做什么
APPLIED + 预占单号是再次用新单号预占记录单号,推进订单状态
NOT_APPLIED + 业务错误否把它当网络故障无限重试按错误码拒绝或走缺货流程
UNKNOWN + 超时/连接错误是直接回滚本地状态、换新单号重试用同一 orderID 重试,或查询预占结果
连接被拒绝(进程未启动)通常否,但不保证假设绝对没生效仍按未知处理,交给幂等兜底

这张表的最后一行值得单独说:连接被拒绝通常意味着请求没到达,但在容器重启、负载均衡切换、连接池复用的场景里,“拒绝”可能发生在请求已经转发之后。工程上最稳妥的默认值是:只要不是明确的业务拒绝,就按 UNKNOWN 处理。 这不是保守,而是把正确性放在性能之前。

与站内文章的分工

分布式系统基础 已经解释了 CAP、BASE、分区容忍这些概念的定义,本节不再重复。本节回答的是更窄的问题:当一段订单代码从进程内搬到跨节点,调用方到底多知道了什么、少知道了什么。

线性一致性模型 讨论的是多副本读写的整体保证。本节只处理单次远程调用的结果分类,二者层次不同:先把单次调用的不确定性说清楚,后面才谈得上多个副本之间的一致性模型。

常见错误与边界

第一,把超时等同于失败。超时只说明调用方等不及了,不说明对端没执行。凡是“超时后删除本地订单”的补偿逻辑,都必须先确认库存侧的真实状态,否则会制造悬空预占。

第二,只在客户端加重试,不在服务端做幂等。重试次数越多,重复扣减的概率越高;幂等键必须由业务标识(这里是 orderID)承担,不能靠“这次一定不会重复”的假设。

第三,用墙上时钟给跨节点事件排序。时钟漂移、NTP 回拨和虚拟机暂停都会让时间戳与因果顺序相反。排序键要用逻辑时钟、版本号或单调序号。

第四,在单机测试里 mock 掉网络,只测成功和业务异常。这样的测试永远不会产生 UNKNOWN,也就永远发现不了“对端已生效但响应丢失”的分支。故障注入必须能区分“执行前失败”和“执行后失败”。

第五,以为把超时调大就能消灭 UNKNOWN。更长的超时只是缩小窗口,不能消除窗口;进程崩溃、网络分区和响应丢失依然存在。正确做法是承认窗口,并用幂等与查询收敛。

小结

从单机到分布式,真正变化的不是“多了一台机器”,而是失败从二值变成了三值:成功、失败、未知。订单节点必须把 UNKNOWN 当成一等结果,服务端必须用业务标识提供幂等,排序必须放弃墙上时钟。本节的双节点模拟器只覆盖了响应丢失和业务拒绝两种故障,下一节把故障本身做成可注入的确定性网络,系统比较崩溃停止、崩溃恢复、丢包、分区与拜占庭,并说明订单系统为什么默认按崩溃-恢复建模。

阅读导航:上一节:目录 · 下一节:故障模型:崩溃、丢包、分区与拜占庭 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「books」更多文章

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