news 2026/4/17 16:19:31

Redis缓存三大问题实战:穿透、雪崩、击穿怎么解决

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
Redis缓存三大问题实战:穿透、雪崩、击穿怎么解决

面试必问三件套:缓存穿透、缓存雪崩、缓存击穿。

但实际生产中踩过坑才知道,这三个问题不只是面试题,是真的会让服务挂掉的。


先搞清楚概念

问题原因后果
缓存穿透查询不存在的数据请求全打到数据库
缓存雪崩大量缓存同时失效瞬间压垮数据库
缓存击穿热点key突然过期并发请求打穿数据库

一张图理解:

正常情况: 请求 → Redis命中 → 返回 缓存穿透: 请求(不存在的key) → Redis未命中 → DB未命中 → 每次都查DB 缓存雪崩: 大量请求 → Redis大面积失效 → 全部打到DB → DB崩溃 缓存击穿: 大量并发请求(同一个热点key) → key刚好过期 → 全部打到DB


缓存穿透

什么是穿透

查询一个根本不存在的数据。缓存里没有,数据库里也没有。

请求: GET /user/999999999 Redis: 没有这个key MySQL: SELECT * FROM users WHERE id = 999999999 → 空 返回: null 下次请求: 还是走MySQL

恶意攻击者可以用大量不存在的ID疯狂请求,每个请求都打到数据库。

解决方案一:缓存空值

func GetUser(ctx context.Context, userID int64) (*User, error) { key := fmt.Sprintf("user:%d", userID) // 1. 查缓存 val, err := rdb.Get(ctx, key).Result() if err == nil { if val == "" { return nil, nil // 空值,说明DB也没有 } var user User json.Unmarshal([]byte(val), &user) return &user, nil } // 2. 查数据库 user, err := db.GetUser(userID) if err != nil { return nil, err } // 3. 写缓存 if user == nil { // 缓存空值,设置较短过期时间 rdb.Set(ctx, key, "", 5*time.Minute) return nil, nil } data, _ := json.Marshal(user) rdb.Set(ctx, key, data, 30*time.Minute) return user, nil }

缺点:如果攻击者用随机key,空值缓存会占用大量内存。

解决方案二:布隆过滤器

布隆过滤器可以判断一个元素一定不存在可能存在

import "github.com/bits-and-blooms/bloom/v3" // 初始化布隆过滤器,存放所有存在的用户ID var userBloom *bloom.BloomFilter func InitBloom() { userBloom = bloom.NewWithEstimates(10000000, 0.01) // 1000万数据,1%误判率 // 加载所有用户ID userIDs, _ := db.GetAllUserIDs() for _, id := range userIDs { userBloom.AddString(fmt.Sprintf("%d", id)) } } func GetUser(ctx context.Context, userID int64) (*User, error) { // 先过布隆过滤器 if !userBloom.TestString(fmt.Sprintf("%d", userID)) { return nil, nil // 一定不存在 } // 可能存在,走正常逻辑 // ... }

Redis也有布隆过滤器模块:

# 安装RedisBloom模块后 BF.ADD users 123 BF.EXISTS users 123 # 返回1 BF.EXISTS users 999 # 返回0

// Go代码 func CheckUserExists(ctx context.Context, userID int64) bool { exists, _ := rdb.Do(ctx, "BF.EXISTS", "users", userID).Bool() return exists }

解决方案三:参数校验

最简单但最有效:

func GetUser(ctx context.Context, userID int64) (*User, error) { // 参数校验 if userID <= 0 || userID > 10000000000 { return nil, errors.New("invalid user id") } // 正常逻辑... }


缓存雪崩

什么是雪崩

大量缓存在同一时间失效,请求全部打到数据库。

常见原因:

  1. 缓存设置了相同的过期时间
  2. Redis服务宕机

解决方案一:过期时间加随机值

func SetUserCache(ctx context.Context, userID int64, user *User) error { key := fmt.Sprintf("user:%d", userID) data, _ := json.Marshal(user) // 基础过期时间 + 随机时间 baseExpire := 30 * time.Minute randomExpire := time.Duration(rand.Intn(300)) * time.Second // 0~5分钟随机 expire := baseExpire + randomExpire return rdb.Set(ctx, key, data, expire).Err() }

这样缓存过期时间分散开,不会同时失效。

解决方案二:多级缓存

请求 → 本地缓存(L1) → Redis(L2) → 数据库

import "github.com/patrickmn/go-cache" var localCache = cache.New(5*time.Minute, 10*time.Minute) func GetUser(ctx context.Context, userID int64) (*User, error) { key := fmt.Sprintf("user:%d", userID) // 1. 查本地缓存 if val, found := localCache.Get(key); found { return val.(*User), nil } // 2. 查Redis val, err := rdb.Get(ctx, key).Result() if err == nil { var user User json.Unmarshal([]byte(val), &user) localCache.Set(key, &user, cache.DefaultExpiration) // 写入本地缓存 return &user, nil } // 3. 查数据库 user, err := db.GetUser(userID) if err != nil { return nil, err } // 4. 写缓存 data, _ := json.Marshal(user) rdb.Set(ctx, key, data, 30*time.Minute) localCache.Set(key, user, cache.DefaultExpiration) return user, nil }

即使Redis挂了,本地缓存还能顶一会。

解决方案三:Redis高可用

  • Redis Sentinel:哨兵模式,自动故障转移
  • Redis Cluster:集群模式,数据分片+高可用

// Sentinel模式连接 rdb := redis.NewFailoverClient(&redis.FailoverOptions{ MasterName: "mymaster", SentinelAddrs: []string{"sentinel1:26379", "sentinel2:26379", "sentinel3:26379"}, })

解决方案四:熔断降级

Redis不可用时,走降级逻辑:

import "github.com/sony/gobreaker" var cb *gobreaker.CircuitBreaker func init() { cb = gobreaker.NewCircuitBreaker(gobreaker.Settings{ Name: "redis", MaxRequests: 3, // 半开状态允许的请求数 Interval: 10 * time.Second, Timeout: 30 * time.Second, // 熔断后多久尝试恢复 ReadyToTrip: func(counts gobreaker.Counts) bool { failureRatio := float64(counts.TotalFailures) / float64(counts.Requests) return counts.Requests >= 3 && failureRatio >= 0.6 }, }) } func GetUser(ctx context.Context, userID int64) (*User, error) { result, err := cb.Execute(func() (interface{}, error) { // 正常的Redis查询逻辑 return getFromRedis(ctx, userID) }) if err != nil { // 熔断了,走降级逻辑 return getFromDB(ctx, userID) } return result.(*User), nil }


缓存击穿

什么是击穿

某个热点key突然过期,大量并发请求同时打到数据库。

和雪崩的区别:雪崩是大面积失效,击穿是单个热点key失效。

热点key: "hot_product:123" 过期瞬间: 请求1 → Redis Miss → 查DB 请求2 → Redis Miss → 查DB 请求3 → Redis Miss → 查DB ... 1000个请求同时查DB

解决方案一:互斥锁

只让一个请求去查数据库,其他请求等待。

func GetHotProduct(ctx context.Context, productID int64) (*Product, error) { key := fmt.Sprintf("product:%d", productID) lockKey := fmt.Sprintf("lock:product:%d", productID) // 1. 查缓存 val, err := rdb.Get(ctx, key).Result() if err == nil { var product Product json.Unmarshal([]byte(val), &product) return &product, nil } // 2. 获取分布式锁 locked, err := rdb.SetNX(ctx, lockKey, "1", 10*time.Second).Result() if err != nil { return nil, err } if !locked { // 没拿到锁,等待后重试 time.Sleep(50 * time.Millisecond) return GetHotProduct(ctx, productID) // 递归重试 } defer rdb.Del(ctx, lockKey) // 释放锁 // 3. 双重检查(可能别人已经写入缓存了) val, err = rdb.Get(ctx, key).Result() if err == nil { var product Product json.Unmarshal([]byte(val), &product) return &product, nil } // 4. 查数据库 product, err := db.GetProduct(productID) if err != nil { return nil, err } // 5. 写缓存 data, _ := json.Marshal(product) rdb.Set(ctx, key, data, 30*time.Minute) return product, nil }

解决方案二:逻辑过期

不设置真正的过期时间,而是在value里存逻辑过期时间。

type CacheValue struct { Data json.RawMessage `json:"data"` ExpireTime int64 `json:"expire_time"` // 逻辑过期时间戳 } func GetHotProduct(ctx context.Context, productID int64) (*Product, error) { key := fmt.Sprintf("product:%d", productID) val, err := rdb.Get(ctx, key).Result() if err != nil { // 缓存不存在,需要预热 return nil, errors.New("cache not found, need warm up") } var cv CacheValue json.Unmarshal([]byte(val), &cv) var product Product json.Unmarshal(cv.Data, &product) // 检查是否逻辑过期 if time.Now().Unix() > cv.ExpireTime { // 过期了,异步刷新 go refreshCache(ctx, productID) } // 返回旧数据(不阻塞) return &product, nil } func refreshCache(ctx context.Context, productID int64) { key := fmt.Sprintf("product:%d", productID) lockKey := fmt.Sprintf("refresh:product:%d", productID) // 获取刷新锁 locked, _ := rdb.SetNX(ctx, lockKey, "1", 30*time.Second).Result() if !locked { return // 已经有人在刷新了 } defer rdb.Del(ctx, lockKey) // 查数据库 product, _ := db.GetProduct(productID) // 写缓存 cv := CacheValue{ ExpireTime: time.Now().Add(30 * time.Minute).Unix(), } cv.Data, _ = json.Marshal(product) data, _ := json.Marshal(cv) rdb.Set(ctx, key, data, 0) // 不设置过期时间 }

特点:用户永远不会等待,总是返回数据(可能是旧的)。

解决方案三:热点数据永不过期

对于真正的热点数据,不设置过期时间,通过后台任务定时更新。

// 后台任务定时刷新热点数据 func RefreshHotData() { ticker := time.NewTicker(5 * time.Minute) for range ticker.C { hotProductIDs := getHotProductIDs() for _, id := range hotProductIDs { product, _ := db.GetProduct(id) key := fmt.Sprintf("product:%d", id) data, _ := json.Marshal(product) rdb.Set(context.Background(), key, data, 0) } } }


实战:热点Key问题

除了击穿,热点Key还有另一个问题:单个Redis节点压力过大

发现热点Key

# Redis 4.0+ redis-cli --hotkeys # 或者用monitor命令(生产慎用,性能影响大) redis-cli monitor | head -n 10000 | awk '{print $4}' | sort | uniq -c | sort -rn | head -20

解决方案:本地缓存+多副本

// 热点key多副本,分散请求 func GetHotProduct(ctx context.Context, productID int64) (*Product, error) { // 先查本地缓存 localKey := fmt.Sprintf("product:%d", productID) if val, found := localCache.Get(localKey); found { return val.(*Product), nil } // 随机选一个副本查询 replica := rand.Intn(3) key := fmt.Sprintf("product:%d:r%d", productID, replica) val, err := rdb.Get(ctx, key).Result() if err == nil { var product Product json.Unmarshal([]byte(val), &product) localCache.Set(localKey, &product, 30*time.Second) return &product, nil } // 查数据库... } // 写入时写多个副本 func SetHotProduct(ctx context.Context, productID int64, product *Product) error { data, _ := json.Marshal(product) pipe := rdb.Pipeline() for i := 0; i < 3; i++ { key := fmt.Sprintf("product:%d:r%d", productID, i) pipe.Set(ctx, key, data, 30*time.Minute) } _, err := pipe.Exec(ctx) return err }


总结

问题原因解决方案
穿透查不存在的数据缓存空值、布隆过滤器、参数校验
雪崩大量缓存同时失效过期时间随机化、多级缓存、Redis高可用、熔断降级
击穿热点key过期互斥锁、逻辑过期、永不过期+后台刷新

实际生产中,这三个问题往往要组合解决:

  1. 所有缓存都加随机过期时间(防雪崩)
  2. 查询前做参数校验(防穿透)
  3. 缓存空值(防穿透)
  4. 热点数据用互斥锁或逻辑过期(防击穿)
  5. Redis做好高可用(防雪崩)
  6. 加上熔断降级兜底
版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/4/18 11:04:25

跨域安全危机迫在眉睫,PHP后端必须部署的6道防火墙

第一章&#xff1a;跨域安全危机的现状与挑战现代Web应用架构日益复杂&#xff0c;跨域请求已成为前端与后端、微服务之间通信的常态。然而&#xff0c;跨域资源共享&#xff08;CORS&#xff09;机制在提供便利的同时&#xff0c;也引入了严重的安全隐患。攻击者可利用配置不当…

作者头像 李华
网站建设 2026/4/18 7:13:26

【PHP边缘计算实战指南】:掌握高效网络通信的5大核心技术

第一章&#xff1a;PHP边缘计算网络通信概述在现代分布式系统架构中&#xff0c;边缘计算正逐步成为提升响应速度与降低带宽消耗的关键技术。PHP 作为一种广泛应用于 Web 开发的脚本语言&#xff0c;虽然传统上运行于中心化服务器环境&#xff0c;但通过合理设计&#xff0c;也…

作者头像 李华
网站建设 2026/4/18 8:03:00

GLM-TTS能否用于航天航空通信模拟?专业指令语音生成

GLM-TTS能否用于航天航空通信模拟&#xff1f;专业指令语音生成 在现代飞行训练与空管仿真系统中&#xff0c;语音交互的真实性、准确性和响应速度直接关系到操作员的判断效率和应急反应能力。传统的通信模拟多依赖预录语音或标准化TTS播报&#xff0c;内容固定、语气单一&…

作者头像 李华
网站建设 2026/4/18 1:53:46

Kafka批量消费实现

批量消费指的是一次性拉取一批消息&#xff0c;然后批量处理 依赖spring-kafka <dependency> <groupId>org.springframework.kafka</groupId> <artifactId>spring-kafka</artifactId> <version>2.2.4.RELEASE</version> &l…

作者头像 李华
网站建设 2026/4/17 21:42:08

2026市场主流APP制作公司有哪些?其核心功能与选择建议梳理

摘要如果你在寻找“最适合自己的APP制作公司”&#xff0c;核心结论是&#xff1a;没有绝对的最优解&#xff0c;只有基于你项目类型、预算、工期和技术栈的最适配方案。 对于追求高定制化、全流程把控且预算充足的中大型项目&#xff0c;拥有CMMI3/ISO27001等国际认证、技术团…

作者头像 李华
网站建设 2026/4/18 8:05:57

GLM-TTS能否用于紧急警报系统?高穿透力语音生成研究

GLM-TTS能否用于紧急警报系统&#xff1f;高穿透力语音生成研究 在地铁站突然响起的广播中&#xff0c;一句“请立即撤离”是否真的能让人听清、听懂、并迅速行动&#xff1f;在火灾、地震或突发公共事件中&#xff0c;时间以秒计算&#xff0c;而信息传递的有效性直接关系到生…

作者头像 李华