Redis 高可用架构实战:缓存穿透、击穿、雪崩的全面防御
Redis 高可用架构实战:缓存穿透、击穿、雪崩的全面防御
Redis 几乎是现代后端缓存的事实标准。但在生产环境中,"缓存三兄弟"——穿透、击穿、雪崩——是压垮系统的高频元凶。本文从问题本质出发,给出可落地的防御方案与高可用架构演进路径。
一、缓存穿透:查询不存在的数据
现象:请求的数据在缓存和数据库都不存在,每次请求都直达数据库。攻击者利用不存在的 key(如负 ID)可轻易打垮数据库。
防御一:缓存空值
func GetUser(id int64) (*User, error) {
key := fmt.Sprintf("user:%d", id)
if v, err := rdb.Get(ctx, key).Result(); err == nil {
if v == "" { return nil, ErrNotFound } // 命中空值缓存
var u User
json.Unmarshal([]byte(v), &u)
return &u, nil
}
u, err := db.GetUser(id)
if errors.Is(err, ErrNotFound) {
rdb.Set(ctx, key, "", 5*time.Minute) // 空值也缓存,TTL 短一点
return nil, ErrNotFound
}
...
}
防御二:布隆过滤器
在缓存前加一层 Bloom Filter,不存在的数据直接短路:
// 初始化:全量用户 ID 写入布隆过滤器
bf := bloom.NewWithEstimates(1_000_000, 0.001)
for id := range allUserIDs() { bf.Add(id) }
func GetUser(id int64) (*User, error) {
if !bf.Test(id) { return nil, ErrNotFound } // 一定不存在,直接返回
// 再走缓存 -> 数据库
}
二、缓存击穿:热点 key 过期瞬间
现象:某个热点 key 恰好过期,高并发请求同时打到数据库。
防御一:互斥锁(只让一个请求回源)
func GetHot(key string, fn func() (any, error)) (any, error) {
if v, err := rdb.Get(ctx, key).Result(); err == nil {
return v, nil
}
lock := rdb.SetNX(ctx, "lock:"+key, "1", 3*time.Second)
if lock.Val() {
defer rdb.Del(ctx, "lock:"+key)
v, err := fn() // 只有一个线程回源
rdb.Set(ctx, key, marshal(v), 10*time.Minute)
return v, err
}
time.Sleep(50 * time.Millisecond) // 其他请求短暂等待
return GetHot(key, fn) // 重试读缓存
}
防御二:逻辑过期(永不过期 + 后台刷新)
缓存不设物理过期,value 中带过期时间戳,读到时判断逻辑过期后异步重建:
type CacheItem struct {
Value string `json:"value"`
ExpiredAt int64 `json:"expired_at"`
}
func Get(key string, fn func() (any, error)) (any, bool) {
item := getFromRedis(key)
if time.Now().Unix() < item.ExpiredAt {
return item.Value, true // 未过期,直接返回
}
// 逻辑过期:返回旧值,同时异步重建
go func() {
v, _ := fn()
setWithLogicalTTL(key, v, 10*time.Minute)
}()
return item.Value, true
}
三、缓存雪崩:大量 key 同时失效
现象:缓存中大量 key 在同一时刻过期,请求全部打向数据库;或 Redis 实例宕机导致缓存整体不可用。
防御一:过期时间加随机抖动
ttl := baseTTL + time.Duration(rand.Intn(600))*time.Second
rdb.Set(ctx, key, v, ttl)
防御二:多级缓存兜底
Redis 之上加本地缓存(Caffeine / freecache),命中本地缓存直接返回,Redis 挂掉时仍有二级兜底。
防御三:限流与熔断
回源数据库前加 Semaphore 限流,超阈值直接降级返回旧数据:
if sem.TryAcquire() {
defer sem.Release()
v, err := db.Query(...) // 回源
cache(key, v)
return v
}
return fallbackData // 降级:返回旧缓存 / 默认值
四、Redis 高可用架构演进
4.1 主从复制 + 哨兵(Sentinel)
- 一主多从,读写分离,主节点故障自动切换;
- Sentinel 负责监控、通知、自动故障转移;
- 解决"单点"问题,但主从切换瞬间可能有短暂写不可用。
4.2 集群模式(Cluster)
- 16384 个 hash slot 分布在多个主节点,数据自动分片;
- 每个主节点带从节点,保证分片高可用;
- 适合数据量超单机内存、需要水平扩展的场景;
- 注意:Cluster 模式不支持多 key 事务、
KEYS、MGET跨 slot 操作。
4.3 持久化策略取舍
| 策略 | 机制 | 优点 | 风险 | |------|------|------|------| | RDB | 定期快照 | 恢复快、文件小 | 可能丢失最近数据 | | AOF | 追加写日志 | 数据完整(appendfsync everysec) | 文件大、恢复慢 | | 混合 | RDB + AOF | 兼顾恢复速度与完整性 | 配置略复杂 |
生产建议:AOF 开启,appendfsync everysec,必要时搭配 RDB 做冷备。
五、常见缓存一致性方案
- Cache Aside(旁路缓存):读 miss 回源写缓存;更新时先更新 DB 再删缓存,是多数业务的首选;
- 延迟双删:先删缓存、更新 DB、稍后(如 500ms)再删一次,规避并发下的脏读窗口;
- 监听 binlog 异步更新:通过 Canal 订阅 MySQL binlog,增量同步到 Redis,彻底解耦业务代码。
结语
缓存系统的核心不是"用不用 Redis",而是想清楚每一层缓存的生命周期与失败模式。穿透、击穿、雪崩本质上都是"缓存不可用"的具体形态:把空值、锁、随机 TTL、多级缓存、限流降级这五板斧用对位置,系统就能在极端流量下依然稳如磐石。
读完了?给作者一点支持
🔗 相关文章
Go 并发编程实战:从 Goroutine、Channel 到 Context 的完整指南
系统梳理 Go 并发的核心机制:GMP 调度模型、Channel 与 CSP、select 高级用法、sync 工具箱、Context 生命周期管理,以及 Worker Pool、Pipeline、Fan-in/Fan-out 三大模式,并给出 goroutine 泄漏与数据竞争的排查方法和最佳实践清单。
构建高质量 RESTful API:设计规范与安全实践
从资源建模、状态码语义、JWT 认证鉴权到接口安全十大防护点,再到版本演进策略,给出可直接落地的 RESTful API 设计与安全实践清单。
Go 设计模式实战:从接口组合到选项模式的 Go 风格实现
用接口、函数值与组合重演工厂、策略、选项等模式,展现 Go 风格的设计模式与惯用法取舍。
评论区加载中…