关键要点

  • 在处理请求过程中以及完成响应后,这些操作可以被视为同一缓存条目的两种不同状态,这样一来,在发生缓存未命中时就可以避免重复计算。
  • 针对每个键采用单例路由机制、使用共享的内存状态,并通过序列化方式执行相关操作,这样就能让某个单一主体安全地协调那些正在处理中的请求以及已被缓存的结果。
  • 这种设计模式有助于减少缓存未命中时出现的一系列问题,通过避免使用分布式锁或轮询机制来简化系统设计,并且能够在水平扩展的情况下保持系统的正确性。
  • 这种方法适用于那些具有类似“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的模式,比如那些使用AkkaOrleans构建的系统,通过Actor的身份识别机制和消息序列化功能,也能提供类似的功能保障。在这种系统中,一个Actor可以自然地同时负责处理针对某个键的正在执行中的计算任务以及已缓存的结果。
  • 有状态的无服务器平台以及“持久性执行”模型也在逐渐兴起,不过它们的API设计和提供的保障机制各不相同。但这些技术共同体现了这样一个理念:并非所有的无服务器计算过程都必须是无状态的,而适当地使用有限的状态数据反而可以帮助解决某些协调问题。

相比之下,那些仅提供无状态功能并结合最终一致性的键值存储系统的平台,则无法干净利落地实现这种设计模式。在没有单一的权威管理机制以及共享的内存执行环境的情况下,处理请求时的去重操作不可避免地会转化为轮询或分布式锁定机制。

因此,这里所描述的模式应该被理解为一种依赖于具体运行时环境的技术。它并不是传统缓存技术的通用替代方案,而是一种只有在特定的运行时模型支持下才能发挥作用的解决方案。

最简单的实现方式

一旦确定了所需的运行时保障机制,实际的实现代码就会变得非常简洁。我们的目标并不是构建一个通用的缓存系统,而是要展示如何通过一种抽象层来同时处理请求过程中的去重操作和响应缓存功能。

下面的示例展示了一个专门用于管理某个键的缓存数据的Durable Object。所有针对该键的请求都会被路由到同一个对象实例上:

export class CacheObject {
  private inflight?: Promise〈Response〉;
  private cached?: Response;

  async fetch(request: Request): Promise〈Response〉 {
    // 如果缓存中已经存在相应的结果,就直接返回它
    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〈Response〉 {
    // 这里可以放置那些耗时较长的操作,比如数据库查询或外部API调用
    const data = await fetch("https://example.com/expensive").then(r => r.text());
    return new Response(data, { status: 200 });
  }
}

这个对象维护两种状态:

  • inflight:表示正在进行的计算。
  • cached:用于存储已完成的响应结果。

当有请求到达时,该对象会首先检查是否存在缓存的响应。如果没有,则会判断是否已有计算任务在运行中。如果有的话,调用者只需等待相同的响应结果即可;如果没有,对象就会启动相应的计算任务,并将最终的结果存储在内存中。

由于持久化对象是按顺序处理请求的,因此不需要使用显式的锁机制或原子操作。用于检查及创建计算任务的逻辑可以在同一个执行环境中确定性地运行完毕。

从调用者的角度来看,这种机制表现得就像一个普通的缓存系统。不同之处在于,即使缓存最初为空,多个并发调用者也不会导致重复的计算工作。一旦计算完成,所有等待的调用者都会得到相同的结果,后续的请求也会直接从缓存的响应中获取结果。

这个示例故意省略了持久化、过期处理以及错误处理这些内容。这些功能可以在后期再添加——例如,可以通过将已完成的响应存储在键值存储系统中来确保数据的持久性——但这并不会改变这种模式的核心思想。关键在于,处于“进行中”状态的计算任务永远不会离开内存,这样就保持了该模式的简洁性和正确性。

为什么这种方法有用

这种模式的主要优点在于它将两个相关的功能整合成了一个统一的抽象概念。它没有把“去重处理”和“响应缓存”看作是独立的问题,而是将它们视为同一个缓存条目的不同状态。

这样做有几个实际的好处:

  • 首先,它避免了在仅靠缓存无法解决问题时出现的重复计算。由于允许多个并发调用者等待同一项正在进行的计算任务,系统就可以避免在缓存未命中时出现大量冗余请求的情况——而这正是传统缓存最不奏效的场景。
  • 其次,这种设计简化了系统的结构。不需要额外的协调层、分布式锁,也不需要将“进行中”的状态信息与缓存数据分开存储。所有与请求处理、执行以及结果重用相关的逻辑都集中在一个地方,由同一个运行时实体负责管理。
  • 第三,这种模式与JavaScript应用程序的编写习惯非常契合。等待一个共享的响应结果是一种常见且被广泛理解的设计模式,而持久化对象使得这种模式可以在多进程环境中得到应用,而不会改变开发者的思维方式。调用者可以像使用本地缓存一样来使用这个系统,尽管实际的计算过程是分布式的。
  • 第四,这种模式能够水平扩展而不影响其正确性。无论流量如何增加,或者请求分布在不同的地理位置,每个请求仍然会路由到同一个负责处理该请求的实体那里。随着更多边缘节点的加入,系统的性能也不会下降——这与那些针对单进程进行的优化方案截然不同。
  • 最后,这种模式具有很强的可扩展性。可以随时添加过期策略、已完成响应的持久化存储功能、监控指标以及重试机制,而这些改动都不会影响到核心的控制流程。本质上看,这个模式依然遵循“一个请求只由一个实体处理,最终结果只会被缓存一次”这一原则。

这些特性使得这种模式非常适合那些重复性工作成本较高、请求并发性难以预测的工作负载,例如边缘API、数据聚合端点或复杂的上下游集成系统。

权衡与局限性

尽管这种模式设计精巧,但它并非适用于所有场景。其实用性在很大程度上取决于底层运行时的执行模型,同时也会带来一些需要仔细考虑的权衡因素。

  • 最显著的局限性在于对运行时的依赖性。在进行去重处理时,需要有一个拥有共享内存状态的权威节点来管理请求。如果没有针对每个键进行单例化处理,这种模式就无法顺利实现。尝试使用最终一致性的键值存储系统来复制这一机制,必然会导致轮询、分布式锁或其他协调机制的出现,从而破坏其原有的简洁性。
  • 该模式的实现本身也可能相当复杂。虽然最简单的示例代码量较少,但实际应用版本必须考虑错误处理、重试机制、超时设置、数据淘汰策略以及内存限制等问题。必须确保失败的计算操作不会使系统长时间处于“进行中”状态,同时也要保证缓存的响应能够被正确地清除。

另一个需要重点考虑的因素是适用性。在许多架构良好的系统中,重复的请求本来就很少出现。幂等的上游API、自然的请求分散机制或粗粒度的缓存策略可能会使去重操作变得没有必要。在这种情况下引入这种模式反而会增加系统的复杂性,而并不会带来实质性的好处。

还存在扩展性方面的权衡。如果将所有针对同一键的请求都路由到同一个处理节点,就会形成天然的序列化点。对于那些某个特定键的处理请求量极大的工作负载来说,这可能会成为性能瓶颈。在这种情况下,采用分片策略或其他缓存方案可能更为合适。

最后需要强调的是,这种模式并不能替代传统的缓存机制,它只是对传统缓存方案的补充。已完成处理的响应仍然需要被保存在键值存储系统中或HTTP缓存中,这样才能确保系统在进程重启或意外关闭后仍能正常访问这些数据。然而,持久化处理应该仅适用于已经完成的结果;如果将正在处理中的请求状态也保存到外部存储中,就会失去这种机制原本带来的优势。

基于以上原因,这种模式应该被视为有针对性的优化措施,而非默认的架构选择。只有当运行时环境支持它,并且相关工作负载确实需要这种机制时,将响应缓存与请求去重功能结合起来使用,才能显著减少重复性工作。而在不符合这些条件的情况下,采用更简单的设计方案通常会更加合适。

结论

本文介绍了一种在分布式JavaScript运行环境中统一响应缓存与请求去重功能的机制:通过利用针对每个键的单例化处理机制和共享内存状态,就可以将正在进行的计算过程及其最终结果视为同一个缓存条目的不同状态,从而消除重复性工作,而无需引入轮询或外部协调机制。

需要强调的是,这种模式主要只是一种设计方案,并非经过实际测试验证过的成熟方案。虽然其中所涉及的基本概念(持久化对象、承诺机制以及序列化执行模型)已经被广泛理解,但这里描述的这些组件的组合在真实生产环境中尚未得到大规模的验证。关于其运行行为、可观测性以及长期性能等方面的问题仍然存在,需要进一步研究才能得出结论。

不过,这种模式的价值在于它清晰地揭示了缓存与执行之间的关系。它表明,在分布式系统中,数据去重所带来的复杂性并非系统本身的固有特性,而是与我们通常使用的执行模型有关的。当运行时为每个键分配一个唯一的、具有权威性的处理者时,这个问题就会变得简单许多。

随着无服务器架构和边缘计算平台的不断发展,带有状态的管理模型正在变得越来越普遍。像这样的模式表明,重新审视一些长期存在的假设(比如缓存与协调机制之间的严格分离)可能会帮助我们设计出更简洁、更具表现力的系统方案。无论这种具体的方法最终会被广泛应用,还是仅仅成为一种小范围的优化手段,它都为未来的运行时框架和应用架构指明了重要的发展方向。

Comments are closed.