Redis 在 Node.js 中的应用

4,795 阅读9分钟

相信很多开发者都了解 Redis,至少听说过它。

Redis 是为集群应用提供分布式缓存机制而闻名。然而,这只是它其中的一个亮点。

Redis 是一个功能强大、通用的内存数据库。强大是因为它非常快;通用是因为它可以处理缓存、类似数据库的特性、会话管理、实时分析、事件流等...

但是,当使用 Redis 作为常规数据库时,必须注意内存中的部分。

在本文中,我们将探索 Redis 缓存模式的一些最有趣的细微差别,使用 Node.js 作为环境来运行一些基准测试。让我们开始吧!

缓存的类型

你可能听说过。随着建立在关系数据库之上的系统快速增长,为了获得更好的性能,通常需要在查询方面减少一些压力。

缓存,作为一种物理实现,在系统应用中随处可见:从数据库层本身到应用服务层,甚至作为远程分布式独立服务(就像 Redis )。

在继续探索之前,我们先来看下这些类型。

Type 1:数据库的集成式缓存

根据你所遵循的系统设计,数据库集成可以帮助你的系统提高一些处理性能。

例如,如果你在读取数据时使用 CQRS 将负载驱动到 NoSQL 数据库,而在写入数据时将负载驱动到关系数据库,那么这可能是一种类似数据库的集成形式,以实现缓存。

然而,这是容易出错的,并且需要大量的人力来运行它,更不用说维护它了。

其他的数据库,如 Aurora 提供了在数据库级别启用缓存的内置机制。换句话说,应用层和客户端不需要知道缓存的存在,因为数据库体系结构本身负责所有事情:通过内部复制逻辑到达时进行更新。

image.png

显然,在内存和跨集群实例的数据同步方面存在一些限制。但是,给定正确的用例场景,这将是一个可以考虑使用的强大集成。

Type 2:本地应用缓存

编程式缓存是最常用的类型之一,因为它们只是存储数据的内存结构。

每一种编程语言都有其内置的或社区驱动的库,可以很快轻松地提供本地缓存。

它的特点就是超级快。数据在内存中,因此你可以快速地访问到数据,比通过像是 TCP 请求来获取数据快得多。

另外,如果你工作在一个分布式微服务的环境世界中,那么集群中的每个节点都拥有自己的版本化数据集,这些数据不会在其他节点之间共享,更不可能在某个节点突然关闭时丢失的所有数据了。

Type 3:远程缓存(Redis)

通常,这种类型的缓存也称为端缓存,这意味着它作为服务存在于其他地方,而不是你的应用或数据库。

因为它们是远程工作的,所以它们必须在极端环境下表现良好。这就是为什么它们通常可以在几毫秒内处理大量数据加载的原因。

选择这种类型的缓存是需要讨论和考虑的。例如,你有关于你的(或你的提供商的)网络延迟的详细信息吗?你能水平扩展你的 Redis 集群吗?

由于应用程序与外部之间存在通信,所以当处理数据失败或变得太慢时,必须有一个计划。开发人员通常通过混合使用本地和远程缓存策略来解决这个问题,这将为他们在边缘情况下提供第二个保护屏障。

缓存的模式

同样,根据你的系统需求,实现缓存的方式可以根据实际情况而变化。

让我们花点时间来分析一下最常见的缓存模式。

Cache-aside 模式

这是最常用的缓存模式。顾名思义,它存在于的系统体系中的不同地方(应用程序除外)。

应用程序负责 缓存数据库 之间的编排,这样会是最好的。请看下图:

image.png

Cache-aside 执行流:

  1. 第一步是检查缓存,看看是否有所需的数据。如果成功,应用程序将信息返回给客户端,而不调用数据库。
  2. 如果没有,应用程序将转到数据库获取最新的信息。
  3. 最后,一旦有了最新版本的数据,应用程序就决定对缓存进行写入,使其也能感知。

这种策略有很多好处,比如可以灵活地处理缓存和数据库中完全不同的数据类型。需要对数据库进行仔细的考虑设计,因为对数据库的更改可能会变得非常痛苦。然而,缓存给了你更多的自由来使用更灵活的数据结构。

请注意,在向数据库写入数据和缓存更新失败的情况下,上面图像中演示的策略可能会带来麻烦。对于这种情况,有备选方案很重要,比如 TTL(生存时间)设置,开发者为使缓存中的特定数据失效建立一个超时。这样,当缓存数据更新失败时,应用程序不会太长时间处理超时的数据。

Write-Through 模式

此模式采用与 Cache-saside 模式相反的方法。在这里,当检测到任何更改时,应用程序首先写入缓存,然后再转到数据库。

这就是它的名字的来源,因为在进行最终的数据库写入之前,它都要经过缓存层。

image.png

Write-Through 执行流:

在对这种策略建模时,必须非常小心,特别是在数据库写入失败的情况下。

对于这种情况,你可以建立一个重试策略来尝试不惜一切代价将数据保存到数据库,或者抛出一个事务错误,回滚之前的缓存写入。

这里,只需注意每个流的总体处理时间的相应增加。

Redis For Node.js

我们将在 Node.js 应用程序上运行一个基准测试,该应用程序将公开两个 API 端点:一个处理缓存的数据,另一个没有缓存。

我们的目标是演示如何快速配置你的项目来使用 cache-aside 模式,同时对这两个 API 端点进行基准测试,看看 Redis 能为 REST API 的性能提升有多大。

环境准备

你可以按照官方的快速入门配置 redis 环境:

wget http://download.redis.io/redis-stable.tar.gz
tar xvzf redis-stable.tar.gz
cd redis-stable
make
make install

然后,执行 redis-server 命令启动 redis 服务:

image.png

接下来,我们创建一个项目文件夹,cd 进入到它。

初始化并安装依赖:

npm init
npm install express redis axios

最后,根目录创建 index.js 文件,添加如下代码:

const axios = require("axios");
const express = require("express");
const redis = require("redis");

const app = express();
const redisClient = redis.createClient(6379); // Redis server started at port 6379
const MOCK_API = "https://jsonplaceholder.typicode.com/users/";

app.get("/users", (req, res) => {
  const email = req.query.email;

  try {
    axios.get(`${MOCK_API}?email=${email}`).then(function (response) {
      const users = response.data;

      console.log("User successfully retrieved from the API");

      res.status(200).send(users);
    });
  } catch (err) {
    res.status(500).send({ error: err.message });
  }
});

app.get("/cached-users", (req, res) => {
  const email = req.query.email;

  try {
    redisClient.get(email, (err, data) => {
      if (err) {
        console.error(err);
        throw err;
      }

      if (data) {
        console.log("User successfully retrieved from Redis");

        res.status(200).send(JSON.parse(data));
      } else {
        axios.get(`${MOCK_API}?email=${email}`).then(function (response) {
          const users = response.data;
          redisClient.setex(email, 600, JSON.stringify(users));

          console.log("User successfully retrieved from the API");

          res.status(200).send(users);
        });
      }
    });
  } catch (err) {
    res.status(500).send({ error: err.message });
  }
});

const PORT = process.env.PORT || 3000;
app.listen(PORT, () => {
  console.log(`Server started at port: ${PORT}`);
});

该代码使用了一个外部 mock API JSONPlaceholder,这对于 API fake 非常有用。在本例中,我们将搜索一些虚拟用户数据。

注意,端口 6379 是 Redis 的默认值,你可以在上图 redis 服务启动日志中可以看到。

有两个 API。第一种基本上从用户的 API 中获取数据,中间没有缓存。通过这种方式,你可以看到后续的 HTTP 调用会给应用程序的总体性能增加多少重载。

第二个 API 总是在 Redis 中检查给定的数据。如果数据的 key 是存在的,我们跳过 mock API 调用,直接从缓存中获取数据,否则,我们会继续获取信息并将结果存储到 Redis 中。

很简单,对吧?让我们继续看看基准测试代码。首先,让我们添加一个 Node 库来帮助我们解决这个问题。我们将使用 api-benchmark 工具,因为它功能强大,能够为基准生成可视化报告。

执行以下代码,安装 api-benchmark:

npm install api-benchmark

然后,在根目录创建另一个文件 benchmark.js , 并添加如下代码:

var apiBenchmark = require("api-benchmark");
const fs = require("fs");

var services = {
  server1: "http://localhost:3000/",
};
var options = {
  minSamples: 100,
};

var routeWithoutCache = { route1: "users?email=Nathan@yesenia.net" };
var routeWithCache = { route1: "cached-users?email=Nathan@yesenia.net" };

apiBenchmark.measure(
  services,
  routeWithoutCache,
  options,
  function (err, results) {
    apiBenchmark.getHtml(results, function (error, html) {
      fs.writeFile("no-cache-results.html", html, function (err) {
        if (err) return console.log(err);
      });
    });
  }
);

apiBenchmark.measure(
  services,
  routeWithCache,
  options,
  function (err, results) {
    apiBenchmark.getHtml(results, function (error, html) {
      fs.writeFile("cache-results.html", html, function (err) {
        if (err) return console.log(err);
      });
    });
  }
);

我们将分别执行两个命令:一个没有缓存;另一个使用 redis 缓存。

这里对每个 API 服务进行 100 个请求的负载,这对于为生产准备的压测不是理想的,但已经足够证明 Redis 的能力了。稍后你可以使用更多请求重新运行测试,以查看差距是如何增大的。

执行 node index.js 命令,接着另起终端,执行 node benchmark.js

如果一切顺利,你可能会在项目的根目录下看到两个新的 HTML 文件。在 web 浏览器中打开它们,可能会显示类似如下所示的结果:

  • Redis 缓存 API 的基准测试结果:

image.png

  • 无缓存的 API 基准此时结果:

image.png

看下右侧面板中生成的统计数据。非缓存 API 在大约 11 秒内结束了测试,而redis 缓存 API 在大约 0.9 秒内就结束了测试。

如果你在开发每秒接收大量请求的应用程序,那么这是一个非常不错的选择😆。

总结

现实中还会有其他的缓存模式,但是,为了简单起见,我们只关注最流行和最强大的缓存模式。

现代应用注重考虑性能。随着时间的推移,这个要求变得越来越严格,因为对应用程序请求的复杂性和数量呈指数级增长。

Redis 只是众多缓存选项中的一个。事实上,它强大、灵活,深受许多公司和技术社区的喜爱。

我建议你实现一下第二个缓存模式,即 write-through,对比与 cache-aside 在性能方面的区别。

你可以在 GitHub 上看到本文中的示例代码。

参考