《互联网产品的服务端架构·交易、增长与平台化》10.3 第三方故障的隔离

物流查询接口超时,整个订单详情页跟着打不开;文章说明第三方故障为什么要按依赖隔离、熔断的 closed/open/half_open 如何流转、降级应该返回什么,并给出 Health、State、Record 的 Go 实现、状态机测试与真实运行输出。

一个物流接口,拖垮了整个订单详情页

订单详情页要聚合四块数据:订单主档、支付状态、物流轨迹、推荐商品。前三个来自内部服务,物流来自一家第三方快递查询接口。平时页面 300 毫秒左右返回,物流偶尔慢一点,用户感知不明显。

某个工作日下午,快递接口开始超时。订单详情页的物流调用配置了 30 秒超时和两次重试,单次请求最坏要等 90 秒;页面并发大约 200,连接池很快被占满。更糟的是,这个连接池和订单列表、售后页共用,物流慢请求把其他页面的额度也吃掉了。半小时内,订单详情页大面积 502,订单列表也开始超时。

事后看,订单主档和支付状态一直是健康的,故障完全来自一个非核心的第三方依赖。页面没有做隔离,一个“展示物流”的旁路能力,把整条主链路拖下水。

隔离的单位是依赖,不是页面

很多团队按页面做降级:订单详情页挂了就整页降级。但页面里的依赖重要性并不相同,订单主档挂了必须报错,物流挂了只需要显示“稍后更新”。按页面隔离会把可降级的依赖和不可降级的依赖绑在一起。

正确的隔离单位是依赖,最好细到“第三方 + 接口”。每个依赖有自己的超时、并发上限、连接池和熔断器;一个依赖变慢,只消耗它自己的配额,不影响其他依赖。

依赖故障表现隔离粒度降级返回
订单主档超时、5xx按订单服务整页报错,不能编造订单
支付状态超时按支付服务显示“状态确认中”,禁止重复支付
物流轨迹超时、限流按物流商 + 接口显示“物流信息稍后更新”,其余正常
推荐商品超时按推荐位返回空列表或兜底热门

隔离不只是加一个熔断器。超时、重试、并发上限、连接池、熔断阈值要一起看:超时太长会让请求堆积,重试太多会放大下游压力,连接池共享会让慢依赖传染。任何一项缺失,其他项的效果都会被打折。

降级必须有明确返回值

降级最怕“返回一个看起来正常的值”。物流查不到时返回空数组,前端分不清是“还没有轨迹”还是“查询失败”;推荐查不到时返回空列表,产品以为用户不喜欢,运营以为算法失效。降级结果必须携带明确的语义,让调用方和用户都能区分。

常见的安全返回值有三类:缓存中的旧数据,明确标注“数据可能延迟”;带状态标记的空结果,例如 available=false;以及直接返回错误区块,让页面只坏这一块。无论哪一种,都要在约定时间内返回,不能因为降级逻辑自己又去同步调用一次下游。

降级还要写日志和指标。降级不是“消失”,而是一次有记录的选择:哪个依赖、什么原因、持续多久、影响多少请求。没有这些记录,故障复盘时只能看到“页面恢复了”,却不知道降级是否真的生效。

失败场景:30 秒超时叠加重试

回到开头。30 秒超时乘以三次尝试,意味着一个请求最多占用连接 90 秒。200 个并发同时进来,连接池瞬间被占满;后续请求排队,排队又让页面超时,形成正反馈。

熔断器本来可以更早介入,但这个团队把阈值设成了“连续 100 次失败”。前 100 个慢请求已经把池子占满,等熔断打开时,故障已经扩散。阈值应该按依赖的容量和可接受影响面来定,而不是拍一个看起来很大的数字。

第二个问题是重试。读路径上的超时重试会放大下游压力:下游已经慢,重试让它更慢。对于物流查询这种可降级的读接口,超时后直接降级通常比重试更划算;只有明确知道是瞬时网络错误时,才值得重试一次。

第三个问题是共享连接池。物流、订单、支付共用一个 HTTP 客户端,物流的慢请求占满连接,订单和支付拿不到连接。隔离连接池之后,最坏情况也只是物流区块降级,订单主档仍然可用。

用 Health、State 与 Record 实现按依赖熔断

下面的实现只做状态机,不做网络调用。State 根据当前时间和冷却期判断位置,Record 把一次调用结果写回状态,ValidatePolicy 在配置加载时挡住非法阈值。

// file: internal/openplatform/isolate.go
// tier: L1
// verify: go test ./internal/openplatform/

package openplatform

import "errors"

var (
	// ErrInvalidThreshold 表示失败阈值不是正数。
	ErrInvalidThreshold = errors.New("openplatform: invalid failure threshold")
	// ErrInvalidCooldown 表示冷却时间不是正数。
	ErrInvalidCooldown = errors.New("openplatform: invalid cooldown")
)

const (
	// StateClosed 表示正常放行请求。
	StateClosed = "closed"
	// StateOpen 表示直接拒绝,不再调用第三方。
	StateOpen = "open"
	// StateHalfOpen 表示允许一次探测请求。
	StateHalfOpen = "half_open"
)

// Health 记录一个第三方依赖的熔断状态。Fails 是连续失败次数,
// OpenedAt 是最近一次进入 open 的 Unix 秒;OpenedAt 为 0 表示从未打开。
type Health struct {
	Fails    int
	OpenedAt int64
}

// ValidatePolicy 检查熔断参数。threshold 是连续失败多少次后打开,
// cooldown 是打开后至少等待多少秒才允许一次半开探测。
func ValidatePolicy(threshold int, cooldown int64) error {
	if threshold <= 0 {
		return ErrInvalidThreshold
	}
	if cooldown <= 0 {
		return ErrInvalidCooldown
	}
	return nil
}

// State 根据状态和当前时间计算熔断位置。判断只看 OpenedAt:
// 没有打开过就是 closed;冷却期没到就是 open;冷却期到了就是 half_open。
// 这里不消费探测名额,是否真正放行由调用方结合单飞或限流决定。
func State(h Health, now, cooldown int64) string {
	if h.OpenedAt <= 0 {
		return StateClosed
	}
	if now-h.OpenedAt < cooldown {
		return StateOpen
	}
	return StateHalfOpen
}

// Record 把一次调用结果写回状态机。closed 下连续失败达到 threshold
// 时打开;open 下不再改变状态;half_open 下成功关闭、失败重新打开。
// 参数不合法时原样返回,避免配置错误悄悄关掉熔断。
func Record(h Health, ok bool, now int64, threshold int, cooldown int64) Health {
	if ValidatePolicy(threshold, cooldown) != nil {
		return h
	}
	if h.Fails < 0 {
		h.Fails = 0
	}
	switch State(h, now, cooldown) {
	case StateHalfOpen:
		if ok {
			return Health{}
		}
		return Health{Fails: threshold, OpenedAt: now}
	case StateOpen:
		return h
	default:
		if ok {
			return Health{}
		}
		h.Fails++
		if h.Fails >= threshold {
			h.OpenedAt = now
		}
		return h
	}
}

实现里的取舍:

  • OpenedAt 同时充当“是否打开过”的标记和冷却起点。为 0 表示从未打开,不需要额外的布尔字段。
  • State 是纯函数,不消费探测名额。半开状态允不允许并发探测,由调用方用单飞或信号量控制,状态机不替它做决定。
  • 冷却期边界用 now-OpenedAt < cooldown 判断,等于冷却期时进入 half_open。边界语义由测试固定,避免不同实现差一秒。
  • closed 下成功会清零连续失败;失败累加,达到 threshold 时记录 OpenedAt。
  • open 下无论成功还是失败都不改变状态。冷却期内的成功可能只是噪声,不能提前关闭熔断。
  • half_open 下成功回到 closed,失败立刻重新打开,并从当前时间重新计时。
  • 参数不合法时原样返回,不悄悄把阈值改成 1,也不悄悄关闭熔断。配置错误应该在启动或加载阶段暴露。
  • 时钟倒退时 State 会落在 open,相当于延长冷却期,这是保守且安全的行为。

测试:阈值、冷却与半开

状态机的测试要覆盖正常流转、边界时间和异常配置,尤其是“半开成功关闭、半开失败重开”这一对分支。

// file: internal/openplatform/isolate_test.go
// tier: L1
// verify: go test ./internal/openplatform/

package openplatform

import (
	"errors"
	"testing"
)

func TestState(t *testing.T) {
	cases := []struct {
		name     string
		h        Health
		now      int64
		cooldown int64
		want     string
	}{
		{"never opened", Health{}, 200, 30, StateClosed},
		{"inside cooldown", Health{Fails: 3, OpenedAt: 100}, 129, 30, StateOpen},
		{"at cooldown", Health{Fails: 3, OpenedAt: 100}, 130, 30, StateHalfOpen},
		{"after cooldown", Health{Fails: 3, OpenedAt: 100}, 300, 30, StateHalfOpen},
		{"clock backwards", Health{Fails: 3, OpenedAt: 100}, 99, 30, StateOpen},
	}
	for _, c := range cases {
		if got := State(c.h, c.now, c.cooldown); got != c.want {
			t.Fatalf("%s: State = %s, want %s", c.name, got, c.want)
		}
	}
}

func TestRecordOpensAfterThreshold(t *testing.T) {
	var h Health
	h = Record(h, false, 100, 3, 30)
	if h.Fails != 1 || h.OpenedAt != 0 {
		t.Fatalf("after 1 failure = %+v, want Fails=1 OpenedAt=0", h)
	}
	h = Record(h, false, 101, 3, 30)
	if got := State(h, 101, 30); got != StateClosed {
		t.Fatalf("after 2 failures state = %s, want closed", got)
	}
	h = Record(h, false, 102, 3, 30)
	if h.Fails != 3 || h.OpenedAt != 102 {
		t.Fatalf("after 3 failures = %+v, want Fails=3 OpenedAt=102", h)
	}
	if got := State(h, 102, 30); got != StateOpen {
		t.Fatalf("state = %s, want open", got)
	}
}

func TestRecordIgnoresResultsWhileOpen(t *testing.T) {
	h := Health{Fails: 3, OpenedAt: 100}
	got := Record(h, true, 110, 3, 30)
	if got != h {
		t.Fatalf("success while open = %+v, want %+v", got, h)
	}
	got = Record(h, false, 120, 3, 30)
	if got != h {
		t.Fatalf("failure while open = %+v, want %+v", got, h)
	}
}

func TestRecordHalfOpenSuccessCloses(t *testing.T) {
	h := Health{Fails: 3, OpenedAt: 100}
	got := Record(h, true, 130, 3, 30)
	if got != (Health{}) {
		t.Fatalf("half-open success = %+v, want zero Health", got)
	}
	if state := State(got, 130, 30); state != StateClosed {
		t.Fatalf("state = %s, want closed", state)
	}
}

func TestRecordHalfOpenFailureReopens(t *testing.T) {
	h := Health{Fails: 3, OpenedAt: 100}
	got := Record(h, false, 130, 3, 30)
	if got.Fails != 3 || got.OpenedAt != 130 {
		t.Fatalf("half-open failure = %+v, want Fails=3 OpenedAt=130", got)
	}
	if state := State(got, 130, 30); state != StateOpen {
		t.Fatalf("state = %s, want open", state)
	}
	if state := State(got, 159, 30); state != StateOpen {
		t.Fatalf("state at 159 = %s, want open", state)
	}
	if state := State(got, 160, 30); state != StateHalfOpen {
		t.Fatalf("state at 160 = %s, want half_open", state)
	}
}

func TestRecordSuccessResetsConsecutiveFailures(t *testing.T) {
	var h Health
	h = Record(h, false, 100, 3, 30)
	h = Record(h, false, 101, 3, 30)
	h = Record(h, true, 102, 3, 30)
	if h != (Health{}) {
		t.Fatalf("after success = %+v, want zero Health", h)
	}
	h = Record(h, false, 103, 3, 30)
	h = Record(h, false, 104, 3, 30)
	if state := State(h, 104, 30); state != StateClosed {
		t.Fatalf("state = %s, want closed", state)
	}
}

func TestRecordRejectsInvalidPolicy(t *testing.T) {
	h := Health{Fails: 1}
	for _, c := range []struct {
		threshold int
		cooldown  int64
	}{
		{0, 30},
		{-1, 30},
		{3, 0},
		{3, -1},
	} {
		if got := Record(h, false, 100, c.threshold, c.cooldown); got != h {
			t.Fatalf("threshold=%d cooldown=%d = %+v, want %+v", c.threshold, c.cooldown, got, h)
		}
	}
	if err := ValidatePolicy(0, 30); !errors.Is(err, ErrInvalidThreshold) {
		t.Fatalf("threshold=0 = %v, want ErrInvalidThreshold", err)
	}
	if err := ValidatePolicy(3, 0); !errors.Is(err, ErrInvalidCooldown) {
		t.Fatalf("cooldown=0 = %v, want ErrInvalidCooldown", err)
	}
}

TestState 覆盖从未打开、冷却期内、正好到冷却期、超过冷却期和时钟倒退五种输入。TestRecordOpensAfterThreshold 逐次断言失败计数,第三次失败时记录 OpenedAt 并进入 open。

TestRecordIgnoresResultsWhileOpen 固定“打开期间的结果不改变状态”。TestRecordHalfOpenSuccessCloses 和 TestRecordHalfOpenFailureReopens 分别验证半开的两个出口,后者还检查重新打开后冷却期从新的时间点开始。

TestRecordSuccessResetsConsecutiveFailures 确认成功会清零连续失败,避免“历史失败累计”导致误打开。TestRecordRejectsInvalidPolicy 覆盖阈值为 0 或负数、冷却期为 0 或负数,确认状态原样返回,并单独校验 ValidatePolicy 的错误类型。

运行命令与真实输出

cd .local/checks/webapp-lab && go test ./internal/openplatform/ -run 'TestState|TestRecord' -count=1 -v

一次本机运行的真实输出如下,耗时随机器变化:

=== RUN   TestState
--- PASS: TestState (0.00s)
=== RUN   TestRecordOpensAfterThreshold
--- PASS: TestRecordOpensAfterThreshold (0.00s)
=== RUN   TestRecordIgnoresResultsWhileOpen
--- PASS: TestRecordIgnoresResultsWhileOpen (0.00s)
=== RUN   TestRecordHalfOpenSuccessCloses
--- PASS: TestRecordHalfOpenSuccessCloses (0.00s)
=== RUN   TestRecordHalfOpenFailureReopens
--- PASS: TestRecordHalfOpenFailureReopens (0.00s)
=== RUN   TestRecordSuccessResetsConsecutiveFailures
--- PASS: TestRecordSuccessResetsConsecutiveFailures (0.00s)
=== RUN   TestRecordRejectsInvalidPolicy
--- PASS: TestRecordRejectsInvalidPolicy (0.00s)
PASS
ok  	webapp/internal/openplatform	0.228s

小结

第三方故障要按依赖隔离,不能按页面隔离。每个依赖有自己的超时、并发、连接池和熔断器;降级要有明确返回值,并且能在约定时间内返回。Health、State、Record 把熔断状态机拆成纯函数,阈值、冷却期和半开的两个出口都由测试固定。下一章从开放平台转向国际化:语言、时区与货币会渗透进每一条链路的默认值。

阅读导航:上一节:10.2 回调、签名与重放 · 下一节:11.1 语言、时区与货币 。

常见错误与边界

  • 按页面做整体降级。可降级的物流和不可降级的订单被绑在一起,旁路故障被放大成主链路故障。
  • 多个第三方共用一个连接池。一个慢依赖占满连接,其他健康依赖也拿不到连接。
  • 超时设成 30 秒还配两次重试。单请求最长占用 90 秒,连接池很快耗尽,重试还会放大下游压力。
  • 熔断阈值拍得过大。前 100 个慢请求已经把池子占满,熔断打开时故障早已扩散。
  • 降级返回空数组或零值。调用方分不清“没有数据”和“查询失败”,产品判断和用户展示都会出错。
  • 冷却期内的成功也去关闭熔断。偶发成功不能证明依赖恢复,应该等半开探测再决定。
  • 半开状态允许多个并发探测。状态机不消费名额,调用方必须用单飞或信号量限制,否则半开会变成全量放行。
  • 配置非法时悄悄改成默认值。错误的阈值会让熔断永不打开,应该在加载阶段直接报错。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「books」更多文章

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