【Redis】三种缓存问题(穿透、击穿、双删)的 Golang 实践

Redis 缓存三大问题(延迟双删、缓存击穿、缓存穿透)Golang 实践指南

本文通过实际业务场景和 Golang 代码示例,详细讲解 Redis 中延迟双删、缓存击穿、缓存穿透的解决方案,帮助 Golang 开发工程师快速理解并落地到业务中。

一、缓存穿透(Cache Penetration)

问题描述

查询不存在的数据(如 ID=-1 的用户、ID=9999 的商品),导致请求绕过缓存直接冲击数据库,可能造成数据库压力过大甚至宕机。

核心解决方案

  1. 缓存空值:对不存在的数据,在缓存中存储一个短期有效的空值(如 "nil"),避免后续无效请求直接访问数据库。

  2. 布隆过滤器:在缓存前拦截无效请求(适用于数据量极大的场景),本文以更易落地的「缓存空值」为例。

场景示例:用户信息查询

假设业务需要根据用户 ID 查询信息,恶意请求可能使用不存在的 ID 频繁查询,需通过缓存空值防护。

c 复制代码
package main

import (
     "context"
     "fmt"
     "time"
     "github.com/go-redis/redis/v8"
)

// 模拟数据库:仅存储 ID=1001 的用户

type MockDB struct{}

func (db *MockDB) GetUserByID(id int64) (map[string]interface{}, bool) {

      if id == 1001 {
           return map[string]interface{}{
                      "id":   1001,
                      "name": "张三",
                       "age":  28,
               }, true

      }
       return nil, false
}

// 用户服务:集成缓存穿透防护

type UserService struct {
       redisClient *redis.Client
       db          *MockDB
       ctx         context.Context
}

func NewUserService(redisClient *redis.Client) *UserService {

       return &UserService{
               redisClient: redisClient,
               db:          &MockDB{},
               ctx:         context.Background(),

       }

}

// GetUserWithPenetrationProtection:带缓存穿透防护的查询方法

func (s *UserService) GetUserWithPenetrationProtection(id int64) (map[string]interface{}, error) {
       cacheKey := fmt.Sprintf("user:%d", id)

     // 1. 先查缓存(命中则直接返回,包括空值)
       cacheVal, err := s.redisClient.Get(s.ctx, cacheKey).Result()

      if err == nil && cacheVal != "" {
               if cacheVal == "nil" { // 命中空值缓存
                       return nil, fmt.Errorf("用户不存在")
              }
              // 实际项目中需反序列化 JSON(此处简化)
               return map\[string]interface{}{"id": id, "data": cacheVal}, nil
       }



       // 2. 缓存未命中,查询数据库
       user, exists := s.db.GetUserByID(id)
       if !exists {
               // 3. 数据库无数据,缓存空值(短期有效,避免长期占用内存)
               if err := s.redisClient.Set(s.ctx, cacheKey, "nil", 5*time.Minute).Err(); err != nil {
                       return nil, fmt.Errorf("缓存空值失败: %v", err)
               }
              return nil, fmt.Errorf("用户不存在")

      }


       // 4. 数据库有数据,写入缓存(设置合理过期时间)
       if err := s.redisClient.Set(s.ctx, cacheKey, "user_data", 30*time.Minute).Err(); err != nil {
               return nil, fmt.Errorf("写入缓存失败: %v", err)
       }


      return user, nil

}

func main() {

       // 初始化 Redis 客户端
       redisClient := redis.NewClient(\&redis.Options{
               Addr:     "localhost:6379",
               Password: "", // 无密码(生产环境需配置)
               DB:       0,  // 默认数据库

      })

       defer redisClient.Close()
       userService := NewUserService(redisClient)
       
       // 测试1:查询存在的用户(ID=1001)
       user, err := userService.GetUserWithPenetrationProtection(1001)
       if err != nil {
              fmt.Println("测试1失败:", err)
      } else {
              fmt.Println("测试1成功:", user)
      }

      // 测试2:首次查询不存在的用户(ID=9999)→ 查DB+缓存空值
       user, err = userService.GetUserWithPenetrationProtection(9999)

       if err != nil {
               fmt.Println("测试2失败:", err)
       } else {
               fmt.Println("测试2成功:", user)

      }
      
       // 测试3:再次查询不存在的用户(ID=9999)→ 命中空值缓存
       user, err = userService.GetUserWithPenetrationProtection(9999)
       if err != nil {
               fmt.Println("测试3失败:", err)
       } else {
              fmt.Println("测试3成功:", user)
       }

}

代码关键说明

  • 空值缓存有效期:设置为 5 分钟(可根据业务调整),避免真实数据插入后缓存长期不一致。

  • 序列化处理:实际项目中需将用户数据序列化为 JSON 存储(示例中简化为字符串)。

  • 错误处理:缓存操作失败不阻断业务(如缓存空值失败仍返回「用户不存在」)。

二、缓存击穿(Cache Breakdown)

问题描述

热点 key(如热门商品、活动页面)过期瞬间,大量并发请求同时访问,导致所有请求绕过缓存直接冲击数据库,造成数据库压力骤增。

核心解决方案

  1. 互斥锁:保证只有一个请求能查询数据库并更新缓存,其他请求等待后重试(本文示例方案)。

  2. 热点 key 永不过期:业务层不设置缓存过期时间,通过后台定时任务更新缓存(适用于更新频率低的场景)。

场景示例:热门商品查询

假设某商品(ID=10086)是爆款,缓存过期时会有大量并发请求,需通过互斥锁控制并发。

c 复制代码
package main

import (

      "context"
       "fmt"
       "sync"
      "time"
       "github.com/go-redis/redis/v8"

)

// 模拟商品数据库:仅存储热门商品 ID=10086

type ProductDB struct{}

func (db *ProductDB) GetProductByID(id int64) (map[string]interface{}, bool) {

       // 模拟数据库查询耗时(实际场景可能更久)
    time.Sleep(100 * time.Millisecond)
    if id == 10086 {
           return map[string]interface{}{
                       "id":    10086,
                       "name":  "热门手机",

                       "price": 3999,

                       "stock": 10000,
               }, true

       }

       return nil, false

}

// 商品服务:集成缓存击穿防护(互斥锁)
type ProductService struct {
       redisClient *redis.Client
       db          *ProductDB
       ctx         context.Context

}

func NewProductService(redisClient *redis.Client) *ProductService {

       return &ProductService{
               redisClient: redisClient,
               db:          &ProductDB{},
               ctx:         context.Background(),
      }
}

// GetProductWithBreakdownProtection:带缓存击穿防护的查询方法
func (s *ProductService) GetProductWithBreakdownProtection(id int64) (map[string]interface{}, error) {

       cacheKey := fmt.Sprintf("product:%d", id)
      // 1. 先查缓存(命中则直接返回)
       cacheVal, err := s.redisClient.Get(s.ctx, cacheKey).Result()
       if err == nil && cacheVal != "" {
              return map[string]interface{}{"id": id, "data": cacheVal}, nil
       }

       // 2. 缓存未命中,尝试获取分布式锁(Redis SET NX)
       lockKey := fmt.Sprintf("lock:product:%d", id)
       lockVal := "1"
       // 锁有效期 3 秒(防止服务异常导致死锁)
      ok, err := s.redisClient.SetNX(s.ctx, lockKey, lockVal, 3*time.Second).Result()
       if err != nil {
               return nil, fmt.Errorf("获取锁失败: %v", err)
       }
       // 3. 未获取到锁:等待 100ms 后重试(简单退避策略)
       if !ok {
               time.Sleep(100 * time.Millisecond)
               return s.GetProductWithBreakdownProtection(id)
       }
       // 确保锁释放(无论后续逻辑是否成功)
       defer s.redisClient.Del(s.ctx, lockKey)
       // 4. 获取到锁:查询数据库
      product, exists := s.db.GetProductByID(id)
       if !exists {
              // 缓存空值(同时防护缓存穿透)
               s.redisClient.Set(s.ctx, cacheKey, "nil", 5*time.Minute)
              return nil, fmt.Errorf("商品不存在")
      }
      
      // 5. 写入缓存(热点 key 可延长过期时间,如 1 小时)
     if err := s.redisClient.Set(s.ctx, cacheKey, "product_data", 1*time.Hour).Err(); err != nil {
               return nil, fmt.Errorf("写入缓存失败: %v", err)
      }    

               return product, nil

}

func main() {

       // 初始化 Redis 客户端

       redisClient := redis.NewClient(&redis.Options{
               Addr:     "localhost:6379",
               Password: "",
               DB:       0,
       })

       defer redisClient.Close()
       productService := NewProductService(redisClient)
       // 模拟 100 个并发请求查询热点商品
      var wg sync.WaitGroup
       wg.Add(100)
       startTime := time.Now()
       for i := 0; i < 100; i++ {

               go func() {

                       defer wg.Done()
                       _, err := productService.GetProductWithBreakdownProtection(10086)
                       if err != nil {
                               fmt.Println("并发请求失败:", err)
                       }
               }()
       }

       wg.Wait()
       fmt.Printf("100 个并发请求总耗时: %v\n", time.Since(startTime))
       // 预期结果:总耗时 ≈ 100ms(仅第一个请求查 DB,其余等待后命中缓存)

}

代码关键说明

  • 分布式锁实现 :使用 Redis 的 SetNX(Set if Not Exists)命令,确保同一时间只有一个请求获取锁。

  • 锁有效期:设置为 3 秒(需大于数据库查询耗时),防止服务崩溃导致锁无法释放。

  • 退避策略:未获取锁的请求等待 100ms 后重试,避免频繁自旋消耗 CPU。

  • 双重防护:同时缓存空值,兼顾缓存穿透问题。

三、延迟双删(Delayed Double Delete)

问题描述

更新数据库后直接删除缓存,可能存在并发一致性问题

  1. 线程 A 先更新数据库,再删除缓存;

  2. 线程 B 在 A 删除缓存前读取缓存(命中旧值),但在 A 更新数据库后写入缓存;

  3. 最终缓存存储旧值,导致「缓存脏数据」。

核心解决方案

延迟双删:更新数据库前后各删除一次缓存,第二次删除延迟一段时间(如 500ms),覆盖并发窗口期。

  • 第一次删除:清除旧缓存,避免更新期间读取旧值;

  • 第二次删除:解决「线程 B 读取旧 DB 数据后写入缓存」的问题。

场景示例:商品库存更新

假设需要更新商品库存(如下单减库存),需通过延迟双删保证缓存与数据库一致性。

c 复制代码
package main

import (
       "context"
       "fmt"
      "time"
       "github.com/go-redis/redis/v8"
)

// 模拟库存数据库:内存 map 存储(实际为 MySQL 等)

type InventoryDB struct {

       stock map[int64]int // key: 商品ID,value: 库存

}

func NewInventoryDB() *InventoryDB {

       return &InventoryDB{
               stock: map[int64]int{
                       10086: 10000, // 初始库存:10000
               },
       }

}

// UpdateStock:更新库存(模拟数据库事务)

func (db *InventoryDB) UpdateStock(id int64, quantity int) (bool, error) {

       current, ok := db.stock[id]

       if !ok {
               return false, fmt.Errorf("商品不存在")
       }

       // 防止库存为负(如下单减库存时)

       if current+quantity < 0 {
               return false, fmt.Errorf("库存不足")
       }

       db.stock[id] += quantity
       return true, nil

}

// GetStock:查询库存

func (db *InventoryDB) GetStock(id int64) (int, bool) {
       stock, ok := db.stock[id]
       return stock, ok
}

// 库存服务:集成延迟双删

type InventoryService struct {
       redisClient *redis.Client
       db          *InventoryDB
       ctx         context.Context

}

func NewInventoryService(redisClient *redis.Client) *InventoryService {

       return &InventoryService{
               redisClient: redisClient,
               db:          NewInventoryDB(),
               ctx:         context.Background(),
       }

}

// UpdateStockWithDoubleDelete:带延迟双删的库存更新方法

func (s *InventoryService) UpdateStockWithDoubleDelete(id int64, quantity int) error {
       cacheKey := fmt.Sprintf("stock:%d", id)
       // 1. 第一次删除缓存:清除旧值
       _ = s.redisClient.Del(s.ctx, cacheKey).Err() // 缓存删除失败不阻断业务
       // 2. 更新数据库(核心业务逻辑,需保证事务性)
       success, err := s.db.UpdateStock(id, quantity)
       if !success || err != nil {
               return fmt.Errorf("更新库存失败: %v", err)
       }

       // 3. 延迟一段时间后,第二次删除缓存(覆盖并发窗口期)
       // 延迟时间:根据业务响应时间调整(通常 100ms-1s)
       go func() {
               time.Sleep(500 \* time.Millisecond)
               _ = s.redisClient.Del(s.ctx, cacheKey).Err()
       }()
      return nil
}

// GetStock:查询库存(先缓存后 DB)

func (s *InventoryService) GetStock(id int64) (int, error) {

       cacheKey := fmt.Sprintf("stock:%d", id)
       // 1. 查缓存
       cacheVal, err := s.redisClient.Get(s.ctx, cacheKey).Int()
       if err == nil {
               return cacheVal, nil
       }

       // 2. 缓存未命中,查 DB
       stock, exists := s.db.GetStock(id)
       if !exists {
               return 0, fmt.Errorf("商品不存在")
       }
       // 3. 写入缓存(设置过期时间)
       _ = s.redisClient.Set(s.ctx, cacheKey, stock, 30*time.Minute).Err()
       return stock, nil

}

func main() {

       // 初始化 Redis 客户端
       redisClient := redis.NewClient(&redis.Options{
               Addr:     "localhost:6379",
               Password: "",
               DB:       0,
       })
       defer redisClient.Close()
       inventoryService := NewInventoryService(redisClient)
       // 测试1:初始查询库存
       stock, _ := inventoryService.GetStock(10086)
       fmt.Println("初始库存:", stock) // 预期:10000

       // 测试2:更新库存(减少 100)

       err := inventoryService.UpdateStockWithDoubleDelete(10086, -100)
       if err != nil {
               fmt.Println("更新失败:", err)
       } else {
               fmt.Println("库存更新成功(减少 100)")
       }

       // 测试3:查询更新后的库存
       stock, _ = inventoryService.GetStock(10086)
       fmt.Println("更新后库存:", stock) // 预期:9900

}

代码关键说明

  • 延迟时间选择:500ms 需根据业务调整(如数据库写入耗时 200ms,并发请求响应耗时 300ms,则延迟 500ms 可覆盖窗口期)。

  • 异步删除:第二次删除通过 goroutine 异步执行,不阻塞主业务流程。

  • 容错性:缓存删除失败不阻断业务(如网络波动导致删除失败,后续查询会自动从 DB 更新缓存)。

四、总结与业务落地建议

问题类型 核心痛点 解决方案 适用场景
缓存穿透 无效请求冲击 DB 缓存空值、布隆过滤器 高频无效查询(如恶意请求)
缓存击穿 热点 key 过期导致并发冲击 DB 互斥锁、热点 key 永不过期 热门商品、活动页面
延迟双删 数据更新后缓存不一致 两次删除 + 延迟 库存更新、用户信息修改等

落地注意事项

  1. 依赖选择 :示例使用 github.com/go-redis/redis/v8(Redis 客户端),生产环境需注意版本兼容性。

  2. 错误处理:缓存操作(如删除、写入)失败不阻断核心业务,避免缓存问题影响主流程。

  3. 过期时间:根据业务数据更新频率设置缓存过期时间(如高频更新数据设置 5-10 分钟,低频更新设置 1-24 小时)。

  4. 分布式锁优化:生产环境可使用 Redisson 等成熟框架,支持锁重入、自动续期等高级特性。

  5. 监控告警:对热点 key、缓存命中率、DB 压力等指标进行监控,及时发现异常。

相关推荐
提笔了无痕4 小时前
什么是Redis的缓存问题,以及如何解决
数据库·redis·后端·缓存·mybatis
lang201509284 小时前
Spring Boot缓存机制全解析
spring boot·后端·缓存
九江Mgx11 小时前
使用 Go + govcl 实现 Windows 资源管理器快捷方式管理器
windows·golang·govcl
李辰洋11 小时前
go tools安装
开发语言·后端·golang
ldmd28411 小时前
Go语言实战:入门篇-4:与数据库、redis、消息队列、API
数据库·redis·缓存
九江Mgx11 小时前
深入理解 Windows 全局键盘钩子(Hook):拦截 Win 键的 Go 实现
golang·windowshook
wanfeng_0911 小时前
go lang
开发语言·后端·golang
绛洞花主敏明11 小时前
go build -tags的其他用法
开发语言·后端·golang
程序员鱼皮11 小时前
老弟第一次学 Redis,被坑惨了!小白可懂的保姆级 Redis 教程
数据库·redis·程序员