1. 程式人生 > 其它 >Redis筆記2

Redis筆記2

setnx的分散式鎖

使用Redis的 SETNX 命令可以實現分散式鎖,下文介紹其實現方法。

SETNX命令簡介

命令格式

SETNX key value

將 key 的值設為 value,當且僅當 key 不存在。
若給定的 key 已經存在,則 SETNX 不做任何動作。
SETNX 是SET if Not eXists的簡寫。

返回值

返回整數,具體為
- 1,當 key 的值被設定
- 0,當 key 的值沒被設定

例子

redis> SETNX mykey “hello”
(integer) 1
redis> SETNX mykey “hello”
(integer) 0
redis> GET mykey
“hello”
redis>

使用SETNX實現分散式鎖

多個程序執行以下Redis命令:

SETNX lock.foo <current Unix time + lock timeout + 1>

如果 SETNX 返回1,說明該程序獲得鎖,SETNX將鍵 lock.foo 的值設定為鎖的超時時間(當前時間 + 鎖的有效時間)。
如果 SETNX 返回0,說明其他程序已經獲得了鎖,程序不能進入臨界區。程序可以在一個迴圈中不斷地嘗試 SETNX 操作,以獲得鎖。

解決死鎖

考慮一種情況,如果程序獲得鎖後,斷開了與 Redis 的連線(可能是程序掛掉,或者網路中斷),如果沒有有效的釋放鎖的機制,那麼其他程序都會處於一直等待的狀態,即出現“死鎖”。

上面在使用 SETNX 獲得鎖時,我們將鍵 lock.foo 的值設定為鎖的有效時間,程序獲得鎖後,其他程序還會不斷的檢測鎖是否已超時,如果超時,那麼等待的程序也將有機會獲得鎖。

然而,鎖超時時,我們不能簡單地使用 DEL 命令刪除鍵 lock.foo 以釋放鎖。考慮以下情況,程序P1已經首先獲得了鎖 lock.foo,然後程序P1掛掉了。程序P2,P3正在不斷地檢測鎖是否已釋放或者已超時,執行流程如下:

  • P2和P3程序讀取鍵 lock.foo 的值,檢測鎖是否已超時(通過比較當前時間和鍵 lock.foo 的值來判斷是否超時)
  • P2和P3程序發現鎖 lock.foo 已超時
  • P2執行 DEL lock.foo命令
  • P2執行 SETNX lock.foo命令,並返回1,即P2獲得鎖
  • P3執行 DEL lock.foo命令將P2剛剛設定的鍵 lock.foo 刪除(這步是由於P3剛才已檢測到鎖已超時)
  • P3執行 SETNX lock.foo命令,並返回1,即P3獲得鎖
  • P2和P3同時獲得了鎖

從上面的情況可以得知,在檢測到鎖超時後,程序不能直接簡單地執行 DEL 刪除鍵的操作以獲得鎖。

為了解決上述演算法可能出現的多個程序同時獲得鎖的問題,我們再來看以下的演算法。
我們同樣假設程序P1已經首先獲得了鎖 lock.foo,然後程序P1掛掉了。接下來的情況:

  • 程序P4執行 SETNX lock.foo 以嘗試獲取鎖
  • 由於程序P1已獲得了鎖,所以P4執行 SETNX lock.foo 返回0,即獲取鎖失敗
  • P4執行 GET lock.foo 來檢測鎖是否已超時,如果沒超時,則等待一段時間,再次檢測
  • 如果P4檢測到鎖已超時,即當前的時間大於鍵 lock.foo 的值,P4會執行以下操作
    GETSET lock.foo <current Unix timestamp + lock timeout + 1>
  • 由於 GETSET 操作在設定鍵的值的同時,還會返回鍵的舊值,通過比較鍵 lock.foo 的舊值是否小於當前時間,可以判斷程序是否已獲得鎖
  • 假如另一個程序P5也檢測到鎖已超時,並在P4之前執行了 GETSET 操作,那麼P4的 GETSET 操作返回的是一個大於當前時間的時間戳,這樣P4就不會獲得鎖而繼續等待。注意到,即使P4接下來將鍵 lock.foo 的值設定了比P5設定的更大的值也沒影響。

另外,值得注意的是,在程序釋放鎖,即執行 DEL lock.foo 操作前,需要先判斷鎖是否已超時。如果鎖已超時,那麼鎖可能已由其他程序獲得,這時直接執行 DEL lock.foo 操作會導致把其他程序已獲得的鎖釋放掉。

程式程式碼

用以下python程式碼來實現上述的使用 SETNX 命令作分散式鎖的演算法。

LOCK_TIMEOUT = 3
lock = 0
lock_timeout = 0
lock_key = 'lock.foo'
 
# 獲取鎖
while lock != 1:
    now = int(time.time())
    lock_timeout = now + LOCK_TIMEOUT + 1
    lock = redis_client.setnx(lock_key, lock_timeout)
    if lock == 1 or (now > int(redis_client.get(lock_key))) and now > int(redis_client.getset(lock_key, lock_timeout)):
        break
    else:
        time.sleep(0.001)
 
# 已獲得鎖
do_job()
 
# 釋放鎖
now = int(time.time())
if now < lock_timeout:
    redis_client.delete(lock_key)

Redis有序集合Zset的底層資料結構:跳躍表(跳錶,skip list)

1 為什麼引入跳躍表
我們知道紅黑樹是一種存在於記憶體中,可以保證在最壞的情況下,對紅黑樹進行例如search,insert,以及delete等基本的動態集合操作的時間複雜度為O(lg n)。

但是顯而易見,紅黑樹實現起來比較複雜,尤其是對紅黑樹進行insert和delete操作。並且在紅黑樹中進行範圍查詢時需要對紅黑樹進行中序遍歷,這也是比較複雜的操作。

那有沒有一種能確保對動態集合search,insert以及delete等操作的時間複雜度在O(lg n)的前提下,實現比較簡單,還能比較方便的進行範圍查詢的資料結構呢?

答案是肯定的,就是我們今天要總結的資料結構——跳躍表(skip list)。

2 引入的過程
例子:假設我們在記憶體中有一個長度達到10萬以上的一個已經排好序的連結串列結構。我們要往這個連結串列結構中插入一個元素。我們是怎麼進行插入的呢?

來看下圖所示的這個列表(為了使連結串列結構簡單,圖中只畫出了8個元素:1,4,5,7,8,9,12,15):

上圖所示連結串列中,各元素按照升序排列,現在要在該連結串列中插入元素10,首先要確定元素10應該插入的位置,如下圖所示。

由於是連結串列結構因此無法使用二分查詢演算法,只能和原連結串列中的結點逐一比較大小來確定位置。這一步的時間複雜度是O(N)。

插入的過程到時很容易,直接改變結點指標的目標,時間複雜度是O(1)。

因此總體的時間複雜度是O(N)。

這對於擁有上十萬的集合來說,這種辦法顯然太慢了。那有什麼辦法可以讓search,insert以及delete操作效能更高一點呢?

search,insert以及delete操作其實歸根結底就是search太慢的問題。所以只要search操作變快insert和delete操作也會變快。

讓我們來回想一下MySQL索引。

所謂的索引就是把資料庫表中的一些特定資訊提取出來,縮小查詢操作時的搜尋範圍,來提升查詢效能。

那我們是不是可以借鑑資料庫索引的思想,提取出連結串列中的部分關鍵結點。

還以上面的例子,那麼我們可以取出所有值為奇數的結點作為關鍵結點。

此時如果要插入一個值為10的新節點,不再需要和原結點1,4,5,7,8,9,12逐一進行比較,只需要比較關鍵結點1,5,7,9,15即可。

確定了新結點在關鍵結點中的位置(9和15之間),就可以回到原連結串列,迅速定位到對應的位置插入(同樣是9和15之間)。

節點數目少,優化效果不是很明顯,如果是十萬個結點,比較次數就整整減少了一半!也就是說雖然增加了50%的額外的空間,但是效能提高了一倍。

不過我們可以進一步思考。既然已經提取出了一層關鍵結點作為索引,那我們為何不能從索引中進一步提取,提出一層索引的索引?

有了2級索引之後,新的結點可以先和2級索引比較,確定大體範圍之後在和1級索引進行比較,最後再回到原連結串列,找到並插入對應位置。

當結點很多的時候,比較次數就會減少到原來的四分之一!當節點足夠多的時候,我們可以不止提出兩層索引,還可以向更高層次提取,保證每一層是上一層結點數的一半。

提取的極限就是同一層只有兩個結點的時候,因為一個結點沒有比較的意義。這樣的多層連結串列結構就是所謂的跳躍表。

3 跳躍表的基本概念
跳躍表是將連結串列改造支援二分法查詢的資料結構 。

如果是一個單鏈表的話,他查詢資料的時間複雜度為O(n),於是給單鏈表新增一級索引每兩個節點提取一個節點到上一級,我們把謅出來的哪一級叫做索引或者索引層,如下圖:

當你查詢12的時候,你只需要遍歷6次就可以得到結果值 ,

先去第一層索引查到,遍歷到9的時候發現下一個節點是15那我們就知道此時12就在這兩個節點之間,所以我們進行down進入下一層

繼續遍歷這個時候我們只需要遍歷兩個節點就可以找到了,所以我們遍歷12在建立上層索引的情況下是隻需要遍歷7次,但是單鏈表便利需要7次,那我們在繼續新增及層索引如下圖:

當有64個節點的連結串列的時候,則會建立多少層索引,通過計算會有5層,那麼每一層的索引個數有 (n為總的索引樹,k為建立的索引層數(不包括原始連結串列資料結構)),

最高的層的索引層的長度為2,那我們計算出 層級為 ,

如果我們每一層遍歷M個元素那麼我們的時間複雜度為 ,

我們的是兩個元素結合為一個節點那麼每一層最多遍歷3個元素,那麼我們時間複雜度為 那麼時間複雜度為

現在就是在原有的得單鏈表上建立了多層索引而達到二分法查詢,達到很高。

那麼現在這樣豈不是浪費了很多的記憶體(空間換用時間)。

有一個問題需要注意:

當大量的新節點通過逐層比較,最終插入到原連結串列之後,上層的索引結點會漸漸變得不夠用。

這時候需要從新結點中選取一部分提到上一層。

可是究竟應該提拔誰呢?

這可能是隨機選取(也叫拋硬幣,50%的可能性會被提拔,50%的可能性不會被提拔)的,也可能是根據某些規則確定性的選取,其中隨機選取更加常見(因為跳躍表的元素刪除和新增是不可預測的,很難用一種有效的演算法來保證跳躍表的索引分佈始終均勻,隨機選取雖然不能保證索引絕對均勻分佈,卻可以大體上趨於均勻)。

下面以隨機選取為例進行說明,比如給定一個長度是7的有序連結串列,結點值一次是1,2,3,5,6,7,8。

那麼我們可以取出所有值為奇數的結點作為關鍵結點。假如值為9的新節點插入原連結串列:

4 跳躍表的更新
4.1跳躍表插入節點
具體看上面的分析,這裡就不再一一贅述。

跳躍表插入節點的流程有以下幾步:

  • 新結點和各層索引結點逐一比較,確定原連結串列的插入位置,時間複雜度是O(logN)。
  • 把索引插入到原連結串列,時間複雜度是O(1)。
  • 利用拋硬幣的隨機方式,決定新結點是否提升到上一級索引。結果為正則提升,並且繼續拋硬幣,結果為負則停止,時間複雜度是O(logN)。

總體上,跳躍表插入操作的時間複雜度是O(logN),而這種資料結構所佔空間是2N。

4.2跳躍表刪除節點
跳躍表的刪除操作比較簡單,只要在索引層找到要刪除的結點,然後順藤摸瓜,刪除每一層的相同結點即可。

這裡還以一個長度是7的有序連結串列為例,結點值一次是1,2,3,5,6,7,8。取出所有值為奇數的結點作為關鍵結點。

如果某一層索引在刪除後只剩下一個結點,那麼整個一層就可以幹掉了。例如要刪除結點的值是5:

我們來總結一下跳躍表刪除結點的操作步驟:

  • 自上而下,查詢第一次出現結點的索引,並逐層找到每一層對應的結點(因為每層索引都是由上層索引晉升的),時間複雜度是O(logN)。
  • 刪除每一層查詢到的結點,如果該層只剩下一個結點,刪除整個一層,時間複雜度是O(logN)。

總體上,跳躍表刪除操作的時間複雜度是O(logN)。

從上面的總結可以看出,相對於紅黑樹來說,由於跳躍表維持結構平衡的成本比較低,完全依靠隨機。而紅黑樹在多次插入和刪除後,需要rebalance來重新調整結構平衡。
對於redis單執行緒的理解

https://zhuanlan.zhihu.com/p/128598311

https://www.icode9.com/content-2-688011.html

阻塞I/O、非阻塞I/O和I/O多路複用