【Redis】三种缓存问题(穿透、击穿、双删)的 Golang 实践
Redis 缓存三大问题(延迟双删、缓存击穿、缓存穿透)Golang 实践指南
本文通过实际业务场景和 Golang 代码示例,详细讲解 Redis 中延迟双删、缓存击穿、缓存穿透的解决方案,帮助 Golang 开发工程师快速理解并落地到业务中。
一、缓存穿透(Cache Penetration)
问题描述
查询不存在的数据(如 ID=-1 的用户、ID=9999 的商品),导致请求绕过缓存直接冲击数据库,可能造成数据库压力过大甚至宕机。
核心解决方案
-
缓存空值:对不存在的数据,在缓存中存储一个短期有效的空值(如 “nil”),避免后续无效请求直接访问数据库。
-
布隆过滤器:在缓存前拦截无效请求(适用于数据量极大的场景),本文以更易落地的「缓存空值」为例。
场景示例:用户信息查询
假设业务需要根据用户 ID 查询信息,恶意请求可能使用不存在的 ID 频繁查询,需通过缓存空值防护。
package mainimport ("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.Clientdb *MockDBctx 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(如热门商品、活动页面)过期瞬间,大量并发请求同时访问,导致所有请求绕过缓存直接冲击数据库,造成数据库压力骤增。
核心解决方案
-
互斥锁:保证只有一个请求能查询数据库并更新缓存,其他请求等待后重试(本文示例方案)。
-
热点 key 永不过期:业务层不设置缓存过期时间,通过后台定时任务更新缓存(适用于更新频率低的场景)。
场景示例:热门商品查询
假设某商品(ID=10086)是爆款,缓存过期时会有大量并发请求,需通过互斥锁控制并发。
package mainimport ("context""fmt""sync""time""github.com/go-redis/redis/v8")// 模拟商品数据库:仅存储热门商品 ID=10086type 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.Clientdb *ProductDBctx 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.WaitGroupwg.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)
问题描述
更新数据库后直接删除缓存,可能存在并发一致性问题:
-
线程 A 先更新数据库,再删除缓存;
-
线程 B 在 A 删除缓存前读取缓存(命中旧值),但在 A 更新数据库后写入缓存;
-
最终缓存存储旧值,导致「缓存脏数据」。
核心解决方案
延迟双删:更新数据库前后各删除一次缓存,第二次删除延迟一段时间(如 500ms),覆盖并发窗口期。
-
第一次删除:清除旧缓存,避免更新期间读取旧值;
-
第二次删除:解决「线程 B 读取旧 DB 数据后写入缓存」的问题。
场景示例:商品库存更新
假设需要更新商品库存(如下单减库存),需通过延迟双删保证缓存与数据库一致性。
package mainimport ("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] += quantityreturn true, nil}// GetStock:查询库存func (db *InventoryDB) GetStock(id int64) (int, bool) {stock, ok := db.stock[id]return stock, ok
}// 库存服务:集成延迟双删type InventoryService struct {redisClient *redis.Clientdb *InventoryDBctx 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. 缓存未命中,查 DBstock, 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 永不过期 | 热门商品、活动页面 |
延迟双删 | 数据更新后缓存不一致 | 两次删除 + 延迟 | 库存更新、用户信息修改等 |
落地注意事项
-
依赖选择:示例使用
github.com/go-redis/redis/v8
(Redis 客户端),生产环境需注意版本兼容性。 -
错误处理:缓存操作(如删除、写入)失败不阻断核心业务,避免缓存问题影响主流程。
-
过期时间:根据业务数据更新频率设置缓存过期时间(如高频更新数据设置 5-10 分钟,低频更新设置 1-24 小时)。
-
分布式锁优化:生产环境可使用 Redisson 等成熟框架,支持锁重入、自动续期等高级特性。
-
监控告警:对热点 key、缓存命中率、DB 压力等指标进行监控,及时发现异常。