一、查询接口的缓存方案
现在假设我们需要暴露一个查询接口:返回前 100 榜单的文章列表。
这个查询接口就是一个高并发并且高可用的接口。你可以预期,类似于微博、小红书之类的,基本上任何用户打开网站或者 APP,都需要调用这个接口。
所以,要保证该查询接口的性能,要引入 本地缓存 。
引入本地缓存有两种思路:
- 缓存提供一个 Redis 实现,提供一个本地实现,同时有一个装饰器同时操作这两个实现。
- 在 Repository 上直接操作 Redis 实现和本地缓存实现。(本文选择该思路)
如果 Repository 本身还有一些很复杂的逻辑,那么选用方案一,否则直接使用方案二(开发起来快一点)
二、Redis 和 本地缓存 的实现
2.1 方案一:仅用 Redis 实现
(1)repo
层
golang
代码解读
复制代码
type CachedRankingRepository struct { // 方案一:仅用 redis 缓存 topN cache cache.RankingCache } func NewCachedRankingRepository(cache cache.RankingCache) RankingRepository { return &CachedRankingRepository{cache: cache} } func (c *CachedRankingRepository) ReplaceTopN(ctx context.Context, articles []domain.Article) error { return c.cache.Set(ctx, articles) } func (c *CachedRankingRepository) GetTopN(ctx context.Context) ([]domain.Article, error) { return c.cache.Get(ctx) }
(2)cache
层
整理了一份Java面试题。这份面试笔记包括了:Java面试、Spring、JVM、MyBatis、Redis、MySQL、并发编程、微服务、Linux、Springboot、SpringCloud、MQ、Kafka 面试专题
需要全套面试笔记【点击此处】即可免费获取
golang
代码解读
复制代码
type RankingRedisCache struct { client redis.Cmdable key string expiration time.Duration } func NewRankingRedisCache(client redis.Cmdable) RankingCache { return &RankingRedisCache{client: client, key: "ranking:top_n", expiration: time.Minute * 3} } func (r *RankingRedisCache) Set(ctx context.Context, articles []domain.Article) error { // note 缓存榜单的 top100 文章时,不要缓存文章内容 for _, article := range articles { article.Content = article.Abstract() } // 直接序列化 []article ,存入 redis res, err := json.Marshal(articles) if err != nil { return err } return r.client.Set(ctx, r.key, res, r.expiration).Err() } func (r *RankingRedisCache) Get(ctx context.Context) ([]domain.Article, error) { var res []domain.Article val, err := r.client.Get(ctx, r.key).Bytes() if err != nil { return nil, err } return res, json.Unmarshal(val, &res) }
2.2 方案二:Redis + 本地缓存实现
(1)repo
层 —— 用于组装 Redis 和 本地缓存
golang
代码解读
复制代码
type CachedRankingRepository struct { // 方案一:仅用 redis 缓存 topN cache cache.RankingCache // 方案二:用 本地缓存 + redis 缓存 topN // note 传入的是 结构体指针 localCache *cache.RankingLocalCache redisCache *cache.RankingRedisCache } func NewCachedRankingRepositoryV1(localCache *cache.RankingLocalCache, redisCache *cache.RankingRedisCache) *CachedRankingRepository { return &CachedRankingRepository{localCache: localCache, redisCache: redisCache} } // ReplaceTopNV1 本地 + Redis 缓存的 // note 更新:先更新本地缓存,再更新 redis func (c *CachedRankingRepository) ReplaceTopNV1(ctx context.Context, articles []domain.Article) error { // 更新本地缓存 _ = c.localCache.Set(ctx, articles) return c.redisCache.Set(ctx, articles) } // GetTopNV1 本地 + Redis 缓存的 // note 查询:先查本地,再查 redis,最后回写本地 func (c *CachedRankingRepository) GetTopNV1(ctx context.Context) ([]domain.Article, error) { articles, err := c.localCache.Get(ctx) if err == nil { return articles, nil } res, err := c.redisCache.Get(ctx) if err != nil { return c.localCache.ForceGet(ctx) } return res, c.localCache.Set(ctx, res) }
注意:GetTopNV1()
方法中所调用的 ForceGet()
是不考虑本地缓存过期时间强制取出缓存数据的(为了提高可用性,下文会提到)。
(2)cache
层
类似于我们这种本地缓存的实现,是可以直接使用原子操作来实现的,因为本质上我们这里并不需要 一个 key-value 的结构
golang
代码解读
复制代码
package cache import ( "context" "errors" "refactor-webook/webook/internal/domain" "sync/atomic" "time" ) // RankingLocalCache 榜单这种本地缓存的实现,可以直接用原子操作,本质上是因为我们不需要一个key-value的结构(所以就不需要类似lru.Cache那样线程安全的kv本地缓存库) type RankingLocalCache struct { topN atomic.Value ddl atomic.Value expiration time.Duration } func (r *RankingLocalCache) Set(ctx context.Context, articles []domain.Article) error { r.topN.Store(articles) r.ddl.Store(time.Now().Add(r.expiration)) return nil } func (r *RankingLocalCache) Get(ctx context.Context) ([]domain.Article, error) { ddl := r.ddl.Load().(time.Time) arts := r.topN.Load().([]domain.Article) if arts == nil || ddl.Before(time.Now()) { return nil, errors.New("本地缓存失效(不存在或过期)") } return arts, nil } // ForceGet 忽略本地缓存的过期时间ddl,从而强制返回数据 func (r *RankingLocalCache) ForceGet(ctx context.Context) ([]domain.Article, error) { arts := r.topN.Load().([]domain.Article) if arts == nil { return nil, errors.New("本地缓存失效(不存在)") } return arts, nil }
三、可用性问题
整个榜单依赖于 数据库 和 Redis
的可用性,那么问题就在于万一这两个东西崩溃了呢?
- 如果
MySQL
崩溃了,那么无法更新榜单了,因为此时的定时任务必然失败,只能查询缓存。 - 如果
Redis
崩溃了,后果就是一旦节点本身的本地缓存也失效了,那么查询接口就会失败。
最简单的做法就是:
- 防止
mysql
崩溃:设置Redis
缓存过期时间为一个比较大的值,甚至永不过期。所以只有在定时任务运行的时候,才会更新这个缓存。
- 防止
Redis
崩溃:给本地缓存设置一个“兜底”。即正常情况下,我们的会从本地缓存里面获取,获取不到就会去Redis
里面获取。但是我们可以在 Redis 崩溃的时候,再次尝试从本地缓存获取。此时不会检查本地缓存是否已经过期了。 也就是上面代码中的ForceGet()
方法的实现。
但是如果一个节点本身没有本地缓存,此时 Redis
又崩溃了,那么这里依旧拿不到榜单数据?
这种情况下,可以考虑走一个 failover(容错)策略,让前端在加载不到热榜数据的情况下,重新发一个请求。这样一来,除非全部后端节点都没有本地数据,Redis
又崩溃了,否则必然可以加载出来一个榜单数据。
四、其他高性能高并发思路
思路一:计算了热榜之后,直接生成一个静态页面,放到 OSS
上,然后走 CDN
这条路。类似的思路还有将数据(一般组装成 JS
文件)上传到 OSS
,再走 CDN
这条路。
思路二:直接放到 nginx
上。
思路三:如果是本地 APP,那么可以定时去后面拉数据,拉的热榜数据会缓存在 APP 本地。
这些需要控制住页面或者数据的 CDN
过期时间和前端资源过期时间
五、总结
本地缓存 + Redis 缓存 + 数据库
在大多数时候,追求极致性能的缓存方案,差不多就是本地缓存 + Redis 缓存 + 数据库。
查找:先查找本地缓存,再查找 Redis,最后查找数据库。
更新:先更新数据库,再更新本地缓存,最后更新(或者删除)Redis。核心在于一点,本地缓存的操作几乎不可能失败