一段看起来没变的调用
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 当成一等结果,服务端必须用业务标识提供幂等,排序必须放弃墙上时钟。本节的双节点模拟器只覆盖了响应丢失和业务拒绝两种故障,下一节把故障本身做成可注入的确定性网络,系统比较崩溃停止、崩溃恢复、丢包、分区与拜占庭,并说明订单系统为什么默认按崩溃-恢复建模。
阅读导航:上一节:目录 · 下一节:故障模型:崩溃、丢包、分区与拜占庭 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。