手记

Node.js 中构建 API 网关:第四部分 — 缓存

使用缓存来提升性能

欢迎来到我们“使用Node.js构建API网关”系列的第四部分。在第一部分,我们介绍了API网关的基本概念,讨论了它的优缺点,并实现了路由组件。在第二部分,我们通过认证和授权保护您的API。在第三部分,我们介绍了不同的限速策略及其如何保护您的底层服务。您可以在下面找到之前的文章:

使用 Node.js 构建 API 网关:第一部分 — 概述与路由...medium.com 使用Node.js构建API网关:安全性、认证和授权 — 保护您的微服务medium.com 构建API网关:第3部分 — 有效限速保护您的APImedium.com

在这一部分,我将探讨你可以实现在你的API网关上的各种缓存技术,以提升性能,减少延迟,并减轻后端服务的负担。从分布式缓存、HTTP缓存头到更高级策略,如去重请求和边缘CDN缓存,我将介绍一些实用的缓存策略,以确保你的API网关既高效又具备良好的扩展性。

概要

缓存 是一种暂时存储频繁访问的数据的技术,从而使未来的请求能够更快地得到响应,而无需反复查询后端服务或数据库。如果你从事过 web 服务的工作,你很可能熟悉这种技术,因为它是最常用的技术之一,用于优化性能和减轻服务器负载。

那么这在实际操作中意味着什么?想象一下你有一个从数据库中获取产品数据的API。如果没有缓存,每当用户请求产品信息时,你的API就需要查询数据库,这在高流量情况下可能会非常耗时。有了缓存,产品数据会被暂时存储在快速访问的存储设备中。这使得后续的请求可以从缓存中获取,从而大大缩短响应时间,并减轻数据库处理重复查询的负担。

你可以将缓存存储在本地内存中,或利用外部存储解决方案来实现更好的扩展性。两种最受欢迎的外部缓存方案是RedisMemcached,它们都以处理大规模缓存的速度和效率著称。

将缓存存储在本地内存中的好处包括:

  • 更快的数据获取:内存缓存提供了近乎即时的数据检索,减少了延迟并提高了响应时间。
  • 减少外部依赖:通过本地存储缓存,您的系统不再依赖外部服务,从而减少了潜在的故障源。
  • 降低网络开销:由于数据直接存储在服务器上,无需网络通信,非常适合需要高速和低延迟的应用。
  • 简单性:实现内存缓存简单且易于与大多数应用程序集成,而无需构建复杂的基础设施。

但这里也有一些不足之处:

  • 有限的可扩展性:本地内存缓存受限于服务器的内存容量,因此随着系统扩展和数据增长,其效果会减弱;
  • 无数据持久化:如果服务器宕机或重启,内存缓存中的数据就会丢失,可能导致冷启动并增加后端服务的负载;
  • 缺乏分布性:本地缓存不能在多个服务器之间共享数据,不适合需要一致共享缓存数据的分布式系统;
  • 数据重复:随着服务数量的增加,缓存数据的副本也会增多,可能导致效率低下。

一如既往,决定权仍然在你这里,你应该仔细权衡一下利弊。如果你在寻找快速简便的解决方案,内存缓存可能更适合你的需求。然而,如果你更看重可扩展性、容错性及其他因素,我建议你可以考虑一下分布式缓存解决方案,比如 Redis 或 Memcached。

你也可以通过在同一个数据库中的一个专门用于存储缓存数据的单独表中缓存长时间运行的查询结果来实现缓存策略。如果使用的是SQL数据库并且需要避免反复执行昂贵的查询,这种方法效果很好。通过存储预先计算的结果,你可以大幅减少查询时间,同时保持现有数据库架构的一致性。

你可能会想,“为什么处处都使用缓存呢?既然缓存能带来显著的性能提升。”原因是虽然缓存可以提升性能,但它也引入了必须小心对待的新问题,例如:

  • 缓存失效:保持缓存中的数据新鲜是一项挑战。如果缓存的数据过时,会导致结果不一致。
  • 数据一致性:当涉及多个系统或服务时,确保这些系统或服务之间的缓存数据保持一致可能很困难。
  • 内存管理:缓存会消耗内存资源,不当的内存管理可能导致资源过度消耗,进而影响整个系统的性能。
  • 复杂性:在分布式环境中实施缓存策略,会增加系统的架构复杂性,同时也会使调试变得更加困难。
  • 冷启动:当缓存未命中或重启后,系统可能会出现性能下降,直到缓存重建,这会使得初始响应时间变慢。
  • 缓存过期:确定缓存数据的合适过期时间需要仔细调整,以平衡性能与数据的新鲜度。

虽然缓存能提供巨大的性能提升,但理解和解决这些挑战以确保它能更好地满足应用程序的需求是很重要的。

想象你正在构建一个处理实时交易的金融系统,例如银行转账或股票交易。在这种情况下,随意使用缓存可能会引发严重问题。例如,如果交易数据或账户余额被缓存起来且没有立即更新,用户可能会看到过时的信息,导致余额显示错误或交易失败。这种不一致情况可能导致严重的后果,比如失去信任、财务错误或违规问题。

在这种类型的系统中,一致性比性能更重要,确保数据一直准确和最新是首要任务。如果缓存被使用,必须非常小心,通常涉及像严格缓存过期这样的方法,或者完全绕过缓存进行关键操作,以避免提供任何过时或不正确的数据。

另外,我们还应该讨论一下不同的缓存策略:服务器端和客户端,

  • 服务器端缓存 — 在托管应用程序或API的服务器上进行的缓存。服务器存储频繁被多个客户端请求的数据,以减少处理时间和减轻后端压力。工作原理:当请求进来时,服务器首先检查其缓存中是否已经有该请求的存储响应。如果有,它会发送缓存响应而无需访问数据库或进行大量计算。如果没有,服务器会处理请求,生成响应,并将其存储在缓存中以供将来请求使用。常见示例:缓存数据库查询结果、存储渲染的HTML页面或API响应。
  • 客户端缓存 — 在客户端(通常是Web浏览器或应用程序)上发生的缓存。客户端会将请求的数据存储在本地缓存中,例如浏览器缓存或本地存储。工作原理:客户端在初次请求并接收数据后,将其响应存储在其本地缓存中。后续对相同数据的请求将从客户端的缓存中提供,而不是再次从服务器获取数据。常见示例:在浏览器中缓存图片、CSS文件、JavaScript文件或API响应。

它们都旨在提高性能和减少延迟,但它们在请求-响应周期的不同阶段工作。服务器端缓存通过最小化服务器上的重复处理,减轻了后端服务的负担,而客户端缓存减少了客户端获取未更改数据时的网络请求。

此外,你可能还听说过内容分发网络(CDN,内容分发网络),这是另一种常见的缓存策略。CDN 是一个分布在全球不同地理位置的服务器网络,这些服务器缓存并分发内容,使内容更接近最终用户的位置。通过存储静态资产(如图片、CSS、JavaScript 甚至包括 API 响应)的副本,CDNs 减少了数据传输的距离,缩短了传输时间,加快了加载时间并减少了延迟。这不仅加快了内容交付速度,尤其是对于距离原始服务器较远的用户,并帮助主服务器减轻负载,从而提高整体的扩展性和性能。

在 API 网关层,实现服务器端的分布式缓存需要更多的努力,因为它涉及到特定的代码更改,以处理跨多个服务器的缓存逻辑和存储。这种方法可以显著提升频繁访问资源的性能。另一方面,客户端缓存更容易实现,因为底层服务只需简单传递诸如 Cache-ControlExpiresETag 等 HTTP 标头,在浏览器中管理缓存规则,使客户端可以存储并重用响应。CDN 通常用于补充这两种策略,在边缘位置缓存静态资产和 API 响应,从而减轻 API 网关和后端服务的负担,并在全球范围内提升用户体验。

那么让我们使用 Redis 采用服务器端缓存功能!

实施

为了启用缓存,我们需要在路由配置中添加缓存选项。我建议最简单的配置是一个包含 ttl(TTL,生存时间)键的 cache 对象。它定义了响应应缓存多长时间。这种基本配置足以实现简单的服务器端缓存机制。

下面是一个配置示例:

[
    {
        "name": "auth-clients-service",
        "methods": ["GET"],
        "context": ["/clients"],
        "target": "http://localhost:3002",
        "pathRewrite": {},
        "internal": true,
        "cache": {
            "ttl": 60
        }
    },
    {
        "name": "auth-service",
        "methods": ["POST"],
        "context": ["/auth"],
        "target": "http://localhost:3002",
        "pathRewrite": {
            "^/auth": "/oauth2"
        }
    },
    {
        "name": "user-service-read",
        "methods": ["GET"],
        "context": ["/users"],
        "target": "http://localhost:3001",
        "pathRewrite": {},
        "security": {
            "scope": "user:read"
        },
        "limits": {
            "client": {
                "rate": 10,
                "window": 60
            },
            "overall": {
                "rate": 100,
                "window": 60
            }
        },
        "cache": {
            "ttl": 60
        }
    },
    {
        "name": "user-service-write",
        "methods": ["POST", "PUT", "DELETE"],
        "context": ["/users"],
        "target": "http://localhost:3001",
        "pathRewrite": {},
        "security": {
            "scope": "user:write"
        },
        "limits": {
            "client": {
                "rate": 5,
                "window": 60
            },
            "overall": {
                "rate": 50,
                "window": 60
            }
        }
    }
]

随着新的变化,处理器接口需要再次扩展来支持缓存功能。为了支持缓存,处理器需要执行两次。

  1. 在请求之前:这一步检查响应是否已经被缓存,如果是,则返回缓存的结果。
  2. 响应准备好之后:这一步会将响应缓存起来供将来使用。

在 JavaScript 里,给执行器的接口添加一个可选的方法如 postProcess 很简单。执行器可以选择是否实现此方法。调用链会像这样。

    for (const processor of this.processors) {  
        // 对于每个处理器,调用其处理方法,并将上下文作为参数传递。
        const result = await processor.process(context)  
        // 更新上下文,合并处理结果中的上下文信息和头部信息。
        context = {  
            ...context,  
            ...result.context,  
            headers: { ...context.headers, ...result.context?.headers },  
        }  

        // 如果处理结果中有响应,则对每个处理器调用后处理方法。
        if (result.response) {  
            for (const postProcessor of this.processors) {  
                if (postProcessor.postProcess) {  
                    await postProcessor.postProcess(context, result.response)  
                }  
            }  
            // 返回包含响应的对象。
            return { response: result.response }  
        }  
    }

目前,响应结构目前没有统一的接口,这使得处理程序因处理不同响应类型的多个 if 分支而变得复杂。为了解决这个问题,我对代码进行了重构以改进这一点,现在响应是一个具有三个属性的统一对象,这使得响应更加一致和易于处理。

  • **状态**(必填):HTTP 状态码。
  • **头部**(可选):包含 HTTP 标头的对象。
  • **内容**(可选):包含响应内容的数据缓冲区。

这里是对代理服务的一些改动:

module.exports = function handler(req) {  
    const { path, method } = req  

    const route = routes.find(  
        (route) =>  
            route.context.some((c) => path.startsWith(c)) &&  
            route.methods.includes(method)  
    )  
    if (!route || route?.internal) {  
        return {  
            response: {  
                status: 404,  
                body: {  
                    error: '找不到路由',  
                    message: '找不到路由',  
                },  
            },  
        }  
    }  

    const chain = new ChainProcessor()  
    chain.add(new HeadersProcessor(route, req))  
    chain.add(new BodyProcessor(req))  
    chain.add(new UrlProcessor(route, req))  
    if (route.security) {  
        chain.add(new SecurityProcessor(route, req))  
    }  
    if (route.limits) {  
        chain.add(new LimiterProcessor(route))  
    }  
    if (route.cache) {  
        chain.add(new CacheProcessor(route, req))  
    }  
    chain.add(new ExecutorProcessor(route))  

    return chain.process({  
        authorization: req.headers.authorization,  
    })  
}

流程会根据缓存执行器在管道中的注册位置而有所不同。如果它是在管道中最早注册的,它可能会跳过关键的安全检查,在安全上下文被验证之前就返回缓存响应。为了避免这种情况的发生,缓存执行器应该被放置在管道的末端位置,即在主要执行器之前。这样就确保了在返回缓存响应之前,所有安全检查和其他验证都已完成。

最有趣的部分是缓存执行程序本身。

    const { getRedisClient } = require('../clients/cache')  

    module.exports = class CacheProcessor {  
        /**  

* 创建一个新的缓存处理器  

* @typedef {import('express').Request} Request  

* @typedef {import('./types').Route} Route  

* @param {Route} 路由  

* @param {Request} req  
         */  
        constructor(route, req) {  
            this.__route = route  
            this.__req = req  
        }  

        /**  

* 处理请求  

* @typedef {import('./types').Result} Result  

* @returns {Promise<Result>}  
         */  
        async process() {  
            const cl = await getRedisClient()  

            const cachedResponse = await cl.get(this.__buildKey())  
            if (cachedResponse) {  
                const response = JSON.parse(cachedResponse) // 将缓存响应解析为JSON对象  
                response.content = Buffer.from(response.content.data)  

                return { context: { cached: true }, response }  
            }  

            return {}  
        }  

        /**  

* 处理请求的后续操作  

* @param {import('./types').Response} response  

* @returns {Promise<void>}  
         */  
        async postProcess(context, response) {  
            if (!context.cached) {  
                const { cache } = this.__route  

                if (response.status >= 200 && response.status < 300) {  
                    const cl = await getRedisClient()  
                    await cl.setEx(this.__buildKey(), cache.ttl, JSON.stringify(response))  
                }  
            }  
        }  

        __buildKey() {  
            return this.__req.originalUrl // 直接引用原始URL  
        }  
    }

Redis缓存响应的键是请求端点的HTTP路径。这意味着如果任何路由或查询参数发生变化时,缓存将不会被命中,请求将直接执行并向目标服务发送。

process 函数检查 Redis 中是否有缓存的响应。如果有缓存,则返回缓存的响应给客户端。

postProcess 方法负责将响应缓存起来,前提是响应还没有被缓存过。注意,即使返回的是缓存的响应,postProcess 方法还是会执行,但此时无需再缓存响应(开头的 if 分支会检查是否需要缓存)。

现在,尝试对用户服务执行一个GET请求(确保事先已经生成了访问令牌)。之后,检查Redis集群中的数据。你应该能看到类似这样的内容:

临时存储的答案

应用代码

你可以在此仓库中找到应用代码:

GitHub - misikdmytro/api-gateway 加入 misikdmytro/api-gateway 项目,在 GitHub 上创建一个账户即可。

如果你喜欢它,就别忘了给项目仓库点个星哦!

总结

实现缓存可以显著提高应用程序的性能和扩展性,通过减轻目标服务的负载并加快用户的响应时间。然而,必须小心设计缓存机制以避免影响安全和数据的一致性。

一些执行器现在可能依赖于其他执行器的状态——例如,如果响应被缓存,速率限制器可能会放宽对请求数的限制。在优化系统的过程中,需要注意组件之间的相互影响。

在接下来的系列部分,我们将聊聊如何实现稳固的监控,针对您的API网关(API GW)。

敬请期待更多关于如何在 Node.js 上构建可靠 API 网关的实用建议和技巧!

0人推荐
随时随地看视频
慕课网APP