Node.js 应用开发实战 - 高级前端开发工程师 - 拉勾教育

在上一讲中我们介绍了网络 I/O 的优化方案,也就是应用缓存来减少网络 I/O 或者用高性能网络 I/O 替换性能较低的网络 I/O。将缓存应用好,也并非一件简单的事情,需要详细地学习和掌握缓存的基础知识。其次在本讲中我会应用 Node.js 来实践开发一个多级缓存的库,让你进一步掌握缓存的应用要点。

缓存概念

从我的理解上来介绍,缓存是临时的一块存储空间,用于存放访问频次较高的数据,用空间换响应速度,核心是减少用户对数据库的查询压力

从以上概念介绍中,我们需要整理出以下几个关键词:

  • 临时,为了避免存储空间的浪费,我们应该尽量设置数据缓存的时间,当过期时自动销毁;
  • 存储空间,一般选择读写性能较高的内存(本地内存或者共享内存),有些会应用 SSD 进一步提升性能;
  • 访问频次较高的数据,为了避免存储空间的浪费,应该尽量选择访问频次较高的数据,切莫将任何数据放入缓存;
  • 数据库的查询压力,我们需要将一些复杂的数据库查询进行缓存,减少数据库访问压力,从而提升用户的响应速度。

在了解了基础概念后,我们再来看下缓存中常见的几个问题,这也是面试过程中常被问及的问题。

缓存问题

如果没有应用好缓存,将会导致一些不可见或者说很难定位的现网事故,主要是三点:缓存雪崩、缓存击穿和缓存穿透。

1. 缓存雪崩

在上面概念中,提到了一个关键词叫作临时,因此大部分数据都有一个过期时间的概念,假设我们有一批数据是通过定时服务从数据库写入缓存中,然后我们统一设置了过期时间。当这个时间节点到了,但是由于某种原因数据又没有从数据库写入缓存,导致这时候所有的数据都会前往数据库查询数据,从而引起数据库查询压力,导致数据库并发过大而瘫痪无法正常服务。

那么应该如何应对呢?

(1)避免所有数据都设置同一个过期时间节点,应该按数据类型、数据更新时效性来设置。

(2)数据过期时间应大于数据更新节点时间,并考虑更新时长,同时增加更新失败异常告警提示。

(3)对于一些相对较高频次或者数据库查询压力较大的数据,可不设置过期时间,主动从程序上来控制该数据的移除或者更替。

2. 缓存穿透

在上面概念中,提到了一个关键句叫作访问频繁较高的数据,这里就会出现一种情况,比如说查询信息一直是空数据,空数据按理不属于访问频繁较高的数据,所以经过了缓存,但是并没有缓存该空数据,而是直接穿透进入了数据库,虽然数据库查询也是空数据,但是还是需要经过数据库的查询,这种现象就是击穿了缓存直接前往了数据库查询。

那么应该如何应对呢?

(1)过滤非正常请求数据,比如一些从参数就可以知道为空的数据,可以直接从程序上处理。

(2)缓存空的结果,为了提升性能,可以将一些查询为空的结果也缓存起来,这样下次用户再进行访问时,可以直接从缓存中判断返回。

(3)由于第 2 种方案在空数据较多时会浪费内存空间,我们可以将这些空数据的键名,使用布隆过滤器来缓存到缓存,这样可以尽可能地减少内存占用,并且更加高效。

3. 缓存击穿

这个概念和缓存雪崩有点类似,但不是大面积的缓存过期失效,而是某个访问频次较高的数据失效了,从而导致这一刻高并发的请求全部穿透到了数据库,从而数据库并发压力较高,响应较慢,也进一步导致数据库异常,影响其他业务。

那么应该如何应对呢?

(1)高频数据、查询较为复杂的数据,可以不设置过期时间,但是需要程序去维护数据的更替删除。

(2)如果需要缓存过期时间,要大于缓存更新时间,避免过期无法找到键。

(3)使用原子操作方案,当多个数据都需要前往数据库查询同一个数据时,告知程序缓存正在生成中,并且告知其他程序可以读取上一次缓存数据,避免同时读取同一份数据。

实现多级缓存

在上一讲中我们已经介绍了两种缓存方案:

  • 本地缓存
  • 共享内存

接下来我们主要基于这两个缓存来实现一个 Node.js 缓存库,以方便后续在项目中应用。

1. 代码实现

关于本地缓存,我们可以借助一个第三方库 node-cache,redis 的话则使用 node-redis 第三方库,为了实现方便,这里就不详细地介绍 redis 安装和配置了,而是借助 redis 云服务。这里我已经申请了一个,具体信息如下,你可以使用以下云服务配置,由于是免费的,也可以自行去申请试用。

  1. host: 'redis-17353.c245.us-east-1-3.ec2.cloud.redislabs.com',
  2. port: 17353,
  3. password: 'nodejs@2021',
  4. db: 0

接下来我们在项目的 lib 中新增一个 cache.js ,在 cache.js 中来实现多级缓存的代码。

还需要提供三种方案,一种是直接使用本地缓存,一种是使用 redis 缓存,还有一种就是都使用,因此我们需要为 Cache 这个类设置 2 个参数,构造函数实现如下:

  1. constructor(localCacheEnable=true, redisEnable=true) {
  2. this.localCacheEnable = localCacheEnable;
  3. this.redisEnable = redisEnable;
  4. if(localCacheEnable){
  5. this.myCache = new NodeCache();
  6. }
  7. if(redisEnable) {
  8. this.client = redis.createClient({
  9. host: 'redis-17353.c245.us-east-1-3.ec2.cloud.redislabs.com',
  10. port: 17353,
  11. password: 'nodejs@2021',
  12. db: 0
  13. });
  14. }
  15. }

在本地缓存 localCacheEnable 为 true 时,才会本地缓存初始化;在 redis 缓存 redisEnable 为 true 的时候,我们才会初始化 redis 缓存。

接下来我们主要看 2 个核心方法的实现,一个是 get 获取缓存内容,一个是 set 设置缓存内容。

get 获取缓存内容的实现代码如下:

  1. *
  2. * @description 获取缓存信息
  3. * @param {string} key
  4. */
  5. async get(key) {
  6. let value;
  7. if(this.localCacheEnable) {
  8. value = this.myCache.get(key);
  9. console.log(`local value is ${value}`);
  10. }
  11. if(!value && this.redisEnable) {
  12. try {
  13. value = await promisify(this.client.get).bind(this.client)(key);
  14. console.log(`redis value is ${value}`)
  15. } catch (err){
  16. console.log(err);
  17. }
  18. }
  19. return value;
  20. }

代码逻辑比较清晰,首先判断是否打开了本地缓存,如果有则先从本地缓存中获取,如果没有则查看 redis 缓存是否打开,并且是否存在缓存数据。上面这段代码中,需要将 redis 的 get 方法转化为 promise,所以应用到了 util 工具中的 promisify。

set 方法的实现代码如下:

  1. *
  2. * @description 保存缓存信息
  3. * @param {string} key 缓存key
  4. * @param {string} value 缓存值
  5. * @param {int} expire 过期时间/秒
  6. * @param {boolean} cacheLocal 是否本地缓存
  7. */
  8. async set(key, value, expire=10, cacheLocal=false) {
  9. let localCacheRet, redisRet;
  10. if(this.localCacheEnable && cacheLocal) {
  11. localCacheRet = this.myCache.set(key, value, expire);
  12. }
  13. if(this.redisEnable) {
  14. try {
  15. redisRet = await promisify(this.client.set).bind(this.client)(key, value, 'EX', expire);
  16. } catch (err){
  17. console.log(err);
  18. }
  19. }
  20. return localCacheRet || redisRet;
  21. }

首先还是判断是否启用了本地缓存,同时判断该数据参数是否需要进行本地数据缓存操作,如果都需要则会调用 node-cache 的 set 方法缓存到本地内存中。接下来就判断是否需要进行 redis 缓存,如果需要则调用 node-redis 的 set 方法进行缓存。

以上就是 2 个核心方法的实现,其他方法比如说 delete 方法可以参照去实现。接下来我们主要看下业务侧的应用以及演示效果。

2. 效果演示

我们在 controller 中新增一个 cache.js,并且新增 3 个方法,分别是 local、 redis 和 both,然后在中间件 router 中新增相应的路由配置。

在 cache.js 中,我们首先需要创建 3 个类型的缓存对象,如下所示:

  1. const cache = require('../lib/cache')(true, false);
  2. const redisCache = require('../lib/cache')(false, true);
  3. const bothCache = require('../lib/cache')(true, true);

我们先来看下本地缓存的应用实现,如下所示:

  1. async local() {
  2. const cacheKey = 'sum_result';
  3. let result = await cache.get(cacheKey);
  4. if(!result){
  5. result = 0;
  6. for(let i=0; i<1000000000; i++){
  7. result = result + i;
  8. }
  9. cache.set(cacheKey, result, 10, true).then();
  10. }
  11. return this.resApi(true, 'success', `sum 0 - 1000000000 is ${result}`);
  12. }

这块代码的逻辑还是与《08 | 优化设计:在 I/O 方面应该注意哪些要点?》的类似,都是一个耗 CPU 的计算,首先我们获取缓存内容,如果没有则去计算,计算完成后再缓存到本地内存中。与上一讲不同的是,我们将本地缓存的操作交给了 cache 库。

redis 和 both 两个方法的实现基本是一样的,只是应用的 cache 实例不一样,不过在 both 中缓存时间也设置得不一样,为了更容易演示,我们只看 both 就可以了,如下所示:

  1. async both() {
  2. const cacheKey = 'sum_result';
  3. let result = await bothCache.get(cacheKey);
  4. if(!result){
  5. result = 0;
  6. for(let i=0; i<1000000000; i++){
  7. result = result + i;
  8. }
  9. bothCache.set(cacheKey, result, 600, true).then();
  10. }
  11. return this.resApi(true, 'success', `sum 0 - 1000000000 is ${result}`);
  12. }

接下来我们用以下命令启动该服务:

  1. pm2 start pm2.config.js --env development

启动成功后,我们先访问如下地址:

你会发现第一次访问较慢,而接下来的 10 秒内访问响应都非常快,这就是本地缓存的作用。同样的方式,我们去访问以下地址:

也是得出一样的结论。虽然两者效果上是一致的,但是在性能上是有一定差距的,这点在之前的《08 | 优化设计:在 I/O 方面应该注意哪些要点?》中已经详细说明过。

接下来我们访问如下地址:

然后打开 PM2 中的日志路径,由于 GitHub 代码中默认的是 /data/nodejs-column-io/info.log 路径(注意如果没有该路径 PM2 会启动失败,需要先创建路径,也可以放在其他路径下),我们打开日志文件目录。

在访问 both-cache 地址后,你会看到缓存会优先从本地缓存中获取,接下来我们重启下服务,使用如下命令:

  1. pm2 restart nodejs-column-io

然后我们再次访问时,你会发现缓存信息只能从 redis 中获取了,因为本地重启,内存被释放,所以没有数据了,因此在应用过程中,建议本地和 redis 缓存同时使用,避免因为现网版本发版或者异常重启导致的缓存穿透击穿现象,从而可能引发服务异常问题。

  1. 2021-02-27 11:25 +08:00: local value is undefined
  2. 2021-02-27 11:25 +08:00: redis value is 499999999067109000

以上就是多级缓存的实现方案,该库的其他方法你可以自行去补充实现,作为一个小作业,原理基本上是一致的,有任何问题都欢迎在评论区留言。

总结

本讲主要介绍了缓存的一些基础知识,着重要掌握的是缓存的三个问题:雪崩、穿透和击穿,这也是面试中常考的点,接下来就是应用 Node.js 实践开发了一个多级缓存的库,可以简单快速地应用本地缓存和 redis 缓存,需要掌握其实现以及后续扩展的实现方法。

接下来我们要进入第二个部分,系统相关的实践案例分析,下一讲我们先来讲解如何监控和保护进程安全。


09 | 缓存与应用:多级缓存策略介绍与应用要点 - 图1

《大前端高薪训练营》

对标阿里 P7 技术需求 + 每月大厂内推,6 个月助你斩获名企高薪 Offer。点击链接,快来领取!