1. 前言
對于一個在線運行的系統(tǒng),如果需要修改數(shù)據(jù)庫已有數(shù)據(jù),需要先讀取舊數(shù)據(jù),再寫入新數(shù)據(jù)。因為讀數(shù)據(jù)和寫數(shù)據(jù)不是原子操作,所以在高并發(fā)的場景下,關(guān)注的數(shù)據(jù)可能會修改失敗,需要使用鎖控制。
2. 分布式場景
2.1 分布式鎖場景
面試官提問: 為什么要使用分布式鎖?分布式鎖解決了什么問題?
題目解析:
首先分析鎖的應(yīng)用場景,我們對于已有數(shù)據(jù)的修改可以歸納為兩個動作:
(1)讀舊數(shù)據(jù);
(2)寫新數(shù)據(jù)。
然后分析并發(fā)操作導(dǎo)致臟數(shù)據(jù)的過程:
對于并發(fā)執(zhí)行的兩次請求,兩個請求同時讀到舊數(shù)據(jù)值為 10,第一個請求執(zhí)行操作后新值為 30,第二個請求執(zhí)行操作后新值為 40,最終只有第二次請求成功寫入數(shù)據(jù)實體,導(dǎo)致第一次請求失效。
在單機(jī)部署的系統(tǒng)中,我們可以直接使用本地的鎖(例如 Java 的 Object 對象鎖)解決上述的并發(fā)沖突問題,但是當(dāng)服務(wù)器分布式部署時,單機(jī)的鎖并不能跨網(wǎng)絡(luò)調(diào)用,所以需要使用分布式鎖解決問題。
2.2 Redis 分布式鎖
面試官提問: 既然談到了分布式鎖的應(yīng)用場景,在實戰(zhàn)環(huán)境是如何實現(xiàn)分布式鎖的呢?
題目解析:
目前分布式鎖最主要有三種實現(xiàn)方式:
(1)基于 Redis 集群的模式;
(2)基于 Zookeeper 集群的模式;
(3)基于 DB 數(shù)據(jù)庫的模式
本章節(jié)只關(guān)注 Redis 的部分,核心思路是通過 setnx 指令,實例:
public static void wrongWayLock(Jedis jedis, String prefix_key, String id, int expire_time) {
// 加鎖
Long result = jedis.setnx(prefix_key, id);
if (result==1){
// 如果加鎖成功,設(shè)置過期時間
jedis.expire(prefix_key,expire_time);
}
}
加鎖步驟主要分為兩步:
(1)通過 setnx 指令加鎖,setnx 的含義是 set if not exist
,即如果 redis 不存在已有的 prefix_key ,則寫入 prefix_key ,設(shè)置對應(yīng) value=id
,并且調(diào)用返回為 1,如果已有 prefix_key ,則不寫入并且返回非 1.
(2)通過 expire 指令,設(shè)置過期時間,如果 prefix_key 代表的鎖一直沒有刪除,則在定時后自動失效,防止產(chǎn)生死鎖的情況。
上述代碼并不完美,其中 setnx()
和 expire()
函數(shù)并不是原子操作,如果執(zhí)行 setnx()
指令之后,redis 集群出現(xiàn)網(wǎng)絡(luò)抖動或者在線服務(wù)本身異常,導(dǎo)致后續(xù) expire()
指令并沒有執(zhí)行,prefix_key 代表的鎖并沒有被加上過期時間,還是有產(chǎn)生死鎖的可能性,我們對上述代碼進(jìn)行改造,實例:
public static boolean setLock(Jedis jedis, String prefix_key, String id, int expire_time) {
if(jedis.set(prefix_key, id, SET_IF_NOT_EXIST, SET_WITH_EXPIRE_TIME, expire_time) == 1) {
return true; //加鎖成功
}
return false; //加鎖失敗
}
這種方案是將加鎖和設(shè)置過期時間合并為一個步驟,一次 set,是原子操作。另外還有諸多開源代碼解決這個問題,例如通過開源 lua 腳本,基于 redis 集群進(jìn)行改造。
既然有加鎖的過程,就有操作執(zhí)行結(jié)束之后釋放鎖的過程,實例:
public static void unLock(Jedis jedis, String prefix_key, String id){
//如果在集群中存在prefix_key的值,并且和之前配置的id相同
if(id.equals(jedis.get(prefix_key))){
//刪除prefix_key鍵值對
jedis.del(prefix_key);
}
}
使用分布式鎖都是為了應(yīng)對高并發(fā)的場景,高并發(fā)場景下,上述代碼存在嚴(yán)重的并發(fā)執(zhí)行問題。
例如第一行 if 判斷完成之后,其他線程已經(jīng)提前進(jìn)入條件判斷并且執(zhí)行了 del 操作,當(dāng)前線程再執(zhí)行 del 操作就不合理。
還是出現(xiàn)了沒有保證操作原子性的問題,通用的解決方案是通過 lua 腳本的 eval()
函數(shù),首先獲取鎖對應(yīng)的 value(即我們的 id ),如果相等
才刪除鎖,lua 腳本能保證原子性,實例:
public boolean unlock(String prefix_key,String request){
//lua腳本
String script = "if redis.call('get', KEYS[1]) == ARGV[1] then return redis.call('del', KEYS[1]) else return 0 end";
Long result = jedis.eval(script, Collections.singletonList(prefix_key), Collections.singletonList(id));
if (result == 1){
return true ;
}
return false;
}
3. 小結(jié)
本章節(jié)介紹了使用 Redis 實現(xiàn)最基礎(chǔ)的分布式鎖問題,給出了滿足原子性的加鎖和解鎖操作,需要候選人能夠給面試官清晰解釋兩步操作的關(guān)注點。另外,本章節(jié)對于一些可能存在的問題沒有給出具體解決方案,例如 prefix_key 經(jīng)過超時時間后自動過期,但是業(yè)務(wù)還沒有執(zhí)行完成,以及 Redis 集群的主從同步可能發(fā)生的宕機(jī)問題。