缓存查询逻辑及问题解决
缓存查询逻辑及问题解决
文章目录
- 缓存查询逻辑及问题解决
- 一.具体实现逻辑
- 二.数据一致性问题
- 2.1 内存淘汰(全自动)
- 2.2 超时剔除(半自动)
- 2.3 主动更新(手动)
- 2.3.1 双写方案
- 2.3.2 读写穿透方案
- 2.3.3 写回方案
- 比较
- 2.4 缓存主动更新策略实现
一.具体实现逻辑
缓存(Cache)就是一块临时存储区域,用来存放访问频繁、计算代价高或获取速度慢的数据。
目的是——加快访问速度,减少系统压力。
假设系统每次都要从数据库查用户信息:
每次都查数据库 → IO 慢、连接池压力大、QPS上不去。
而这些用户数据其实很多时候都是重复访问的。
所以我们可以:
- 第一次查数据库;
- 把结果放进缓存;
- 下次有人再查 → 直接读缓存(内存级访问),速度提升一个数量级。
//总体思路
1.首先我们提交商品id
2.我们到redis中查询缓存
3.如果缓存命中,即查到了目标商品,我们就返回相应信息;
如果没有,我们进一步根据id去数据库中查询,如果商品存在,将数据写入Redis,不存在则返回错误
package serviceimport ("context""encoding/json""errors""time""gorm.io/gorm""github.com/redis/go-redis/v9"
)// Shop 结构体 —— 对应数据库表结构
type Shop struct {ID int64 `json:"id" gorm:"primaryKey"`Name string `json:"name"`Address string `json:"address"`Phone string `json:"phone"`// 其他字段省略...
}// Result 统一返回结构
type Result struct {Code int `json:"code"`Msg string `json:"msg"`Data interface{} `json:"data,omitempty"`
}func Ok(data interface{}) Result {return Result{Code: 200, Msg: "success", Data: data}
}func Fail(msg string) Result {return Result{Code: 400, Msg: msg}
}// 常量定义
const (CacheShopKeyPrefix = "cache:shop:"CacheTTL = 30 * time.Minute
)type ShopService struct {db *gorm.DBrdb *redis.Client
}// 构造函数
/*这段代码是 ShopService 的构造函数,用来依赖注入数据库和 Redis 客户端,确保后续方法能使用它们操作缓存与数据库。*/
func NewShopService(db *gorm.DB, rdb *redis.Client) *ShopService {return &ShopService{db: db, rdb: rdb}
}// 查询店铺数据逻辑
func (s *ShopService) QueryById(ctx context.Context, id int64) Result {key := CacheShopKeyPrefix + string(rune(id))// 1. 从 Redis 查询缓存shopJSON, err := s.rdb.Get(ctx, key).Result()if err == nil && shopJSON != "" {// 2. 缓存命中var shop Shopif err := json.Unmarshal([]byte(shopJSON), &shop); err == nil {return Ok(shop)}} else if err != nil && !errors.Is(err, redis.Nil) {// Redis 查询异常(非未命中)return Fail("Redis 查询错误: " + err.Error())}// 3. 缓存未命中,查询数据库var shop Shopif err := s.db.WithContext(ctx).First(&shop, id).Error; err != nil {if errors.Is(err, gorm.ErrRecordNotFound) {return Fail("店铺不存在")}return Fail("数据库查询失败: " + err.Error())}// 4. 数据库存在,写入 Redisbytes, _ := json.Marshal(shop)s.rdb.Set(ctx, key, bytes, CacheTTL)// 5. 返回结果return Ok(shop)
}
查询缓存部分函数分析
// 2. 缓存命中var shop Shopif err := json.Unmarshal([]byte(shopJSON), &shop); err == nil {return Ok(shop)}} else if err != nil && !errors.Is(err, redis.Nil) {// Redis 查询异常(非未命中)return Fail("Redis 查询错误: " + err.Error())}
如果缓存命中了,那么将Redis中的JSON反序列化成go结构体shop中的内容,如果反序列化失败,分析是系统错误(例如Redis挂了)且不是缓存未命中的情况,返回查询错误。
必须判断是否是Redis.Nil,如果不判断这个,代码就会把“key 不存在”当作系统错误,直接
Fail("Redis 查询错误"),这就错了。
// 3. 缓存未命中,查询数据库var shop Shopif err := s.db.WithContext(ctx).First(&shop, id).Error; err != nil {if errors.Is(err, gorm.ErrRecordNotFound) {return Fail("店铺不存在")}return Fail("数据库查询失败: " + err.Error())}
db.WithContext(ctx)
/*在当前这次数据库操作中,带上一个上下文(Context),
用来控制超时、取消操作、日志追踪等*///s.db.WithContext(ctx).First(&shop, id)
/*等价于告诉 GORM:
“执行 SELECT ... FROM shops WHERE id = ? 这个 SQL 时,要遵守 ctx 这个上下文的生命周期。”*//*例如:
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
defer cancel()if err := s.db.WithContext(ctx).First(&shop, id).Error; err != nil {...
}含义:
如果数据库查询超过 2 秒还没返回结果,
那么 GORM 会中断这次查询;
底层的数据库连接也会收到取消信号;
不会卡死、不会继续占用连接池。不写WithContext:
GORM 会默认用 context.Background();
没有超时控制;
上层 HTTP 请求取消了,它依然继续查;
查询慢时,可能拖垮整个服务(阻塞连接池)。
*/
查询店铺类型也是相似的逻辑
二.数据一致性问题
使用缓存降低了后端的负载压力同时提高了效率,但同时提高了维护成本也带来了 数据一致性问题
数据一致性问题就是缓存和数据库中的数据不同步的问题,为了解决这个问题,我们需要选择好的缓存更新策略
1.内存淘汰——>6种常见的淘汰策略缓存更新 ————{2.超时剔除
策略(常见) 3.1 双写方案3.主动更新——{3.2 读写穿透方案3.3 写回方案
2.1 内存淘汰(全自动)
这是利用Redis的内存淘汰机制实现缓存更新,Redis的内存淘汰机制是当Redis发现内存不足时,会根据一定的策略自动淘汰部分数据
常见的6种淘汰策略
- noeviction(默认):当达到内存限制并且客户端尝试执行写入操作时,Redis 会返回错误信息,拒绝新数据的写入,保证数据完整性和一致性
- allkeys-lru:从所有的键中选择最近最少使用(Least Recently Used,LRU)的数据进行淘汰。即优先淘汰最长时间未被访问的数据,LRU是常见的内存淘汰算法
- allkeys-random:从所有的键中随机选择数据进行淘汰
- volatile-lru:从设置了过期时间的键中选择最近最少使用的数据进行淘汰
- volatile-random:从设置了过期时间的键中随机选择数据进行淘汰
- volatile-ttl:从设置了过期时间的键中选择剩余生存时间(Time To Live,TTL)最短的数据进行淘汰
优点:
- 能够在缓存空间不足时自动管理缓存数据,保证热点数据得到优先缓存。
- 可以减少内存占用,避免内存溢出。
缺点:
- 如果不恰当地选择淘汰策略,可能会导致常用数据被频繁移除,降低缓存命中率。
适用场景:
- 高并发、数据量大,但缓存空间有限的场景。
- 需要根据一定的算法来优化缓存的存储效率。
例子:
- 在一个社交媒体平台中,缓存用户最近浏览的帖子信息。使用LRU策略,确保热点帖子常驻缓存,其他冷门帖子被逐渐淘汰。
2.2 超时剔除(半自动)
手动给缓存数据添加TTL,到期后Redis自动删除缓存
特点:
- 时间控制:每个缓存数据都有一个固定的过期时间,超出这个时间后,数据就会被认为是过期的,缓存会被删除或不再返回给客户端。
- 自动过期:在超时后,缓存内容会自动失效,不再提供给请求方。
- 短期缓存:适用于那些需要定期更新的、数据不稳定或不重要的缓存。
优点:
- 可以确保缓存中的数据不会过时,定期更新缓存内容,避免数据不一致。
- 配合缓存清理机制,减少内存占用。
缺点:
- 可能会导致缓存中有些数据在有效期内被频繁删除或重新加载,增加了系统开销。
- 需要合理设置TTL时间,以免过期的缓存影响用户体验或系统性能。
适用场景:
- 数据变化比较频繁,不能长时间缓存的场景。
- 缓存的内容有明确的生命周期,定期需要更新的场景。
例子:
- 在一个商品的促销活动中,商品的折扣信息会定期更新。可以设定缓存的有效期为1小时,确保每小时从数据库加载最新的活动信息。
2.3 主动更新(手动)
手动编码实现缓存更新,在修改数据库的同时更新缓存
优点:
- 可以保证缓存数据的实时更新,避免数据不一致。
- 对于需要频繁更新的系统,主动更新能够减少缓存过期带来的不一致性问题。
缺点:
- 可能会引入一定的性能开销,特别是在高并发场景中,频繁的更新操作可能影响系统性能。
- 需要额外的机制来保证数据更新的同步性和可靠性。
适用场景:
- 需要保证缓存与数据源一致性的高频更新场景。
- 数据源变化频繁,并且必须保证缓存能立即反映变化的场景。
例子:
- 一个在线购物系统中,用户的购物车数据发生变化时,通过事件通知系统,主动更新缓存中的购物车数据,以确保用户在查看购物车时能够看到最新数据。
- 在分布式缓存场景中,某个节点的数据更新时,通过消息队列通知其他节点更新相应的缓存。
2.3.1 双写方案
人工编码方式,缓存调用者在更新完数据库后再去更新缓存。使用困难,灵活度高。
读取
//需要读取数据时
先检查缓存是否存在该数据——存在,返回缓存中的数据|不存在,从数据库中获取并存到缓存中
写入
//数据写入操作
先更新底层数据(如数据库数据)—— 直接更新缓存中的数据|或将缓存中与修改数据相关的条目标记为无效状态(缓存失效),避免下一次读取时返回过时的数据下一次读取时重新加载最新数据
考虑的问题
我们是使用更新缓存模式还是使用删除缓存模式 ?
如果用更新缓存模式:
我们每次更新数据库都更新缓存,无效写操作较多
推荐删除缓存模式
更新数据时更新数据库并删除缓存,查询时更新缓存,无效写操作较少
那么我们是先操作缓存还是先操作数据库?
//操作缓存
线程1:删缓存到更新数据库时间段|线程2进入查询数据——>未加锁且线程1将缓存删除——>缓存击穿,请求打到数据库
//发生的概率很大,因为缓存的读写速度块,而数据库的读写较慢。
//操作数据库
线程1:查询缓存未命中,准备写入缓存时间段|线程2进入更新数据库数据——>未加锁且线程1返回旧数据——> 脏读//概率小,因为需要先满足缓存未命中,且在写入缓存的那段事件内有一个线程进行更新操作,缓存的查询很快,这段空隙时间很小
我们可以将缓存与数据库操作放在同一个事务中保证缓存与数据库的操作的原子性
2.3.2 读写穿透方案
将读取和写入操作首先在缓存中执行,然后再传播到数据存储
读取穿透
读取请求——> 检查缓存 ——> 数据找到,返回|未找到,请求给数据库获取数据——> 拿到数据存到缓存,返回
写入穿透
写入请求——> 将数据写入缓存——> 写操作传入数据库,确保数据一致
2.3.3 写回方案
调用者只操作缓存,其他线程去异步处理数据库,实现最终一致
读取
缓存是否有数据——> 没有,从数据库中获取并将数据存到缓存中 |
有则直接返回
写入
更新数据库——> 待写入数据放入一个缓存队列中——> 批量操作或异步处理,将缓存队列中的数据写入数据库
比较
| 策略 | 主要特点 | 优点 | 缺点 | 适用场景 |
|---|---|---|---|---|
| 内存淘汰 | 根据算法(如LRU、LFU)淘汰不活跃缓存 | 节省内存空间、保持活跃数据 | 可能导致热点数据被淘汰、复杂的算法开销 | 数据量大且内存有限的场景 |
| 超时剔除 | 设置缓存过期时间,自动清除过期数据 | 自动管理缓存过期,减少无效数据 | 可能会频繁加载数据,增加系统开销 | 数据变化频繁,需要定期更新缓存的场景 |
| 主动更新 | 数据变动时主动更新缓存 | 保证数据一致性,减少缓存失效 | 可能引入性能开销,需要额外的同步机制 | 数据更新频繁,需要确保缓存与源数据一致的场景 |
2.4 缓存主动更新策略实现
开启事务的方法
通过
gorm.DB实例的 事务方法 来显式开启、提交和回滚事务
1. 启动事务
使用 gorm.DB 的 Begin 方法来开始一个事务。
tx := db.Begin()
2. 提交事务
如果操作没有出现错误,可以调用 Commit 方法提交事务。
tx.Commit()
3. 回滚事务
如果出现错误,使用 Rollback 方法回滚事务,恢复到事务开始时的状态。
tx.Rollback()
这里我们修改之前的实现逻辑,缓存未命中,查数据库并写入缓存,设置超时时间,根据id修改商铺时,先改数据库,再删除缓存
// QueryByID 根据id查询商铺数据(查询时,重建缓存)
func (s *ShopService) QueryByID(ctx context.Context, id int64) Result {key := fmt.Sprintf("%s%d", CacheShopKey, id)
//生成 Redis 的缓存 key,格式为 cache:shop:<id>。// 1. 从 Redis 查询店铺数据shopJSON, err := s.rdb.Get(ctx, key).Result()var shop Shop// 2. 判断缓存是否命中if err == nil && shopJSON != "" {// 2.1 缓存命中,反序列化返回if err := json.Unmarshal([]byte(shopJSON), &shop); err == nil {return Ok(shop)}} else if err != nil && !errors.Is(err, redis.Nil) {// 处理 Redis 查询异常(不是缓存未命中)return Fail("Redis 查询错误: " + err.Error())}// 2.2 缓存未命中,从数据库查询if err := s.db.WithContext(ctx).First(&shop, id).Error; err != nil {return Fail("数据库查询失败: " + err.Error())}// 4. 判断数据库是否存在店铺数据if shop.ID == 0 {return Fail("店铺不存在")}// 4.2 店铺数据存在,重建缓存bytes, _ := json.Marshal(shop)s.rdb.Set(ctx, key, bytes, CacheShopTTL)return Ok(shop)
}// UpdateShop 更新商铺数据(更新时,更新数据库,删除缓存)
func (s *ShopService) UpdateShop(ctx context.Context, shop Shop) Result {// 开始事务tx := s.db.Begin()// 如果事务启动失败if tx.Error != nil {return Fail("启动事务失败: " + tx.Error.Error())}// 1. 更新数据库中的店铺数据if err := tx.Save(&shop).Error; err != nil {tx.Rollback() // 如果更新失败,回滚事务return Fail("数据库更新失败: " + err.Error())}// 2. 删除缓存if err := s.rdb.Del(ctx, fmt.Sprintf("%s%d", CacheShopKey, shop.ID)).Err(); err != nil {tx.Rollback() // 如果缓存删除失败,回滚事务return Fail("缓存删除失败: " + err.Error())}// 提交事务if err := tx.Commit().Error; err != nil {return Fail("提交事务失败: " + err.Error())}return Ok("更新成功")
}
UpdateShop 方法:
tx := s.db.Begin()
开始一个新的事务,返回一个*gorm.DB类型的事务对象。if err := tx.Save(&shop).Error; err != nil
更新数据库中的店铺数据。Save会自动判断是否是新增或更新操作。if err := s.rdb.Del(ctx, fmt.Sprintf("%s%d", CacheShopKey, shop.ID)).Err()
更新数据库后,删除 Redis 中的缓存,因为数据库已经更新,缓存需要删除。tx.Commit().Error
提交事务。如果数据库更新和缓存删除没有问题,则提交事务。tx.Rollback()
如果在任何步骤中发生错误,回滚事务。
