《后端架构设计·分布式与数据一致性》1.3 远程调用的八个假设与端到端原则

网络可靠、延迟为零、带宽无限等八个假设,在订单链路上会以重试放大、重复扣款和超时误判的形式出现。本文用可运行的 Go 重试模拟器测出三层各重试三次会把一次调用放大成 64 次,并说明为什么端到端可靠性必须由调用方与服务端的幂等共同保证,而不能交给中间链路。

三次重试,为什么下游看到了 64 次

一个下单请求经过三层:客户端 SDK、API 网关、订单服务。每层都配置了“失败重试 3 次”,也就是最多尝试 4 次。产品经理解释得很合理:多试几次总能成功,可用性会更高。

库存服务看到的不是 4 次,而是 4×4×4=64 次。原因很简单:最内层每次失败,订单服务重试 4 次;订单服务的 4 次全失败,网关再重试 4 次,于是订单服务被调用 16 次;客户端再重试 4 次,网关被调用 64 次。每一层都以为自己在“兜底”,实际是在把下游的失败放大成雪崩。

更糟的是,重试不一定失败。如果库存服务已经扣减成功,只是响应在返回路上丢了,那么每一次重试都会命中一个“看起来没成功”的请求。没有幂等,重试就是在重复扣库存。

这两个现象指向同一组错误假设:调用方默认网络可靠、延迟可忽略、失败可以靠重试消除。这些假设在单机进程内成立,在跨节点调用里几乎全部不成立。

八个假设逐条拆

假设现实arch-lab 里的后果应对
网络可靠丢包、连接重置、半开连接都会发生预留请求丢失或响应丢失,调用方无法区分超时 + 重试 + 幂等键
延迟为零同城往返毫秒级,跨区几十毫秒,尾延迟更长串行调用库存、支付、优惠券,p99 叠加超时预算、并行化、异步化
带宽无限序列化、代理缓冲、跨区流量都有限大订单快照拖慢链路,挤占其他请求字段裁剪、分页、压缩
网络安全内网也会被横向移动,回调可被伪造伪造支付成功、重放预留请求mTLS、签名、幂等与时间窗
拓扑不变扩缩容、滚动发布、故障切换每天都在发生长连接指向已下线实例,请求打到旧地址服务发现、重连、就绪探针
只有一个管理员多团队、多集群、多套配置超时和重试策略不一致,故障时互相踩踏契约、版本化配置、统一观测
传输成本为零跨区流量计费,序列化和压缩消耗 CPU大批量对账把网络和 CPU 同时打满批量合并、本地缓存、限流
网络同构内核、MTU、代理、NAT 超时各不相同长连接被中间设备静默断开心跳、应用层超时、标准化协议

这张表不需要背。真正要记住的是:每一条假设都对应一个必须在应用层显式处理的失败模式。中间件能降低概率,不能把概率变成零。

可靠性:TCP 保证的是字节,不是业务

TCP 会重传丢失的段、按序交付字节流,但它不能保证“服务端只执行一次”,也不能保证“调用方知道服务端执行了”。连接断开时,发送方可能已经写完数据,接收方可能已经提交事务,而确认没有回到发送方。这就是 1.1 里的 UNKNOWN。

数据库连接池、HTTP/2 多路复用、服务网格的自动重试,都只能改善传输层行为。“库存只扣一次”是业务不变量,只能由库存服务自己维护。

延迟:平均值会骗人

假设库存、支付、优惠券三个下游的平均延迟都是 20ms,串行调用的平均值是 60ms,看起来可以接受。但如果每个下游都有 1% 的请求超过 500ms,三个下游里至少一个超时的概率约为 3%,这部分请求的端到端延迟会被最慢的那个拖到 500ms 以上。平均值掩盖了尾部,而用户体验和超时配置都由尾部决定。

重试会进一步放大尾部:一次 500ms 的超时触发重试,下游要多处理一次,队列更长,更多请求超过阈值。重试在系统接近饱和时不是恢复手段,而是负载放大器。

安全与拓扑:内网不是信任的同义词

“内网调用不用鉴权”是另一个常见假设。攻击者一旦进入内网,未鉴权的库存接口可以被直接调用;伪造的回调可以把订单改成已支付。服务网格的 mTLS 解决的是传输身份,不解决“这个请求是否代表一次合法业务操作”。后者仍然需要签名、幂等键和状态机校验。

拓扑假设同样脆弱。滚动发布时,旧实例正在退出,新实例还没就绪;客户端缓存的地址列表可能同时包含两者。没有服务发现和重连,请求会持续打到已经关闭的端口。

端到端原则:正确性只能由端点保证

端到端原则(end-to-end argument)说的是:一个功能如果必须由通信两端共同保证,那么只在中间层实现它是不可靠的。中间层可以优化,但不能替代端点。

放到订单链路上:

  • 网关可以校验请求格式,但不能替库存服务保证“同一订单只预留一次”;
  • 消息队列可以持久化消息,但不能替消费者保证“副作用只发生一次”;
  • 负载均衡可以重试,但不能替调用方判断“上一次是否已经成功”;
  • 代理可以算校验和,但不能替接收方验证“这个响应对应的是哪次请求”。

因此,可靠性必须落在两个端点上:调用方带稳定的幂等键(orderID),服务端用这个键去重;调用方设置总超时预算,服务端在预算内完成或明确拒绝;双方都能查询“这笔操作的真实状态”,而不是靠猜。

调用方                     中间层                     服务端
  |-- req(orderID, seq) ----->|------------------------->|
  |                           |   (可重试、可丢、可重复)   |
  |<-- ack / timeout ---------|<-------------------------|
  |                                                      |
  |  端点责任:稳定幂等键 + 总超时预算 + 状态查询         |
  |  中间层责任:转发、限流、观测,不承诺业务语义         |

用代码测出重试放大

下面的模拟器把“每层重试”写成递归:Retry(layers, retries, fn) 在每一层最多调用下一层 retries+1 次,最内层才是真正的订单服务。Server 记录调用次数和副作用次数,两者分开统计,才能看出“调用了很多次”和“扣了很多次库存”不是一回事。

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

import (
	"errors"
	"fmt"
)

// Errors returned by the simulated terminal service.
var (
	ErrUnavailable = errors.New("edgecall: downstream unavailable")
	ErrTimeout     = errors.New("edgecall: acknowledgement lost")
)

// FailMode selects how the terminal service fails.
type FailMode int

const (
	FailNever FailMode = iota
	FailAlways
	FailAfterEffect
)

// Server is the terminal order service reached by every retry layer.
type Server struct {
	Idempotent bool
	Mode       FailMode
	Calls      int
	Effects    int
	seen       map[string]bool
	attempts   map[string]int
}

// NewServer creates a terminal service with an empty dedupe set.
func NewServer(idempotent bool, mode FailMode) *Server {
	return &Server{
		Idempotent: idempotent,
		Mode:       mode,
		seen:       make(map[string]bool),
		attempts:   make(map[string]int),
	}
}

// Call performs the order-side effect once and reports the transport result.
func (s *Server) Call(reqID string) error {
	s.Calls++
	s.attempts[reqID]++
	if s.Mode == FailAlways {
		return fmt.Errorf("call %s: %w", reqID, ErrUnavailable)
	}
	if s.Mode == FailAfterEffect && s.attempts[reqID] == 1 {
		s.apply(reqID)
		return fmt.Errorf("call %s: %w", reqID, ErrTimeout)
	}
	s.apply(reqID)
	return nil
}

func (s *Server) apply(reqID string) {
	if s.Idempotent && s.seen[reqID] {
		return
	}
	s.seen[reqID] = true
	s.Effects++
}

// Retry runs fn with at most retries+1 attempts per layer.
func Retry(layers, retries int, fn func() error) error {
	if layers == 0 {
		return fn()
	}
	var err error
	for i := 0; i <= retries; i++ {
		if err = Retry(layers-1, retries, fn); err == nil {
			return nil
		}
	}
	return err
}

// Attempts is the worst-case number of terminal calls.
func Attempts(layers, retries int) int {
	n := 1
	for i := 0; i < layers; i++ {
		n *= retries + 1
	}
	return n
}

FailAlways 用来测放大上限:下游持续不可用时,所有层都会把重试打满。FailAfterEffect 用来测“副作用已发生但确认丢失”:第一次调用先执行 apply,再返回超时;后续重试会再次到达 apply。Idempotent 决定第二次 apply 是否真的增加 Effects。

表驱动测试与真实输出

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

import (
	"errors"
	"fmt"
	"testing"
)

func TestRetryAmplification(t *testing.T) {
	cases := []struct {
		name      string
		layers    int
		retries   int
		wantCalls int
	}{
		{"single layer", 1, 3, 4},
		{"two layers", 2, 3, 16},
		{"three layers", 3, 3, 64},
		{"four layers", 4, 3, 256},
		{"no retry", 3, 0, 1},
	}

	for _, tc := range cases {
		t.Run(tc.name, func(t *testing.T) {
			srv := NewServer(true, FailAlways)
			_ = Retry(tc.layers, tc.retries, func() error { return srv.Call("ord-1") })
			if srv.Calls != tc.wantCalls {
				t.Fatalf("calls = %d, want %d", srv.Calls, tc.wantCalls)
			}
			if got := Attempts(tc.layers, tc.retries); got != tc.wantCalls {
				t.Fatalf("Attempts = %d, want %d", got, tc.wantCalls)
			}
		})
	}
}

func TestLostAckDuplicatesEffectWithoutDedupe(t *testing.T) {
	cases := []struct {
		name       string
		idempotent bool
		wantCalls  int
		wantEffect int
	}{
		{"non-idempotent retry", false, 2, 2},
		{"idempotent retry", true, 2, 1},
	}

	for _, tc := range cases {
		t.Run(tc.name, func(t *testing.T) {
			srv := NewServer(tc.idempotent, FailAfterEffect)
			err := Retry(1, 1, func() error { return srv.Call("ord-1") })
			if err != nil {
				t.Fatalf("retry: %v", err)
			}
			if srv.Calls != tc.wantCalls {
				t.Fatalf("calls = %d, want %d", srv.Calls, tc.wantCalls)
			}
			if srv.Effects != tc.wantEffect {
				t.Fatalf("effects = %d, want %d", srv.Effects, tc.wantEffect)
			}
		})
	}
}

func TestEndToEndDedupeUnderRepeatedLostAcks(t *testing.T) {
	const requests = 50
	srv := NewServer(true, FailAfterEffect)
	for i := 0; i < requests; i++ {
		reqID := fmt.Sprintf("ord-%d", i)
		if err := Retry(2, 1, func() error { return srv.Call(reqID) }); err != nil {
			t.Fatalf("request %s: %v", reqID, err)
		}
	}
	if srv.Effects != requests {
		t.Fatalf("effects = %d, want %d", srv.Effects, requests)
	}
	if srv.Calls <= requests {
		t.Fatalf("calls = %d, want more than %d retries", srv.Calls, requests)
	}
}

func TestPermanentFailureIsReported(t *testing.T) {
	srv := NewServer(true, FailAlways)
	err := Retry(2, 2, func() error { return srv.Call("ord-9") })
	if !errors.Is(err, ErrUnavailable) {
		t.Fatalf("err = %v, want ErrUnavailable", err)
	}
	if srv.Effects != 0 {
		t.Fatalf("effects = %d, want 0", srv.Effects)
	}
}

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

=== RUN   TestRetryAmplification
=== RUN   TestRetryAmplification/single_layer
=== RUN   TestRetryAmplification/two_layers
=== RUN   TestRetryAmplification/three_layers
=== RUN   TestRetryAmplification/four_layers
=== RUN   TestRetryAmplification/no_retry
--- PASS: TestRetryAmplification (0.00s)
    --- PASS: TestRetryAmplification/single_layer (0.00s)
    --- PASS: TestRetryAmplification/two_layers (0.00s)
    --- PASS: TestRetryAmplification/three_layers (0.00s)
    --- PASS: TestRetryAmplification/four_layers (0.00s)
    --- PASS: TestRetryAmplification/no_retry (0.00s)
=== RUN   TestLostAckDuplicatesEffectWithoutDedupe
=== RUN   TestLostAckDuplicatesEffectWithoutDedupe/non-idempotent_retry
=== RUN   TestLostAckDuplicatesEffectWithoutDedupe/idempotent_retry
--- PASS: TestLostAckDuplicatesEffectWithoutDedupe (0.00s)
    --- PASS: TestLostAckDuplicatesEffectWithoutDedupe/non-idempotent_retry (0.00s)
    --- PASS: TestLostAckDuplicatesEffectWithoutDedupe/idempotent_retry (0.00s)
=== RUN   TestEndToEndDedupeUnderRepeatedLostAcks
--- PASS: TestEndToEndDedupeUnderRepeatedLostAcks (0.00s)
=== RUN   TestPermanentFailureIsReported
--- PASS: TestPermanentFailureIsReported (0.00s)
PASS
ok  	archlab/internal/platform/edgecall	0.509s

读这张表要分两组。放大组:一层 4 次、两层 16 次、三层 64 次、四层 256 次;重试次数是每层 3 次,所以是 4 的层数次方。对照组:同样发生了一次“已执行但确认丢失”,非幂等服务端 Calls=2、Effects=2,幂等服务端 Calls=2、Effects=1。调用次数完全一样,副作用次数不同——差别只来自服务端是否认 reqID。

最后一组 50 个请求里,每个都经历确认丢失和两层重试,最终 Effects=50,而 Calls 大于 50。它验证的是端到端去重的实际效果:重试可以发生很多次,副作用只发生一次。

可靠性的责任怎么分

责任调用方中间层服务端
请求送达选可用实例、重连转发、限流无
不重复执行携带稳定幂等键不生成新键按幂等键去重
超时可控总预算、截止时间传递不擅自放大重试预算内完成或拒绝
结果可判定超时后查询或按未知处理记录访问日志提供按幂等键查询
故障可定位记录 attempt 序号传递 trace ID记录幂等键与请求序号

这张表的关键是最后一列:服务端不是被动执行者,它是可靠性的共同责任人。 调用方再谨慎,服务端不去重,重复副作用仍然会发生;服务端再去重,调用方每次重试都换一个新请求 ID,去重也失效。

与站内文章的分工

分布式幂等与可靠性 已经系统讨论了幂等键的存储、TTL 与并发同键问题。本节只把幂等放进远程调用的假设拆解里:没有幂等,重试只是把不确定性放大;有了幂等,重试才是一种可接受的恢复手段。

常见错误与边界

第一,每层都配置重试,却不设总预算。三层各重试三次就是 64 倍放大;正确做法是只在最外层重试,或者给整条调用链一个共享的截止时间和尝试次数上限。

第二,把重试当成幂等。重试解决“可能没送到”,不解决“可能已经执行”;后者只能靠服务端按业务键去重。

第三,只在入口设置超时,内层调用不传截止时间。上游 500ms 超时后已经放弃,下游还在用默认 30s 继续执行,资源被无效请求占满。截止时间必须沿调用链传递。

第四,认为 TCP 或消息队列的“可靠投递”等于业务可靠。传输层保证字节到达或消息持久化,不保证副作用只发生一次,也不保证调用方能区分“已执行”和“未执行”。

第五,把端到端校验放在中间代理。代理可以校验、可以丢弃,但攻击者或故障也可能发生在代理之后;接收方必须自己验证请求的来源、完整性和幂等键。

小结

八个假设的共同点是:它们都把跨节点调用当成了进程内调用。重试放大实验给出了一个可复现的上界——三层各重试三次是 64 次;确认丢失实验给出了一个更隐蔽的结论——调用次数相同,副作用次数取决于服务端是否幂等。端到端原则因此不是一句口号:可靠性必须由调用方和服务端共同实现,中间层只能优化。下一节进入时间维度,看物理时钟、NTP 校正与时钟漂移如何让“最后写入获胜”悄悄丢数据。

阅读导航:上一节:故障模型:崩溃、丢包、分区与拜占庭 · 下一节:物理时钟、NTP 与时钟漂移的可见后果 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「books」更多文章

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