Redis 缓存三大问题(延迟双删、缓存击穿、缓存穿透)Golang 实践指南
本文通过实际业务场景和 Golang 代码示例,详细讲解 Redis 中延迟双删、缓存击穿、缓存穿透的解决方案,帮助 Golang 开发工程师快速理解并落地到业务中。
一、缓存穿透(Cache Penetration)
问题描述
查询不存在的数据(如 ID=-1 的用户、ID=9999 的商品),导致请求绕过缓存直接冲击数据库,可能造成数据库压力过大甚至宕机。
核心解决方案
-
缓存空值:对不存在的数据,在缓存中存储一个短期有效的空值(如 "nil"),避免后续无效请求直接访问数据库。
-
布隆过滤器:在缓存前拦截无效请求(适用于数据量极大的场景),本文以更易落地的「缓存空值」为例。
场景示例:用户信息查询
假设业务需要根据用户 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(如热门商品、活动页面)过期瞬间,大量并发请求同时访问,导致所有请求绕过缓存直接冲击数据库,造成数据库压力骤增。
核心解决方案
-
互斥锁:保证只有一个请求能查询数据库并更新缓存,其他请求等待后重试(本文示例方案)。
-
热点 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)
问题描述
更新数据库后直接删除缓存,可能存在并发一致性问题:
-
线程 A 先更新数据库,再删除缓存;
-
线程 B 在 A 删除缓存前读取缓存(命中旧值),但在 A 更新数据库后写入缓存;
-
最终缓存存储旧值,导致「缓存脏数据」。
核心解决方案
延迟双删:更新数据库前后各删除一次缓存,第二次删除延迟一段时间(如 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 永不过期 | 热门商品、活动页面 |
延迟双删 | 数据更新后缓存不一致 | 两次删除 + 延迟 | 库存更新、用户信息修改等 |
落地注意事项
-
依赖选择 :示例使用
github.com/go-redis/redis/v8
(Redis 客户端),生产环境需注意版本兼容性。 -
错误处理:缓存操作(如删除、写入)失败不阻断核心业务,避免缓存问题影响主流程。
-
过期时间:根据业务数据更新频率设置缓存过期时间(如高频更新数据设置 5-10 分钟,低频更新设置 1-24 小时)。
-
分布式锁优化:生产环境可使用 Redisson 等成熟框架,支持锁重入、自动续期等高级特性。
-
监控告警:对热点 key、缓存命中率、DB 压力等指标进行监控,及时发现异常。