CloudflareDurable ObjectsWebSocketServerlessMultiplayerIndie Dev

房間代碼就是伺服器位址:我做了一個即時多人遊戲,沒寫配對服務也沒開資料庫

·閱讀約 13 分鐘

我想在自己的網站上放一個可以連線玩的阿瓦隆。

五個人各自用手機開網頁、輸入房間代碼、看到自己的身分牌,然後互相投票說謊。規則不難寫,難的是它需要的東西。

我列了一張清單,然後有點想放棄:

需求傳統做法
房間代碼 → 找到那一局配對服務 + 房間表
遊戲狀態要活著Redis 或資料庫
即時推播給五個人常駐的 WebSocket 伺服器
同一局的操作不能互相打亂鎖,或單執行緒的房間 worker
沒人玩的房間要消失cron 或 TTL 清理 job
一個即時多人房間,傳統架構需要什麼

五個元件,為了一個朋友之間玩的桌遊。

後來我用 Cloudflare Durable Objects 寫完了,這張表塌成了兩行程式碼。

房間代碼就是伺服器位址

Durable Object 最反直覺的地方是它的定址方式。你不是「開一台伺服器然後查它在哪」,你是拿一個字串去換一個實例:

javascript
const id = env.AVALON_ROOMS.idFromName(code);   // code 是 'K7M2P' 這種房間代碼
const stub = env.AVALON_ROOMS.get(id);
return stub.fetch(request);

同一個字串永遠對到同一個實例,全球唯一,而且是決定性的——不需要任何地方記錄「K7M2P 這個房間在哪台機器上」,因為代碼本身就是位址。

配對服務就這樣消失了。

而且因為每個房間是一個獨立的單執行緒實例,同一局裡兩個人同時投票不會互相打亂——並發控制也消失了,不是我解決了它,是這個模型裡不存在這個問題。

我後來又用同一招做了打字測驗的每日排行榜,那次的字串是日期:

DOidFromName 用什麼一個實例代表
AvalonRoom房間代碼('K7M2P')一局遊戲
TypingBoard日期('2026-07-27')那一天的排行榜
同一個 worker 裡的兩種 Durable Object

排行榜不需要「取得今天的榜」這種查詢,因為今天的日期就是今天那個榜的位址。過期的榜也不需要刪,它自己會消失(後面會講)。

冷啟動:我原本以為會很痛

按需建立實例聽起來很美,但如果每次進房間都要等一秒,遊戲就不能玩。所以我量了。

方法是在同一條 TLS 連線裡連續打三次:先打一個從來沒有存在過的日期(全新實例)、再打一個已經活著的、最後再打一次那個新的。

請求TLSTTFB總計
全新的 DO(從未存在)25 ms710 ms711 ms
已存在的 DO(連線重用)54 ms54 ms
同一個新 DO,第二次54 ms54 ms
同一條連線內連續三次請求(台灣連線實測)

第一次 711 毫秒。我一開始猜那是 DNS 加 TLS,結果不是——TLS 只花了 25 毫秒,剩下的 685 毫秒是伺服器那一端。那就是冷啟動的真實代價。

但第三行才是重點:同一個實例第二次請求就掉到 54 毫秒,跟一個已經活著的實例完全一樣。冷啟動是一次性的,不是每次請求的稅。

對遊戲來說這剛好可以接受:第一個人開房間會等大約 0.7 秒,那時候他正在看「建立房間」的畫面,等待有解釋。之後所有人的每一次操作都是幾十毫秒。

要說明的是,這個 711 毫秒裡同時包含了 worker 本身的冷啟動,從外面量不出來哪個佔多少。所以它是上界,不是 DO 單獨的成本。

WebSocket 的陷阱:你的 DO 會在連線還開著的時候被關掉

這是我踩得最深的一個觀念。

Cloudflare 有一個叫 Hibernation 的機制:當一個 DO 沒有在處理事情、只是掛著幾條 WebSocket 在等訊息時,它會被從記憶體裡逐出,但連線保持開著。有訊息進來時再叫醒它。

這對帳單很好——閒置的房間不算錢。但它有一個直接的後果:任何你放在 JavaScript 變數裡的東西,都會在你不知情的時候消失。

所以「這條連線是哪個玩家」不能寫成 this.sockets.set(socket, playerId),必須交給 runtime 保管:

javascript
const pair = new WebSocketPair();
const [client, server] = Object.values(pair);

server.serializeAttachment({ playerId });   // 存進 runtime,撐過 hibernation
this.ctx.acceptWebSocket(server);           // 不是 server.accept()

return new Response(null, { status: 101, webSocket: client });

之後要知道某條連線屬於誰,是問 runtime 而不是問記憶體:

javascript
for (const socket of this.ctx.getWebSockets()) {
  const { playerId } = socket.deserializeAttachment() || {};
  ...
}

訊息處理也換了寫法。一般的 WebSocket 是 socket.addEventListener('message', ...),但那個 listener 活不過 hibernation。Hibernation 版本是把處理器寫成 class 的方法,讓 runtime 在叫醒你的時候呼叫:

javascript
async webSocketMessage(socket, message) { ... }
async webSocketClose(socket, code, reason) { ... }
async webSocketError() { ... }

這個差別在本機測試時幾乎看不出來——本機不會 hibernate。它會在線上、在房間安靜幾分鐘之後才出現,而且症狀是「某些玩家的畫面不再更新」,很難聯想到原因。

同一份狀態,五個玩家看到五種真相

阿瓦隆的核心是資訊不對稱:壞人知道同伴是誰,梅林知道壞人是誰,其他人什麼都不知道。

這件事不能交給前端。如果伺服器把完整狀態送給五個人、讓前端各自遮住不該看的部分,那打開 DevTools 就贏了。

所以廣播不是送一份,是每條連線各算一份:

javascript
for (const socket of this.ctx.getWebSockets()) {
  const { playerId } = socket.deserializeAttachment() || {};
  socket.send(JSON.stringify({
    type: 'state',
    state: sanitizeState(state, playerId, connectedIds),   // 每個人不一樣
  }));
}

sanitizeState 是唯一一個決定「誰能看到什麼」的地方,而它跑在伺服器上。前端拿到的資料裡根本沒有它不該知道的欄位——不是藏起來,是不存在。

這也是為什麼我沒有把它做成一份 payload 廣播出去。多花的那點 CPU 換掉一整類作弊。

沒有 cron 的過期:setAlarm

房間要會消失,否則就是無限累積的垃圾。

傳統做法是一個定時 job 去掃「哪些房間該死了」。DO 的做法是每個實例自己帶一個鬧鐘:

javascript
async persist(state) {
  state.expiresAt = Date.now() + 12 * 60 * 60 * 1000;   // 每次操作往後推 12 小時
  await this.ctx.storage.put('state', state);
  await this.ctx.storage.setAlarm(state.expiresAt);
}

async alarm() {                                          // 時間到,runtime 呼叫這裡
  for (const socket of this.ctx.getWebSockets()) socket.close(1000, 'room_expired');
  await this.ctx.storage.deleteAll();
}

每次有人操作就把鬧鐘往後推,所以「還在玩的房間」永遠不會過期,「沒人管的房間」十二小時後自己刪掉自己並踢掉殘留的連線。

排行榜用的是同一招,但只在第一次寫入時設一次三天:

javascript
if (existing.size === 0) {
  await this.ctx.storage.setAlarm(Date.now() + 3 * 24 * 60 * 60 * 1000);
}

所以我不需要維護「哪些日期的榜還要留著」。每天的榜自己知道什麼時候該退場。整個系統沒有一行清理排程。

那個潛伏半個月的 bug:少一個 await

排行榜上線那天,我對 API 打了一輪 smoke test。合法的請求都對,但送一個超出範圍的成績(每分鐘 9999 字)應該回 invalid_score,實際回的是 internal_error。

我的第一個反應是驗證函式壞了。但看 wrangler tail 的時候,答案完全不在我猜的方向:

text
"outcome": "exception",
"entrypoint": "TypingBoard",
"exceptions": [{
  "stack": "GameError: invalid_score\n    at validateScore (index.js:323:61)\n    at TypingBoard.score (index.js:376:24)",
  "message": "invalid_score"
}]

錯誤碼是對的。GameError 正確地被丟出來了。問題是它沒有被我的 try/catch 接到,而是直接變成整個 DO 的未處理例外。

原因在路由那一行:

javascript
async fetch(request) {
  try {
    if (url.pathname === '/score') return this.score(request);       // ❌
    if (url.pathname === '/score') return await this.score(request); // ✅
  } catch (error) {
    if (error instanceof GameError) return json({ error: error.code }, error.status);
  }
}

return this.score(request) 回傳的是一個 Promise。函式在那一行就結束了,try 區塊也跟著結束——之後 Promise 才 reject,而那時候已經沒有人在接了。加上 await,函式會停在 try 裡面等它,rejection 就變成同一個 try 抓得到的例外。

這是很基本的 JavaScript,我知道這件事。但在 DO 裡它有兩個特別惡毒的地方。

第一,錯誤不會消失,它會變形。呼叫端拿到的不是「沒接到」,是一個 DO 層級的例外,最後被外層 worker 翻成 internal_error。所以症狀是「錯誤處理沒生效」,而不是「有個 Promise 沒被 await」。

第二,它會傳染。我寫排行榜的時候是照著自己半個月前寫的 Avalon 路由抄的——而那份就有這個 bug。也就是說,Avalon 從上線第一天起,room_not_found、host_only、wrong_phase 這些錯誤碼從來沒有一次正確回到前端過。

沒有人回報過,因為前端對這些錯誤的處理是「顯示一個通用訊息」。它安靜地錯了半個月。

兩邊補上 await 之後再測,錯誤碼就正常了:

請求修正前修正後
成績 9999 字/分internal_errorinvalid_score
空白暱稱internal_errorinvalid_name
加入不存在的房間internal_errorroom_not_found
補上 await 前後,同樣的請求

如果你要在 DO 裡包一層錯誤處理,這件事值得當成規則記下來:try 裡面的路由分派一律 return await,不要 return Promise。

房間代碼撞號:讓 DO 自己回答

還有一個小地方我覺得漂亮。

房間代碼是隨機五碼,用的字母表刻意去掉了 I、O、0、1 這些手抄會看錯的字元,剩 32 個字元,也就是 33,554,432 種組合。

會撞號嗎?會。但我不需要一張表去查「這個代碼用過沒有」——因為 idFromName 是決定性的,那個代碼對應的 DO 就是唯一的權威。所以我直接問它:

javascript
async create(request) {
  const previous = await this.ctx.storage.get('state');
  if (previous && previous.expiresAt > Date.now()) return json({ error: 'room_exists' }, 409);
  ...
}

外層 worker 收到 409 就換一個代碼再試,最多八次。撞號處理是八行程式碼,沒有任何額外的儲存。

而且注意 expiresAt 那個條件:過期的房間會被視為不存在,所以代碼可以被回收。這是免費得來的——那個判斷本來就是為了「房間過期了」而寫的。

整個後端有多大

兩個 Durable Object、一個 WebSocket 廣播層、一份阿瓦隆規則引擎、一個排行榜,加上 CORS 和權杖驗證:

項目大小
上傳總量26.96 KiB
gzip 後6.52 KiB
wrangler deploy 的實際輸出

沒有 Redis、沒有資料庫、沒有常駐伺服器、沒有排程。閒置時所有房間都在 hibernation,不佔記憶體。

什麼時候不該這樣做

這個模型很適合「天然可以切成獨立小單位」的東西:一局遊戲、一個房間、一天的排行榜、一份共編文件。切得開,就享受得到單執行緒和自動定址。

但它有幾個真實的限制:

一、跨實例查詢很難。我可以馬上拿到「今天的排行榜」,但「這個月誰最快」需要跨三十個實例,DO 沒有給你 JOIN。要做這種查詢就要另外寫一份彙總,或者根本不該用 DO 存。

二、單一實例是單執行緒。這是好處也是天花板——一個爆紅的房間不能靠加機器解決,因為它就是一個實例。

三、第一次請求會付那 0.7 秒。使用者按下按鈕之後等待可以接受;如果是頁面載入的關鍵路徑上,就要想清楚。

四、綁 Cloudflare。idFromName、hibernation、setAlarm 這三個沒有標準對應物,搬家等於重寫。

如果你要開始

照這個順序,每一步都會踩到不同的東西:

一、先想清楚你的 idFromName 要放什麼。這是整個設計裡最重要的決定,它決定了你的資料怎麼切、能不能被查詢。選錯了之後很難改,因為位址就是那個字串。

二、WebSocket 一開始就用 hibernation 版本的 API。ctx.acceptWebSocket 加上 serializeAttachment,不要用 socket.accept 和記憶體變數。事後改要動所有連線相關的程式碼。

三、過期用 setAlarm,不要寫 cron。每次操作往後推,比對著時間表清理簡單得多。

四、DO 的 fetch 路由一律 return await。這是本文最便宜的一課,也是我最貴的一個 bug。

五、上線後真的去看 wrangler tail。那個 invalid_score 的 bug 我從回應內容永遠猜不出來,是 log 裡的 stack trace 直接指到那一行的。

文中的遊戲:線上阿瓦隆同一個 worker 的排行榜:中文打字每日挑戰

參考資料