淺談Redis在分布式系統中的協調性運用_Redis

來源:互聯網
上載者:User

在分布式系統中,各個進程(本文使用進程來描述分布式系統中的運行主體,它們可以在同一個物理節點上也可以在不同的物理節點上)相互之間通常是需要協調進行運作的,有時是不同進程所處理的資料有依賴關係,必須按照一定的次序進行處理,有時是在一些特定的時間需要某個進程處理某些事務等等,人們通常會使用分布式鎖、選舉演算法等技術來協調各個進程之間的行為。因為分布式系統本身的複雜特性,以及對於容錯性的要求,這些技術通常是重量級的,比如 Paxos 演算法,欺負選舉演算法,ZooKeeper 等,側重於訊息的通訊而不是共用記憶體,通常也是出了名的複雜和難以理解,當在具體的實現和實施中遇到問題時都是一個挑戰。
Redis 經常被人們認為是一種 NoSQL 軟體,但其本質上是一種分布式的資料結構伺服器軟體,提供了一個分布式的基於記憶體的資料結構儲存服務。在實現上,僅使用一個線程來處理具體的記憶體資料結構,保證它的資料操作命令的原子特性;它同時還支援基於 Lua 的指令碼,每個 Redis 執行個體使用同一個 Lua 解譯器來解釋運行 Lua 指令碼,從而 Lua 指令碼也具備了原子特性,這種原子操作的特性使得基於共用記憶體模式的分布式系統的協調方式成了可能,而且具備了很大的吸引力,和複雜的基於訊息的機制不同,基於共用記憶體的模式對於很多技術人員來說明顯容易理解的多,特別是那些已經瞭解多線程或多進程技術的人。在具體實踐中,也並不是所有的分布式系統都像分散式資料庫系統那樣需要嚴格的模型的,而所使用的技術也不一定全部需要有堅實的理論基礎和數學證明,這就使得基於 Redis 來實現分布式系統的協調技術具備了一定的實用價值,實際上,人們也已經進行了不少嘗試。本文就其中的一些協調技術進行介紹。

signal/wait 操作
在分布式系統中,有些進程需要等待其它進程的狀態的改變,或者通知其它進程自己的狀態的改變,比如,進程之間有操作上的依賴次序時,就有進程需要等待,有進程需要發射訊號通知等待的進程進行後續的操作,這些工作可以通過 Redis 的 Pub/Sub 系列命令來完成,比如:

複製代碼 代碼如下:

import redis, time
rc = redis.Redis()
def wait( wait_for ):
ps = rc.pubsub()  
ps.subscribe( wait_for )
ps.get_message()
wait_msg = None
while True:
msg = ps.get_message()
if msg and msg['type'] == 'message':
wait_msg = msg
break
time.sleep(0.001)
ps.close()
return wait_msgdef
signal_broadcast( wait_in, data ):
wait_count = rc.publish(wait_in, data)
return wait_count
用這個方法很容易進行擴充實現其它的等待策略,比如 try wait,wait 逾時,wait 多個訊號時是要等待全部訊號還是任意一個訊號到達即可返回等等。因為 Redis 本身支援基於模式比對的訊息訂閱(使用 psubscribe 命令),設定 wait 訊號時也可以通過模式比對的方式進行。
和其它的資料操作不同,訂閱訊息是即時易逝的,不在記憶體中儲存,不進行持久化儲存,如果用戶端到服務端的串連斷開的話也是不會重發的,但是在配置了 master/slave 節點的情況下,會把 publish 命令同步到 slave 節點上,這樣我們就可以同時在 master 以及 slave 節點的串連上訂閱某個頻道,從而可以同時接收到發行者發布的訊息,即使 master 在使用過程中出故障,或者到 master 的串連出了故障,我們仍然能夠從 slave 節點獲得訂閱的訊息,從而獲得更好的魯棒性。另外,因為資料不用寫入磁碟,這種方法在效能上也是有優勢的。
上面的方法中訊號是廣播的,所有在 wait 的進程都會收到訊號,如果要將訊號設定成單播,只允許其中一個收到訊號,則可以通過約定頻道名稱模式的方式來實現,比如:
頻道名稱 = 頻道名首碼 (channel) + 訂閱者全域唯一 ID(myid)
其中唯一 ID 可以是 UUID,也可以是一個隨機數字串,確保全域唯一即可。在發送 signal 之前先使用“pubsub channels channel*”命令獲得所有的訂閱者訂閱的頻道,然後發送訊號給其中一個隨機指定的頻道;等待的時候需要傳遞自己的唯一 ID,將頻道名首碼和唯一 ID 合并為一個頻道名稱,然後同前面例子一樣進行 wait。樣本如下:

複製代碼 代碼如下:

import random
single_cast_script="""
local channels = redis.call('pubsub', 'channels', ARGV[1]..'*');
if #channels == 0
then
return 0;
end;
local index= math.mod(math.floor(tonumber(ARGV[2])), #channels) + 1;     
return redis.call( 'publish', channels[index], ARGV[3]); """
def wait_single( channel, myid):
return wait( channel + myid )
def signal_single( channel, data):
rand_num = int(random.random() * 65535)
return rc.eval( single_cast_script, 0, channel, str(rand_num), str(data) )


分布式鎖 Distributed Locks
分布式鎖的實現是人們探索的比較多的一個方向,在 Redis 的官方網站上專門有一篇文檔介紹基於 Redis 的分布式鎖,其中提出了 Redlock 演算法,並列出了多種語言的實現案例,這裡作一簡要介紹。
Redlock 演算法著眼於滿足分布式鎖的三個要素:
安全性:保證互斥,任何時間至多隻有一個用戶端可以持有鎖
免死結:即使當前持有鎖的用戶端崩潰或者從叢集中被分開了,其它用戶端最終總是能夠獲得鎖。
容錯性:只要大部分的 Redis 節點線上,那麼用戶端就能夠擷取和釋放鎖。

鎖的一個簡單直接的實現方法就是用 SET NX 命令設定一個設定了存活周期 TTL 的 Key 來擷取鎖,通過刪除 Key 來釋放鎖,通過存活周期來保證避免死結。不過這個方法存在單點故障風險,如果部署了 master/slave 節點,則在特定條件下可能會導致安全性方面的衝突,比如:

  • 用戶端 A 從 master 節點獲得鎖
  • master 節點在將 key 複製到 slave 節點之前崩潰了
  • slave 節點提升為新的 master 節點
  • 用戶端 B 從新的 master 節點獲得了鎖,而這個鎖實際上已經由用戶端 A 所持有,導致了系統中有兩個用戶端在同一時間段內持有同一個互斥鎖,破壞了互斥鎖的安全性。

在 Redlock 演算法中,通過類似於下面這樣的命令進行加鎖:

複製代碼 代碼如下:

SET resource_name my_random_value NX PX 30000


這裡的 my_random_value 為全域不同的隨機數,每個用戶端需要自己產生這個隨機數並且記住它,後面解鎖的時候需要用到它。
解鎖則需要通過一個 Lua 指令碼來執行,不能簡單地直接刪除 Key,否則可能會把別人持有的鎖給釋放了:

複製代碼 代碼如下:

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


這個 ARGV[1] 的值就是前面加鎖的時候的 my_random_value 的值。
如果需要更好的容錯性,可以建立一個有 N(N 為奇數)個相互獨立完備的 Redis 冗餘節點的叢集,這種情況下,一個用戶端獲得鎖和釋放鎖的演算法如下:
先擷取目前時間戳 timestamp_1,以毫秒為單位。
以相同的 Key 和隨機數值,依次從 N 個節點擷取鎖,每次擷取鎖都設定一個逾時,逾時時限要保證小於所有節點上該鎖的自動釋放時間,以免在某個節點上耗時過長,通常都設的比較短。
用戶端將目前時間戳減去第一步中的時間戳記 timestamp_1,計算擷取鎖總消耗時間。只有當用戶端獲得了半數以上節點的鎖,而且總耗時少於鎖存活時間,該用戶端才被認為已經成功獲得了鎖。
如果獲得了鎖,則其存活時間為開始預設鎖存活時間減去擷取鎖總耗時間。
如果用戶端不能獲得鎖,則應該馬上在所有節點上解鎖。
如果要重試,則在隨機延時之後重新去擷取鎖。
獲得了鎖的用戶端要釋放鎖,簡單地在所有節點上解鎖即可。

Redlock 演算法不需要保證 Redis 節點之間的時鐘是同步的(不論是物理時鐘還是邏輯時鐘),這點和傳統的一些基於同步時鐘的分布式鎖演算法有所不同。Redlock 演算法的具體的細節可以參閱 Redis 的官方文檔,以及文檔中列出的多種語言版本的實現。

選舉演算法
在分布式系統中,經常會有些事務是需要在某個時間段內由一個進程來完成,或者由一個進程作為 leader 來協調其它的進程,這個時候就需要用到選舉演算法,傳統的選舉演算法有欺負選舉演算法(霸道選舉演算法)、環選舉演算法、Paxos 演算法、Zab 演算法 (ZooKeeper) 等,這些演算法有些依賴於訊息的可靠傳遞以及時鐘同步,有些過於複雜,難以實現和驗證。新的 Raft 演算法相比較其它演算法來說已經容易了很多,不過它仍然需要依賴心跳廣播和邏輯時鐘,leader 需要不斷地向 follower 廣播訊息來維持從屬關係,節點擴充時也需要其它演算法配合。
選舉演算法和分布式鎖有點類似,任意時刻最多隻能有一個 leader 資源。當然,我們也可以用前面描述的分布式鎖來實現,設定一個 leader 資源,獲得這個資源鎖的為 leader,鎖的生命週期過了之後,再重新競爭這個資源鎖。這是一種競爭性的演算法,這個方法會導致有比較多的空檔期內沒有 leader 的情況,也不好實現 leader 的連任,而 leader 的連任是有比較大的好處的,比如 leader 執行任務可以比較準時一些,查看日誌以及排查問題的時候也方便很多,如果我們需要一個演算法實現 leader 可以連任,那麼可以採用這樣的方法:

複製代碼 代碼如下:

import redis
rc = redis.Redis()
local_selector = 0def master():
global local_selector
master_selector = rc.incr('master_selector')
if master_selector == 1:
 # initial / restarted
local_selector = master_selector
else:
if local_selector > 0: # I'm the master before
if local_selector > master_selector: # lost, maybe the db is fail-overed.  
local_selector = 0
else: # continue to be the master
local_selector = master_selector
if local_selector > 0: # I'm the current master
rc.expire('master_selector', 20) return local_selector > 0


這個演算法鼓勵連任,只有當前的 leader 發生故障或者執行某個任務所耗時間超過了任期、或者 Redis 節點發生故障恢複之後才需要重新選舉出新的 leader。在 master/slave 模式下,如果 master 節點發生故障,某個 slave 節點提升為新的 master 節點,即使當時 master_selector 值尚未能同步成功,也不會導致出現兩個 leader 的情況。如果某個 leader 一直連任,則 master_selector 的值會一直遞增下去,考慮到 master_selector 是一個 64 位元的整數型別,在可預見的時間內是不可能溢出的,加上每次進行 leader 更換的時候 master_selector 會重設為從 1 開始,這種遞增的方式是可以接受的,但是碰到 Redis 用戶端(比如 Node.js)不支援 64 位元整數型別的時候就需要針對這種情況作處理。如果當前 leader 進程處理時間超過了任期,則其它進程可以重建新的 leader 進程,老的 leader 進程處理完畢事務後,如果新的 leader 的進程經曆的任期次數超過或等於老的 leader 進程的任期次數,則可能會出現兩個 leader 進程,為了避免這種情況,每個 leader 進程在處理完任期事務之後都應該檢查一下自己的處理時間是否超過了任期,如果超過了任期,則應當先設定 local_selector 為 0 之後再調用 master 檢查自己是否是 leader 進程。

訊息佇列
訊息佇列是分布式系統之間的通訊基本設施,通過訊息可以構造複雜的進程間的協調操作和互操作。Redis 也提供了構造訊息佇列的原語,比如 Pub/Sub 系列命令,就提供了基於訂閱/發布模式的訊息收發方法,但是 Pub/Sub 訊息並不在 Redis 內保持,從而也就沒有進行持久化,適用於所傳輸的訊息即使丟失了也沒有關係的情境。
如果要考慮到持久化,則可以考慮 list 系列操作命令,用 PUSH 系列命令(LPUSH, RPUSH 等)推送訊息到某個 list,用 POP 系列命令(LPOP, RPOP,BLPOP,BRPOP 等)擷取某個 list 上的訊息,通過不同的組合方式可以得到 FIFO,FILO,比如:

複製代碼 代碼如下:

import redis
rc = redis.Redis()
def fifo_push(q, data):
 rc.lpush(q, data)
def fifo_pop(q):
return rc.rpop(q)
def filo_push(q, data):
rc.lpush(q, data)
def filo_pop(q):
return rc.lpop(q)


如果用 BLPOP,BRPOP 命令替代 LPOP, RPOP,則在 list 為空白的時候還支援阻塞等待。不過,即使按照這種方式實現了持久化,如果在 POP 訊息返回的時候網路故障,則依然會發生訊息丟失,針對這種需求 Redis 提供了 RPOPLPUSH 和 BRPOPLPUSH 命令來先將提取的訊息儲存在另外一個 list 中,用戶端可以先從這個 list 查看和處理訊息資料,處理完畢之後再從這個 list 中刪除訊息資料,從而確保了訊息不會丟失,樣本如下:

複製代碼 代碼如下:

def safe_fifo_push(q, data):
rc.lpush(q, data)
def safe_fifo_pop(q, cache):
msg = rc.rpoplpush(q, cache) # check and do something on msg    
rc.lrem(cache, 1) # remove the msg in cache list. return msg


如果使用 BRPOPLPUSH 命令替代 RPOPLPUSH 命令,則可以在 q 為空白的時候阻塞等待。

相關文章

聯繫我們

該頁面正文內容均來源於網絡整理,並不代表阿里雲官方的觀點,該頁面所提到的產品和服務也與阿里云無關,如果該頁面內容對您造成了困擾,歡迎寫郵件給我們,收到郵件我們將在5個工作日內處理。

如果您發現本社區中有涉嫌抄襲的內容,歡迎發送郵件至: info-contact@alibabacloud.com 進行舉報並提供相關證據,工作人員會在 5 個工作天內聯絡您,一經查實,本站將立刻刪除涉嫌侵權內容。

A Free Trial That Lets You Build Big!

Start building with 50+ products and up to 12 months usage for Elastic Compute Service

  • Sales Support

    1 on 1 presale consultation

  • After-Sales Support

    24/7 Technical Support 6 Free Tickets per Quarter Faster Response

  • Alibaba Cloud offers highly flexible support services tailored to meet your exact needs.