新闻、帮助、产品更新动态

最新的业界新闻,产品系统更新开发动态,帮助教程和活动发布

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

发布日:2022-02-04 20:39       阅读数:

最近一个印象十分深刻的经历:
 
打开 google sheets,编辑完数据准备导出,文件下的子菜单,从上到下扫了几遍愣是没找到导出按钮,不对呀,明明一直在这???
 
正准备 google 一下怎么回事,发现没网...
 
恍然大悟:这编辑了半天,居然是在没网下进行的,而且压根没感知到没网:文件间跳转流畅,编辑的核心功能丝毫没受到影响。
 
不由得赞叹,离线缓存做的太好,产品用心的让人感动。
 
也是 google 把应用和应用运行环境(浏览器)协同演进的威力:chrome 每个牛逼特性,也大概都有应用倒逼的身影。
 
要同时掌控运行时和运行环境的风格,似乎体现在 google 很多产品中,flutter?
 
现在回到 「缓存」。
 
背景
缓存的应用无处不在,小到函数,缓存中间计算结果(比如 dp),大到整个应用的缓存(比如序中)。
 
在 HTTP Server 中,缓存同样重要:因为它有办法,让所有的查询接口保持在 50ms 以内,不管依赖的服务有多慢。
 
是不是单单这一条,就让人无法拒绝呢。
 
下面看看如何让你交付的接口,快如闪电,而且几乎不改变现有编码方式。
 
方案设计
缓存的接口限定在:无副作用的查询接口。
 
这里有两个关键词:
 
查询接口。只进行读操作,无写数据行为。
无副作用。不能在执行查询时,影响下次或其他接口的返回,比如执行计数操作等。
下面针对无副作用的查询接口,设计通用缓存中间件。
 
缓存策略
优先缓存,仅在无缓存时,执行真正的查询;
每次缓存命中,都触发一次对应缓存的异步更新;
本地内存缓存 和 redis 分布式缓存并用。
本地缓存 10min 失效,redis 缓存 30min。
为什么要设置缓存失效:
 
防止命中较久的数据;
防止缓存撑爆;
为什么本地 10min,redis 30min:
 
对于多实例应用,redis 缓存更新(读取触发更新)相对于本地缓存,更加频繁,数据新鲜度高,失效时间可以设置久点。
防止缓存击穿,本地失效了,还有 redis 兜着。
cache key 的计算
一般 get 请求,可以把 query string 作为 cache key,但是注意两种情况:
 
如果有用于幂等的参数,比如 once,要从 cache key 的计算中剔除;
如果返回结果和登录态有关,cookie 也要加入 cache key 的计算;
所以,要支持 cache key 的自定义计算函数。
 
实现
以 koa http server 的中间件为例。

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

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,这是在赌:新值的赋值一定在旧值赋值之后,没问题吗?
 
next 里把 ctx.body 赋为新值,一般在一次 网络IO 后,如果这样,就赌赢了;
如果在 controller 前还有其他中间件,那么也没问题,新值赋值至少在 micro task 里执行,所以也一定在同步任务(旧值赋值)之后。
如果 controller 里直接同步运算了一个值放到 ctx.body 呢?那么新值就在旧值之前被赋到 ctx.body。这种情况,缓存永远无法得到更新。
但是第三种情况,在实际应用中几乎不存在,因为:没有 controller 不依赖外部服务(存储服务、RPC 服务等)就能直接返回。
 
如果真有这样的 controller,就要质疑下它存在的必要性了:
 
如果没有任何依赖,端上就能运算了,为啥还要跑到服务器运算呢?
 
当缓存中间件出错,为什么直接 throw 呢?
 
缓存中间件出错,可能出错在 next 执行后(回忆下缓存更新策略),如果再执行 next,根据 koa 机制,重复执行 next 会导致异常。
 
除非细分了缓存中间件里不同类型的 error,否则不要直接重试 next。
 
下面看看如果封装 getWithCache 和 setWithCache 来屏蔽本地缓存和 redis 缓存。

2. 实现 getWithCache 和 setWithCache

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

// 本地缓存类
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 中使用

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

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

总结

上面实现的通用缓存中间件具备:
 
本地缓存 + 分布式缓存,尤其多实例应用,分布式缓存必不可少;
缓存优先;
对业务代码(controller)无侵入。
技术要点
无副作用的查询接口,才可以应用缓存;
根据请求量和容器配置,平衡:缓存击穿和内存撑爆的风险;
关注 cache key 的计算,决定缓存是否被正确命中;
拓展
缓存失效机制,还一个著名的 LRU。
 
上面的实现是根据时间失效,而 LRU 是根据数量失效的:只保留最近使用的几个。
 
从这里,不难看出它们的利弊:高并发应用,LRU 能有效防止内存撑爆。
 
所以,一定要根据应用实际场景决策。

编辑:航网科技   来源:腾讯云

本文版权归原作者所有 转载请注明出处

联系我们

客服部:深圳市龙华区龙胜商业大厦5楼B5区

业务部:深圳市南山区讯美科技广场2栋12楼1202

资质证书

  • Copyright © 2011-2020 www.hangw.com. All Rights Reserved 深圳航网科技有限公司 版权所有 增值电信业务经营许可证:粤B2-20201122 - 粤ICP备14085080号

    在线客服

    微信扫一扫咨询客服


    全国免费服务热线
    0755-36300002

    返回顶部