通用「接口缓存中间件」的一种实现

2022-01-21 20:06:14 浏览数 (1)

所有的执行优化,最后都归到两个词:剪枝缓存

最近一个印象十分深刻的经历:

打开 google sheets,编辑完数据准备导出,文件下的子菜单,从上到下扫了几遍愣是没找到导出按钮,不对呀,明明一直在这???

正准备 google 一下怎么回事,发现没网...

恍然大悟:这编辑了半天,居然是在没网下进行的,而且压根没感知到没网:文件间跳转流畅,编辑的核心功能丝毫没受到影响。

不由得赞叹,离线缓存做的太好,产品用心的让人感动。

也是 google 把应用和应用运行环境(浏览器)协同演进的威力:chrome 每个牛逼特性,也大概都有应用倒逼的身影。

要同时掌控运行时和运行环境的风格,似乎体现在 google 很多产品中,flutter?

现在回到 「缓存」。

背景

缓存的应用无处不在,小到函数,缓存中间计算结果(比如 dp),大到整个应用的缓存(比如序中)。

在 HTTP Server 中,缓存同样重要:因为它有办法,让所有的查询接口保持在 50ms 以内,不管依赖的服务有多慢。

是不是单单这一条,就让人无法拒绝呢。

下面看看如何让你交付的接口,快如闪电,而且几乎不改变现有编码方式。

方案设计

缓存的接口限定在:无副作用的查询接口。

这里有两个关键词:

  1. 查询接口。只进行读操作,无写数据行为。
  2. 无副作用。不能在执行查询时,影响下次或其他接口的返回,比如执行计数操作等。

下面针对无副作用的查询接口,设计通用缓存中间件。

缓存策略

  1. 优先缓存,仅在无缓存时,执行真正的查询;
  2. 每次缓存命中,都触发一次对应缓存的异步更新;
  3. 本地内存缓存 和 redis 分布式缓存并用。
  4. 本地缓存 10min 失效,redis 缓存 30min。

为什么要设置缓存失效:

  • 防止命中较久的数据;
  • 防止缓存撑爆;

为什么本地 10min,redis 30min:

  • 对于多实例应用,redis 缓存更新(读取触发更新)相对于本地缓存,更加频繁,数据新鲜度高,失效时间可以设置久点。
  • 防止缓存击穿,本地失效了,还有 redis 兜着。

cache key 的计算

一般 get 请求,可以把 query string 作为 cache key,但是注意两种情况:

  1. 如果有用于幂等的参数,比如 once,要从 cache key 的计算中剔除;
  2. 如果返回结果和登录态有关,cookie 也要加入 cache key 的计算;

所以,要支持 cache key 的自定义计算函数。

实现

以 koa http server 的中间件为例。

1. 缓存策略实现和缓存 key 计算

代码语言:javascript复制
import { Context, Next } from 'koa';

// 默认的 cache key 计算规则:直接取 query string
const defaultCacheKeyFn =  <T  extends Context>(ctx: T) =>  ctx.querystring;

// 默认的缓存获取策略
const defaultGetCacheFn = async <T  extends Context> (cacheKey: string, ctx: T, next: Next) => {
  // 根据 cache key 获取 cache value
  // getWithCache: 封装本地和 redis 缓存获取
  const cacheVal = await getWithCache(cacheKey);
  // 缓存优先,缓存命中触发异步更新
  if (cacheVal) {
    // 异步执行 next 获取新鲜的 值
    next().then(() => {
      // 通过 ctx.body 获取新值,更新到缓存
      setWithCache(cacheKey, JSON.stringify(ctx.body));
    });
    ctx.body = JSON.parse(cacheVal);
    return;
  }

  // 没有缓存,直接执行
  await next();
  // 新建缓存
  setWithCache(cacheKey, JSON.stringify(ctx.body));
};

// 获取缓存中间件
export const getCacheMiddleware = <T  extends Context>(cacheKeyFn = defaultCacheKeyFn, getCacheFn = defaultGetCacheFn) => async (ctx: T, next: Next) => {
  try {
    // 为这次请求计算一个 cache key
    const cacheKey = cacheKeyFn<T>(ctx);
    // 封装缓存策略
    await getCacheFn<T>(cacheKey, ctx, next);
  } catch (e) {
    console.error('缓存中间件出错', e);
    throw e;
    // 不能降级,有可能 next 已经执行了
    // await next();
  }
};

看看刚刚发生了什么。

缓存异步更新如何做的呢?

调用了 next,但是没有 await,ctx.body 附上缓存的旧值,直接 return 了。

写到这里,当时想了很久:新值的读取,也是从 ctx.body,这是在赌:新值的赋值一定在旧值赋值之后,没问题吗?

  1. next 里把 ctx.body 赋为新值,一般在一次 网络IO 后,如果这样,就赌赢了;
  2. 如果在 controller 前还有其他中间件,那么也没问题,新值赋值至少在 micro task 里执行,所以也一定在同步任务(旧值赋值)之后。
  3. 如果 controller 里直接同步运算了一个值放到 ctx.body 呢?那么新值就在旧值之前被赋到 ctx.body。这种情况,缓存永远无法得到更新。

但是第三种情况,在实际应用中几乎不存在,因为:没有 controller 不依赖外部服务(存储服务、RPC 服务等)就能直接返回。

如果真有这样的 controller,就要质疑下它存在的必要性了:

如果没有任何依赖,端上就能运算了,为啥还要跑到服务器运算呢?

当缓存中间件出错,为什么直接 throw 呢?

缓存中间件出错,可能出错在 next 执行后(回忆下缓存更新策略),如果再执行 next,根据 koa 机制,重复执行 next 会导致异常。

除非细分了缓存中间件里不同类型的 error,否则不要直接重试 next。

下面看看如果封装 getWithCache 和 setWithCache 来屏蔽本地缓存和 redis 缓存。

2. 实现 getWithCache 和 setWithCache

代码语言:javascript复制
const localCacheExp = 10 * 60; // 10min
// 获取缓存值:先本地,后 redis
export const getWithCache = async (key: string) => {
  // 先计算一个内部 key
  const localCacheKey = `_redis_key_${key}`;
  // 本地缓存如果有,直接返回
  if (defaultLocalCache.exists(localCacheKey)) return defaultLocalCache.get(localCacheKey);

  // 否则,到 redis 里取
  // redis 里不管存在与否,都作为最终结果返回
  const result = await defaultRedisClient.get(key);
  if (result) defaultLocalCache.setex(localCacheKey, localCacheExp, result);

  return result;
};

// 设置缓存值,先 redis,后本地
export const setWithCache = async (key: string, value: string | number) => {
  // 30min 失效
  await defaultRedisClient.setex(key, 30 * 60, value);
  const localCacheKey = `_redis_key_${key}`;
  defaultLocalCache.setex(localCacheKey, localCacheExp, value);
};

下面看看 defaultLocalCache,如何实现类 redis 的接口,并具备缓存失效机制。

3. 实现 defaultLocalCache

代码语言:javascript复制
// 本地缓存类
export class LocalCache {
    private cache: Record<string, any>
    constructor() {
      this.cache = {};
    }

    // 类似 redis setex
    setex(key: string, seconds: number, value: any) {
      this.cache[key] = value;
      // 缓存定时失效
      setTimeout(() => {
        delete this.cache[key];
      }, seconds * 1000);
    }

    get(key: string) {
      return this.cache[key];
    }

    exists(key: string) {
      return Object.prototype.hasOwnProperty.call(this.cache, key);
    }
}

// 使用单例 plain object
export const defaultLocalCache = new LocalCache();

4. router 中使用

前面三步,已经实现了缓存中间件,最后一步,看看怎么使用。

代码语言:javascript复制
import Router from 'koa-router';
// 使用默认的 cache key 计算以及缓存策略
const cacheMiddleware = getCacheMiddleware();
export const router = new Router();
// 在特定 API 应用
router.get('/article/detail', cacheMiddleware, detailController);

总结

上面实现的通用缓存中间件具备:

  • 本地缓存 分布式缓存,尤其多实例应用,分布式缓存必不可少;
  • 缓存优先;
  • 对业务代码(controller)无侵入。

技术要点

  1. 无副作用的查询接口,才可以应用缓存;
  2. 根据请求量和容器配置,平衡:缓存击穿和内存撑爆的风险;
  3. 关注 cache key 的计算,决定缓存是否被正确命中;

拓展

缓存失效机制,还一个著名的 LRU。

上面的实现是根据时间失效,而 LRU 是根据数量失效的:只保留最近使用的几个。

从这里,不难看出它们的利弊:高并发应用,LRU 能有效防止内存撑爆。

所以,一定要根据应用实际场景决策。

0 人点赞