Durable Objects 是单线程的,为什么还会丢更新?

我在本地把“先读 storage.get,等待 5ms,再 storage.put(v+1)”并发调用 10 次,返回值全是 1,最终存储值也是 1,十次更新丢了九次。

答案是:单线程只保证一段同步代码执行期间不会被打断,不保证整个方法不被穿插。只要在读和写之间放了非存储的 await,其他请求就能插进来,更新就会丢。

这并不是说 DO 的并发保护是假的。它有两道闸门,在存储操作上很有效,只是在非存储的等待上不管用。下面把边界一条条划出来,模型本身见 Durable Objects 为什么能让一千个请求排队改同一份状态?。

输入闸门和输出闸门分别保护什么?

  • 输入闸门:在同步执行和存储操作期间,暂不投递新事件,所以“读存储、改、写存储”这类只依赖存储的流程通常是安全的。
  • 输出闸门:回复、出站消息会被扣住,直到之前的写入落盘确认,客户端不会收到“已保存”却实际丢失的确认。同时,连续不夹 await 的写入会被合并为一次原子提交。

但输入闸门只保护存储 I/O,不保护 fetch()、R2、setTimeout 这类非存储等待。官方文档明确说,这些点上其他请求可以插进来。

到底什么写法会丢更新,什么写法不会?

我在本地跑了三组对照:

  • 读改写之间夹了 5ms 的等待:并发 10 次,最终值是 1,更新全部丢失;内存字段同样的写法结果也是 1。
  • 读改写只用异步的 storage.get 加 storage.put,中间没有任何非存储的 await:并发 30 次,最终值精确是 30,这正是输入闸门在起作用。
  • 全部用同步的 sql.exec 完成:并发 50 次得到精确的 50。

所以丢更新的条件不是“用了 async API”,而是“在读和写之间放了非存储的等待”。输出闸门在本地难以观察,需要制造写盘失败。

读改写应该怎么写才安全?

  • 尽量写成中间没有 await 的同步段,SQL 一条语句或 transactionSync 最理想。
  • 必须跨 await 时,用乐观锁:写之前检查版本号,不对就重试。
  • 别把内存变量当作可跨 await 安全维护的真值。

transactionSync 和 blockConcurrencyWhile 怎么用?

transactionSync(() => {...}) 里抛错会回滚,我在本地跑过,抛错后值保持原样。它的回调必须是同步的。BEGIN/SAVEPOINT 不能直接用 exec 执行,本地会抛出要求改用 transaction()/transactionSync() 的错误。

blockConcurrencyWhile 会暂停所有并发,官方建议只用于构造函数里的初始化和迁移,不要包住外部 I/O,否则单实例吞吐会被拖垮。文档按每次约 5ms 估算,约 200 req/s。

丢更新的三组对照、transactionSync 回滚和 BEGIN/SAVEPOINT 报错都在本地 wrangler dev 上跑过。输入闸门不覆盖非存储等待、输出闸门的行为,以及 blockConcurrencyWhile 的吞吐估算来自官方文档,输出闸门我没有在本地制造写盘失败来观察。数字与行为以官方文档为准,会变化。