一、查询接口的缓存方案
现在假设我们需要暴露一个查询接口:返回前 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
层
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。核心在于一点,本地缓存的操作几乎不可能失败