Go 服务优雅降级的几种实践
在微服务架构中,下游依赖抖动是常态而不是异常。一个服务如果只在依赖健康时才能工作,那它的可用性上界就被最脆弱的那个依赖锁死了。这篇文章整理了我在生产里真正用过的三种降级手段,以及它们各自的适用边界。
一、先把超时传播做对
很多降级方案失效的根因不是策略写错了,而是超时根本没传下去。上游给了 500ms 预算,下游的 HTTP client 却用了 http.DefaultClient(没有超时),结果上游先断,下游的 goroutine 还在跑,资源被白白占住。
正确的做法是让 context 成为唯一的截止时间载体:
func (s *Service) GetProfile(ctx context.Context, uid string) (*Profile, error) {
ctx, cancel := context.WithTimeout(ctx, 300*time.Millisecond)
defer cancel()
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
if err != nil {
return nil, err
}
return s.do(ctx, req)
}
有两个容易忽略的细节。第一,defer cancel() 不能省,否则会泄漏 timer;第二,超时预算要逐层递减,如果每一层都设成相同的值,最外层的预算实际上永远用不完,因为内层已经先超时了。
二、熔断:快速失败优于慢慢等死
熔断的价值不在于"保护下游",而在于保护自己。当下游持续超时时,没有熔断的服务会把连接池、goroutine 和内存全部耗在注定失败的请求上,最终自己先崩。
我一般用三态模型:Closed(正常放行)、Open(直接拒绝,不发请求)、Half-Open(放少量探测流量)。参数上,错误率阈值设 50%、统计窗口 10s、Open 持续 5s 是一个比较稳妥的起点,之后再按实际 QPS 调。
需要注意的是,熔断器必须按依赖维度隔离。如果整个服务共用一个熔断器,那么某个冷门接口的故障会把所有下游一起熔掉,这比不熔断更糟。
三、本地缓存兜底:返回旧数据好过返回错误
对于配置类、字典类这种"稍微旧一点也没关系"的数据,本地缓存兜底的收益非常高。实现上就是一个带时间戳的内存副本:
type staleCache struct {
mu sync.RWMutex
data map[string]entry
}
func (c *staleCache) getOrStale(ctx context.Context, key string,
fetch func(context.Context) (string, error)) (string, error) {
v, err := fetch(ctx)
if err == nil {
c.put(key, v)
return v, nil
}
if e, ok := c.peek(key); ok {
staleHits.Inc()
return e.value, nil // 明确降级:返回过期数据
}
return "", err
}
关键是降级必须可观测。staleHits 这个计数器一定要打出来并配告警,否则你会在毫不知情的情况下长期给用户返回三天前的数据。
三者的关系
超时是地基,熔断是保险丝,缓存兜底是备胎。三者不是互斥的选项,而是叠加使用的:请求先受 context 约束,失败累积触发熔断,熔断打开期间走缓存返回旧值。任何一层单独拿出来都不足以支撑一个高可用服务。
最后一点体会:降级逻辑本身也需要测试。我见过不止一次"降级代码写了但从来没生效过"的情况——通常是那个 if err != nil 的分支条件写错了。用 chaos-mesh 或者简单的 toxiproxy 定期演练,比写十页设计文档有用。