关键要点
- 在处理请求过程中以及完成响应后,这些操作可以被视为同一缓存条目的两种不同状态,这样一来,在发生缓存未命中时就可以避免重复计算。
- 针对每个键采用单一实例进行路由处理、使用共享的内存状态,并通过序列化方式执行相关操作,这样就能让某个唯一的负责者安全地协调那些正在处理中的请求以及已被缓存的结果。
- 这种设计模式有助于减少缓存未命中时带来的性能问题,通过避免使用分布式锁或轮询机制来简化系统设计,并且能够在系统进行水平扩展的情况下保持其正确性。
- 这种方法适用于那些具有类似“Actor”模型特性的运行环境,比如Cloudflare Durable Objects、Akka或Orleans;不过,如果仅使用无状态函数以及最终具有一致性的键值存储系统,就很难重现这种设计模式带来的效果。
- 对于那些被频繁访问的键,其相关请求会在某个唯一的负责者范围内被序列化处理;在实际应用中,还需要考虑超时、重试、数据淘汰、错误处理以及是否需要持久化保存已完成的响应等问题。
引言
在优化分布式系统时,缓存通常是工程师们首先会采用的工具之一。我们会将已经完成的处理结果(比如数据库查询的结果或HTTP响应内容)缓存起来,从而避免重复进行耗时的计算操作。然而,传统的缓存机制并没有解决另一个常常被忽视的低效问题:重复的、正在处理中的请求。
当多个客户端几乎同时请求同一资源时,缓存未命中可能会导致几项完全相同的计算操作并行执行。在单进程的JavaScript应用程序中,通常会通过将那些正在处理中的Promise对象存储在内存中来避免这种情况,这样后续的调用者就可以等待相同的结果。在其他语言和运行环境中,虽然可以通过不同的并发机制来实现类似的效果,但根本的假设都是相同的:共享内存以及单一的执行上下文。
但在分布式、无服务器或边缘计算环境中,这种假设就不再成立了。每个实例都有自己的内存空间,因此任何形式的重复消除措施都只能局限于单个进程的生命周期和作用范围内。
工程师们通常会采取另一种方法来解决问题:在他们使用的缓存机制之外,引入锁、标记符或协调记录等机制,以便跟踪正在处理中的任务。但这些方法往往难以被合理理解,而且常常会退化为轮询或粗粒度的同步操作。
本文提出了一种不同的解决方案:将已完成的响应与正在处理中的请求视为同一缓存条目的两种不同状态。通过使用Cloudflare Workers和Durable Objects,我们可以为每个缓存键指定一个唯一的、权威的“所有者”。这个“所有者”可以安全地存储那些正在处理中的任务的相关信息,允许其他请求等待这些任务的完成结果,而一旦任务完成,该缓存条目就会被更新为已完成的响应。
这种方案并没有引入额外的协调层,而是将缓存机制与正在处理中的请求的去重操作统一在同一个抽象模型之下。虽然它依赖于某些并非在所有环境中都可用的高级运行时特性,但对于那些支持针对每个键仅执行一次任务的环境来说,这种方法确实是一种简洁而实用的技术方案。
问题的本质
从高层次来看,问题并不在于缓存本身,而在于在缓存条目真正生成之前所发生的一系列操作。
以一个耗时较长的操作为例:比如数据库查询、外部API调用,或者需要大量CPU资源的计算任务。在分布式边缘环境中,多个客户端可能会在非常短的时间内同时请求同一个资源。如果缓存中还没有这个键对应的值,那么每个请求都会独立地触发相同的处理流程。
由于边缘计算环境通常是横向扩展的,因此这些请求往往会被不同的执行环境来处理。每个执行环境都会看到同样的“缓存未命中”情况,并且会像第一个发起请求的客户端一样继续执行后续操作。结果就是会出现大量的重复性工作,而本来缓存应该是能够避免这种情况发生的,但由于“缓存只有在第一次请求完成之后才能发挥作用”,因此它实际上无法起到预期的效果。
为了解决这个问题,许多系统都会引入额外的机制来跟踪正在处理中的任务。其中一个缓存用于存储已完成的响应结果,而另一个结构——有时是内存中的映射,有时是分布式存储系统——则用于标记那些尚未完成、仍在处理中的请求。这种分离机制很快就会增加系统的复杂性:现在需要在这两个独立的系统中协调请求的生命周期,并且还要仔细处理竞态条件、故障以及超时等问题。
进程级的内存去重机制可以在一定程度上缓解这个问题,但这种效果仅限于单个运行时实例的范围之内。在无服务器和边缘计算环境中,运行时实例通常是短寿命的,并且被设计成相互隔离的。因此,即使两个请求在逻辑上是相同的,但如果它们来自不同的节点,那么它们也无法共享那些正在处理中的状态信息。随着流量量的增加或者分布范围的扩大,这种优化机制的效果也会迅速下降。
这种机制在单体服务或长期运行的服务中表现良好,但在那些需要进行大规模水平扩展的环境中却会遇到问题。
当缓存结果尚未生成与首次计算完成之间存在时间间隔时,传统的缓存策略就无法提供帮助;而在这种情况下,实时去重机制就变得既必不可少,又极其难以在分布式环境中实现。
为什么持久对象适合用于这种场景
上一节中提到的这些难题,实际上正是现代无服务器架构与边缘计算平台设计方式的直接后果。隔离的执行环境、短生命周期的进程以及水平扩展能力,这些都是这些平台的特性,而非缺陷。因此,任何解决实时去重问题的方案都必须适应这些限制条件,而不是试图绕过它们。
Cloudflare的持久对象技术为实现这一目标提供了一套关键且必要的保障机制。
首先,持久对象实例具有键级单例特性。对于同一个对象标识符,无论请求来自何处,所有请求都会被路由到同一个逻辑实例上。这种设计彻底消除了所有权上的模糊性:对于某个特定的缓存键来说,其状态信息只存在于一个地方。
其次,持久对象允许跨多个请求保持内存中的状态。与传统的工作进程不同,传统进程的内存状态仅限于单次调用范围内,而持久对象则能够在多次请求之间保留其内存状态。这使得它能够独立地记录正在进行的计算过程,而无需外部协调。
第三,对持久对象的请求会按顺序处理。这种序列化的执行模型使得在检查或更新状态信息时完全不需要使用显式的锁定机制。判断某个计算任务是否已经开始、如果还没有开始则创建它,以及添加额外的等待逻辑,所有这些操作都可以在同一个执行环境中确定性地完成。
综上所述,这些特性使得持久对象能够成为处理正在进行中的计算任务以及已完成的缓存结果的权威机制。调用者无需再询问“这个请求是否已经在其他地方被启动了”,只需将请求转发给负责该键的对象,然后等待结果即可。
重要的是,这种能力是无法通过最终一致性的键值存储系统来模拟的。虽然键值存储系统非常适合用于保存已完成的计算结果,但它们无法反映计算过程的实时状态,也无法让多个调用者在没有轮询或外部信号通知的情况下共同等待同一个内存操作的结果。相比之下,持久对象将实时处理任务提升为了一个核心功能。
当然,这并不意味着持久对象适用于所有场景。本文描述的这种机制依赖于它们的单例特性和内存状态保留能力,因此只适用于那些具备类似功能的运行环境。但只要这些保障存在,持久对象就能为统一缓存管理和实时去重机制提供简洁而高效的基础,而无需增加额外的协调层。
该模式的适用范围并不限于Cloudflare
虽然本文中的示例使用了Cloudflare Workers和Durable Objects,但这种底层设计模式并非Cloudflare所独有。真正重要的是上述运行时环境所提供的保障机制,而非具体的平台本身。
至少,一个合格的运行时环境必须满足以下要求:
- 针对每个键值对,必须确保仅执行一次相应的处理操作:所有针对同一键值的请求都会被路由到同一个逻辑实例进行处理。
- 对于该实例而言,不同请求之间必须能够共享内存中的状态数据。
- 请求处理过程必须支持序列化机制,或者具备其他等效的保障措施,从而无需使用显式的锁定机制。
Cloudflare的Durable Objects明确满足了这些要求,因此它们成为了这一设计模式的理想示例。在其他环境中也可以找到类似的设计理念,只不过实现方式可能有所不同,名称也可能不同,所需要做出的权衡也会有所差异:
- 基于Actor的模式,比如那些使用Akka或Orleans构建的系统,也能通过Actor的身份识别机制和消息序列化功能提供类似的保护机制。在这种系统中,一个Actor可以同时负责处理针对某个键值的请求以及存储其处理结果。
- 有状态的无服务器平台以及“持久性执行”模型也在逐渐兴起,不过它们的API设计及所提供的保障机制各不相同。但这些技术都有一个共同点:并非所有的无服务器计算过程都必须是无状态的,而适当地使用有限的状态数据反而可以帮助解决某些协调问题。
相比之下,那些仅提供无状态功能,并且依赖最终一致性的键值存储系统的平台,则无法清晰地实现这种设计模式。由于缺乏一个权威的“所有者”以及共享的内存执行环境,这些系统在处理请求时不可避免地会陷入轮询或分布式锁定的困境中。
因此,这里所描述的设计模式应该被理解为一种依赖于具体运行时环境的机制。它并不能替代传统的缓存技术,而只是一种在特定运行时环境下才能有效发挥作用的解决方案。
最简单的实现方案
一旦确定了所需的运行时保障机制,实际的实现代码就会变得非常简洁。我们的目标并不是构建一个通用的缓存系统,而是想展示如何通过一种抽象层来同时处理请求的去重处理和响应数据的缓存存储工作。
下面的示例展示了一个专门用于管理某个键值对的Durable Object。所有针对该键值的请求都会被路由到同一个对象实例进行处理:
export class CacheObject {
private inflight?: Promise);
private cached?: Response;
async fetch(request: Request): Promise。 {
// 如果缓存中已经存在响应数据,直接返回即可
if (this.cached) {
return this.cached.clone();
}
// 如果还没有开始处理请求,就启动相应的计算过程
if (!this.inflight) {
this.inflight = thiscompute().then((response) => {
// 将处理完成的响应数据存储到缓存中
this.cached = response.clone();
// 清除正在处理的请求状态
this.inflight = undefined;
return response;
});
}
// 等待计算结果完成,然后返回结果
return (await this.inflight).clone();
}
private async compute(): Promise。 {
// 这里可以放置耗时较长的操作逻辑,例如数据库查询或外部API调用
const data = await fetch("https://example.com/expensive").then(r => r.text());
return new Response(data, { status: 200 });
}
}
这个对象维护两种状态:
inflight:表示正在进行的计算。cached:用于存储一旦计算完成后的响应结果。
当有请求到达时,该对象会首先检查是否存在已缓存的响应。如果不存在,则会判断是否已经有计算任务在运行中。如果正在运行,调用者只需等待相同的响应结果即可;如果没有进行任何计算,对象就会启动相应的计算过程,并将最终的响应结果存储在内存中。
由于持久化对象是按顺序处理请求的,因此无需使用显式的锁机制或原子操作。用于检测和创建“inflight”状态的相关逻辑,在同一个执行环境中能够被确定性地执行完毕。
从调用者的角度来看,这种机制的表现与普通的缓存系统类似。不同之处在于:即使缓存最初是空的,多个并发请求也不会导致重复计算。一旦计算完成,所有等待的调用者都会得到相同的结果,后续的请求也会直接从已缓存的响应中获取数据。
这个示例故意省略了持久化、过期处理以及错误处理等相关内容。这些功能可以在后期再添加——例如,可以选择将已完成的响应结果存储在键值存储系统中以确保数据的持久性——但这样做并不会改变这种模式的核心理念。至关重要的是,“inflight”状态始终存在于内存中,因此这种模式的简洁性和正确性得到了有效保障。
为什么这种方法有用
这种模式的主要优点在于它将两个相关的功能整合到了同一个抽象层中。它没有把“去重处理”和“响应缓存”视为独立的问题,而是将它们看作是同一缓存条目的不同状态。
这样做有几个实际的好处:
- 首先,它避免了在单纯依靠缓存无法解决问题的情况下出现重复计算。由于允许多个并发请求等待同一个正在进行的计算任务,系统能够有效避免在缓存未命中时出现的冗余请求现象——而这正是传统缓存机制最不擅长的场景。
- 其次,这种设计简化了系统的整体结构。无需额外的协调层、分布式锁机制,也不需要将“计算进行中”的状态信息与缓存数据分开存储。所有与请求处理、执行以及结果重用相关的逻辑都集中在同一个地方,由同一个运行时实体来管理。
- 第三,这种模式非常符合JavaScript应用程序的编写习惯。等待一个共享的响应结果是一种常见且被广泛理解的设计模式,而持久化对象使得这种模式可以在多进程环境中得到应用,而不会改变开发者的思维方式。调用者可以像使用本地缓存一样来使用这个持久化缓存系统,尽管实际的计算过程是分布式的。
- 第四,这种模式能够水平扩展而不影响其正确性。无论流量如何增加,或者请求分布在不同的地理位置,每个请求仍然会被路由到同一个负责处理该请求的实体那里。随着更多边缘节点的加入,系统的性能也不会下降——这与那些针对单进程环境设计的优化方案截然不同。
- 最后,这种模式具有很强的可扩展性。可以随时添加过期策略、已完成响应的持久化存储功能、监控指标以及重试机制等元素,而不会改变核心的控制流程。其基本原则始终不变:一个请求只由一个实体来处理,只会进行一次计算,最终只会生成一个缓存结果。
这些特性使得这种模式适用于那些重复性工作所带来的开销较高,且请求并发性难以预测的工作负载场景,例如边缘API、数据聚合端点或成本较高的上游集成系统。
权衡与局限性
尽管这种模式设计得十分精巧,但它并非适用于所有情况。其实际效果在很大程度上取决于底层运行时的执行模型,同时也会带来一些需要仔细考虑的权衡因素。
- 最显著的局限性在于对运行时的依赖性。在进行去重处理时,必须有一个拥有共享内存状态的权威节点;如果没有针对每个键进行单例化处理,这种模式就无法顺利实现。尝试使用最终一致性的键值存储系统来复制这一机制,必然会导致轮询、分布式锁或其他形式的协调操作,从而破坏其原有的简洁性。
- 该模式的实现过程本身也可能相当复杂。虽然最简单的示例代码体积很小,但实际应用于生产环境时,还需要考虑错误处理、重试机制、超时设置、数据淘汰策略以及内存限制等问题。必须确保失败的操作不会使系统陷入永久性的“进行中”状态,同时也要保证缓存的响应能够被正确地清除。
另一个需要重点考虑的因素是适用性。在许多架构良好的系统中,重复的请求本来就很少出现。如果上游API具有幂等性,请求会自然分散处理,或者使用了粗粒度的缓存机制,那么进行去重处理可能就没有必要了。在这种情况下引入这种模式反而会增加系统的复杂性,而并不会带来实质性的好处。
还存在扩展性方面的权衡。如果将所有针对同一键的请求都路由到同一个节点进行处理,就会形成天然的序列化点。对于那些某个特定键的处理需求特别高的工作负载来说,这可能会成为性能瓶颈。在这种情况下,采用分片策略或其他替代性的缓存方法可能更为合适。
最后需要强调的是,这种模式并不能取代传统的缓存机制,它只是对这些机制的一种补充。已完成处理的响应结果仍然需要被保存在键值存储系统或HTTP缓存中,这样才能确保系统在进程被终止或重新启动后仍能正常访问这些数据。不过,持久化处理应该仅适用于已经完成的结果;如果将进行中的计算状态也保存到外部存储中,就会失去这种机制原本带来的优势。
基于以上原因,这种模式应该被视为一种有针对性的优化措施,而非默认的架构选择。只有当运行时环境支持它,且工作负载确实需要这种机制时,将响应缓存与进行中的去重处理结合起来使用,才能显著减少重复性工作。而在不符合这些条件的情况下,采用更简单的设计方案通常会更加合适。
结论
本文介绍了一种在分布式JavaScript运行环境中统一响应缓存与请求去重处理的机制:通过利用针对每个键的单例化执行机制以及共享的内存状态,就可以将正在进行的计算过程及其最终结果视为同一缓存条目的不同状态,从而消除重复性工作,而无需使用轮询或外部协调机制。
需要强调的是,这种设计模式主要只是一种设计提案,并非经过实际应用验证过的成熟方案。虽然其基础组成部分(持久化对象、承诺机制以及序列化执行模型)早已为人们所熟知,但这里描述的具体组合在真实生产环境中尚未得到广泛验证。关于其运行行为、可观测性以及长期性能等方面的问题仍然存在,需要进一步研究。
不过,这种设计模式的价值在于它清晰地揭示了缓存与执行之间的关联。它表明,实时去重所带来的复杂性并非分布式系统的固有特性,而是我们通常采用的执行模型所导致的。当运行时为每个键分配一个唯一的“所有者”时,这个问题就会变得简单许多。
随着无服务器架构和边缘计算平台的不断发展,具有状态保持功能的执行模型正变得越来越普遍。像这样的设计模式表明,重新审视一些长期存在的假设(比如缓存与协调机制之间的严格分离)可能会帮助我们创造出更简洁、更具表现力的设计方案。无论这种具体方法能否得到广泛应用,还是仅仅属于一种小范围的优化方案,它都为未来的运行时架构与应用设计指明了重要的发展方向。
