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的吞吐估算来自官方文档,输出闸门我没有在本地制造写盘失败来观察。数字与行为以官方文档为准,会变化。