Redis 从 0 到 1(二十八):Redis 抖一下,重试把它打挂了——超时/重试/熔断的 Go 封装

我不会起名字322 · 后端 / 算法 / 数据库

📘 技术栈 | 🧩 力扣 Hot100 | 🐹 Go 项目 | 🟥 Redis | 🐬 MySQL

文章目录

  • [Redis 从 0 到 1(二十八):Redis 抖一下,重试把它打挂了------超时/重试/熔断的 Go 封装](#Redis 从 0 到 1(二十八):Redis 抖一下,重试把它打挂了——超时/重试/熔断的 Go 封装)

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 分钟。

事后复盘三个失守点:

  1. 超时没配:默认无超时,一个卡顿连接挂住所有等待它的 goroutine;
  2. 重试无节制:MaxRetries=3 且无退避,200 个实例 × 3 次重试 = 600 倍放大;
  3. 没有熔断:明明 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%

观察指标三连:

  1. 无熔断时:请求堆积 → goroutine 数暴涨 → OOM;
  2. 有熔断时:熔断后错误秒级返回,服务内存平稳,日志出现"熔断中";
  3. 恢复后:10 秒(resetTimeout)自动半开试探,成功则关闭熔断。

五、命令清单与排查步骤

命令/指标 作用
INFO clients 看 connected_clients 与 rejected_connections
应用侧 PoolStats.Timeouts 与熔断次数对照,判断"抖"的烈度
应用日志"熔断中"频次 调 threshold/resetTimeout 的依据

排查"Redis 抖一下服务就挂":

  1. 查客户端超时配置:没有 ReadTimeout 的一律先补;
  2. 查 MaxRetries 与退避:>2 或无退避的降档;
  3. 查是否有熔断与降级:没有就按第三节补;
  4. 查服务端 rejected_connections 突增时间是否与客户端重试风暴重合。

六、总结

  1. 超时是底线、退避是礼貌、熔断是自保------三件套缺一不可;
  2. MaxRetries 只给读命令;写命令靠幂等 + 业务补偿;
  3. 熔断后必须有降级路径,否则只是把超时换成了报错;
  4. 用故障注入验证韧性,别等真故障做测试。

下一篇预告:性能优化弧线收官。下一弧线全是"命案现场"------下一篇《CPU 突然 100%------commandstats 定位法与 6 步排查链路》,我们开讲故障排查第一案。

相关推荐
我不会起名字3221 小时前
Redis 从 0 到 1(三十):删了一半数据 RSS 不降——内存碎片与 activedefrag 观测
redis·内存碎片·rss·排查·activedefrag
xyzzklk2 小时前
变相解决华为Mate 8锁屏后无法建立入站网络连接
linux·运维·服务器·网络·华为·go·安卓
csdn_aspnet16 小时前
使用 Docker 和 RedisInsight 设置 Redis 以进行本地开发
redis·docker·容器·redisinsight
用户0942485680321 小时前
第22章:Redis 持久化 RDB 与 AOF:数据不丢的底线
redis·架构·源码
没有气的汽水21 小时前
升级完客户端,redis 又开始大量返回 MOVED:一个埋了七个月的本地配置
redis
csdn_aspnet1 天前
如何在本地 Docker 容器中运行 Redis,并使用 Redis Insight 和 Redis CLI 进行管理
redis·docker·容器
vipxieliang1 天前
语言对比卡:PHP vs Go vs Java 的优缺点、适用场景与「为什么选 PHP」
java·go·php
IT规划师1 天前
Redis 数据类型全解 - Bitfield:99% 开发者没用过的位域操作,把内存再砍 80%(七)
redis·bitfield·位域