昨天我們替瀏覽器裡的資料規劃了三種儲存機制的生命週期。但不管資料存在哪裡,前端要拿到後端的資料,中間永遠有一段「等待」的時間——這段等待處理不好,會產生比資料遺失更棘手的問題:兩個人同時搶最後一件庫存,結果系統兩邊都賣出去了。這就是今天要談的非同步處理與競態條件。
定義:代表一個非同步操作的最終完成或失敗及其結果值。
狀態:Pending(進行中)、Fulfilled(已成功)、Rejected(已失敗)。
作用:解決了傳統回調函數(Callback)導致的「回調地獄」問題,讓流程控制更具可讀性。
定義:建立在 Promise 之上的語法糖,讓非同步程式碼看起來更像同步程式碼。
定義:當多個程序或請求同時讀取與修改同一塊共享資料,且最終結果取決於執行的精確順序時,就會發生此問題。在 Node.js 環境中,雖然是單執行緒,但非同步 I/O 的操作順序若未處理好,同樣會引發嚴重的資料一致性問題——因為 await 讓出執行權的那一刻,另一個完全獨立的請求可以插進來執行。
想像一個熱門商品只剩下最後 1 個庫存。
錯誤的情境:使用者 A 與使用者 B 幾乎同時點擊「購買」,系統啟動了兩個獨立的非同步請求,各自執行:
// 危險寫法:檢查跟寫入是兩個分開的步驟
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 順序。這篇沒有特別卡住的地方,反而是想通了「如果沒有這個機制會怎樣」——如果沒有把檢查跟扣庫存綁成同一個原子操作,兩個人搶最後一件商品時,系統會兩邊都以為自己搶到了,資料就這樣被搞錯、賣超。理解「少了它會出什麼包」,比單純背「什麼是原子操作」更容易讓我記住為什麼這件事重要。