今天我們來聊一聊分散式鎖的那些事。
相信大家對鎖已經不陌生了,我們在多執行緒環境中,如果需要對同一個資源進行操作,為了避免資料不一致,我們需要在操作共享資源之前進行加鎖操作。在電腦科學中,鎖(lock)或互斥(mutex)是一種同步機制,用於在有許多執行執行緒的環境中強制對資源的訪問限制。
比如你去相親,發現你和一大哥同時和一個女的相親,那怎麼行呢...,搞不好還要被揍一頓。
那什麼是分散式鎖呢。當多個客戶端需要爭搶鎖時,我們就需要分散式鎖。這把鎖不能是某個客戶端本地的鎖,否則的話,其它客戶端是無法訪問的。所以分散式鎖是需要儲存在共享儲存系統中的,比如Redis、Zookeeper等,可以被多個客戶端共享訪問和獲取。今天我們就來看一下如何使用Redis來實現分散式鎖。
一、前言
在正式開始之前,我們先來了解兩個Redis的命令:
SETNX key value
這個命名的含義是,當key存在時,不做任何賦值操作;當key不存在時,就建立key,並賦值成value,即(不存在即設定)。
SET key value [EX seconds | PX milliseconds] NX
SET後加NX選項,就和SETNX命令類似了,也實現不存在即設定的功能。此外,這個命令在執行時,可以通過EX或者PX設定鍵值對的過期時間。
二、正文
開始之前,我們先引入一個場景:
假設要給某個商品舉行秒殺活動,我們事先把庫存資料100已經存入到了redis中,我們現在需要來進行庫存扣減。
如圖所示,我們假設有1000個客戶端來進行庫存扣減操作,那我們該如何做,才能保證庫存扣減順序一致且不會超扣呢。
我們首先想到的就是加鎖,在進行庫存扣減之前,我們先拿到鎖,然後進行扣減,最後再釋放鎖。在redis中我們建立一個key來代表一個鎖變數,然後對應的值來表示鎖變數的值。我們來看一下如何進行加鎖。
假設1000個客戶端同時進行加鎖請求。因為redis使用單執行緒來處理請求,所以redis會序列執行他們的請求操作。假設redis先處理客戶端2的請求,讀取lock_key的值,發現lock_key為0,所以客戶端2就把lock_key的value設定成1,表示已經進行了加鎖操作。如果此時客戶端3被處理,發現lock_key的值已經為1了,所以就返回加鎖失敗的資訊。
當拿到鎖的客戶端2處理完共享資源後,就要進行釋放鎖的操作,釋放鎖很簡單,就是將lock_key重新設定為0。
由於加鎖操作包含了三個操作(讀取鎖變數、判斷鎖變數的值以及把鎖變數的值設定成1),而這三個操作在執行的過程中需要保證原子性。那怎麼保證原子性呢?
我們可以使用SETNX命令來實現加鎖操作,SETNX命令表示key不存在時就建立,key存在時就不做任何賦值操作,當加鎖時候,我們執行
SETNX lock_key 1
對於釋放鎖操作來說,我們可以使用DEL命令來刪除鎖變數。比如客戶端2進行加鎖,執行SETNX lock_key 1,如果lock_key不存在,則會建立lock_key,返回加鎖成功,此時客戶端2可以進行共享資源的訪問。如果這時客戶端1來發起請求加鎖操作,而此時lock_key已經存在,SETNX lock_key 1不做任何賦值操作操作,返回加鎖失敗,所以客戶端1加鎖失敗。當客戶端2執行完共享資源訪問後,執行DEL命令來釋放鎖。此時當有其它客戶端再來訪問時,lock_key已經不存在了,就可以進行正常的加鎖操作了。所以,我們可以使用SETNX和DEL命令組合來進行加鎖和釋放鎖的操作。
不過這裡有兩個問題:
1.當某個客戶端執行完SETNX命令、加鎖後,此時發生了異常,結果一直沒有執行DEL操作命令來釋放鎖。因此,這個客戶端一直佔用著這個鎖,其它客戶端無法拿到鎖。
解決這個問題,一個有效的方法就是,給鎖變數設定一個過期時間。這樣一來,即使持有鎖的客戶端發生了異常,無法主動的釋放鎖,Redis也會根據鎖變數的過期時間把它刪除。其它客戶端在鎖變數過期後,就可以重新進行加鎖操作了。
2.如果客戶端1執行了SETNX 命令加鎖後。如果此時客戶端2執行DEL命令刪除鎖,這時,客戶端A的鎖就被誤釋放了。這是我們不能接受的。
為了解決這個問題,我們需要能區分來自不同客戶端的鎖操作。我們該如何做呢?我們可以給每個客戶端生成一個唯一值,在進行加鎖時,我們把鎖變數賦值成這個唯一值。這樣在釋放鎖的時候,客戶端需要判斷,當前鎖變數的值是否和自己的唯一標識相等,在相等的情況下,才能釋放鎖。
下面來看一下如何在Redis中進行實現。我們可以使用SET加EX/PX和NX選項,來進行加鎖操作。
SET lock_key uuid NX PX 100
其中lock_key是鎖變數,uuid表示客戶端的唯一標識,PX 100表示100ms過期。由於我們在釋放鎖時需要對比客戶端的標識和鎖變數的值是否一致,這包含了多個操作,為了保證原子性,我們需要使用lua指令碼,下面是lua指令碼的實現。
if redis.call("get",KEYS[1]) == ARGV[1] then return redis.call("del",KEYS[1]) else return 0 end
其中KEY[1]表示lock_key,ARGV[1]表示當前客戶端的唯一標識,這兩個值是我們在執行lua指令碼時作為引數傳入的。下面我們來看一下完整的程式碼實現。
import redis import traceback import uuid import time class Inventory(object): def __init__(self): pool = redis.ConnectionPool(host='localhost', port=6379, decode_responses=True) client = redis.StrictRedis(connection_pool=pool, max_connections=20) self.client=client self.uuid=str(uuid.uuid1()) print(self.uuid) self.key="lock_key" self.inventory_key="inventory" def unlock(self): unlock_script="" \ "if redis.call(\"get\",KEYS[1]) == ARGV[1] then" \ " return redis.call(\"del\",KEYS[1])" \ "else" \ " return 0 " \ "end" try: unlock_cmd=self.client.register_script(unlock_script) result=unlock_cmd(keys=[self.key],args=[self.uuid]) if result==1: print("釋放成功") else: print("釋放出錯") except: print(traceback.format_exc()) def lock(self): try: while True: result=self.client.set(self.key,self.uuid,px=100,nx=True) print(result) if result==1: break print("sleep 1s") time.sleep(1) print("加鎖成功") return True except: print(traceback.format_exc()) def inventory(self): if self.lock(): print("庫存扣減") self.client.decr(self.inventory_key) print("扣減完成") self.unlock() inv=Inventory() inv.inventory()
到此,我們就把Redis實現分散式鎖就聊完了。既然都讀到了這裡,不妨給個「三連」吧,你的三連就是我最大的動力。
三、後記
更多硬核知識,請關注公眾號【程式設計師學長】。回覆【資料】可以獲得上百本電子書資料
我們下期見。