Redis深度歷險筆記02 Redis分佈式鎖

對鎖的理解?(待簡化)

  • 在單進程的系統中,當存在多個線程可以同時改變某個變量(可變共享變量)時,就需要對變量或代碼塊做同步,使其在修改這種變量時能夠線性執行消除併發修改變量。
  • 而同步的本質是通過鎖來實現的。爲了實現多個線程在一個時刻同一個代碼塊只能有一個線程可執行,那麼需要在某個地方做個標記,這個標記必須每個線程都能看到,當標記不存在時可以設置該標記,其餘後續線程發現已經有標記了則等待擁有標記的線程結束同步代碼塊取消標記後再去嘗試設置標記。這個標記可以理解爲鎖。
  • 不同地方實現鎖的方式也不一樣,只要能滿足所有線程都能看得到標記即可。如 Java 中 synchronize 是在對象頭設置標記,Lock 接口的實現類基本上都只是某一個 volitile 修飾的 int 型變量其保證每個線程都能擁有對該 int 的可見性和原子修改,linux 內核中也是利用互斥量或信號量等內存數據做標記。
  • 除了利用內存數據做鎖其實任何互斥的都能做鎖(只考慮互斥情況),如流水錶中流水號與時間結合做冪等校驗可以看作是一個不會釋放的鎖,或者使用某個文件是否存在作爲鎖等。只需要滿足在對標記進行修改能保證原子性和內存可見性即可。

分佈式的 CAP 理論

  • 任何一個分佈式系統都無法同時滿足一致性(Consistency)、可用性(Availability)和分區容錯性(Partition tolerance),最多隻能同時滿足兩項。
  • 目前很多大型網站及應用都是分佈式部署的,分佈式場景中的數據一致性問題一直是一個比較重要的話題。基於 CAP理論,很多系統在設計之初就要對這三者做出取捨。在互聯網領域的絕大多數的場景中,都需要犧牲強一致性來換取系統的高可用性,系統往往只需要保證最終一致性。

分佈式鎖

爲什麼使用分佈式鎖

  • 爲了保證一個方法或屬性在高併發情況下的同一時間只能被同一個線程執行
  • 單機情況要解決共享資源的訪問很容易,因爲只有一個JVM在運行我們的代碼,Java的API提供了很豐富的解決方案,常見的諸如synchronize,lock,volatile,JUC包等等。
  • 在分佈式部署下,會出現一套代碼出現在多個服務器的JVM中,請求落在哪一個上面是隨機的。這個時候Java的API提供的一些解決機制就沒法滿足要求,它只能解決當前JVM中能保證順序訪問共享資源,但是不能保證多臺機器順序訪問。
  • 這時就需要一種跨JVM的互斥機制來控制共享資源的訪問,就要使用分佈式鎖。

現在分佈式鎖有三種實現方案

  1. 基於數據庫。
  2. 基於緩存環境,redis,memcache等。
  3. 基於zookeeper。

分佈式鎖需要具備什麼特點(條件)

  1. 首先保證在分佈式的環境中,同一個方法只能被一個服務器上的一個線程執行。
  2. 鎖要可重入,如果獲取鎖之後如果需要再次獲取時發現不能獲取了,會造成死鎖。(避免死鎖)
  3. 鎖要可阻塞。這一般只要保證有個超時時間就行。
  4. 高可用的加鎖和釋放鎖功能。
  5. 加鎖和釋放鎖的性能要好。
  6. 具備鎖失效機制,防止死鎖。
    什麼是分佈式鎖?
  • 當在分佈式模型下,數據只有一份(或有限制),此時需要利用鎖的技術控制某一時刻修改數據的進程數。
  • 與單機模式下的鎖不僅需要保證進程可見,還需要考慮進程與鎖之間的網絡延時等問題。
  • 分佈式鎖要將標記存在公共環境中,如 利用Redis、Memcache、數據庫等做鎖,只要保證標記能互斥就行。

數據庫分佈式鎖

一、基於表主鍵唯一做分佈式鎖(樂觀鎖)

思路:

在數據庫中創建一個表,表中包含方法名等字段,並在方法名字段上創建唯一索引,想要執行某個方法,就使用這個方法名向表中插入數據,由於主鍵唯一的特性,如果有多個請求同時提交到數據庫的話,數據庫會保證只有一個操作可以成功,那麼我們就可以認爲操作成功的那個線程獲得了該方法的鎖,當方法執行完畢之後,想要釋放鎖的話,刪除這條數據庫記錄即可。

在數據庫中有一張表,這張表類似一個公共資源池,每個線程都要來這邊獲取條件,看能不能獲取到當前方法的鎖。
在這裏插入圖片描述

  • 獲取鎖時,只要執行insert語句
insert into lock_table("method_name","time")
  • 釋放鎖時,執行對應的delete語句就行。

問題:

  1. 這個表中沒有設計失效時間,一旦出現加鎖成功但是解鎖失敗的情況,就會導致鎖記錄一直在數據庫中,其他線程無法再獲得到鎖。
  2. 這把鎖是非重入的,同一個線程在沒有釋放鎖之前無法再次獲得該鎖。因爲數據中數據已經存在了。
  3. 這把鎖只能是非阻塞的,因爲數據的 insert操作,一旦插入失敗就會直接報錯。沒有獲得鎖的線程並不會進入排隊隊列,要想再次獲得鎖就要再次觸發獲得鎖操作。
  4. 數據庫是一個單點,一旦數據庫掛了,就不能使用了
  5. 這把鎖是非公平鎖,所有等待鎖的線程憑運氣去爭奪鎖。

解決:

  1. 需要在表中新增一列,用於記錄失效時間,做一個定時任務,每隔一定時間把數據庫中的超時數據清理一遍。代碼中在加鎖時可以先判斷當前記錄是不是已經超過最大允許時間,超過了說明已經失效了,先手動釋放鎖,再加鎖。
  2. 在數據庫表中加入一個字段記錄當前記錄當前獲得鎖的機器的主機信息和線程信息,在再次獲取鎖的時候,先查詢表中機器和線程信息是否和當前機器線程相同,若相同則直接獲取鎖。
  3. 代碼裏執行while循環,設置一個允許最大時間,超過了,直接失敗。
  4. 使用兩個數據庫,雙機部署、數據同步、主備切換。
  5. 再建一張中間表,將等待鎖的線程全記錄下來,並根據創建時間排序,只有最先創建的允許獲取鎖。
二、基於排他鎖的實現

在查詢語句後面增加for update,數據庫會在查詢過程中給數據庫表增加排他鎖也就是寫鎖。 (注意: InnoDB 引擎在加鎖的時候,只有通過索引進行檢索的時候纔會使用行級鎖,否則會使用表級鎖。這裏我們希望使用行級鎖,就要給要執行的方法字段名添加索引,值得注意的是,這個索引一定要創建成唯一索引,否則會出現多個重載方法之間無法同時被訪問的問題。重載方法的話建議把參數類型也加上。)當某條記錄被加上排他鎖之後,其他線程無法再在該行記錄上增加排他鎖。

我們可以認爲獲得排他鎖的線程即可獲得分佈式鎖,當獲取到鎖之後,可以執行方法的業務邏輯,執行完方法之後,通過connection.commit()操作來釋放鎖。

解決了上面提到的無法釋放鎖和阻塞鎖的問題:

  1. 是阻塞的。使用了select * for update時,其他想要獲取鎖的事務讀不出數據,一直阻塞在那兒
  2. 宕機之後就自動釋放了。

沒解決的問題:

  1. 單點問題,數據庫是一個單點,一旦數據庫掛了,就不能使用了
  2. 可重入問題。
  3. 雖然我們對方法字段名使用了唯一索引,並且顯示使用 for update 來使用行級鎖。但是,MySQL 會對查詢進行優化,即便在條件中使用了索引字段,但是否使用索引來檢索數據是由 MySQL 通過判斷不同執行計劃的代價來決定的,如果 MySQL 認爲全表掃效率更高,比如對一些很小的表,它就不會使用索引,這種情況下 InnoDB 將使用表鎖,而不是行鎖。如果發生這種情況就悲劇了。。。
  4. 還有一個問題,就是我們要使用排他鎖來進行分佈式鎖的 lock,那麼一個排他鎖長時間不提交,就會佔用數據庫連接。一旦類似的連接變得多了,就可能把數據庫連接池撐爆。

優點:簡單,易於理解

缺點:會有各種各樣的問題(如上)操作數據庫需要一定的開銷,使用數據庫的行級鎖並不一定靠譜,性能不靠譜)

基於緩存實現(redis分佈式鎖)

簡單來說就是在Redis裏佔個位置,當別的線程也要來佔時,發現已經被佔了,只能等待

Redis具有很高的性能;Redis的命令實現起來很方便;

命令介紹:

SETNX

// 當且僅當key不存在時,set一個key爲val的字符串,返回1;
// 若key存在,則什麼都不做,返回0。
SETNX key val;

存在的問題:如果程序出異常,可能導致del命令沒有被調用,就會陷入死鎖,鎖永遠得不到釋放。所以要加上一個過期時間。

expire

// 爲key設置一個超時時間,單位爲second,超過這個時間鎖會自動釋放,避免死鎖。
expire key timeout;

delete

// 刪除key
delete key;

我們通過Redis實現分佈式鎖時,主要通過上面的這三個命令。

通過Redis實現分佈式的核心思想爲:

  1. 獲取鎖的時候,使用setnx加鎖,並使用expire命令爲鎖添加一個超時時間,超過該時間自動釋放鎖,鎖的value值爲一個隨機生成的UUID,通過這個value值,在釋放鎖的時候進行判斷。
  2. 獲取鎖的時候還設置一個獲取的超時時間,若超過這個時間則放棄獲取鎖。
  3. 釋放鎖的時候,通過UUID判斷是不是當前持有的鎖,若是該鎖,則執行delete進行鎖釋放
一、基於 REDIS 的 SETNX()、EXPIRE() 方法做分佈式鎖

setnx():setnx 的含義就是 SET if Not Exists,其主要有兩個參數 setnx(key, value)。該方法是原子的,如果 key 不存在,則設置當前 key 成功,返回 1;如果當前 key 已經存在,則設置當前 key 失敗,返回 0。

expire():expire 設置過期時間,要注意的是 setnx 命令不能設置 key 的超時時間,只能通過 expire() 來對 key 設置。

使用步驟:

  1. setnx(lockkey, 1) 如果返回 0,則說明佔位失敗;如果返回 1,則說明佔位成功
  2. expire() 命令對 lockkey 設置超時時間,爲的是避免死鎖問題。
  3. 執行完業務代碼後,可以通過 delete 命令刪除 key。

可能出現的問題及解決辦法:

問題:使用setnx指令後繼續使用expire指令。但是這兩部操作必定不是原子性的,如果執行expire失敗,會出現死鎖的問題。

解決:在Redis2.8 之後,setnx 和 expire命令一起使用了,SET lock_key lock_value NX PX 30000

二、使用set key value NX PX 30000命令加鎖
set key value NX PX 30000
  • my_random_value是由客戶端生成的一個隨機字符串,用於唯一標識鎖的持有者。
  • NX表示只有當key值不存在的時候才能SET成功,從而保證只有一個客戶端能獲得鎖,而其它客戶端在鎖被釋放之前都無法獲得鎖。
  • PX 30000表示這個鎖有一個30秒的自動過期時間。(目的是爲了防止持有鎖的客戶端故障後,無法主動釋放鎖而導致死鎖)

鎖自動過期存在的隱患:

隱患1. 例如我們有兩個線程A、B,此時線程A搶到了鎖,且設置自動過期時間爲10s鍾,因爲系統其他原因導致系統A發生阻塞。而此刻10s鍾後鎖自動過期,線程C獲取到了同一個資源的鎖,線程A從阻塞中恢復,認爲自己仍然持有鎖,繼續操作同一資源。這樣就使得加鎖的互斥性失效了。

解決:

讓獲取鎖的線程開啓一個守護線程,給線程還沒執行完,又快要過期的鎖續航。大概是這樣的,線程A還沒執行完,守護線程每當快過期時,延時expire時間。當線程A執行完,顯示關閉守護線程。如果中間宕機,鎖超過超時,守護線程也不在了,自動釋放鎖。

隱患2. 因爲有了過期時間,如果一個線程加鎖後,執行業務邏輯時間太長,鎖超過了30秒過期時間,鎖已經過期了,並且已經被別的線程加鎖了,然後這個舊線程delete了別人的鎖。

解決:爲了防止客戶端1獲得的鎖,被客戶端2給釋放,採用下面的Lua腳本來釋放鎖,Lua 腳本可 以保證連續多個指令的原子性執行

if redis.call("get",KEYS[1]) == ARGV[1] then
    return redis.call("del",KEYS[1])
else
    return 0
end

在執行這段LUA腳本的時候,KEYS[1]的值爲resource_name,ARGV[1]的值爲my_random_value。原理就是先獲取鎖對應的value值,保證和客戶端穿進去的my_random_value值相等,這樣就能避免自己的鎖被其他人釋放。

問題:過期時間如何設置

  1. 重入的問題沒有解決。
  2. redis中如何保證鎖的容錯性。需要注意加鎖成功,但是設置失效時間時宕機的場景,保證不出現死鎖。
  3. 一旦redis掛了,或者如果是主從結構的redis,master節點掛了,鎖還沒有同步到從節點,然後從節點就被哨兵選舉成master節點,這時另一個客戶端請求加鎖會被批准,就會導致一把鎖會兩個客戶端持有。
三、基於 REDIS 的 SETNX()、GET()、GETSET()方法做分佈式鎖

主要是在 setnx() 和 expire() 的方案上針對可能存在的死鎖問題,做了一些優化。

getset():這個命令主要有兩個參數 getset(key,newValue)。該方法是原子的,對 key 設置 newValue 這個值,並且返回 key 原來的舊值。假設 key 原來是不存在的,那麼多次執行這個命令,會出現下邊的效果:

getset(key, “value1”) 返回 null 此時 key 的值會被設置爲 value1
getset(key, “value2”) 返回 value1 此時 key 的值會被設置爲 value2

使用步驟:

  1. setnx(lockkey, 當前時間+過期超時時間),如果返回 1,則獲取鎖成功;如果返回 0 則沒有獲取到鎖,轉向 2。
  2. get(lockkey) 獲取值 oldExpireTime ,並將這個 value 值與當前的系統時間進行比較,如果小於當前系統時間,則認爲這個鎖已經超時,可以允許別的請求重新獲取,轉向 3。
  3. 計算 newExpireTime = 當前時間+過期超時時間,然後 getset(lockkey, newExpireTime) 會返回當前 lockkey 的值currentExpireTime。
  4. 判斷 currentExpireTime 與 oldExpireTime 是否相等,如果相等,說明當前 getset 設置成功,獲取到了鎖。如果不相等,說明這個鎖又被別的請求獲取走了,那麼當前請求可以直接返回失敗,或者繼續重試。
  5. 在獲取到鎖之後,當前線程可以開始自己的業務處理,當處理完畢後,比較自己的處理時間和對於鎖設置的超時時間,如果小於鎖設置的超時時間,則直接執行 delete 釋放鎖;如果大於鎖設置的超時時間,則不需要再鎖進行處理。
四、基於 REDLOCK 做分佈式鎖

使用Redlock時,需要提供多個Redis實例,這些實例之間相互獨立沒有主從關係,加鎖時,會向過半節點發送加鎖指令,只要過半節點加鎖成功,那就認爲加鎖成功。釋放鎖時,需要向所有的節點發送del指令。

Redlock 是 Redis 的作者 antirez 給出的集羣模式的 Redis 分佈式鎖,它基於 N 個完全獨立的 Redis 節點(通常情況下 N 可以設置成 5)。

算法的步驟如下:

  1. 客戶端獲取當前時間,以毫秒爲單位。
  2. 客戶端嘗試獲取 N 個節點的鎖,(每個節點獲取鎖的方式和前面說的緩存鎖一樣),N 個節點以相同的 key 和 value 獲取鎖。客戶端需要設置接口訪問超時,接口超時時間需要遠遠小於鎖超時時間,比如鎖自動釋放的時間是 10s,那麼接口超時大概設置 5-50ms。這樣可以在有 redis 節點宕機後,訪問該節點時能儘快超時,而減小鎖的正常使用。
  3. 客戶端計算在獲得鎖的時候花費了多少時間,方法是用當前時間減去在步驟一獲取的時間,只有客戶端獲得了超過 3 個節點的鎖,而且獲取鎖的時間小於鎖的超時時間,客戶端才獲得了分佈式鎖。
  4. 客戶端獲取的鎖的時間爲設置的鎖超時時間減去步驟三計算出的獲取鎖花費時間。
  5. 如果客戶端獲取鎖失敗了,客戶端會依次刪除所有的鎖。

使用 Redlock 算法,可以保證在掛掉最多 2 個節點的時候,分佈式鎖服務仍然能工作,這相比之前的數據庫鎖和緩存鎖大大提高了可用性,由於 redis 的高效性能,分佈式緩存鎖性能並不比數據庫鎖差。

基於zk的實現

redis分佈式鎖,輪詢獲取鎖,比較消耗性能,zk分佈式鎖,監聽回調機制,性能開銷較小

zookeeper不會存在,服務掛了,鎖永遠存在的線程,zookeeper可以創建臨時節點,zookeeper感應到服務掛了,會自己刪除鎖節點。

基於zk實現分佈式鎖的原理是:創建臨時順序性節點,通過監聽機制來實現,監聽機制指的是在指定節點上註冊一些事件監聽器,當節點發生變化時,將事件通知給客戶端。假設100個服務器同時發來請求,就創建100個臨時順序性節點,分別編號爲001、002,到100,同時每個服務器都會監聽自己前面的一個節點。當001節點處理完畢,刪除節點,然後002收到通知,去獲取鎖,開始執行,執行完畢後再刪除節點,再003獲取鎖,以此類推。

臨時順序性節點:臨時指的是一旦ZooKeeper客戶端斷開了連接,ZooKeeper服務端就不再保存這個節點;順序性指的是在創建節點的時候,ZooKeeper會自動給節點編號,比如0000001,0000002這種。

爲什麼要選擇redis分佈式鎖?

對比優缺點:
在這裏插入圖片描述
哪種方式都無法做到完美。就像CAP一樣,在複雜性、可靠性、性能等方面無法同時滿足,所以,要根據不同的應用場景選擇最適合自己的。

從理解的難易程度角度(從低到高)
數據庫 > 緩存 > Zookeeper

從實現的複雜性角度(從低到高)
Zookeeper >= 緩存 > 數據庫

從性能角度(從高到低)
緩存 > Zookeeper >= 數據庫

從可靠性角度(從高到低)
Zookeeper > 緩存 > 數據庫

數據庫的性能有限,如果在高併發的情況下會頻發的訪問數據庫,對數據庫會造成較大的壓力。

參考博客:
https://www.cnblogs.com/justuntil/p/10458211.html
https://www.jianshu.com/p/9055ca856aaf
https://blog.csdn.net/wuzhiwei549/article/details/80692278
https://www.jianshu.com/p/5b9041d1f2cd
https://www.cnblogs.com/seesun2012/p/9214653.html
https://www.cnblogs.com/shoshana-kong/p/9581557.html
https://www.cnblogs.com/rjzheng/p/9310976.html
https://www.jianshu.com/p/8bddd381de06

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章