从0到1理解Go熔断器:sony/gobreaker 源码剖析 + 仿TikTok Feed 项目实战

从0到1理解Go熔断器:sony/gobreaker 源码剖析 + 仿TikTok Feed 项目实战

本文基于 sony/gobreaker v2 和一个真实的 TikTok Feed 系统项目,带你从"为什么需要熔断器"到"如何在生产中落地",完整走一遍。


一、为什么需要熔断器?

1.1 一个生活类比

你家里的电闸上有一个保险丝。当电路过载时,保险丝会熔断,切断电流,保护后面的电器不被烧毁。等你排除故障后,重新合闸,电路恢复正常。

熔断器(Circuit Breaker)就是微服务架构中的"保险丝"------当下游服务出问题时,自动切断调用,防止故障扩散。

1.2 没有熔断器会怎样?

假设你有一个 Web 服务,每次处理请求都要调用 Redis。正常情况下,Redis 的响应时间在 1ms 以内。但如果 Redis 突然变慢(网络抖动、内存满、主从切换),响应时间飙升到 5 秒。

没有熔断器时

  1. 每个请求都傻等 5 秒才超时
  2. Goroutine 堆积,内存飙升
  3. 上游请求也在排队,连接池耗尽
  4. 整个系统从"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  │ <───────────┘
      探测成功            └───────────┘

关键转换规则

  1. Closed → Open:连续失败次数达到阈值(比如 5 次)
  2. Open → HalfOpen:经过一段超时时间(比如 10 秒)
  3. HalfOpen → Closed:探测请求成功
  4. 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 有两种拒绝错误(ErrOpenStateErrTooManyRequests),项目统一映射为 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 中的 GetBytesSetBytes 为例:

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 操作上:ZAddZRangeWithScoresZRevRangeByScoreSAddSRemSMembersMGetDelExpireZincrBy......每个操作都:

  1. breaker.Execute 包裹
  2. 记录 Prometheus 计数(按操作名 + 状态分组)
  3. 记录 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 指标:

  1. circuit_breaker_state_changes_total{to_state="open"} --- 触发次数
  2. 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 项目展示了如何在生产中落地:包裹、过滤、降级、监控,缺一不可。

相关推荐
SimonKing2 小时前
线程池面试被问到怕?看完这篇让他当场沉默
java·后端·程序员
大刚测试开发实战2 小时前
TestHub重磅更新!AI用例生成增加流式输出、Markdown文档上传、模型配置检测、AI评审开关控制...
vue.js·后端·github
阿狸猿2 小时前
论企业应用系统的分层架构风格
java·开发语言·架构
JAVA9652 小时前
JAVA面试-并发篇 07-CAS底层原理是什么有什么缺陷如何解决
java·开发语言·面试
San813_LDD2 小时前
[QT]Qt对象树笔记:父子关系与内存管理
开发语言·qt
程序员阿卢2 小时前
01-基于springboot框架调用ollama下的模型完成基本功能
spring boot·后端·ollama·通义千问模型qwen
IT_陈寒2 小时前
Python列表的+=操作符坑了我一整天
前端·人工智能·后端
gaohe26AIliuzeyu2 小时前
Java接口
java·开发语言
码云骑士2 小时前
【3.1Java基础】Java运算符常见错误排查:10个高频编译运行错误一网打尽
java·开发语言