文章目录
-
- [一、Durable Objects 为什么需要自动重试](#一、Durable Objects 为什么需要自动重试)
- 二、自动重试最难解决的是执行状态不确定
- [三、`retryable()` 如何改变方法的重试行为](#三、
retryable()如何改变方法的重试行为) - 四、动手实现一个可测试的幂等操作模型
- 五、重试次数增加后,系统负载可能发生什么变化
- 六、从一次运行时更新看分布式系统的可靠性设计
- 参考资料
- 附录:实验范围与运行说明
在分布式系统中,一次远程调用失败,并不一定意味着被调用方没有执行操作。
假设我们正在开发一个在线购物车服务。用户点击"增加一件商品",前端向服务器发送请求,服务器再调用负责保存购物车状态的远程对象。
如果网络连接在请求执行期间突然中断,调用方可能只收到一个超时错误。
这时应该怎么办?
最直观的处理方式是重新发送请求。但如果第一次请求实际上已经完成,只是响应没有成功返回,那么第二次请求就可能让商品数量增加两次。
这种问题在分布式系统中并不少见。网络故障、服务迁移、连接中断和运行时异常,都可能让调用方无法准确判断远程操作的执行状态。
2026 年 11 月 5 日,Cloudflare 宣布为 Durable Objects 的 fetch() 和 RPC 调用增加自动重试机制。
新机制能够在部分可重试错误发生时,由运行时自动重新发起调用。开发者不再需要为所有短暂的连接故障手动编写重试循环。
但真正值得研究的地方,并不只是运行时能够自动重试,而是它如何处理一个更复杂的问题:
如果第一次请求已经执行,运行时怎样避免重试导致同一个操作执行两次?
这涉及分布式系统中的失败语义、幂等性和重试策略,也是理解此次更新的关键。
一、Durable Objects 为什么需要自动重试
Cloudflare Workers 提供了运行在边缘环境中的无服务器计算能力。开发者可以使用 JavaScript 或 TypeScript 编写请求处理逻辑,并将其部署到 Cloudflare 的运行环境中。
普通 Worker 通常适合处理无状态任务,例如验证请求、调用外部 API、转换数据或生成响应。
但有些业务需要持续维护状态。
例如,一个实时聊天室需要记录房间成员和消息顺序;一个在线游戏需要维护玩家状态;一个购物车需要保存商品数量;一个协作编辑系统需要协调多个客户端对同一文档的修改。
这些场景可以使用 Durable Objects。
Durable Objects 将计算逻辑与持久化状态关联起来。开发者可以根据对象标识访问特定实例,并通过对象提供的方法处理状态读写。
一个典型的调用关系如下:
text
Client
|
v
Cloudflare Worker
|
| RPC / fetch
v
Durable Object
|
v
Persistent Storage
在这种结构中,Worker 是调用方,Durable Object 是被调用方。
虽然二者都运行在 Cloudflare 平台中,但它们之间仍然存在远程通信过程。
当调用方与 Durable Object 之间出现短暂网络故障时,请求可能无法正常完成。
过去,开发者通常需要在应用层处理这类错误。
例如:
javascript
async function callWithRetry(operation) {
for (let attempt = 0; attempt < 3; attempt++) {
try {
return await operation();
} catch (error) {
if (attempt === 2) {
throw error;
}
}
}
}
这段代码看起来很简单,却隐藏了几个问题。
第一个问题是,它没有区分错误类型。
如果失败来自网络连接中断,重试可能有意义;如果失败来自业务逻辑,例如库存不足或者参数错误,重复执行通常没有帮助。
第二个问题是,它没有控制重试间隔。
当大量请求同时失败时,所有调用方可能立即重试,反而进一步增加系统负载。
第三个问题更重要:它没有考虑远程操作是否已经执行。
对于读取状态的方法,重复调用通常不会产生明显问题。但对于增加数量、扣减余额或创建订单等操作,重复执行可能改变最终结果。
Cloudflare 此次更新将一部分重试判断交给运行时处理。
根据 11 月 5 日的官方公告,Durable Objects 的 fetch() 和 RPC 调用在遇到符合条件的错误时,可以由运行时自动重试。
默认情况下,运行时最多重试四次,并且不会在初始调用开始超过十秒后再启动新的重试。
这里需要区分两个概念。
"最多重试四次"并不等于整个调用一定会执行五次。实际重试次数取决于错误是否符合条件、调用是否已经完成以及运行时的其他约束。
同样,十秒限制约束的是新重试的启动时间,不能简单理解为整个远程调用的总执行时间必然小于十秒。
官方还明确说明,Durable Object 方法自身抛出的错误不会因此自动重试,带有 .overloaded 标记的错误也不会自动重试。
这说明运行时并没有把所有失败都视为可以重新执行的故障。
二、自动重试最难解决的是执行状态不确定
理解此次更新,需要把远程调用的失败过程拆开。
考虑一个负责维护商品数量的方法:
javascript
async addOne(itemId) {
const quantity =
(await this.ctx.storage.get(itemId)) ?? 0;
await this.ctx.storage.put(
itemId,
quantity + 1
);
}
如果调用成功,商品数量增加一。
如果请求在发送之前就失败,重新调用通常不会导致重复修改,因为第一次请求根本没有到达 Durable Object。
但还有一种情况:
text
Worker Durable Object
| |
|------ addOne() --------->|
| |
| quantity += 1
| |
|<---- response lost -------X
|
| timeout / connection error
|
|------ retry addOne() --->|
在这个过程中,Durable Object 已经完成了第一次修改,只是响应在返回途中丢失。
调用方看到的仍然是失败。
如果简单重试,商品数量就可能从原来的 1 增加到 3,而用户实际上只要求增加一次。
这正是分布式系统中常见的执行状态不确定问题。
从调用方视角看,它只能确定自己没有收到成功响应,却不能据此断言远程操作没有执行。
因此,远程调用的失败状态至少需要区分:
- 请求确定没有到达被调用方;
- 请求已经到达,但执行结果未知;
- 请求已经执行,响应在返回过程中丢失;
- 被调用方明确返回了业务错误。
不同情况适合不同的处理方式。
Cloudflare 的新机制专门考虑了这种区别。
官方文档说明,当连接中断、运行时无法确认 Durable Object 是否收到请求时,运行时仍可能尝试重试,但 Durable Object 会拒绝已经到达过的调用所对应的重试。
因此,对于没有显式声明允许重复执行的方法,运行时不会仅仅因为连接故障就让同一个方法重复运行。
这是一项重要的安全设计。
不过,它不意味着 Durable Objects 自动提供了任意业务场景下的全局 Exactly Once 保证。
例如,如果应用程序自己发起两次独立调用,或者客户端因为超时重新提交了一次新的 HTTP 请求,平台的单次 RPC 重试保护不能自动把它们识别为同一个业务操作。
如果业务要求"同一订单只能创建一次",仍然需要使用业务层面的请求标识和去重机制。
平台重试保护与业务幂等性解决的是不同层次的问题。
三、retryable() 如何改变方法的重试行为
Cloudflare 此次还提供了 retryable(),允许开发者明确声明某个 RPC 方法能够安全地重复执行。
官方给出的典型例子是设置商品数量。
假设购物车中某个商品的数量应该设置为 3。
执行一次:
javascript
setQuantity("item-1", 3);
最终数量是 3。
再执行一次相同操作:
javascript
setQuantity("item-1", 3);
最终数量仍然是 3。
这种操作通常具有幂等性。
从数学角度看,若操作记为函数 f,满足:
text
f(f(x)) = f(x)
那么它就是幂等操作。
与之相反,增加数量的操作通常不满足这一条件。
例如:
text
addOne(1) = 2
addOne(addOne(1)) = 3
重复执行改变了最终状态。
这也是为什么不能把所有写入操作都统一标记为可重试。
按照 Cloudflare 官方文档,可以这样声明一个允许重复执行的方法:
javascript
import {
DurableObject
} from "cloudflare:workers";
import {
retryable
} from "cloudflare:durable-objects";
export class Cart extends DurableObject {
static {
retryable(this.prototype.setQuantity);
}
async setQuantity(itemId, quantity) {
await this.ctx.storage.put(
itemId,
quantity
);
}
async addOne(itemId) {
const quantity =
(await this.ctx.storage.get(itemId)) ?? 0;
await this.ctx.storage.put(
itemId,
quantity + 1
);
}
}
这里有一个非常明确的区别。
setQuantity() 被标记为允许重试,因为相同参数的重复执行不会继续增加数量。
addOne() 没有被标记,因为它会在每次执行时改变状态。
这段代码采用官方公告中的 API 形式。使用 retryable() 需要 Wrangler 4.158.0 或更新版本。
需要强调,幂等性应当根据操作的完整业务效果判断,而不能只看函数名称。
例如:
javascript
async setQuantity(itemId, quantity) {
await this.ctx.storage.put(
itemId,
quantity
);
await sendNotification(
"quantity changed"
);
}
表面上,商品数量设置操作仍然是幂等的。
但如果 sendNotification() 每次都会发送一条新消息,那么重复调用就可能产生多条通知。
此时整个方法并不一定具有预期的幂等效果。
类似问题还可能发生在邮件发送、第三方支付、外部 API 调用和事件发布过程中。
对于包含外部副作用的方法,开发者应当检查所有相关操作,而不能仅依据 Durable Object 本地存储的最终值判断是否可以重试。
四、动手实现一个可测试的幂等操作模型
为了更直观地观察重复执行的影响,我们可以使用 Node.js 编写一个独立实验。
这个实验不需要 Cloudflare 账号,也不需要部署 Workers。
它的目的不是复现 Cloudflare 内部的重试算法,而是验证两种不同操作在重复执行时会产生什么结果。
准备 Node.js 20 或更新版本。
创建目录:
bash
mkdir durable-retry-demo
cd durable-retry-demo
新建 demo.mjs:
javascript
class Cart {
constructor() {
this.items = new Map();
}
getQuantity(itemId) {
return this.items.get(itemId) ?? 0;
}
setQuantity(itemId, quantity) {
this.items.set(itemId, quantity);
return this.getQuantity(itemId);
}
addOne(itemId) {
const current = this.getQuantity(itemId);
this.items.set(
itemId,
current + 1
);
return this.getQuantity(itemId);
}
}
const cart = new Cart();
console.log("Initial:", cart.getQuantity("A"));
cart.setQuantity("A", 3);
cart.setQuantity("A", 3);
console.log(
"After repeated set:",
cart.getQuantity("A")
);
cart.addOne("A");
cart.addOne("A");
console.log(
"After repeated add:",
cart.getQuantity("A")
);
执行:
bash
node demo.mjs
按照程序逻辑,预期输出为:
text
Initial: 0
After repeated set: 3
After repeated add: 5
这个结果能够说明两种操作的区别。
重复设置数量时,最终状态保持不变。
重复增加数量时,每次执行都会修改状态。
但这还不足以解决真实业务中的重复请求问题。
假设一个客户端向服务器发送了创建订单请求。由于网络超时,客户端无法确定订单是否创建成功,于是重新发送请求。
如果服务端仅仅根据订单内容创建新记录,那么两次请求可能产生两个订单。
解决这类问题的一种常见方式是使用 Idempotency Key,也就是幂等键。
客户端为同一个业务操作生成唯一标识,并在重试时继续使用相同标识。
服务端记录已经处理过的标识。如果再次收到相同请求,就返回之前的处理结果,而不是重新执行操作。
下面使用 Node.js 实现一个简化版本。
创建 idempotency.mjs:
javascript
class OrderService {
constructor() {
this.orders = new Map();
this.completed = new Map();
}
createOrder(requestId, payload) {
if (this.completed.has(requestId)) {
return this.completed.get(requestId);
}
const orderId =
`order-${this.orders.size + 1}`;
const order = {
orderId,
itemId: payload.itemId,
quantity: payload.quantity
};
this.orders.set(orderId, order);
this.completed.set(
requestId,
order
);
return order;
}
getOrderCount() {
return this.orders.size;
}
}
const service = new OrderService();
const first = service.createOrder(
"request-001",
{
itemId: "A",
quantity: 2
}
);
const second = service.createOrder(
"request-001",
{
itemId: "A",
quantity: 2
}
);
console.log(first);
console.log(second);
console.log(
"Order count:",
service.getOrderCount()
);
执行:
bash
node idempotency.mjs
预期结果是两次调用返回相同的订单对象,订单总数为 1。
这个示例中的 request-001 就是幂等键。
需要注意,这只是单进程内存模型,不能直接用于生产系统。
真实服务通常需要把幂等键和处理结果保存在持久化存储中,并保证"检查幂等键"和"创建业务记录"之间不会发生并发竞争。
例如,如果两个请求同时检查同一个幂等键,发现它尚不存在,然后分别创建订单,那么即使使用了幂等键,也仍然可能产生重复记录。
因此,实际实现需要结合事务、唯一约束或其他原子操作。
另外,幂等键还应当绑定请求参数。
如果客户端使用同一个幂等键提交两组不同的订单内容,服务端不应简单返回旧结果而忽略参数冲突。
这些细节与 Durable Objects 的自动重试属于互补关系。
运行时负责处理特定远程调用故障;业务系统负责定义什么才算同一个业务操作。
五、重试次数增加后,系统负载可能发生什么变化
自动重试能够改善短暂故障下的请求成功率,但重试本身也会消耗资源。
假设某个系统的应用层原本最多尝试三次远程调用。
现在,底层运行时对每次调用又允许最多四次重试。
在极端情况下,一次应用层尝试可能对应初始调用加四次运行时重试,也就是最多五次底层尝试。
如果应用层继续尝试三轮,理论上的尝试次数上界就可能达到:
text
3 × (1 + 4) = 15
这只是用于说明重试嵌套的理论上界,并不意味着 Cloudflare 在真实故障中一定会发起十五次请求。
实际次数还受到错误类型、运行时判断、时间窗口以及调用状态等条件限制。
但它揭示了一个重要问题:不同层次的重试机制可能相互叠加。
如果服务已经因为负载过高而出现故障,大量请求同时重试,可能进一步增加压力。
这种现象通常被称为重试风暴。
例如,某个服务每秒正常处理 1000 次请求。当部分请求失败后,如果所有客户端立即重试,服务收到的请求量可能明显增加。
如果这些重试再次失败并继续触发新的重试,负载就可能进一步上升。
因此,设计重试策略时不能只考虑单个请求是否最终成功,还需要考虑整个系统的承载能力。
常见的工程方法包括限制最大尝试次数、设置超时预算、使用指数退避以及加入随机抖动。
指数退避意味着随着失败次数增加,重试间隔逐步延长。
例如:
text
Attempt 1: immediate
Attempt 2: wait 100 ms
Attempt 3: wait 200 ms
Attempt 4: wait 400 ms
随机抖动则用于避免大量客户端在完全相同的时间重新发起请求。
Cloudflare 此次更新已经在运行时提供了相应的重试控制能力。
官方允许开发者在获取 Durable Object stub 时设置 retry 参数:
javascript
const stub = env.CART.getByName(
"user-123",
{
retry: {
maxRetries: 1,
timeoutMs: 2000
}
}
);
其中,maxRetries 可以设置为 0 到 10。
timeoutMs 可以设置为 500 到 60000 毫秒。
如果不希望运行时自动重试,可以将 maxRetries 设置为 0。
对于已经实现应用层重试的项目,这项配置尤其重要。
开发者需要决定究竟由哪一层负责处理特定错误,而不是让多个层次独立执行相同的重试策略。
例如,如果应用层需要根据业务状态决定是否重试,可以考虑关闭对应 stub 的运行时重试,并在应用层集中管理尝试次数和超时预算。
如果业务只需要处理短暂的连接故障,则可以优先利用运行时提供的机制,减少重复实现。
这两种方式没有绝对优劣,关键是明确责任边界。
对于购物车、实时协作和有状态 Agent 等系统,还需要结合具体操作判断是否允许重复执行。
一个设置状态的方法可能适合重试,而一个触发外部支付的方法即使通过 Durable Object 发起,也不应该在缺少业务幂等保障时随意重复执行。
六、从一次运行时更新看分布式系统的可靠性设计
Cloudflare Durable Objects 的自动重试更新,看起来只是减少了一部分开发者需要编写的错误处理代码。
但它实际上涉及远程调用语义中的一个核心问题:当网络无法可靠传递执行结果时,系统如何判断是否应该重新执行操作。
如果请求确定没有到达,被调用方尚未执行操作,重试通常比较安全。
如果请求已经到达,但响应丢失,重新执行就可能产生重复副作用。
如果业务方法本身具有幂等性,重复执行的风险会降低。
如果业务操作不具备幂等性,则需要额外的去重机制,或者避免在执行状态不确定时直接重新执行。
此次更新通过运行时重试、重复执行保护、retryable() 声明以及可配置的重试参数,为开发者提供了更明确的控制方式。
它能够减少部分短暂网络故障对应用的影响,但并不能代替业务系统自身的正确性设计。
尤其是在订单、支付、库存和外部事件发布等场景中,开发者仍然需要认真定义一次业务操作的身份、状态和完成条件。
对于已经使用 Durable Objects 的项目,比较值得开展的工作是检查现有的应用层重试逻辑,确认它们是否与新的运行时机制叠加,并重新审视哪些方法真正具有幂等性。
如果准备开发新的有状态应用,也可以从一开始就将状态设置、状态增量修改和外部副作用分开设计。
这样不仅能够更安全地利用运行时重试,也有利于在未来引入消息队列、任务调度和跨服务调用时保持清楚的执行语义。
分布式系统无法消除所有网络故障,但可以通过明确的失败语义和业务约束,让故障发生后的行为更加可预测。
Cloudflare 的这次更新,正是沿着这一方向提供了一项具体的运行时能力。
参考资料
1 Cloudflare Developers. Automatic retries for Durable Object calls. 2026-11-05.
https://developers.cloudflare.com/changelog/post/2026-11-05-durable-objects-automatic-retries/
2 Cloudflare Developers. Durable Objects Documentation.
https://developers.cloudflare.com/durable-objects/
3 Cloudflare Developers. Durable Objects RPC.
4 Node.js Documentation. ECMAScript Modules.
https://nodejs.org/api/esm.html
5 Cloudflare Developers. Workers Documentation.
https://developers.cloudflare.com/workers/
附录:实验范围与运行说明
本文提供的两个 Node.js 示例是独立教学程序,用于说明幂等操作与业务请求去重。
建议运行环境为 Node.js 20 或更新版本。
示例不依赖第三方 npm 包,也不需要 Cloudflare API Key。
本文给出了根据代码逻辑推导的预期输出,但没有在本次撰写环境中实际执行完整示例,因此不将预期输出描述为已验证的实验结果。
示例不包含真实网络故障注入、Durable Object 部署、持久化事务和多进程并发测试。
其中的内存去重实现仅用于理解基本原理,不适合直接用于生产订单系统。
Cloudflare 平台 API 的使用方式以 2026 年 11 月 5 日官方更新文档为依据。实际部署时,应使用 Wrangler 4.158.0 或更新版本,并根据当前官方文档检查配置兼容性。