榜单模型(四):查询接口的缓存方案和可用性分析

一、查询接口的缓存方案

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

相关推荐
Estar.Lee6 小时前
查手机号归属地免费API接口教程
android·网络·后端·网络协议·tcp/ip·oneapi
2401_857610038 小时前
SpringBoot社团管理:安全与维护
spring boot·后端·安全
凌冰_8 小时前
IDEA2023 SpringBoot整合MyBatis(三)
spring boot·后端·mybatis
码农飞飞8 小时前
深入理解Rust的模式匹配
开发语言·后端·rust·模式匹配·解构·结构体和枚举
一个小坑货8 小时前
Rust 的简介
开发语言·后端·rust
monkey_meng9 小时前
【遵守孤儿规则的External trait pattern】
开发语言·后端·rust
Estar.Lee9 小时前
时间操作[计算时间差]免费API接口教程
android·网络·后端·网络协议·tcp/ip
新知图书10 小时前
Rust编程与项目实战-模块std::thread(之一)
开发语言·后端·rust
盛夏绽放10 小时前
Node.js 和 Socket.IO 实现实时通信
前端·后端·websocket·node.js
Ares-Wang10 小时前
Asp.net Core Hosted Service(托管服务) Timer (定时任务)
后端·asp.net