1. 前言
對于一個在線運行的系統,如果需要修改數據庫已有數據,需要先讀取舊數據,再寫入新數據。因為讀數據和寫數據不是原子操作,所以在高并發的場景下,關注的數據可能會修改失敗,需要使用鎖控制。
2. 分布式場景
2.1 分布式鎖場景
面試官提問: 為什么要使用分布式鎖?分布式鎖解決了什么問題?
題目解析:
首先分析鎖的應用場景,我們對于已有數據的修改可以歸納為兩個動作:
(1)讀舊數據;
(2)寫新數據。
然后分析并發操作導致臟數據的過程:
對于并發執行的兩次請求,兩個請求同時讀到舊數據值為 10,第一個請求執行操作后新值為 30,第二個請求執行操作后新值為 40,最終只有第二次請求成功寫入數據實體,導致第一次請求失效。
在單機部署的系統中,我們可以直接使用本地的鎖(例如 Java 的 Object 對象鎖)解決上述的并發沖突問題,但是當服務器分布式部署時,單機的鎖并不能跨網絡調用,所以需要使用分布式鎖解決問題。
2.2 Redis 分布式鎖
面試官提問: 既然談到了分布式鎖的應用場景,在實戰環境是如何實現分布式鎖的呢?
題目解析:
目前分布式鎖最主要有三種實現方式:
(1)基于 Redis 集群的模式;
(2)基于 Zookeeper 集群的模式;
(3)基于 DB 數據庫的模式
本章節只關注 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){
// 如果加鎖成功,設置過期時間
jedis.expire(prefix_key,expire_time);
}
}
加鎖步驟主要分為兩步:
(1)通過 setnx 指令加鎖,setnx 的含義是 set if not exist
,即如果 redis 不存在已有的 prefix_key ,則寫入 prefix_key ,設置對應 value=id
,并且調用返回為 1,如果已有 prefix_key ,則不寫入并且返回非 1.
(2)通過 expire 指令,設置過期時間,如果 prefix_key 代表的鎖一直沒有刪除,則在定時后自動失效,防止產生死鎖的情況。
上述代碼并不完美,其中 setnx()
和 expire()
函數并不是原子操作,如果執行 setnx()
指令之后,redis 集群出現網絡抖動或者在線服務本身異常,導致后續 expire()
指令并沒有執行,prefix_key 代表的鎖并沒有被加上過期時間,還是有產生死鎖的可能性,我們對上述代碼進行改造,實例:
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; //加鎖失敗
}
這種方案是將加鎖和設置過期時間合并為一個步驟,一次 set,是原子操作。另外還有諸多開源代碼解決這個問題,例如通過開源 lua 腳本,基于 redis 集群進行改造。
既然有加鎖的過程,就有操作執行結束之后釋放鎖的過程,實例:
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);
}
}
使用分布式鎖都是為了應對高并發的場景,高并發場景下,上述代碼存在嚴重的并發執行問題。
例如第一行 if 判斷完成之后,其他線程已經提前進入條件判斷并且執行了 del 操作,當前線程再執行 del 操作就不合理。
還是出現了沒有保證操作原子性的問題,通用的解決方案是通過 lua 腳本的 eval()
函數,首先獲取鎖對應的 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. 小結
本章節介紹了使用 Redis 實現最基礎的分布式鎖問題,給出了滿足原子性的加鎖和解鎖操作,需要候選人能夠給面試官清晰解釋兩步操作的關注點。另外,本章節對于一些可能存在的問題沒有給出具體解決方案,例如 prefix_key 經過超時時間后自動過期,但是業務還沒有執行完成,以及 Redis 集群的主從同步可能發生的宕機問題。