一文搞懂Redis击穿/穿透/雪崩&实战

75 阅读16分钟

1. 学起来

XDM,大家好,我是专注Golang的王中阳,最近在带着大家疯狂做项目。

我把对大家有帮助的,尤其是对新手小白非常友好的内容,整理分享出来,希望对大家有帮助。

本文将详细介绍这些常见的缓存问题,并结合我们的电商项目,提供完整的解决方案和实现代码,帮助新手小白理解并掌握Redis缓存策略的正确使用方法。

2. Redis缓存常见问题概念解释

2.1 缓存穿透

什么是缓存穿透?

缓存穿透是指用户请求一个不存在的数据,由于缓存中没有该数据,请求会直接打到数据库。如果大量的请求都访问不存在的数据,就会导致数据库压力过大,甚至宕机。

举例说明:

在电商网站中,用户查询一个不存在的商品ID(如-1或者一个非常大的随机数)。由于这个商品ID在缓存中不存在,所以每次请求都会直接查询数据库,而数据库查询后发现也没有该商品。如果有大量这样的恶意请求,数据库的压力就会急剧增加。

缓存穿透的危害:

  1. 数据库压力过大,可能导致数据库宕机
  2. 系统响应时间延长
  3. 服务可用性降低

2.2 缓存击穿

什么是缓存击穿?

缓存击穿是指一个热点数据的缓存过期后,大量并发请求同时访问该数据,导致所有请求都直接打到数据库,造成数据库瞬时压力过大。

举例说明:

在电商网站中,某件热销商品的缓存突然过期。此时,大量用户同时访问该商品详情,由于缓存已经过期,所有的请求都会直接查询数据库。数据库在短时间内需要处理大量请求,可能会导致性能下降甚至宕机。

缓存击穿的危害:

  1. 数据库瞬时压力过大
  2. 系统响应时间延长
  3. 可能导致数据库宕机

2.3 缓存雪崩

什么是缓存雪崩?

缓存雪崩是指大量缓存数据在同一时间段内过期,导致大量请求直接打到数据库,造成数据库压力骤增,甚至宕机。

举例说明:

如果我们在系统上线时,为所有的商品缓存设置了相同的过期时间(比如都设置为1小时),那么在1小时后,所有的商品缓存都会同时过期。这时,大量用户访问网站时,所有的请求都会直接打到数据库,数据库可能无法承受这样的压力而宕机。

缓存雪崩的危害:

  1. 数据库压力骤增,可能导致数据库宕机
  2. 系统响应时间严重延长
  3. 服务可能完全不可用

3. 项目中现有的Redis缓存实现分析

在我们的电商项目中,Redis缓存主要应用在商品服务中,用于缓存商品详情和分类信息。下面我们将分析现有的缓存实现以及存在的问题。

3.1 现有Redis缓存实现

3.1.1 Redis初始化配置

项目使用GoFrame框架的Redis组件进行缓存管理。在goodsRedis/redis.go文件中,实现了Redis的初始化逻辑:

  • 从配置文件中读取Redis连接信息
  • 创建Redis实例
  • 初始化gcache的Redis适配器
  • 测试连接并提供缓存实例获取方法

3.1.2 商品缓存操作

goodsRedis/goods.go文件中,实现了商品和分类的Redis缓存基本操作:

  • 提供了GetGoodsDetailSetGoodsDetailDeleteGoodsDetail等方法
  • 使用JSON序列化/反序列化缓存数据
  • 包含了批量删除缓存的方法
  • 实现了延迟双删逻辑,用于在更新数据库后删除缓存

3.1.3 现有缓存策略

现有实现中已经包含了一些基础的缓存策略:

  • 空值缓存:通过SetEmptyGoodsDetail方法设置短时间空值,初步防止缓存穿透
  • 缓存键管理:通过统一的键生成规则管理缓存键

3.2 现有实现存在的问题

虽然现有实现已经包含了一些基础的缓存功能,但仍然存在以下问题:

  1. 缓存击穿防护缺失:当热点商品缓存过期时,没有有效的机制防止大量并发请求同时打到数据库
  2. 缓存雪崩防护不完善:所有缓存使用固定过期时间,可能导致缓存雪崩
  3. 空值缓存策略简单:空值缓存的处理方式相对简单,没有结合其他机制提供更完善的防护
  4. 缺乏统一的缓存策略接口:缓存策略分散在各个方法中,不利于维护和扩展
  5. 并发安全考虑不足:在高并发场景下,缓存的读取和更新可能存在并发安全问题

正是由于这些问题,我们需要实现一套完整的缓存策略解决方案,以应对缓存穿透、击穿和雪崩问题。

4. 缓存策略解决方案设计与实现

为了解决缓存穿透、击穿和雪崩问题,我们设计并实现了一套完整的缓存策略解决方案。该方案通过创建一个统一的缓存策略接口,结合多种技术手段,提供全面的缓存问题防护。

4.1 缓存策略接口设计

我们首先定义了一个统一的缓存策略接口,以便于实现不同的缓存策略:

// CacheStrategy 缓存策略接口
type CacheStrategy interface {
    // Get 获取缓存数据,如果缓存不存在则调用loader加载数据
    Get(key string, loader func() (interface{}, error)) (interface{}, error)
    // GetWithLock 获取缓存数据,使用本地锁防止缓存击穿
    GetWithLock(key string, loader func() (interface{}, error), expiration time.Duration) (interface{}, error)
    // Set 设置缓存数据
    Set(key string, value interface{}, expiration time.Duration) error
    // Delete 删除缓存数据
    Delete(key string) error
    // SetEmptyValue 设置空值缓存,防止缓存穿透
    SetEmptyValue(key string) error
}

4.2 防缓存穿透解决方案

4.2.1 空值缓存

当数据库中不存在请求的数据时,我们将一个特殊的空值标记(如__EMPTY__)存入缓存,但设置较短的过期时间(如5分钟)。这样可以避免恶意请求直接打到数据库。

4.2.2 布隆过滤器(可选)

对于频繁访问不存在的数据的场景,可以考虑使用布隆过滤器预先过滤掉一定不存在的数据。布隆过滤器可以在极低的空间复杂度下,快速判断一个数据是否可能存在。

4.3 防缓存击穿解决方案

4.3.1 本地锁机制

我们使用双重检查锁定模式结合本地锁,防止缓存击穿:

  1. 首先尝试从缓存获取数据
  2. 如果缓存不存在,获取本地锁
  3. 获取锁后,再次检查缓存是否存在(双重检查)
  4. 如果仍不存在,才去查询数据库并更新缓存
  5. 最后释放锁

这样可以确保在高并发场景下,只有一个请求会去查询数据库,其他请求都从缓存获取数据。

4.3.2 锁管理

为了高效管理本地锁,我们使用sync.Map来存储锁对象,键为缓存键,值为互斥锁。这样可以避免为所有可能的键创建锁对象,节省内存空间。

4.4 防缓存雪崩解决方案

4.4.1 随机过期时间

我们为每个缓存项设置一个基础过期时间,并添加一个随机的时间偏移(如基础时间的5%-15%)。这样可以避免大量缓存在同一时间过期。

4.4.2 缓存预热

在系统启动或低峰期,提前将热点数据加载到缓存中,避免在高峰期缓存未命中的情况。

4.4.3 多级缓存

结合本地缓存(如内存缓存)和远程缓存(如Redis),可以减轻远程缓存的压力,并在远程缓存不可用时提供一定的容错能力。

4.5 缓存一致性保障

为了保障缓存与数据库的一致性,我们实现了以下机制:

4.5.1 延迟双删

在更新数据库后,先删除缓存,然后等待一小段时间(如100毫秒),再次删除缓存。这样可以避免在更新过程中,其他线程读取到旧数据并更新到缓存。

4.5.2 过期时间兜底

即使出现缓存与数据库不一致的情况,设置合理的过期时间也可以确保最终一致性。

5. 项目代码实现示例

下面我们将通过具体的代码示例,展示如何在项目中实现和使用我们的缓存策略解决方案。

5.1 缓存策略实现代码

我们创建了一个新的文件cache_strategy.go,实现了完整的缓存策略解决方案:

package goodsRedis

import (
	"errors"
	"math/rand"
	"sync"
	"time"

	"github.com/gogf/gf/v2/os/gcache"
)

// 常量定义
const (
	// EmptyValue 空值标记,用于防止缓存穿透
	EmptyValue = "__EMPTY__"
	// EmptyValueExpiration 空值缓存的过期时间
	EmptyValueExpiration = time.Minute * 5
	// DefaultExpiration 默认缓存过期时间
	DefaultExpiration = time.Hour
	// JitterPercent 随机过期时间的抖动百分比范围
	JitterMinPercent = 5
	JitterMaxPercent = 15
)

// CacheStrategy 缓存策略接口
type CacheStrategy interface {
	// Get 获取缓存数据,如果缓存不存在则调用loader加载数据
	Get(key string, loader func() (interface{}, error)) (interface{}, error)
	// GetWithLock 获取缓存数据,使用本地锁防止缓存击穿
	GetWithLock(key string, loader func() (interface{}, error), expiration time.Duration) (interface{}, error)
	// Set 设置缓存数据
	Set(key string, value interface{}, expiration time.Duration) error
	// Delete 删除缓存数据
	Delete(key string) error
	// SetEmptyValue 设置空值缓存,防止缓存穿透
	SetEmptyValue(key string) error
}

// RedisCacheStrategy Redis缓存策略实现
type RedisCacheStrategy struct {
	cache *gcache.Cache
	locks sync.Map // 使用sync.Map存储锁对象,键为缓存键,值为互斥锁
}

// NewRedisCacheStrategy 创建新的Redis缓存策略实例
func NewRedisCacheStrategy(cache *gcache.Cache) *RedisCacheStrategy {
	return &RedisCacheStrategy{
		cache: cache,
	}
}

// Get 获取缓存数据
func (s *RedisCacheStrategy) Get(key string, loader func() (interface{}, error)) (interface{}, error) {
	// 尝试从缓存获取数据
	value, err := s.cache.Get(key)
	if err == nil {
		// 检查是否是空值标记
		if str, ok := value.(string); ok && str == EmptyValue {
			return nil, errors.New("empty value")
		}
		return value, nil
	}

	// 缓存未命中,调用loader加载数据
	if loader != nil {
		return loader()
	}

	return nil, errors.New("cache miss and no loader provided")
}

// GetWithLock 获取缓存数据,使用本地锁防止缓存击穿
func (s *RedisCacheStrategy) GetWithLock(key string, loader func() (interface{}, error), expiration time.Duration) (interface{}, error) {
	// 第一次检查缓存
	value, err := s.cache.Get(key)
	if err == nil {
		// 检查是否是空值标记
		if str, ok := value.(string); ok && str == EmptyValue {
			return nil, errors.New("empty value")
		}
		return value, nil
	}

	// 获取锁对象
	lock, _ := s.locks.LoadOrStore(key, &sync.Mutex{})
	mutex := lock.(*sync.Mutex)
	mutex.Lock()
	defer mutex.Unlock()

	// 双重检查,防止在获取锁的过程中缓存被其他线程更新
	value, err = s.cache.Get(key)
	if err == nil {
		// 检查是否是空值标记
		if str, ok := value.(string); ok && str == EmptyValue {
			return nil, errors.New("empty value")
		}
		return value, nil
	}

	// 缓存仍未命中,调用loader加载数据
	if loader != nil {
		data, err := loader()
		if err != nil {
			// 如果loader返回错误,设置空值缓存防止缓存穿透
			s.SetEmptyValue(key)
			return nil, err
		}

		// 如果数据不为空,设置缓存
		if data != nil {
			// 添加随机过期时间,防止缓存雪崩
			s.Set(key, data, s.getExpirationWithJitter(expiration))
		} else {
			// 数据为空,设置空值缓存
			s.SetEmptyValue(key)
		}

		return data, nil
	}

	return nil, errors.New("cache miss and no loader provided")
}

// Set 设置缓存数据
func (s *RedisCacheStrategy) Set(key string, value interface{}, expiration time.Duration) error {
	return s.cache.Set(key, value, expiration)
}

// Delete 删除缓存数据
func (s *RedisCacheStrategy) Delete(key string) error {
	// 删除缓存
	err := s.cache.Remove(key)
	if err != nil {
		return err
	}

	// 移除对应的锁对象
	s.locks.Delete(key)
	return nil
}

// SetEmptyValue 设置空值缓存,防止缓存穿透
func (s *RedisCacheStrategy) SetEmptyValue(key string) error {
	return s.cache.Set(key, EmptyValue, EmptyValueExpiration)
}

// getExpirationWithJitter 计算带随机抖动的过期时间,防止缓存雪崩
func (s *RedisCacheStrategy) getExpirationWithJitter(base time.Duration) time.Duration {
	// 如果基础时间小于0,使用默认过期时间
	if base <= 0 {
		base = DefaultExpiration
	}

	// 生成5%-15%之间的随机百分比
	jitter := rand.Intn(JitterMaxPercent-JitterMinPercent+1) + JitterMinPercent
	jitterDuration := time.Duration(jitter) * base / 100

	// 添加随机抖动到基础时间
	return base + jitterDuration
}

// DelayedDelete 延迟删除缓存,用于延迟双删策略
func (s *RedisCacheStrategy) DelayedDelete(key string, delay time.Duration) {
	go func() {
		time.Sleep(delay)
		s.Delete(key)
	}()
}

5.2 在商品控制器中使用新的缓存策略

我们修改了goods_info/goods_info.go文件,使用新的缓存策略替代了原来的缓存逻辑:

// GetDetail 获取商品详情
func (c *GoodsInfoController) GetDetail(ctx context.Context, req *v1.GoodsDetailReq) (res *v1.GoodsDetailRes, err error) {
	// 获取商品ID
	goodsId := req.Id
	
	// 构建缓存键
	cacheKey := GetGoodsDetailKey(goodsId)
	
	// 创建缓存策略实例
	cacheStrategy := NewRedisCacheStrategy(GetCache())
	
	// 使用缓存策略获取数据,带锁防止缓存击穿
	goodsDetail, err := cacheStrategy.GetWithLock(
		cacheKey,
		// loader函数:从数据库获取数据
		func() (interface{}, error) {
			return c.GetDetailFromDB(ctx, goodsId)
		},
		// 基础过期时间:1小时
		DefaultExpiration,
	)
	
	// 处理错误
	if err != nil {
		if err.Error() == "empty value" {
			// 空值缓存,直接返回商品不存在
			return nil, gerror.New("商品不存在")
		}
		return nil, err
	}
	
	// 将结果转换为响应格式
	if detail, ok := goodsDetail.(*v1.GoodsDetailRes); ok {
		return detail, nil
	}
	
	return nil, gerror.New("数据格式错误")
}

// GetDetailFromDB 从数据库获取商品详情
func (c *GoodsInfoController) GetDetailFromDB(ctx context.Context, goodsId int) (*v1.GoodsDetailRes, error) {
	// 从数据库查询商品信息
	goodsInfo, err := c.goodsInfoService.FindOne(ctx, goodsId)
	if err != nil {
		return nil, err
	}
	
	if goodsInfo == nil {
		return nil, gerror.New("商品不存在")
	}
	
	// 构建响应数据
	res := &v1.GoodsDetailRes{
		Id:          goodsInfo.Id,
		Title:       goodsInfo.Title,
		Price:       goodsInfo.Price,
		OriginalPrice: goodsInfo.OriginalPrice,
		Description: goodsInfo.Description,
		// 其他字段...
	}
	
	return res, nil
}

5.3 缓存键管理

我们在goodsRedis/goods.go文件中实现了统一的缓存键管理:

// GetGoodsDetailKey 获取商品详情缓存键
func GetGoodsDetailKey(goodsId int) string {
	return fmt.Sprintf("goods:detail:%d", goodsId)
}

// GetCategoryInfoKey 获取分类信息缓存键
func GetCategoryInfoKey(categoryId int) string {
	return fmt.Sprintf("category:info:%d", categoryId)
}

5.4 Redis初始化与配置

goodsRedis/redis.go文件中,我们实现了Redis的初始化逻辑:

var (
	// cache 缓存实例
	cache *gcache.Cache
)

// InitRedisCache 初始化Redis缓存
func InitRedisCache() error {
	// 从配置获取Redis连接信息
	host := g.Cfg().MustGet(ctx, "redis.host").String()
	port := g.Cfg().MustGet(ctx, "redis.port").String()
	password := g.Cfg().MustGet(ctx, "redis.password").String()
	db := g.Cfg().MustGet(ctx, "redis.db").Int()

	// 创建Redis实例
	redisClient := gredis.New(gredis.Config{
		Host:     host,
		Port:     port,
		Password: password,
		DB:       db,
	})

	// 测试连接
	if err := redisClient.Ping(ctx); err != nil {
		return err
	}

	// 初始化gcache的Redis适配器
	cache = gcache.New()
	cache.SetAdapter(gcache.NewAdapterRedis(redisClient))

	return nil
}

// GetCache 获取缓存实例
func GetCache() *gcache.Cache {
	return cache
}

5.5 延迟双删实现

在商品更新操作中,我们使用延迟双删策略确保缓存一致性:

// Update 更新商品信息
func (c *GoodsInfoController) Update(ctx context.Context, req *v1.GoodsUpdateReq) error {
	// 更新数据库
	err := c.goodsInfoService.Update(ctx, req)
	if err != nil {
		return err
	}

	// 构建缓存键
	cacheKey := GetGoodsDetailKey(req.Id)
	cacheStrategy := NewRedisCacheStrategy(GetCache())

	// 第一次删除缓存
	err = cacheStrategy.Delete(cacheKey)
	if err != nil {
		log.Errorf("第一次删除缓存失败: %v", err)
	}

	// 延迟100毫秒后再次删除缓存
	cacheStrategy.DelayedDelete(cacheKey, 100*time.Millisecond)

	return nil
}

6. 使用指南和最佳实践

为了帮助新手更好地使用我们实现的缓存策略,下面提供了一些使用指南和最佳实践建议。

6.1 缓存策略使用指南

6.1.1 基本使用流程

  1. 初始化缓存:在服务启动时,调用InitRedisCache()初始化Redis缓存
  2. 创建缓存策略实例:使用NewRedisCacheStrategy(GetCache())创建缓存策略实例
  3. 获取数据:使用GetWithLock方法获取数据,传入缓存键、数据加载函数和过期时间
  4. 更新缓存:在数据更新后,使用DeleteDelayedDelete方法删除缓存

6.1.2 缓存键命名规范

为了便于管理缓存,建议遵循以下命名规范:

  • 使用冒号(:)分隔缓存键的不同部分
  • 格式:{业务模块}:{数据类型}:{唯一标识}
  • 例如:goods:detail:123category:info:456

6.1.3 过期时间设置建议

  • 常规数据:1小时(DefaultExpiration
  • 空值缓存:5分钟(EmptyValueExpiration
  • 热点数据:根据访问频率调整,建议30分钟到2小时
  • 不常变化的数据:可以设置更长的过期时间,如24小时

6.2 最佳实践

6.2.1 性能优化建议

  1. 合理设置过期时间:根据数据的更新频率和重要性设置合理的过期时间
  2. 缓存预热:在系统启动或低峰期,预先加载热点数据到缓存
  3. 批量操作:尽量使用批量操作减少与Redis的交互次数
  4. 数据压缩:对于大型对象,可以考虑压缩后再存入缓存
  5. 连接池配置:合理配置Redis连接池参数,避免连接泄漏

6.2.2 缓存一致性保障

  1. 延迟双删:在更新数据库后,使用延迟双删策略确保缓存一致性
  2. 最终一致性:接受缓存与数据库的短暂不一致,通过过期时间保证最终一致性
  3. 监控告警:监控缓存命中率和延迟,及时发现问题

6.2.3 异常处理

  1. 缓存降级:当Redis不可用时,直接返回数据库查询结果
  2. 错误重试:对于临时性错误,可以考虑添加重试机制
  3. 日志记录:记录缓存操作的关键日志,便于问题排查

6.2.4 常见问题排查

  1. 缓存命中率低:检查缓存键设计是否合理,过期时间是否设置过短
  2. 缓存更新不及时:检查延迟双删是否正确实现,延迟时间是否合理
  3. 内存占用过高:检查是否存在缓存数据过大或缓存未及时过期的情况
  4. 性能问题:检查是否存在缓存热点问题,考虑使用本地缓存分担压力

6.3 代码优化建议

  1. 接口抽象:使用接口抽象缓存操作,便于后续扩展和替换实现
  2. 参数校验:添加适当的参数校验,提高代码健壮性
  3. 错误处理:统一错误处理方式,提供友好的错误信息
  4. 日志记录:添加关键操作的日志记录,便于问题排查
  5. 单元测试:为缓存策略实现添加单元测试,确保功能正确性

7. 总结

本文详细介绍了Redis缓存中常见的三个问题:缓存穿透、缓存击穿和缓存雪崩,并提供了完整的解决方案。我们通过创建统一的缓存策略接口,结合空值缓存、本地锁和随机过期时间等技术手段,有效解决了这些问题。

在实际项目中,我们需要根据业务场景和性能需求,灵活选择和调整缓存策略。同时,还需要关注缓存一致性、异常处理和监控告警等方面,确保缓存系统的稳定运行。

希望本文能帮助你理解并掌握Redis缓存策略的正确使用方法,在实际项目中避免常见的缓存问题,提升系统性能和稳定性。

8. 链接我

XDM,觉好留赞哈,如果你觉得这篇内容对你有帮助,或者想进一步学习这个项目,可以关注我,在掘金私信我:微服务电商,我发你更详细的介绍。

或者也可以直接加我微信:wangzhongyang1993。