iT邦幫忙

2026 iThome 鐵人賽

DAY 11
0
Software Development

文科生的軟體工程啟蒙:用一個代購 App,看懂 30 個系統設計觀念系列 第 11 篇

Day 11:非同步處理與競態條件(Race Conditions)——Promise、Async/Await 與資料等待機制

  • 分享至 

  • xImage
  •  

昨天我們替瀏覽器裡的資料規劃了三種儲存機制的生命週期。但不管資料存在哪裡,前端要拿到後端的資料,中間永遠有一段「等待」的時間——這段等待處理不好,會產生比資料遺失更棘手的問題:兩個人同時搶最後一件庫存,結果系統兩邊都賣出去了。這就是今天要談的非同步處理與競態條件。

一、非同步基礎概念

Promise

定義:代表一個非同步操作的最終完成或失敗及其結果值。

狀態:Pending(進行中)、Fulfilled(已成功)、Rejected(已失敗)。

作用:解決了傳統回調函數(Callback)導致的「回調地獄」問題,讓流程控制更具可讀性。

Async / Await

定義:建立在 Promise 之上的語法糖,讓非同步程式碼看起來更像同步程式碼。

  • Async:宣告函式為非同步,回傳值自動封裝為 Promise。
  • Await:暫停函式執行,等待 Promise 解析完成後再繼續執行「這個函式裡」接下來的程式碼。

競態條件(Race Conditions)

定義:當多個程序或請求同時讀取與修改同一塊共享資料,且最終結果取決於執行的精確順序時,就會發生此問題。在 Node.js 環境中,雖然是單執行緒,但非同步 I/O 的操作順序若未處理好,同樣會引發嚴重的資料一致性問題——因為 await 讓出執行權的那一刻,另一個完全獨立的請求可以插進來執行。

二、案例:代購 App 的庫存更新

想像一個熱門商品只剩下最後 1 個庫存。

錯誤的情境:使用者 A 與使用者 B 幾乎同時點擊「購買」,系統啟動了兩個獨立的非同步請求,各自執行:

  1. 讀取庫存(發現庫存為 1)
  2. 計算(1 − 1 = 0)
  3. 寫入資料庫
// 危險寫法:檢查跟寫入是兩個分開的步驟
async function buyItem(itemId) {
  const item = await db.findItem(itemId);      // 讀取
  if (item.stock > 0) {
    await db.updateStock(itemId, item.stock - 1); // 寫入
    return { success: true };
  }
  return { success: false };
}

A 呼叫 buyItem 的 await db.findItem 執行到一半讓出執行權時,B 的請求也進來讀了一次庫存——兩邊讀到的都是 1,兩邊都通過檢查,最後兩次寫入都把庫存設成 0,但實際上賣出了 2 個商品。這裡的 await 沒有錯,錯的是把「檢查」跟「寫入」拆成了兩個獨立步驟,中間留了一個誰都能插進來的縫隙。

容易誤解的地方:await 只能控制「同一個函式內」接下來程式碼的執行順序,它沒有辦法讓 A 的請求去等 B 的請求——A 跟 B 是兩個完全獨立的呼叫,各自的 await 之間本來就會互相交錯執行。單靠 async/await,擋不住這個問題。

真正的解決方案:把「檢查」跟「扣除」寫成資料庫能夠一次執行完的單一操作,讓資料庫自己保證這句話中間不會被插隊:

// 安全寫法:檢查條件直接寫進 UPDATE 的 WHERE 裡,一句話做完
async function buyItem(itemId) {
  const result = await db.query(
    'UPDATE items SET stock = stock - 1 WHERE id = ? AND stock > 0',
    [itemId]
  );
  // 看這句話實際改到了幾筆:改到 1 筆代表搶到了,0 筆代表被搶走了
  return { success: result.affectedRows === 1 };
}

這樣不管 A、B 誰先誰後,資料庫都會讓其中一個的 UPDATE 先完整執行完,另一個再執行時 stock > 0 這個條件已經不成立,自然影響 0 筆,不會有兩邊都賣出同一件商品的狀況。

三、結論

非同步處理是現代 Web 開發的基石,而競態條件則是隱藏在其中的地雷。這篇最重要的收穫是釐清一個常見的誤會:await 保證的是「同一段程式碼裡,這行做完才做下一行」,不是「別人的請求會等我」。真正能擋住競態條件的,是把檢查跟修改綁進資料庫層級的單一原子操作,而不是前端或後端程式碼裡的 await 順序。這篇沒有特別卡住的地方,反而是想通了「如果沒有這個機制會怎樣」——如果沒有把檢查跟扣庫存綁成同一個原子操作,兩個人搶最後一件商品時,系統會兩邊都以為自己搶到了,資料就這樣被搞錯、賣超。理解「少了它會出什麼包」,比單純背「什麼是原子操作」更容易讓我記住為什麼這件事重要。


上一篇
Day 10:快取與持久化策略——LocalStorage、Session 與記憶體快取的生命週期管理
下一篇
Day 12:資料驗證與防禦性設計——前端資料過濾、型別邊界(Type Boundaries)與防呆
系列文
文科生的軟體工程啟蒙:用一個代購 App,看懂 30 個系統設計觀念 共 13 篇
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言