利用Redis實現分佈式鎖
在之前的答題對戰項目中,遊戲規則是根據雙方回答同一道題所花時間長短判勝負,但測試過程中遇到一個偶現BUG:“對戰開始後雙方收到的題目不一致”,經過分析代碼,發現問題原因在於發題邏輯沒有加鎖,所以寫篇文章聊聊這個話題。
什麼是鎖
在編程領域,鎖是一種用來做獨佔代碼執行的方式,通俗來講就是在打算做某件事情之前要申請許可證,如果沒有得到許可證則無法做這件事。需要許可證的原因是因爲並發現象:“同時出現多個人做這件事”,這種並發現象可能導致邏輯錯誤。在許可證這個比喻中,“人”是計算機領域的線程、進程等,要做的“事情”則是執行代碼。
線程鎖
假設有這樣一段Java代碼:
class Account {
private int money;
Account(int init) {
this.money = init;
}
// 購買一個價格爲price的商品,成功返回true,否則返回false
public boolean buyWithPrice(int price) {
if (this.money < price) {
return false;
}
this.money -= price;
return true;
}
public static void main(String[] args) {
Account account = new Account(100);
for (int i = 0; i < 2; i++) {
new Thread(new Runnable() {
@Override
public void run() {
if (account.buyWithPrice(100)) {
System.out.println("buy success");
}
}
}).start();
}
}
}
因爲Javascript是單線程模型,不方便直接模擬線程併發,所以這一段示例代碼用的是Java語言。示例中模擬了一個最簡單的賬戶購物場景:“用賬戶餘額(money)購買指定價格(price)的商品,購買成功返回true,餘額不夠則返回false”,場景中初始化一個餘額爲100的賬戶,然後創建兩個線程去執行購買操作。
上述場景期望的正確結果是隻有一個線程購買成功,另外一個會由於餘額不足購買失敗,所以我們執行代碼只會打印一行'buy success'
。但實際運行結果卻可能與我們所期望的不同,出現兩個線程都購買成功,而出現兩者都成功的原因是因爲buyWithPrice
方法沒有“加鎖”(Java中稱爲非線程同步方法),在併發情況下,線程雙方在this.money -= price
沒有執行之前,都執行到if (this.money < price)
這一行,判斷條件不成立緊接着也就購買成功了,所以最終打印兩次'buy success'
,餘額變成了-100。
tips: 這個例子由於代碼從判斷餘額充足到扣除餘額執行過快,運行多次也難遇到一次兩者都購買成功,但如果在
this.money -= price
前添加一個短時間的sleep,則很容易出現兩者都購買成功
導致兩個並行線程能夠同時進入buyWithPrice
方法的原因是沒有鎖機制,在Java中添加鎖機制避免這種行爲的最簡單辦法是用synchronized
關鍵詞將方法聲明爲“同步方法”,這樣就能自動利用內置鎖機制保證同時只有一個線程進入到方法執行,可以有效避免出現餘額被扣成負數。
tips: 出現線程併發執行的原因不只多核CPU,理論上單核CPU線程上下文切換同樣可以出現場景中一樣的併發問題
以上內容便是線程鎖的概念,用於避免多個線程同時執行某一段代碼的鎖,線程鎖的實現依賴編程語言層面提供的解決方案,在Java中有synchronized
,而在Javascript中則不存在,因爲Javascript的執行是單線程的。
分佈式鎖
解釋完線程鎖,來看看什麼是分佈式鎖?我認爲可簡單理解爲進程鎖,線程鎖解決的是線程併發所帶來的問題,而分佈式鎖解決的是進程併發所帶來的問題,它們之間“鎖”是一樣的概念,但scope不同。使用分佈式鎖的進程可以在同一臺機器上,也可以在不同機器上,可能正是因爲能跨機器的特點,讓它被稱爲分佈式鎖。
對於使用Node.js的朋友而言,線程鎖難以接觸,但分佈式鎖或者說進程鎖則相對常見,尤其是做HTTP Server,假設有一個使用餘額購買並支付指定商品的接口,這個接口的處理步驟大致如下:
- 根據商品ID查詢商品信息
- 查詢用戶餘額,餘額不夠則返回購買失敗
- 扣除餘額
- 創建訂單
用戶發起一個購買請求,服務器就按上述四個步驟去處理,假設某用戶只有100的餘額,他同時發起兩個請求去購買價格爲100的商品,服務器應該如何保證他只成功購買一個?因爲判斷餘額充足與扣除餘額是兩個操作,在餘額沒被扣除之前,兩個請求可能同時執行到判斷餘額是否充足的步驟,然後再分別執行後續步驟(扣除餘額、創建訂單),最終導致用戶用100餘額購買了200的商品。
要避免併發所帶來的問題,有多種方式,利用分佈式鎖就是其中一種比較常見的方式,通過給“查餘額”與“扣餘額”步驟進行加鎖,這個鎖能保證同時只有一個請求能處於這兩個步驟中,當一個請求釋放鎖之後,其他的請求再次獲得鎖並查詢餘額時會發現餘額不足,從而購買失敗。
上述的查餘額、扣餘額絕大數情況下是數據庫操作,以MySQL舉例,上述場景的對應鎖的實現,參考以下代碼:
function buyWithGoodsIdAndUserId(goodsId, userId) {
// 執行SQL
return mysql.execSql('select price from goods where id = :goodsId', {
goodsId
}).then(ret => {
let goods = ret;
if (_.isEmpty(goods)) {
return Promise.reject(new Error('not found'));
}
let user = {};
// 事務封裝
return mysql.transactionTask([
query => {
// 查詢用戶餘額,鎖的開始
return query('select balance from user where id = :userId for update', {
userId
}).then(ret => user = ret);
},
query => {
if (user.balance < goods.price) {
return Promise.reject('insufficient balance');
}
// 扣除餘額
return query('update user set balance = balance - :price where id = :userId', {
userId,
price: goods.price
});
},
query => {
return query('insert order ...');
}
]); // 事務提交,鎖的結束
});
}
以上代碼片段描述了購買商品4個步驟對應的數據庫操作,看起來好像未體現分佈式鎖的有關內容,但其中確實隱含着分佈式鎖。mysql.transactionTask
事務操作中的第一句SQL是:“select … for update”,這句SQL是MySQL中鎖的一個體現,具體細節有機會寫MySQL相關文章的時候再細聊,在這個代碼示例中由於transactionTask
中用到的“select for update”,會導致buyWithGoodsIdAndUserId
方法相對對於同一個用戶,不論同時有多少調用者,只會有一個調用者處於事務裏的三個步驟中,其他調用者需要等待那個唯一正在處理的完成之後纔可能進行處理。
分佈式鎖的實現
利用數據庫的鎖機制來實現業務的分佈式鎖是一個比較常見的做法,前文的代碼示例是其中隱含有一個分佈式鎖,而稍加修改則可以變成一個較爲通用的分佈式鎖,實現原理是藉助數據庫本身的鎖機制在代碼內實現lock與unlock兩個操作,一種方案:
- lock(key):insert into lock(id) values(:key);
- unlock(key): delete from lock where id = :key;
其中lock表的id字段上有唯一約束,可以保證同時多個client去嘗試拿鎖時,只有一個可以成功
除了用MySQL實現分佈式鎖之外,我們還可以藉助Redis實現分佈式鎖,原理是利用的Reids的SETNX命令,SETNX在給指定key設置value時會判斷key是否已經存在,如果已存在直接返回0,否則設置key=value並返回1。利用這個特性,當出現多個client同時設置同一個key的值時,僅有一個client會得到返回值1,表明拿到了鎖,其他client則未拿到。
藉助Redis實現分佈式鎖的代碼示例如下:
// key是鎖名稱,fn是需要做的事情,只支持Promise形式的異步,timeout是鎖自動釋放的超時間隔
function lockCallWithKey(key, fn, timeout) {
timeout = timeout || 30000;
let lockKey = `redis-lock:${key}`;
let value = uuid.v4();
return client.setAsync(lockKey, value, 'NX', 'PX', timeout).then(ret => {
if (ret !== 'OK') {
return new Promise((resolve, reject) => {
setTimeout(() => {
lockCallWithKey(key, fn, timeout).then(resolve).catch(reject);
}, Math.ceil(100 + Math.random() * 100)); // try after 100-200ms
});
}
return fn();
}).finally(() => {
let unlockScript = 'if redis.call("get", KEYS[1]) == ARGV[1] then return redis.call("del", KEYS[1]) else return 0 end';
client.evalAsync(unlockScript, 1, lockKey, value).catch(err => {
logger.error(err);
});
});
}
// 使用
lockCallWithKey('example', () => {
// 同時多個client調用,只會有一個處於執行這個代碼塊的過程中
return Promise.resolve('ok');
});
上述代碼藉助Redis實現了一個分佈式鎖的機制,其中使用lua腳本而不是Redis del命令釋放鎖,是爲了避免釋放不屬於自己的鎖,假設client-A拿到鎖在timeout之後才處理完成,如果使用del命令,則有可能釋放了其他client獲得的鎖,從而可能導致多個client同時持有鎖,而使用lua腳本利用value相同才釋放的原則可以保證任意client只釋放屬於自己的鎖。
利用Redis實現分佈式鎖,是一個比較常見的話題,Redis有官方說明,有興趣的朋友可以參考,Node.js也有已實現的redlock,有需要可以直接拿來使用。
解決問題
寫了這麼多,回到自己項目中遇到的問題上:“對戰雙方收到相同題目”,對戰流程是收到客戶端的ready消息後派發該次對戰的題目,BUG出現在兩個人同時ready,服務器派發題目的邏輯判斷該次對戰的題目還未確定,雙方都去隨機挑選題目然後發放,導致題目不同。而如果雙方非同時ready,則是較早的一方爲該次對戰隨機挑選題目,另外一方發現對戰題目已經確定,故不會重新挑選。
這個問題與餘額的例子是一樣的,客戶端讀某個值然後依賴這個值做某件事繼而更新這個值,在併發且沒有鎖的情況下就容易出現邏輯BUG,修改的方法就是藉助上面Redis實現的鎖機制爲這三個步驟加鎖,代碼如下:
function dispatchQuestionById(matchId, userId) {
// avoid client receive different question
return lib.redis.lockCallWithKey(`zslt:question:${matchId}`, () => {
return cache.match.questionById(matchId).then(ret => {
if (!_.isEmpty(ret)) {
return ret;
}
return proxy.question.randomItem().then(ret => {
ret.timestamp = Date.now();
return cache.match.setQuestion(matchId, ret, 15).then(() => ret);
});
});
}).then(ret => {
return tunnel.sendMessageByUserId(userId, {
id: uuid.v4(),
name: C.MESSAGE.QUESTION,
content: {
matchId,
question: ret
}
});
}).catch(err => {
if (err && err.isBreak) {
return err.result;
}
logger.error(err);
return {};
});
}