十分鐘搞懂redis原子操作

2022-02-17 19:00:39
本篇文章給大家帶來了關於redis原子操作的相關知識,為了保證並行存取的正確性,Redis 提供了兩種方法,分別是加鎖和原子操作,希望對大家有幫助。

redis原子操作

我們在使用 Redis 時,不可避免地會遇到並行存取的問題,比如說如果多個使用者同時下單,就會對快取在 Redis 中的商品庫存並行更新。一旦有了並行寫操作,資料就會被修改,如果我們沒有對並行寫請求做好控制,就可能導致資料被改錯,影響到業務的正常使用(例如庫存資料錯誤,導致下單異常)。

為了保證並行存取的正確性,Redis 提供了兩種方法,分別是加鎖和原子操作。

加鎖是一種常用的方法,在讀取資料前,使用者端需要先獲得鎖,否則就無法進行操作。當一個使用者端獲得鎖後,就會一直持有這把鎖,直到使用者端完成資料更新,才釋放這把鎖。
看上去好像是一種很好的方案,但是,其實這裡會有兩個問題:一個是,如果加鎖操作多,會降低系統的並行存取效能;第二個是,Redis 使用者端要加鎖時,需要用到分散式鎖,而分散式鎖實現複雜,需要用額外的儲存系統來提供加解鎖操作,我會在下節課向你介紹。

原子操作是另一種提供並行存取控制的方法。原子操作是指執行過程保持原子性的操作,而且原子操作執行時並不需要再加鎖,實現了無鎖操作。這樣一來,既能保證並行控制,還能減少對系統並行效能的影響。

並行存取中需要對什麼進行控制?
我們說的並行存取控制,是指對多個使用者端存取操作同一份資料的過程進行控制,以保證任何一個使用者端傳送的操作在 Redis 範例上執行時具有互斥性。例如,使用者端 A 的存取操作在執行時,使用者端 B 的操作不能執行,需要等到 A 的操作結束後,才能執行。

並行存取控制對應的操作主要是資料修改操作。當用戶端需要修改資料時,基本流程分成兩步:

  1. 使用者端先把資料讀取到本地,在本地進行修改;
  2. 使用者端修改完資料後,再寫回 Redis。

我們把這個流程叫做「讀取 - 修改 - 寫回」操作(Read-Modify-Write,簡稱為 RMW 操作)。當有多個使用者端對同一份資料執行 RMW 操作的話,我們就需要讓 RMW 操作涉及的程式碼以原子性方式執行。存取同一份資料的 RMW 操作程式碼,就叫做臨界區程式碼。

不過,當有多個使用者端並行執行臨界區程式碼時,就會存在一些潛在問題,接下來,我用一個多使用者端更新商品庫存的例子來解釋一下。

我們先看下臨界區程式碼。假設使用者端要對商品庫存執行扣減 1 的操作,虛擬碼如下所示:

current = GET(id)
current--
SET(id, current)

可以看到,使用者端首先會根據商品 id,從 Redis 中讀取商品當前的庫存值 current(對應 Read),然後,使用者端對庫存值減 1(對應 Modify),再把庫存值寫回 Redis(對應 Write)。當有多個使用者端執行這段程式碼時,這就是一份臨界區程式碼。

如果我們對臨界區程式碼的執行沒有控制機制,就會出現資料更新錯誤。在剛才的例子中,假設現在有兩個使用者端 A 和 B,同時執行剛才的臨界區程式碼,就會出現錯誤,你可以看下下面這張圖。
在這裡插入圖片描述

可以看到,使用者端 A 在 t1 時讀取庫存值 10 並扣減 1,在 t2 時,使用者端 A 還沒有把扣減後的庫存值 9 寫回 Redis,而在此時,使用者端 B 讀到庫存值 10,也扣減了 1,B 記錄的庫存值也為 9 了。等到 t3 時,A 往 Redis 寫回了庫存值 9,而到 t4 時,B 也寫回了庫存值 9。

如果按正確的邏輯處理,使用者端 A 和 B 對庫存值各做了一次扣減,庫存值應該為 8。所以,這裡的庫存值明顯更新錯了。

出現這個現象的原因是,臨界區程式碼中的使用者端讀取資料、更新資料、再寫回資料涉及了三個操作,而這三個操作在執行時並不具有互斥性,多個使用者端基於相同的初始值進行修改,而不是基於前一個使用者端修改後的值再修改。

為了保證資料並行修改的正確性,我們可以用鎖把並行操作變成序列操作,序列操作就具有互斥性。一個使用者端持有鎖後,其他使用者端只能等到鎖釋放,才能拿鎖再進行修改。

下面的虛擬碼顯示了使用鎖來控制臨界區程式碼的執行情況,你可以看下。

LOCK()
current = GET(id)
current--
SET(id, current)
UNLOCK()

雖然加鎖保證了互斥性,但是加鎖也會導致系統並行效能降低。

如下圖所示,當用戶端 A 加鎖執行操作時,使用者端 B、C 就需要等待。A 釋放鎖後,假設 B 拿到鎖,那麼 C 還需要繼續等待,所以,t1 時段內只有 A 能存取共用資料,t2 時段內只有 B 能存取共用資料,系統的並行效能當然就下降了。
在這裡插入圖片描述

和加鎖類似,原子操作也能實現並行控制,但是原子操作對系統並行效能的影響較小,接下來,我們就來了解下 Redis 中的原子操作。

Redis 的兩種原子操作方法

為了實現並行控制要求的臨界區程式碼互斥執行,Redis 的原子操作採用了兩種方法:

  1. 把多個操作在 Redis 中實現成一個操作,也就是單命令操作;
  2. 把多個操作寫到一個 Lua 指令碼中,以原子性方式執行單個 Lua 指令碼。

我們先來看下 Redis 本身的單命令操作。

Redis 是使用單執行緒來序列處理使用者端的請求操作命令的,所以,當 Redis 執行某個命令操作時,其他命令是無法執行的,這相當於命令操作是互斥執行的。當然,Redis 的快照生成、AOF 重寫這些操作,可以使用後臺執行緒或者是子程序執行,也就是和主執行緒的操作並行執行。不過,這些操作只是讀取資料,不會修改資料,所以,我們並不需要對它們做並行控制。

你可能也注意到了,雖然 Redis 的單個命令操作可以原子性地執行,但是在實際應用中,資料修改時可能包含多個操作,至少包括讀資料、資料增減、寫回資料三個操作,這顯然就不是單個命令操作了,那該怎麼辦呢?

別擔心,Redis 提供了 INCR/DECR 命令,把這三個操作轉變為一個原子操作了。INCR/DECR 命令可以對資料進行增值 / 減值操作,而且它們本身就是單個命令操作,Redis 在執行它們時,本身就具有互斥性。

比如說,在剛才的庫存扣減例子中,使用者端可以使用下面的程式碼,直接完成對商品 id 的庫存值減 1 操作。即使有多個使用者端執行下面的程式碼,也不用擔心出現庫存值扣減錯誤的問題。

DECR id

所以,如果我們執行的 RMW 操作是對資料進行增減值的話,Redis 提供的原子操作 INCR 和 DECR 可以直接幫助我們進行並行控制。

但是,如果我們要執行的操作不是簡單地增減資料,而是有更加複雜的判斷邏輯或者是其他操作,那麼,Redis 的單命令操作已經無法保證多個操作的互斥執行了。所以,這個時候,我們需要使用第二個方法,也就是 Lua 指令碼。

Redis 會把整個 Lua 指令碼作為一個整體執行,在執行的過程中不會被其他命令打斷,從而保證了 Lua 指令碼中操作的原子性。如果我們有多個操作要執行,但是又無法用 INCR/DECR 這種命令操作來實現,就可以把這些要執行的操作編寫到一個 Lua 指令碼中。
然後,我們可以使用 Redis 的 EVAL 命令來執行指令碼。這樣一來,這些操作在執行時就具有了互斥性。

再舉個例子,具體解釋下 Lua 的使用。
當一個業務應用的存取使用者增加時,我們有時需要限制某個使用者端在一定時間範圍內的存取次數,比如爆款商品的購買限流、社群網路中的每分鐘點贊次數限制等。

那該怎麼限制呢?我們可以把使用者端 IP 作為 key,把使用者端的存取次數作為 value,儲存到 Redis 中。使用者端每存取一次後,我們就用 INCR 增加存取次數。

不過,在這種場景下,使用者端限流其實同時包含了對存取次數和時間範圍的限制,例如每分鐘的存取次數不能超過 20。所以,我們可以在使用者端第一次存取時,給對應鍵值對設定過期時間,例如設定為 60s 後過期。同時,在使用者端每次存取時,我們讀取使用者端當前的存取次數,如果次數超過閾值,就報錯,限制使用者端再次存取。你可以看下下面的這段程式碼,它實現了對使用者端每分鐘存取次數不超過 20 次的限制。

//獲取ip對應的存取次數
current = GET(ip)
//如果超過存取次數超過20次,則報錯
IF current != NULL AND current > 20 THEN
    ERROR "exceed 20 accesses per second"
ELSE
    //如果存取次數不足20次,增加一次存取計數
    value = INCR(ip)
    //如果是第一次存取,將鍵值對的過期時間設定為60s後
    IF value == 1 THEN
        EXPIRE(ip,60)
    END
    //執行其他操作
    DO THINGS
END

可以看到,在這個例子中,我們已經使用了 INCR 來原子性地增加計數。但是,使用者端限流的邏輯不只有計數,還包括存取次數判斷和過期時間設定。

對於這些操作,我們同樣需要保證它們的原子性。否則,如果使用者端使用多執行緒存取,存取次數初始值為 0,第一個執行緒執行了 INCR(ip) 操作後,第二個執行緒緊接著也執行了 INCR(ip),此時,ip 對應的存取次數就被增加到了 2,我們就無法再對這個 ip 設定過期時間了。這樣就會導致,這個 ip 對應的使用者端存取次數達到 20 次之後,就無法再進行存取了。即使過了 60s,也不能再繼續存取,顯然不符合業務要求。

所以,這個例子中的操作無法用 Redis 單個命令來實現,此時,我們就可以使用 Lua 指令碼來保證並行控制。我們可以把存取次數加 1、判斷存取次數是否為 1,以及設定過期時間這三個操作寫入一個 Lua 指令碼,如下所示:

local current
current = redis.call("incr",KEYS[1])
if tonumber(current) == 1 then
    redis.call("expire",KEYS[1],60)
end

假設我們編寫的指令碼名稱為 lua.script,我們接著就可以使用 Redis 使用者端,帶上 eval 選項,來執行該指令碼。指令碼所需的引數將通過以下命令中的 keys 和 args 進行傳遞。

redis-cli  --eval lua.script  keys , args

這樣一來,存取次數加 1、判斷存取次數是否為 1,以及設定過期時間這三個操作就可以原子性地執行了。即使使用者端有多個執行緒同時執行這個指令碼,Redis 也會依次序列執行指令碼程式碼,避免了並行操作帶來的資料錯誤。

推薦學習:《》、《》

以上就是十分鐘搞懂redis原子操作的詳細內容,更多請關注TW511.COM其它相關文章!