一个物流接口,拖垮了整个订单详情页
订单详情页要聚合四块数据:订单主档、支付状态、物流轨迹、推荐商品。前三个来自内部服务,物流来自一家第三方快递查询接口。平时页面 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 个慢请求已经把池子占满,熔断打开时故障早已扩散。
- 降级返回空数组或零值。调用方分不清“没有数据”和“查询失败”,产品判断和用户展示都会出错。
- 冷却期内的成功也去关闭熔断。偶发成功不能证明依赖恢复,应该等半开探测再决定。
- 半开状态允许多个并发探测。状态机不消费名额,调用方必须用单飞或信号量限制,否则半开会变成全量放行。
- 配置非法时悄悄改成默认值。错误的阈值会让熔断永不打开,应该在加载阶段直接报错。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。