1. 学起来
XDM,大家好,我是专注Golang的王中阳,最近在带着大家疯狂做项目。
我把对大家有帮助的,尤其是对新手小白非常友好的内容,整理分享出来,希望对大家有帮助。
本文将详细介绍这些常见的缓存问题,并结合我们的电商项目,提供完整的解决方案和实现代码,帮助新手小白理解并掌握Redis缓存策略的正确使用方法。
2. Redis缓存常见问题概念解释
2.1 缓存穿透
什么是缓存穿透?
缓存穿透是指用户请求一个不存在的数据,由于缓存中没有该数据,请求会直接打到数据库。如果大量的请求都访问不存在的数据,就会导致数据库压力过大,甚至宕机。
举例说明:
在电商网站中,用户查询一个不存在的商品ID(如-1或者一个非常大的随机数)。由于这个商品ID在缓存中不存在,所以每次请求都会直接查询数据库,而数据库查询后发现也没有该商品。如果有大量这样的恶意请求,数据库的压力就会急剧增加。
缓存穿透的危害:
- 数据库压力过大,可能导致数据库宕机
- 系统响应时间延长
- 服务可用性降低
2.2 缓存击穿
什么是缓存击穿?
缓存击穿是指一个热点数据的缓存过期后,大量并发请求同时访问该数据,导致所有请求都直接打到数据库,造成数据库瞬时压力过大。
举例说明:
在电商网站中,某件热销商品的缓存突然过期。此时,大量用户同时访问该商品详情,由于缓存已经过期,所有的请求都会直接查询数据库。数据库在短时间内需要处理大量请求,可能会导致性能下降甚至宕机。
缓存击穿的危害:
- 数据库瞬时压力过大
- 系统响应时间延长
- 可能导致数据库宕机
2.3 缓存雪崩
什么是缓存雪崩?
缓存雪崩是指大量缓存数据在同一时间段内过期,导致大量请求直接打到数据库,造成数据库压力骤增,甚至宕机。
举例说明:
如果我们在系统上线时,为所有的商品缓存设置了相同的过期时间(比如都设置为1小时),那么在1小时后,所有的商品缓存都会同时过期。这时,大量用户访问网站时,所有的请求都会直接打到数据库,数据库可能无法承受这样的压力而宕机。
缓存雪崩的危害:
- 数据库压力骤增,可能导致数据库宕机
- 系统响应时间严重延长
- 服务可能完全不可用
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缓存基本操作:
- 提供了
GetGoodsDetail、SetGoodsDetail、DeleteGoodsDetail等方法 - 使用JSON序列化/反序列化缓存数据
- 包含了批量删除缓存的方法
- 实现了延迟双删逻辑,用于在更新数据库后删除缓存
3.1.3 现有缓存策略
现有实现中已经包含了一些基础的缓存策略:
- 空值缓存:通过
SetEmptyGoodsDetail方法设置短时间空值,初步防止缓存穿透 - 缓存键管理:通过统一的键生成规则管理缓存键
3.2 现有实现存在的问题
虽然现有实现已经包含了一些基础的缓存功能,但仍然存在以下问题:
- 缓存击穿防护缺失:当热点商品缓存过期时,没有有效的机制防止大量并发请求同时打到数据库
- 缓存雪崩防护不完善:所有缓存使用固定过期时间,可能导致缓存雪崩
- 空值缓存策略简单:空值缓存的处理方式相对简单,没有结合其他机制提供更完善的防护
- 缺乏统一的缓存策略接口:缓存策略分散在各个方法中,不利于维护和扩展
- 并发安全考虑不足:在高并发场景下,缓存的读取和更新可能存在并发安全问题
正是由于这些问题,我们需要实现一套完整的缓存策略解决方案,以应对缓存穿透、击穿和雪崩问题。
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 本地锁机制
我们使用双重检查锁定模式结合本地锁,防止缓存击穿:
- 首先尝试从缓存获取数据
- 如果缓存不存在,获取本地锁
- 获取锁后,再次检查缓存是否存在(双重检查)
- 如果仍不存在,才去查询数据库并更新缓存
- 最后释放锁
这样可以确保在高并发场景下,只有一个请求会去查询数据库,其他请求都从缓存获取数据。
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 基本使用流程
- 初始化缓存:在服务启动时,调用
InitRedisCache()初始化Redis缓存 - 创建缓存策略实例:使用
NewRedisCacheStrategy(GetCache())创建缓存策略实例 - 获取数据:使用
GetWithLock方法获取数据,传入缓存键、数据加载函数和过期时间 - 更新缓存:在数据更新后,使用
Delete和DelayedDelete方法删除缓存
6.1.2 缓存键命名规范
为了便于管理缓存,建议遵循以下命名规范:
- 使用冒号(
:)分隔缓存键的不同部分 - 格式:
{业务模块}:{数据类型}:{唯一标识} - 例如:
goods:detail:123、category:info:456
6.1.3 过期时间设置建议
- 常规数据:1小时(
DefaultExpiration) - 空值缓存:5分钟(
EmptyValueExpiration) - 热点数据:根据访问频率调整,建议30分钟到2小时
- 不常变化的数据:可以设置更长的过期时间,如24小时
6.2 最佳实践
6.2.1 性能优化建议
- 合理设置过期时间:根据数据的更新频率和重要性设置合理的过期时间
- 缓存预热:在系统启动或低峰期,预先加载热点数据到缓存
- 批量操作:尽量使用批量操作减少与Redis的交互次数
- 数据压缩:对于大型对象,可以考虑压缩后再存入缓存
- 连接池配置:合理配置Redis连接池参数,避免连接泄漏
6.2.2 缓存一致性保障
- 延迟双删:在更新数据库后,使用延迟双删策略确保缓存一致性
- 最终一致性:接受缓存与数据库的短暂不一致,通过过期时间保证最终一致性
- 监控告警:监控缓存命中率和延迟,及时发现问题
6.2.3 异常处理
- 缓存降级:当Redis不可用时,直接返回数据库查询结果
- 错误重试:对于临时性错误,可以考虑添加重试机制
- 日志记录:记录缓存操作的关键日志,便于问题排查
6.2.4 常见问题排查
- 缓存命中率低:检查缓存键设计是否合理,过期时间是否设置过短
- 缓存更新不及时:检查延迟双删是否正确实现,延迟时间是否合理
- 内存占用过高:检查是否存在缓存数据过大或缓存未及时过期的情况
- 性能问题:检查是否存在缓存热点问题,考虑使用本地缓存分担压力
6.3 代码优化建议
- 接口抽象:使用接口抽象缓存操作,便于后续扩展和替换实现
- 参数校验:添加适当的参数校验,提高代码健壮性
- 错误处理:统一错误处理方式,提供友好的错误信息
- 日志记录:添加关键操作的日志记录,便于问题排查
- 单元测试:为缓存策略实现添加单元测试,确保功能正确性
7. 总结
本文详细介绍了Redis缓存中常见的三个问题:缓存穿透、缓存击穿和缓存雪崩,并提供了完整的解决方案。我们通过创建统一的缓存策略接口,结合空值缓存、本地锁和随机过期时间等技术手段,有效解决了这些问题。
在实际项目中,我们需要根据业务场景和性能需求,灵活选择和调整缓存策略。同时,还需要关注缓存一致性、异常处理和监控告警等方面,确保缓存系统的稳定运行。
希望本文能帮助你理解并掌握Redis缓存策略的正确使用方法,在实际项目中避免常见的缓存问题,提升系统性能和稳定性。
8. 链接我
XDM,觉好留赞哈,如果你觉得这篇内容对你有帮助,或者想进一步学习这个项目,可以关注我,在掘金私信我:微服务电商,我发你更详细的介绍。
或者也可以直接加我微信:wangzhongyang1993。