我不会起名字322 · 后端 / 算法 / 数据库
文章目录
- [Redis 从 0 到 1(二十八):Redis 抖一下,重试把它打挂了------超时/重试/熔断的 Go 封装](#Redis 从 0 到 1(二十八):Redis 抖一下,重试把它打挂了——超时/重试/熔断的 Go 封装)
-
- 一、现象:一次抖动引发的雪崩
- [二、6 个参数定生死(收藏点 1)](#二、6 个参数定生死(收藏点 1))
- [三、Go 实操:退避 + 熔断封装(收藏点 2)](#三、Go 实操:退避 + 熔断封装(收藏点 2))
- [四、故障注入压测(收藏点 3)](#四、故障注入压测(收藏点 3))
- 五、命令清单与排查步骤
- 六、总结
Redis 从 0 到 1(二十八):Redis 抖一下,重试把它打挂了------超时/重试/熔断的 Go 封装
上一篇《Redis 从 0 到 1(二十七):JSON 体积是 Protobuf 的 3.7 倍?3 种序列化的 Go 基准测试》我们把缓存体积压了下去,链接:https://blog.csdn.net/2501_92769340/article/details/167400194 。
本篇是性能优化弧线的韧性篇:Redis 不可能永远健康,客户端要在"Redis 病了"的时候保护它,而不是杀死它。
一、现象:一次抖动引发的雪崩
某晚 Redis 因 AOF 重写短暂卡顿 3 秒。正常情况下 3 秒过去就自愈了------但这次服务挂了:所有实例同时对超时命令发起重试,重试流量是正常流量的数倍,Redis 刚从卡顿里缓过来就被重试风暴再次打满,恶性循环 20 分钟。
事后复盘三个失守点:
- 超时没配:默认无超时,一个卡顿连接挂住所有等待它的 goroutine;
- 重试无节制:MaxRetries=3 且无退避,200 个实例 × 3 次重试 = 600 倍放大;
- 没有熔断:明明 Redis 已经拒新连接了,客户端还在源源不断地送。
二、6 个参数定生死(收藏点 1)
go-redis 客户端侧:
| 参数 | 建议 | 理由 |
|---|---|---|
ReadTimeout / WriteTimeout |
2s / 2s | 与 P99 对齐(第 21 篇数据),超了这个时间业务层也救不回来了 |
PoolTimeout |
3s | 取不到连接就快速失败,别让请求在池门口排队堆积 |
MaxRetries |
1~2 | 只覆盖瞬时网络抖动;超过 2 次大概率是 Redis 病了,重试是帮凶 |
MinRetryBackoff/MaxRetryBackoff |
100ms / 1s | 指数退避让重试错峰,避免同刻共振 |
context.WithTimeout |
业务级 | 每条命令的兜底,客户端超时和业务超时要成对出现 |
服务端配套:timeout 0 不要开(防客户端超时后服务端还在执行),tcp-keepalive 60。
重试红线:非幂等的写命令(SET 不带 NX、INCR、LPUSH)重试可能造成重复副作用------本系列第 2 篇的封装里 MaxRetries=2 是给读命令的;写路径要么幂等设计(第 12、17 篇),要么失败即抛由业务补偿。
三、Go 实操:退避 + 熔断封装(收藏点 2)
go-redis 自带有限重试(上表),缺的是熔断------用一个三态熔断器包一层:
go
package resilience
import (
"context"
"errors"
"sync"
"time"
"github.com/redis/go-redis/v9"
)
// 熔断器三态:Closed(正常)→ Open(熔断,快速失败)→ HalfOpen(半开,试探)
type Breaker struct {
mu sync.Mutex
failures int
threshold int // 连续失败 N 次熔断
resetTimeout time.Duration // 熔断多久后进入半开
openUntil time.Time
lastFail time.Time
}
func NewBreaker(threshold int, reset time.Duration) *Breaker {
return &Breaker{threshold: threshold, resetTimeout: reset}
}
// Allow 是否允许请求通过
func (b *Breaker) Allow() error {
b.mu.Lock()
defer b.mu.Unlock()
if time.Now().Before(b.openUntil) {
return errors.New("熔断中:Redis 不可用,快速失败")
}
return nil // Closed 或 HalfOpen 都放行(半开就是靠试探恢复的)
}
func (b *Breaker) OnSuccess() {
b.mu.Lock()
b.failures = 0
b.mu.Unlock()
}
func (b *Breaker) OnFailure() {
b.mu.Lock()
b.failures++
if b.failures >= b.threshold {
b.openUntil = time.Now().Add(b.resetTimeout)
b.failures = 0 // 半开重新计数
}
b.mu.Unlock()
}
// Do 带熔断的调用模板:业务侧统一走这里
func Do(ctx context.Context, b *Breaker, fn func(ctx context.Context) error) error {
if err := b.Allow(); err != nil {
return err // 快速失败,不碰 Redis
}
err := fn(ctx)
if err != nil && !errors.Is(err, redis.Nil) {
b.OnFailure()
} else {
b.OnSuccess()
}
return err
}
使用:
go
breaker := resilience.NewBreaker(5, 10*time.Second) // 连续 5 次失败熔断 10 秒
err := resilience.Do(ctx, breaker, func(ctx context.Context) error {
cctx, cancel := context.WithTimeout(ctx, 2*time.Second)
defer cancel()
return rdb.Get(cctx, "config:rate").Err()
})
if err != nil {
// 熔断中/超时/redis.Nil 三种情况分别处理:降级本地默认值 / 重试业务层 / 未命中回源
}
降级策略是熔断的另一半:配置类读不到就用本地默认值 + 日志,绝不能直接把错误透传给用户。
四、故障注入压测(收藏点 3)
bash
# 用 tc 模拟 Redis 网络劣化(在容器/网关上)
tc qdisc add dev eth0 root netem delay 500ms loss 10%
观察指标三连:
- 无熔断时:请求堆积 → goroutine 数暴涨 → OOM;
- 有熔断时:熔断后错误秒级返回,服务内存平稳,日志出现"熔断中";
- 恢复后:10 秒(resetTimeout)自动半开试探,成功则关闭熔断。
五、命令清单与排查步骤
| 命令/指标 | 作用 |
|---|---|
INFO clients |
看 connected_clients 与 rejected_connections |
| 应用侧 PoolStats.Timeouts | 与熔断次数对照,判断"抖"的烈度 |
| 应用日志"熔断中"频次 | 调 threshold/resetTimeout 的依据 |
排查"Redis 抖一下服务就挂":
- 查客户端超时配置:没有 ReadTimeout 的一律先补;
- 查 MaxRetries 与退避:>2 或无退避的降档;
- 查是否有熔断与降级:没有就按第三节补;
- 查服务端 rejected_connections 突增时间是否与客户端重试风暴重合。
六、总结
- 超时是底线、退避是礼貌、熔断是自保------三件套缺一不可;
- MaxRetries 只给读命令;写命令靠幂等 + 业务补偿;
- 熔断后必须有降级路径,否则只是把超时换成了报错;
- 用故障注入验证韧性,别等真故障做测试。
下一篇预告:性能优化弧线收官。下一弧线全是"命案现场"------下一篇《CPU 突然 100%------commandstats 定位法与 6 步排查链路》,我们开讲故障排查第一案。