写点什么

如何使用 Durable Objects 处理响应和进行中的请求

作者:Gabor Koos
  • 2026-02-12
    北京
  • 本文字数:5489 字

    阅读完需:约 18 分钟

引言

缓存是工程师优化分布式系统时首先采用的工具之一。我们会缓存已完成的响应(如数据库查询结果或 HTTP 响应体),以避免重复执行昂贵的任务。然而,传统缓存未能解决一个经常被忽视的低效源头,即重复的进行中请求(duplicate in-flight request)

当多个客户端几乎同时请求同一资源时,缓存未命中会触发相同的计算会并行开始执行。在单进程的 JavaScript 应用中,通常会通过在内存中存储进行中请求的Promise来缓解该问题,使后续调用者可以等待同一个结果。在其他语言和运行时中,可通过不同的并发原语实现类似效果,但底层假设是相同的,也就是共享内存和单一执行上下文。

在分布式、无服务器或边缘环境中,这一假设就难以成立了。每个实例都有自己的内存,任何形式的进行中请求去重都仅限于单个进程的生命周期和范围之内。

工程师通常会在缓存之外引入第二种机制,比如,锁、标记(marker)或协调记录(coordination record),以跟踪正在进行的工作。这些方法难以进行推理,并且经常退化为轮询或粗粒度的同步。

本文提出了一个不同的模型,那就是将已完成的响应和进行中的请求视为同一缓存条目的两种状态。借助 Cloudflare Workers 和 Durable Objects,我们可以为每个缓存键分配一个单一、权威的所有者。该所有者可以安全地持有正在进行工作的内存表示,允许并发调用者等待它,然后在工作完成后将条目转换为已缓存的响应。

该模式并没有引入单独的协调层,而是将缓存和进行中请求去重统一在单一抽象之后。尽管它依赖于并非普遍可用的运行时特性,但在支持按键单例执行的环境中提供了一种简洁且实用的方法。

深入分析该问题

从宏观层面来看,问题不在于缓存本身,而是在于缓存条目存在之前发生了什么。

考虑一项代价高昂的操作:数据库查询、外部 API 调用或 CPU 密集型计算。在分布式边缘环境中,多个客户端可能在非常短的时间窗口内请求同一资源。如果缓存尚未包含该键的值,每个请求都会独立触发相同的工作。

因为边缘运行时会有意地进行水平扩展,这些请求通常由不同的执行上下文处理。每个上下文都观察到相同的缓存未命中,并且会继续执行,就好像它是第一个请求者一样。结果就是冗余的工作激增,而缓存本应防止这种情况发生,但由于缓存只有在第一个请求完成后才能发挥作用,因此这种现象难以避免。

作为应对措施,很多系统引入了额外的机制来跟踪进行中的工作。一个缓存用于已完成的结果,而另一个结构(有时是内存映射,有时是分布式存储)用于标记请求为“进行中(in-flight)”。这种分离迅速增加了复杂性。请求的生命周期现在必须在两个独立系统之间协调,必须仔细处理竞争条件、失败和超时。

按照进程进行内存去重部分缓解了这个问题,但是这种方法仅限于单个运行时实例的范围内。在无服务器和边缘环境中,实例的生命周期很短,并且在设计上是隔离的。两个同时请求不同节点的请求即使它们在逻辑上是相同的,也无法共享彼此的进行中状态。随着流量增长或地理分布更广泛,这种优化的效果迅速降低。

它造成的结果就是,在单体或长期存活服务中表现良好的模式,但在水平扩展最激烈的环境中却崩溃了。

缓存结果的缺失和第一次计算完成之间的这个时间差恰好是传统缓存策略无能为力的地方,也是进行中请求去重在分布式运行时中变得既必要又特别困难的地方。

为何 Durable Objects 是合适的方案?

上一节所述的困难是现代无服务器和边缘平台设计的直接结果。隔离的执行上下文、短暂的进程和水平扩展都是它们的特性,而非缺陷。因此,任何针对进行中请求去重的解决方案都必须在这些约束下工作,而不是试图绕过它们。

Cloudflare Durable Objects提供了一套小但关键的保障措施,使得这一点成为可能。

首先,Durable Object 实例是一个按键存在的单例(per-key singleton)。对于给定的对象标识符,所有请求都路由到同一个逻辑实例,无论它们来自哪里。这立即消除了所有权的模糊性:对于给定的缓存键,只有一个地方可以存放进行中的状态。

其次,Durable Objects 提供了跨请求共享的可变内存。与传统 Worker 不同,Durable Object 可以在请求之间保留内存状态。这允许它持有正在进行的工作的表述,例如一个正在进行的计算,而无需外部协调。

第三,对 Durable Object 的请求是按顺序处理的。这种串行执行模型消除了在检查或更新进行中状态时需要显式锁定的需求。检查是否已经有计算正在进行中,如果没有的话,则创建它,并且它能够附加额外的等待者,这些都可以在单一执行上下文中确定性地进行。

综合这些特性,Durable Object 能够充当进行中和已完成缓存条目的权威所有者。调用者不再需要询问“这个请求是否已经在其他地方开始了?”,而是简单地将请求转发给负责该键的对象并等待结果即可。

重要的是,这种能力不是支持最终一致性的键值存储可以模拟的。KV 系统非常适合持久化已完成的结果,但它们无法在避免使用轮询或外部信号的情况下,表示执行中的过程或允许多个调用者等待同一块内存中的操作。相比之下,Durable Objects 对进行中工作的支持成为了首要的关注点。

这并不意味着 Durable Objects 在所有情况下都适用。本文描述的模式依赖于它们的单例和内存保证,因此只适用于提供类似语义的运行时。在这些保证能够达成的环境中,Durable Objects 提供了一个干净且最小的基础,可以统一缓存和进行中请求去重,而无需引入额外的协调层。

突破 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> {    // Fast path: return cached response if it exists    if (this.cached) {      return this.cached.clone();    }    // If no computation is in-flight, start one    if (!this.inflight) {      this.inflight = this.compute().then((response) => {        // Store completed response        this.cached = response.clone();        // Clear in-flight state        this.inflight = undefined;        return response;      });    }    // Await the same in-flight computation    return (await this.inflight).clone();  }  private async compute(): Promise<Response> {    // Placeholder for an expensive operation    // e.g. database query or external API call    const data = await fetch("https://example.com/expensive").then(r => r.text());    return new Response(data, { status: 200 });  }}
复制代码

该对象维护两种状态:

  • inflight,表示正在进行的计算。

  • cached,存储可用的已完成响应。

当请求到达时,对象首先检查是否存在缓存的响应。如果没有,它会检查是否已经有计算正在进行中。如果有的话,调用者只需等待同一个 Promise。如果没有,对象就会启动计算,并将结果 Promise 存储在内存中。

由于 Durable Objects 按顺序处理请求,无需显式锁或原子操作。检查和创建进行中 Promise 的逻辑在单一执行上下文中能够确定性地执行。

从调用者的角度来看,这就像一个普通的缓存一样。不同之处在于,即使缓存最初是空的,并发调用者也不会触发重复的工作。一旦计算完成,所有等待的调用者都会收到相同的结果,后续请求直接从缓存响应中提供服务。

此示例有意省略了持久化、过期和错误处理。这些问题可以在不改变核心思想的情况下分层进行处理。比如,可选择将已完成的响应存储在键值存储中以实现持久性,但关键是,进行中的状态永远不会离开内存,从而保持了该模式的简洁性和正确性。

该方法的优势

该模式的主要优势是将两个相关的关注点合并为单一抽象。它不将进行中请求去重和响应缓存视为独立的问题,而是将其建模为同一缓存条目的不同状态。

这会带来多项实际的优势:

  • 首先,它消除了缓存无法发挥作用的情况下的重复工作。通过允许多个并发调用者等待同一个正在进行的计算,系统避免了在缓存未命中期间出现的冗余请求激增,这正是传统缓存最无能为力的场景。

  • 其次,该方法简化了系统设计。没有必要引入第二个协调层、分布式锁、与缓存数据分开存储的“进行中”标记。所有与请求合并、执行和结果重用相关的逻辑都集中在一个地方,由单一的运行时实体拥有。

  • 第三,它与 JavaScript 应用的编写方式自然对齐。等待共享 Promise 是惯用且易于理解的模式,Durable Objects 使此模型可扩展到单个进程之外,而不必改变思维模型。调用者与缓存的交互,就像在本地进行一样,尽管它的执行是分布式的。

  • 第四,该模式可水平扩展而不丧失正确性。随着流量增长或地理分布更广泛,请求仍路由到每个键的同一权威所有者。行为不会随着更多边缘节点的添加而退化,这与按进程优化的常见情况不同。

  • 最后,该模型可增量扩展。过期策略、已完成响应的持久化、指标和重试均可添加,而不改变核心控制流。基本思想就是,一个所有者、一个进行中请求的计算、一个缓存结果的思路保持不变。

这些特性使该模式适用于重复工作成本高且请求并发不可预测的工作负载,如边缘 API、聚合端点或昂贵的上游集成。

权衡与限制

尽管优雅,此模式并非普遍适用。其有效性在很大程度上取决于底层运行时的执行模型,并引入了需仔细考虑的权衡项。

  • 最显著的限制是运行时依赖。进行中请求的去重需要具有共享内存状态的单一权威所有者。如果没有按键单例执行,就无法整洁地实现该模式。如果试图使用最终一致性的键值存储来模拟它,必然会导致轮询、分布式锁或其他形式的协调,从而破坏原有的简洁性。

  • 实现本身可能是平平无奇的。虽然最小化示例很小,但生产就绪版本必须考虑错误传播、重试、超时、驱逐和内存限制。必须小心确保失败的计算不会使系统处于永久的“进行中”状态,并且缓存的响应能够正确失效。

另一个重要的考虑因素是相关性。在许多架构良好的系统中,重复的进行中请求已经很少见了。幂等的上游 API、自然地请求分散或粗粒度的缓存可能使进行中请求的去重变得无关紧要。在这些情况下,引入该模式可能会增加复杂性,而不会带来有价值的好处。

还有一个就是扩展方面的权衡。将给定键的所有请求路由到单一所有者引入了一个自然的序列化点。对于单个键非常热门的工作负载,这可能会成为瓶颈。在这些情况下,分片策略或替代缓存方法可能更合适。

最后,该模式并不替代传统的缓存策略。它是对它们的补充。已完成的响应可能仍需要持久化在键值存储或 HTTP 缓存中,以在进程驱逐或冷启动时生存下来。然而,关键在于,持久化应该只适用于已完成的结果,将进行中的状态移入外部存储会抵消该方法的好处。

因此,该模式应被视为一种针对性的优化,而非默认的架构选择。如果运行时支持它并且工作负载证明它是合理的,那么统一响应缓存和进行中请求去重可以显著减少冗余工作。当这些条件不满足时,更简单的设计通常会更明智。

结论

本文概述了一个在分布式 JavaScript 运行时中统一响应缓存和进行中请求去重的模式:通过依赖于按键单例执行和共享内存状态,可以将正在进行的计算及其最终结果视为同一缓存条目的两种状态,从而消除重复性的工作,而无需引入轮询或外部协调。

需要强调的是,这种模式主要是一种设计提案,而不是经过实战验证的方案。虽然底层原语(Durable Objects、Promise 和串行执行)是众所周知的,但这里描述的组合尚未在生产系统中得到广泛验证。关于运维行为、可观测性和长期性能特征的问题仍然存在,并且需要进一步探索。

尽管如此,该模式的价值可能在于它清晰地揭示了缓存与执行之间的关系。它表明,进行中请求去重的困难并非分布式系统所固有的,而是我们通常使用的执行模型所致。当运行时能够为每个键提供单一的权威所有者时,问题就大大简化了。

随着无服务器和边缘平台的不断发展,有状态执行模型变得越来越普遍。这样的模式表明,重新审视长期以来的假设,比如,缓存和协调之间的严格分离,可能会产生更简单、更具表现力的设计。无论这种特定方法是否证明了广泛的实用性,还是仅仅作为一种小众优化存在,它都凸显了未来运行时和应用架构的重要方向。

原文链接:

https://www.infoq.com/articles/durable-objects-handle-inflight-requests/