不要「處理」鎖,去解決你的設計問題
很多人看到競爭條件(Race Condition),反射性地就想用鎖(Lock) 去「解決」。
這就像是頭痛醫頭、腳痛醫腳。
一個好的工程師看到競爭條件,會先問:「為什麼我會有一個可變的狀態,需要被多個執行緒共享?」
好設計先消除競爭的根源,不需要修補競爭的後果。
程式碼的品質,看的不是演算法,而是資料結構。
並行問題的根源,幾乎永遠都是糟糕的資料結構設計。
// 🔴 臭味本體:這行程式碼本身就是問題所在
let balance = 100;
async function withdraw(amount) {
// ...一堆混亂的讀取-修改-寫入...
}
問題不在 withdraw
函式,而在於 let balance = 100;
。
創造了一個誰都可以隨時讀寫的全域變數,然後才驚訝地發現人們會同時去寫它。
這是「自找麻煩」。
解決並行問題的唯一乾淨方法,是確保在任何一個時間點,只有一個實體(Entity)對某塊資料擁有寫入的權力。
其他的程式碼不是去「修改」資料,而是去「請求所有者進行修改」。
與其自己管理狀態,不如將這個複雜的任務交給更專業的系統。
對於 Web 應用,答案通常是:用資料庫。
// 🟢 好味道:我根本不持有狀態,我只發出指令
async function withdraw(userId, amount) {
// 指令的意圖很清楚:「在餘額足夠的前提下,減去指定金額」
// 資料庫會保證這個操作的原子性。所有競爭條件都在資料庫層級被解決了。
const result = await db.query(
'UPDATE accounts SET balance = balance - $1 WHERE user_id = $2 AND balance >= $1',
[amount, userId]
);
if (result.rowCount === 0) {
throw new Error('餘額不足或使用者不存在');
}
}
應用程式碼裡沒有鎖、沒有 try...finally
、沒有複雜的狀態管理。
只有一個簡單的、描述「意圖」的指令,就是「好味道」。
另一個方式是訊息傳遞 (Message Passing)。讓不同的執行緒透過佇列溝通,只有一個消費者執行緒負責修改狀態。這從架構上消除了競爭。
當你確實需要在記憶體中處理簡單的共享狀態(例如:監控指標、快取命中計數)且效能至關重要時,原子操作 (Atomics) 是比鎖更輕量、更高效的選擇。
原子操作由硬體層級保證其不可中斷性,避免了鎖帶來的上下文切換、死鎖等複雜問題。
JavaScript 在這方面通常不具備原生能力,但在 Node.js 的 worker_threads
環境中,可以透過 SharedArrayBuffer
和 Atomics
來實現。
// 🟢 Node.js 範例:使用 Atomics
// 假設在一個 Worker Thread 中
const { parentPort, workerData } = require('worker_threads');
// workerData.buffer 是一個由主執行緒共享的 ArrayBuffer
const sharedArray = new Int32Array(workerData.buffer);
// 從 index 0 的位置取出值並加上 1
// Atomics.add 是一個不可中斷的操作(原子操作),高效且執行緒安全
Atomics.add(sharedArray, 0, 1);
parentPort.postMessage('更新完成');
適用場景:主要用於計數器、旗標等簡單數值類型的無鎖(Lock-Free)操作。對於複雜的資料結構,原子操作就顯得力不從心。
如果面對的邏輯非常複雜,無法用資料庫或原子操作解決(這種情況比你想像的要少得多),才需要考慮鎖。
某些高效能場景,無法避免地需要處理複雜的共享資料結構。此時,鎖成為了必要的工具。
但使用鎖,請遵守兩條鐵律:
永遠不要自己發明鎖。 請使用你的語言或框架提供的、經過千錘百鍊的
Mutex
(互斥鎖) 或Semaphore
(信號量)。鎖定的臨界區(Critical Section)要極小,且絕不包含 I/O 或其他耗時操作。
這些函式庫之所以可靠,是因為它們依賴於作業系統或底層語言提供的原子原語。
錯誤的鎖用法:
// 🔴 臭味:在鎖內部執行 await 是常見的效能殺手和死鎖來源
async function badWithdraw(amount) {
await lock.runExclusive(async () => {
const currentBalance = balance;
await someAsyncDelay(); // 災難!鎖被長時間佔用,等待 I/O
balance = currentBalance - amount;
});
}
正確的鎖用法: 鎖的目的是保護記憶體操作的瞬間,而不是整個業務流程。
// 🟢 鎖只保護必要的記憶體讀寫,立即釋放
const { Mutex } = require('async-mutex');
const lock = new Mutex();
let balance = 100;
async function withdraw(amount) {
let newBalance;
// 1. 進入臨界區,快速完成記憶體操作
await lock.runExclusive(() => {
if (balance >= amount) {
balance -= amount;
newBalance = balance;
} else {
throw new Error('餘額不足');
}
});
// 2. 離開臨界區後,再執行耗時的 I/O 操作
await logTransaction(newBalance);
}
即便正確使用,鎖依然會帶來死鎖、效能開銷等風險,因此它應該是我們工具箱中,經過深思熟慮後才拿出的工具。
無論你採用哪種同步策略,在分散式系統中,網路是不可靠的。
你隨時可能收到重複的請求。確保你的 API 操作可以安全地重試,這就是冪等性 (Idempotency)。
// 🟢 透過唯一 ID 和狀態檢查,實現安全的重試
async function processPayment(orderId) {
// 使用唯一的 orderId 查詢
const order = await db.findOrder(orderId);
// 檢查狀態,如果已處理,則直接成功返回
if (order.status === 'paid') {
return 'Already Paid';
}
// 在同一個交易中執行操作並更新狀態
await db.transaction(async (tx) => {
await paymentGateway.charge(order.amount, { transaction: tx });
await tx.updateOrderStatus(orderId, 'paid');
});
}
停止迷戀複雜的工具。並行問題的解決方案優先順序很清楚:
首選:重新設計資料結構,消除共享狀態。 把問題交給資料庫。
進階:如果必須在記憶體中操作,使用原子操作。
最後手段:萬不得已,使用經過驗證的函式庫提供的鎖。
基本功:所有外部 API 都必須是冪等的。
在並行(Concurrent)或平行(Parallel)的環境中,許多 Bug 並非源於邏輯錯誤,而是誕生於資源共享的邊界。
這些問題難以追蹤,因為它們的出現充滿了不確定性。
主要心法:減少共享,擁抱原子,實現冪等。
在動手處理鎖(Lock)之前,優先考慮從設計上避免問題:
不可變性 (Immutability):盡量使用不可變資料。與其就地修改物件,不如回傳一個全新的物件。
序列化存取 (Serialized Access):將所有對共享狀態的修改,集中到單一執行緒或透過佇列(Queue)依序處理。
冪等性 (Idempotency):確保外部 I/O 操作(如 API 請求、事件處理)可以安全地重試,而不會產生非預期的副作用。
訊息傳遞 (Message Passing):讓不同的執行單元透過訊息佇列傳遞「意圖」或「事件」,而不是直接存取共享記憶體。
資料庫原子操作:善用資料庫提供的交易(Transaction)或原子更新指令(如 UPDATE counter SET value = value + 1 WHERE id = ?
),將並行控制的複雜性交給更專業的底層系統。
並行問題的測試極具挑戰性,需要特定策略:
壓力測試:模擬高併發場景,長時間運行以機率性地觸發競爭條件,並驗證最終狀態的統計一致性。
模擬重試:在測試中刻意重複呼叫同一個 API 或事件處理器,驗證系統是否具備冪等性。
模擬超時:測試當鎖競爭激烈或發生死鎖時,系統的回退與錯誤處理機制是否正常運作。
是否存在多個執行緒可能同時修改的「共享可變狀態」?
對於共享資源的存取,是否已透過鎖、交易或原子操作保護?
對外的 API 或事件處理,是否設計成冪等的,能安全重試?
在與外部系統互動時,是否有去重或重試保護機制?
首選無共享:從設計上根除競爭的土壤。
保護臨界區:若必須共享,就必須上鎖或使用原子操作。
冪等保平安:讓你的系統能從容應對重試與網路的不確定性。
將並行問題從「隨機的災難」變為「可控的設計」,正是消除程式碼壞味道的進階修行。