从0到1理解Go熔断器:sony/gobreaker 源码剖析 + 仿TikTok Feed 项目实战
本文基于 sony/gobreaker v2 和一个真实的 TikTok Feed 系统项目,带你从"为什么需要熔断器"到"如何在生产中落地",完整走一遍。
一、为什么需要熔断器?
1.1 一个生活类比
你家里的电闸上有一个保险丝。当电路过载时,保险丝会熔断,切断电流,保护后面的电器不被烧毁。等你排除故障后,重新合闸,电路恢复正常。
熔断器(Circuit Breaker)就是微服务架构中的"保险丝"------当下游服务出问题时,自动切断调用,防止故障扩散。
1.2 没有熔断器会怎样?
假设你有一个 Web 服务,每次处理请求都要调用 Redis。正常情况下,Redis 的响应时间在 1ms 以内。但如果 Redis 突然变慢(网络抖动、内存满、主从切换),响应时间飙升到 5 秒。
没有熔断器时:
- 每个请求都傻等 5 秒才超时
- Goroutine 堆积,内存飙升
- 上游请求也在排队,连接池耗尽
- 整个系统从"Redis 慢"变成"整个服务不可用"
这就是级联故障(Cascading Failure)------一个下游组件的问题拖垮了整条链路。
1.3 Demo 1:无保护的调用,体验雪崩
下面这段代码模拟了一个没有熔断器保护的 HTTP 服务。下游服务在第 3 秒后开始超时,观察 500 个并发请求会发生什么:
go
package main
import (
"fmt"
"net/http"
"sync"
"time"
)
// 模拟一个不稳定的下游服务
func slowService() string {
time.Sleep(5 * time.Second) // 模拟下游超时
return "ok"
}
func handler(w http.ResponseWriter, r *http.Request) {
result := slowService() // 无保护,每个请求都傻等 5 秒
fmt.Fprint(w, result)
}
func main() {
http.HandleFunc("/api", handler)
// 模拟 500 个并发请求
var wg sync.WaitGroup
start := time.Now()
for i := 0; i < 500; i++ {
wg.Add(1)
go func() {
defer wg.Done()
resp, err := http.Get("http://localhost:8080/api")
if err != nil {
fmt.Println("请求失败:", err)
return
}
resp.Body.Close()
}()
}
wg.Wait()
fmt.Printf("500 个请求总耗时: %v\n", time.Since(start))
fmt.Println("注意: 所有请求都等了 5 秒,服务端的 goroutine 数飙升到 500")
}
运行结果:500 个请求全部阻塞 5 秒,服务端瞬间积压 500 个 goroutine。如果请求量再大一些,服务直接 OOM 崩溃。
加上熔断器后:连续失败达到阈值,熔断器直接拒绝后续请求(毫秒级返回错误),服务端的 goroutine 数保持在可控范围内,等待下游恢复后再逐步放行。
二、熔断器核心原理:三状态状态机
2.1 三个状态
| 状态 | 含义 | 行为 |
|---|---|---|
| Closed(闭合) | 正常状态,请求正常放行 | 统计失败次数,达到阈值则切换到 Open |
| Open(断开) | 熔断状态,请求直接拒绝 | 不调用下游,快速返回错误,等待超时,超时后转换为探测模式 |
| HalfOpen(半开) | 探测状态,放行少量请求 | 试探下游是否恢复,成功则回到 Closed,失败则回到 Open |
2.2 状态转换图
失败次数达到阈值
┌──────────────────────────────────────────┐
│ │
▼ │
┌────────┐ 超时到期 ┌──────────┐ 探测失败
│ Closed │ ──────────────> │ Open │ ──────────┐
└────────┘ └──────────┘ │
▲ │ │
│ │ 超时到期 │
│ ▼ │
│ ┌───────────┐ │
└────────────────────│ HalfOpen │ <───────────┘
探测成功 └───────────┘
关键转换规则:
- Closed → Open:连续失败次数达到阈值(比如 5 次)
- Open → HalfOpen:经过一段超时时间(比如 10 秒)
- HalfOpen → Closed:探测请求成功
- HalfOpen → Open:探测请求失败,重新计时
2.3 关键参数
| 参数 | 作用 | 典型值 |
|---|---|---|
ConsecutiveFailures |
连续失败多少次触发熔断 | 5 |
Timeout |
Open 状态持续多久后进入 HalfOpen | 10s |
MaxRequests |
HalfOpen 状态放行几个探测请求 | 1 |
Interval |
Closed 状态下统计窗口的长度 | 60s |
三、sony/gobreaker v2 源码精读
sony/gobreaker 是 Go 生态中最流行的熔断器库,3.6k Star,被 5500+ 项目引用。它只依赖 Go 标准库,代码量极小(v2 核心约 400 行),非常适合学习。
3.1 核心类型
go
// 状态定义
type State int
const (
StateClosed State = iota // 0 - 正常放行
StateHalfOpen // 1 - 探测恢复
StateOpen // 2 - 拒绝所有请求
)
// 请求计数器
type Counts struct {
Requests uint32 // 总请求数
TotalSuccesses uint32 // 总成功数
TotalFailures uint32 // 总失败数
ConsecutiveSuccesses uint32 // 连续成功数
ConsecutiveFailures uint32 // 连续失败数
TotalExclusions uint32 // 排除的请求数(v2 新增)用来过滤掉一些不应该作为熔断判断指标的错误
}
// 配置
type Settings struct {
Name string // 熔断器名称
MaxRequests uint32 // HalfOpen 最大探测数
Interval time.Duration // Closed 状态计数重置周期
BucketPeriod time.Duration // v2: 滑动窗口桶大小
Timeout time.Duration // Open 状态超时时间
ReadyToTrip func(Counts) bool // 判断是否应该熔断
OnStateChange func(name string, from, to State) // 状态变化回调
IsSuccessful func(err error) bool // 判断请求是否成功
IsExcluded func(err error) bool // v2: 判断是否排除该请求
}
3.2 Execute 方法:核心流程
Execute 是 gobreaker 唯一的入口方法,整个熔断逻辑都在这里面:
go
func (cb *CircuitBreaker[T]) Execute(req func() (T, error)) (T, error) {
// 第一步:beforeRequest - 检查状态,决定是否放行
generation, err := cb.beforeRequest()
if err != nil {
return *new(T), err // Open 或 HalfOpen 超限,直接拒绝
}
// 第二步:执行实际请求(带 panic 恢复)
defer func() {
if r := recover(); r != nil {
cb.afterRequest(generation, false) // panic 算失败
panic(r) // 重新抛出
}
}()
result, err := req()
// 第三步:afterRequest - 根据结果更新计数器
cb.afterRequest(generation, cb.isSuccessful(err))
return result, err
}
3.3 beforeRequest:状态检查
go
func (cb *CircuitBreaker[T]) beforeRequest() (uint64, error) {
cb.mutex.Lock()
defer cb.mutex.Unlock()
now := time.Now()
state, generation := cb.currentState(now) // 可能触发自动状态转换
if state == StateOpen {
return generation, ErrOpenState // 直接拒绝
}
if state == StateHalfOpen && cb.counts.Requests >= cb.maxRequests {
return generation, ErrTooManyRequests // HalfOpen 超限
}
// 放行,记录请求
cb.counts.onRequest()
return generation, nil
}
3.4 afterRequest:更新计数器
go
func (cb *CircuitBreaker[T]) afterRequest(before uint64, success bool) {
cb.mutex.Lock()
defer cb.mutex.Unlock()
// generation 不匹配说明状态已切换,丢弃本次结果
if cb.generation != before {
return
}
if success {
cb.counts.onSuccess()
// HalfOpen 状态下连续成功达到阈值 → 回到 Closed
if cb.state == StateHalfOpen && cb.counts.ConsecutiveSuccesses >= cb.maxRequests {
cb.setState(StateClosed, time.Now())
}
} else {
cb.counts.onFailure()
// Closed 状态下满足熔断条件 → 切到 Open
if cb.state == StateClosed && cb.readyToTrip(cb.counts) {
cb.setState(StateOpen, time.Now())
}
// HalfOpen 状态下任何失败 → 回到 Open
if cb.state == StateHalfOpen {
cb.setState(StateOpen, time.Now())
}
}
}
3.5 generation 计数器:防止过期结果污染
这是一个精巧的设计。每次状态切换,generation 都会递增。Execute 在执行请求前记录当前 generation,执行完后对比------如果 generation 变了,说明在请求执行期间发生了状态切换,本次结果被静默丢弃。
为什么需要这个? 假设 HalfOpen 放行了 1 个探测请求,但这个请求执行很慢。在它返回之前,另一个更快的请求先失败了,状态切回 Open。如果慢请求后来成功了,它的结果不应该让状态回到 Closed------因为它属于"上一代"的 HalfOpen,已经被作废了。
3.6 v2 新特性:滑动窗口
v1 的 Interval 是固定窗口------每隔 N 秒清零一次计数器。这有个问题:在窗口边界附近,可能出现"刚清零就连续失败"的情况。
v2 引入了 BucketPeriod,将 Interval 划分为多个桶,形成滑动窗口:
go
// v2 配置:10 秒窗口,每 1 秒一个桶
st.Interval = 10 * time.Second
st.BucketPeriod = 1 * time.Second // 形成 10 个桶的滑动窗口
滑动窗口的好处:统计的是"最近 10 秒"而不是"当前这个 10 秒窗口",对突发故障的响应更平滑。
四、基础用法 Demo
4.1 Demo 2:用 gobreaker 保护不稳定的服务
go
package main
import (
"errors"
"fmt"
"math/rand"
"time"
"github.com/sony/gobreaker/v2"
)
// 模拟一个 30% 失败率的外部服务
func unreliableService() (string, error) {
if rand.Float64() < 0.3 {
return "", errors.New("服务暂时不可用")
}
return "数据正常返回", nil
}
func main() {
// 创建熔断器
cb := gobreaker.NewCircuitBreaker[string](gobreaker.Settings{
Name: "demo-service",
Timeout: 5 * time.Second, // Open 状态持续 5 秒
ReadyToTrip: func(counts gobreaker.Counts) bool {
// 连续失败 3 次就熔断
return counts.ConsecutiveFailures >= 3
},
OnStateChange: func(name string, from, to gobreaker.State) {
fmt.Printf("⚡ [%s] 状态切换: %s → %s\n", name, from, to)
},
})
// 模拟 20 次调用
for i := 1; i <= 20; i++ {
result, err := cb.Execute(func() (string, error) {
return unreliableService()
})
state := cb.State()
if err != nil {
fmt.Printf("第 %2d 次: 失败 | 状态: %s | 错误: %v\n", i, state, err)
} else {
fmt.Printf("第 %2d 次: 成功 | 状态: %s | 结果: %s\n", i, state, result)
}
time.Sleep(500 * time.Millisecond)
}
}
运行输出示例:
第 1 次: 成功 | 状态: closed | 结果: 数据正常返回
第 2 次: 失败 | 状态: closed | 错误: 服务暂时不可用
第 3 次: 失败 | 状态: closed | 错误: 服务暂时不可用
第 4 次: 失败 | 状态: closed | 错误: 服务暂时不可用
⚡ [demo-service] 状态切换: closed → open
第 5 次: 失败 | 状态: open | 错误: circuit breaker is open
第 6 次: 失败 | 状态: open | 错误: circuit breaker is open
...
⚡ [demo-service] 状态切换: open → half-open
第 11 次: 成功 | 状态: half-open | 结果: 数据正常返回
⚡ [demo-service] 状态切换: half-open → closed
第 12 次: 成功 | 状态: closed | 结果: 数据正常返回
...
观察要点:
- 第 2-4 次连续失败后,第 5 次直接被拒绝(Open 状态)
- Open 状态下的请求根本不执行
unreliableService,毫秒级返回 - 5 秒超时后进入 HalfOpen,探测成功,恢复到 Closed
4.2 Demo 3:配置 OnStateChange,接入监控
在生产环境中,状态切换是最需要关注的事件。你可以把它接入日志、Prometheus、告警系统:
go
package main
import (
"errors"
"fmt"
"time"
"github.com/sony/gobreaker/v2"
)
// 模拟 Prometheus 指标上报
func reportMetrics(state string) {
fmt.Printf("📊 [Prometheus] circuit_breaker_state{state=\"%s\"} +1\n", state)
}
// 模拟告警
func sendAlert(from, to gobreaker.State) {
if to == gobreaker.StateOpen {
fmt.Printf("🚨 [告警] 熔断器触发!服务可能故障,请检查下游依赖\n")
}
if from == gobreaker.StateOpen && to == gobreaker.StateClosed {
fmt.Printf("✅ [恢复] 熔断器已恢复,服务正常\n")
}
}
func main() {
cb := gobreaker.NewCircuitBreaker[any](gobreaker.Settings{
Name: "redis-breaker",
Timeout: 3 * time.Second,
ReadyToTrip: func(counts gobreaker.Counts) bool {
return counts.ConsecutiveFailures >= 3
},
OnStateChange: func(name string, from, to gobreaker.State) {
fmt.Printf("⚡ [%s] %s → %s\n", name, from, to)
reportMetrics(to.String())
sendAlert(from, to)
},
})
// 模拟 Redis 故障
for i := 0; i < 5; i++ {
_, _ = cb.Execute(func() (any, error) {
return nil, errors.New("redis: connection refused")
})
time.Sleep(200 * time.Millisecond)
}
// 等待恢复
fmt.Println("\n--- 等待 3 秒,模拟下游恢复 ---\n")
time.Sleep(3 * time.Second)
// 探测成功
_, _ = cb.Execute(func() (any, error) {
return "PONG", nil
})
}
五、Feed 项目中的熔断器实战
这一章我们来看一个真实项目------TikTok Feed 系统------是如何使用熔断器的。
5.1 项目架构概览
这是一个类 TikTok 的视频 Feed 系统,核心是三级缓存架构:
用户请求 → L1 本地缓存(3s) → L2 Redis → L3 MySQL
Redis 承担了最关键的热数据层:视频时间线(ZSET)、用户收件箱(ZSET)、热度排行榜(ZSET)。如果 Redis 不可用,整个 Feed 系统就瘫痪了。
所以项目用 sony/gobreaker v2 包裹了所有 Redis 调用。
5.2 breaker.go 源码逐行解读
文件路径:internal/middleware/redis/breaker.go
go
// ErrBreakerOpen 统一对外暴露的熔断错误。
// 调用方只需要判断 errors.Is(err, ErrBreakerOpen),不用关心 gobreaker 内部的两种错误。
var ErrBreakerOpen = errors.New("circuit breaker is open")
设计决策 1:统一错误语义 。gobreaker 有两种拒绝错误(ErrOpenState 和 ErrTooManyRequests),项目统一映射为 ErrBreakerOpen,简化调用方的判断逻辑。
go
type Breaker struct {
cb *gobreaker.CircuitBreaker[any]
}
设计决策 2:薄封装。不搞复杂的抽象层,只封装三个能力:统一错误、redis.Nil 过滤、Prometheus 指标。
go
func DefaultBreakerConfig() BreakerConfig {
return BreakerConfig{
Name: "redis",
MaxRequests: 1, // HalfOpen 只放行 1 个探测请求
Interval: 60 * time.Second, // 60s 滚动窗口
Timeout: 10 * time.Second, // Open 持续 10s 后探测
ConsecutiveFailures: 5, // 连续 5 次失败触发熔断
}
}
参数选择的考量:
MaxRequests: 1--- Redis 探测请求成本低,1 个就够了,快速判断是否恢复Timeout: 10s--- 给 Redis 足够的恢复时间(主从切换通常 5-15 秒),但不会让用户等太久ConsecutiveFailures: 5--- 避免偶发的单次超时误触发,5 次连续失败说明确实有问题
关键设计:redis.Nil 过滤
这是整个项目最精巧的设计。先看问题:
Redis 的 GET 命令在 key 不存在时返回 redis.Nil 错误。这是正常的业务结果(缓存未命中),不是 Redis 故障。如果把它算作失败,缓存命中率低的时候熔断器就会误触发。
go
func (b *Breaker) Execute(fn func() error) error {
if b == nil || b.cb == nil {
return fn() // nil 安全:没有熔断器就直接执行
}
var origErr error
_, cbErr := b.cb.Execute(func() (any, error) {
origErr = fn()
if origErr != nil && IsMiss(origErr) {
// 缓存未命中 → 向 gobreaker 报告"成功"
return nil, nil
}
return nil, origErr // 其他错误原样上报
})
if cbErr != nil {
if errors.Is(cbErr, gobreaker.ErrOpenState) || errors.Is(cbErr, gobreaker.ErrTooManyRequests) {
observability.CircuitBreakerRejections.Inc()
return ErrBreakerOpen
}
return cbErr
}
// gobreaker 认为成功:可能是真成功,也可能是 cache miss
return origErr // 注意:这里返回原始错误,调用方可以拿到 redis.Nil
}
两层错误的分离:
cbErr:gobreaker 看到的错误(cache miss 被过滤为 nil)origErr:实际的业务错误(cache miss 仍然是 redis.Nil)
调用方拿到 redis.Nil 后可以判断"缓存没有,需要回源 MySQL"。但 gobreaker 内部只看到 nil(成功),不会把 cache miss 计入失败。
Prometheus 指标上报
每次状态切换都会上报 Prometheus 计数器:
go
OnStateChange: func(name string, from, to gobreaker.State) {
observability.CircuitBreakerStateChanges.WithLabelValues(stateLabel(to)).Inc()
},
对应的 Prometheus 指标定义(internal/observability/metrics.go):
go
// 熔断器状态切换计数
var CircuitBreakerStateChanges = prometheus.NewCounterVec(
prometheus.CounterOpts{
Namespace: "feedsystem",
Name: "circuit_breaker_state_changes_total",
Help: "Total number of circuit breaker state transitions",
},
[]string{"to_state"}, // closed / open / half_open
)
// 熔断器拒绝的请求数
var CircuitBreakerRejections = prometheus.NewCounter(
prometheus.CounterOpts{
Namespace: "feedsystem",
Name: "circuit_breaker_rejections_total",
Help: "Total number of requests rejected by circuit breaker",
},
)
有了这两个指标,你可以在 Grafana 面板上看到:
- 熔断器多久触发一次(
state_changes_total{to_state="open"}) - 每次触发拒绝了多少请求(
rejections_total) - 恢复速度(
open → half_open → closed的时间差)
5.3 所有 Redis 调用都走熔断器
项目中每一个 Redis 操作都通过 breaker.Execute 包裹。以 cache.go 中的 GetBytes 和 SetBytes 为例:
go
// SetBytes 写入缓存
func (c *Client) SetBytes(ctx context.Context, key string, value []byte, ttl time.Duration) error {
if c == nil || c.rdb == nil {
return nil
}
return c.breaker.Execute(func() error {
start := time.Now()
err := c.rdb.Set(ctx, key, value, ttl).Err()
dur := time.Since(start).Seconds()
if err != nil {
observability.RedisOperationsTotal.WithLabelValues("set", "error").Inc()
observability.RedisOperationDuration.WithLabelValues("set", "error").Observe(dur)
return err
}
observability.RedisOperationsTotal.WithLabelValues("set", "success").Inc()
observability.RedisOperationDuration.WithLabelValues("set", "success").Observe(dur)
return nil
})
}
// GetBytes 读取缓存
func (c *Client) GetBytes(ctx context.Context, key string) ([]byte, error) {
if c == nil || c.rdb == nil {
return nil, nil
}
var val string
err := c.breaker.Execute(func() error {
start := time.Now()
v, e := c.rdb.Get(ctx, key).Result()
dur := time.Since(start).Seconds()
if e != nil {
if IsMiss(e) {
// redis.Nil 是缓存未命中,正常业务结果
observability.RedisOperationsTotal.WithLabelValues("get", "miss").Inc()
} else {
observability.RedisOperationsTotal.WithLabelValues("get", "error").Inc()
}
observability.RedisOperationDuration.WithLabelValues("get", "miss").Observe(dur)
return e
}
observability.RedisOperationsTotal.WithLabelValues("get", "success").Inc()
observability.RedisOperationDuration.WithLabelValues("get", "success").Observe(dur)
val = v
return nil
})
return []byte(val), err
}
同样的模式应用在所有 Redis 操作上:ZAdd、ZRangeWithScores、ZRevRangeByScore、SAdd、SRem、SMembers、MGet、Del、Expire、ZincrBy......每个操作都:
- 被
breaker.Execute包裹 - 记录 Prometheus 计数(按操作名 + 状态分组)
- 记录 Prometheus 延迟直方图
5.4 降级路径:熔断后怎么办?
熔断器不是终点------拒绝请求只是第一步,关键是被拒绝后怎么办。Feed 项目为每个 Redis 依赖路径都设计了降级方案。
场景 1:Feed 查询降级到 MySQL
go
// service.go - ListLatest 方法
func (f *FeedService) ListLatest(ctx context.Context, limit int, latestBefore time.Time, viewerAccountID uint) (ListLatestResponse, error) {
zsetTail, err := f.rediscache.ZRangeWithScores(ctx, "feed:global_timeline", 0, 0)
if err != nil {
// 🔥 熔断器触发 → 降级到 MySQL 直查
if errors.Is(err, rediscache.ErrBreakerOpen) {
return f.listLatestFromDB(ctx, limit, latestBefore, viewerAccountID)
}
return ListLatestResponse{}, err
}
// ... 正常的冷热分离逻辑
}
// 热查询路径同样有降级
videoIDStr, err := f.rediscache.ZRevRangeByScore(ctx, "feed:global_timeline", maxScore, "-inf", 0, int64(limit))
if err != nil {
if errors.Is(err, rediscache.ErrBreakerOpen) {
return f.listLatestFromDB(ctx, limit, latestBefore, viewerAccountID)
}
return ListLatestResponse{}, err
}
降级逻辑:Redis 不可用 → 直接查 MySQL。用户体验上,响应时间从 1ms 变成 20-50ms,但功能完全正常。这比"返回错误页面"好得多。
场景 2:Fanout 推送降级
Fanout Worker 负责把新视频推送到粉丝的收件箱(Redis ZSET)。它使用 Pipeline 批量写入以减少 RTT。但 Pipeline 无法通过 breaker.Execute 包裹,所以项目用了 IsBreakerOpen() 快速判断:
go
// fanoutworker.go - fanoutBatch 方法
func (w *FanoutWorker) fanoutBatch(ctx context.Context, followerIDs []uint, videoIDStr string, score float64) {
rdb := w.cache.GetRedisClient()
// 🔥 熔断器打开 → 降级为逐条写入(每条都走 breaker,失败会被 breaker 拦截)
if rdb == nil || w.cache.IsBreakerOpen() {
for _, fid := range followerIDs {
inboxKey := fmt.Sprintf("inbox:%d", fid)
_ = w.cache.ZAdd(ctx, inboxKey, redis.Z{Member: videoIDStr, Score: score})
}
return
}
// 正常路径:Pipeline 批量写入
pipe := rdb.Pipeline()
for _, fid := range followerIDs {
inboxKey := fmt.Sprintf("inbox:%d", fid)
pipe.ZAdd(ctx, inboxKey, redis.Z{Member: videoIDStr, Score: score})
}
_, _ = pipe.Exec(ctx)
}
场景 3:限流器 Fail-Open
限流器依赖 Redis 做分布式计数。如果 Redis 不可用,限流器选择放行(fail-open)------宁可放过一些请求,也不能因为限流器故障导致所有请求被拒:
go
// sliding_window.go - 滑动窗口限流中间件
rdb := cache.GetRedisClient()
if rdb == nil || cache.IsBreakerOpen() {
c.Next() // 放行
return
}
场景 4:消息队列消费者重试
Fanout Worker 和 Timeline Consumer 在遇到 ErrBreakerOpen 时,会 sleep 1 秒后 Nack 消息,让 MQ 重新投递:
go
// fanoutworker.go / outboxworker.go
if errors.Is(err, rediscache.ErrBreakerOpen) {
time.Sleep(time.Second) // 等一等,给 Redis 恢复的时间
}
_ = d.Nack(false, true) // 重新入队
5.5 测试用例解读
breaker_test.go 有 6 个测试用例,覆盖了熔断器的核心行为:
go
// 测试 1:连续失败触发熔断
func TestBreakerTripsOnConsecutiveFailures(t *testing.T) {
cfg := BreakerConfig{
Name: "test",
MaxRequests: 1,
Interval: time.Minute,
Timeout: 100 * time.Millisecond,
ConsecutiveFailures: 3,
}
b := NewBreaker(cfg)
bizErr := errors.New("boom")
for i := 0; i < 3; i++ {
err := b.Execute(func() error { return bizErr })
// 前 3 次失败:返回业务错误,不是熔断错误
if !errors.Is(err, bizErr) {
t.Fatalf("iter %d: expected biz error, got %v", i, err)
}
}
// 第 4 次:熔断器已经 Open,返回 ErrBreakerOpen
if b.State() != "open" {
t.Fatalf("expected open after %d failures", cfg.ConsecutiveFailures)
}
err := b.Execute(func() error { return nil })
if !errors.Is(err, ErrBreakerOpen) {
t.Fatalf("expected ErrBreakerOpen, got %v", err)
}
}
go
// 测试 2:缓存未命中不触发熔断(最关键的设计验证)
func TestBreakerCacheMissNotCountedAsFailure(t *testing.T) {
cfg := BreakerConfig{
ConsecutiveFailures: 2, // 2 次连续失败就熔断
}
b := NewBreaker(cfg)
// 模拟 10 次缓存未命中
for i := 0; i < 10; i++ {
err := b.Execute(func() error { return redis.Nil })
if !errors.Is(err, redis.Nil) {
t.Fatalf("expected redis.Nil to passthrough, got %v", err)
}
}
// 10 次 cache miss 后仍然是 Closed 状态!
if b.State() != "closed" {
t.Fatalf("cache misses must not trip breaker, state=%s", b.State())
}
}
go
// 测试 3:探测失败回到 Open
func TestBreakerHalfOpenFailsBackToOpen(t *testing.T) {
// ... 配置 ConsecutiveFailures: 1, Timeout: 50ms
// 触发熔断
_ = b.Execute(func() error { return bizErr })
// 等待进入 HalfOpen
time.Sleep(100 * time.Millisecond)
// 探测失败 → 回到 Open
_ = b.Execute(func() error { return bizErr })
if b.State() != "open" {
t.Fatalf("expected open after failed probe, got %s", b.State())
}
}
go
// 测试 4:nil 安全
func TestBreakerNilSafe(t *testing.T) {
var b *Breaker // nil breaker
called := false
err := b.Execute(func() error {
called = true
return nil
})
// nil breaker 直接执行 fn,不 panic
if err != nil || !called {
t.Fatal("nil breaker should passthrough")
}
}
六、生产环境最佳实践
从 Feed 项目中总结的 5 条经验:
6.1 缓存未命中的错误分类
这是最容易踩的坑。如果你的熔断器包裹了缓存调用,一定要把 cache miss 从失败统计中排除。否则缓存预热不充分、或者大量冷查询时,熔断器会误触发。
gobreaker v2 提供了两种方式:
IsSuccessful:自定义什么算成功(项目用的是默认:err == nil)IsExcluded:v2 新增,直接排除某些错误(如context.Canceled)
Feed 项目的做法是在 Execute 中手动过滤 redis.Nil,效果等同于 IsExcluded。
6.2 熔断 ≠ 丢弃,必须有降级兜底
熔断器拒绝请求只是第一步。真正的价值在于:被拒绝后,系统能不能用降级方案继续提供服务?
| 场景 | 熔断后的降级方案 |
|---|---|
| Feed 查询 | 回源 MySQL 直查 |
| Fanout 推送 | 降级为逐条写入 |
| 限流器 | 放行(fail-open) |
| MQ 消费者 | 重试(Nack + sleep) |
6.3 可观测性不能少
没有监控的熔断器就是"瞎子"------你不知道它什么时候触发了,也不知道它保护了什么。
Feed 项目上报了两个关键 Prometheus 指标:
circuit_breaker_state_changes_total{to_state="open"}--- 触发次数circuit_breaker_rejections_total--- 拒绝的请求数
建议在 Grafana 面板上同时展示:
- Redis 操作的 P99 延迟
- 熔断器状态变化时间线
- 被拒绝请求的数量趋势
6.4 熔断粒度选择
| 粒度 | 优点 | 缺点 |
|---|---|---|
| 按服务(如整个 Redis) | 简单,保护力度大 | 一个 key 的问题影响所有 key |
| 按接口(如 GET / POST) | 精细,读写隔离 | 配置多,维护成本高 |
| 按实例(如每个 Redis 节点) | 精准定位故障节点 | 需要服务发现配合 |
Feed 项目选择的是按服务 ------一个 Breaker 包裹所有 Redis 操作。这在 Redis 单实例或主从架构下足够了。如果是 Redis Cluster,可以考虑按分片粒度。
6.5 与 singleflight、限流器的配合
熔断器不是孤立的。Feed 项目中,它与另外两个机制协同工作:
请求 → 限流器(控制流量) → 熔断器(保护下游) → singleflight(防击穿) → Redis
- 限流器:控制进入系统的流量上限,防止过载
- 熔断器:当下游故障时快速失败,防止级联
- singleflight:多个请求同时查同一个 key 时,只让一个去查,其他人等结果
三者各司其职,缺一不可。
七、进阶话题
7.1 v2 滑动窗口 vs v1 固定窗口
v1 的 Interval 是固定窗口------每隔 N 秒清零计数器。问题:在窗口重置的瞬间,可能刚好清掉了之前的失败记录,导致熔断触发延迟。
v2 的 BucketPeriod 将窗口分成多个桶,形成滑动窗口。统计的是"最近 N 秒"而不是"当前这个 N 秒窗口",对突发故障的响应更及时。
go
// v1 风格:固定窗口,每 60 秒清零
st.Interval = 60 * time.Second
// v2 风格:滑动窗口,每 5 秒一个桶,共 12 个桶
st.Interval = 60 * time.Second
st.BucketPeriod = 5 * time.Second
7.2 分布式熔断
sony/gobreaker 是进程内的熔断器------每个实例独立计数,互不影响。这在大多数场景下够用了,因为:
- 每个实例独立判断"我看到的下游是否正常"
- 不需要额外的网络通信
- 不存在分布式一致性问题
但在某些场景下,你可能需要分布式熔断(所有实例共享熔断状态):
- 故障检测更快(一个实例发现故障,所有实例立即知道)
- 避免"每个实例都试一遍"的探测开销
分布式熔断的实现方式:
- 基于 Redis 共享计数器(但 Redis 本身可能就是被保护的对象......)
- 基于服务网格(如 Istio 的熔断策略)
- 使用专门的组件(如 Hystrix + Turbine,或 Sentinel)
对于大多数项目,进程内熔断器(如 gobreaker)已经足够。
7.3 与其他库的对比
| 特性 | sony/gobreaker | hystrix-go | resilience4j (Java) |
|---|---|---|---|
| 代码量 | ~400 行 | ~2000 行 | ~5000 行 |
| 依赖 | 无外部依赖 | 无 | 多 |
| 泛型 | v2 支持 | 不支持 | N/A |
| 滑动窗口 | v2 支持 | 支持 | 支持 |
| 隔离策略 | 无(只有熔断) | 线程池/信号量 | 无 |
| 适合场景 | 轻量级保护 | 复杂微服务 | Java 生态 |
gobreaker 的优势是极简------只做熔断一件事,代码量小到你可以完整读完并理解每一行。
总结
| 章节 | 核心要点 |
|---|---|
| 为什么需要 | 防止级联故障,一个下游组件的问题拖垮整条链路 |
| 状态机 | Closed → Open → HalfOpen → Closed,三个状态两个转换条件 |
| gobreaker 源码 | Execute = beforeRequest + fn() + afterRequest,generation 防过期 |
| Feed 项目实战 | 薄封装 + redis.Nil 过滤 + 统一错误语义 + Prometheus 指标 |
| 降级方案 | 熔断后回源 MySQL / 逐条写入 / 放行 / 重试 |
| 最佳实践 | 错误分类、降级兜底、可观测性、粒度选择、与其他机制配合 |
一句话总结:熔断器的本质是"快速失败"------与其让请求傻等超时,不如毫秒级拒绝,把资源留给还能正常处理的请求。sony/gobreaker 用 400 行代码实现了这个模式,而 Feed 项目展示了如何在生产中落地:包裹、过滤、降级、监控,缺一不可。