Redis 到底能不能保證原子性?
Redis是 Java程序員工作中經(jīng)常使用的一個 NoSQL,很多人把它對標(biāo)成數(shù)據(jù)庫,因此,原子性成了特別關(guān)注的問題。那么,Redis到底能不能保證原子性?這篇文章來聊一聊。
一、原子性
要想弄清楚這個問題,我們需要對“原子性”這個概念有一個清晰的認(rèn)識,因此,首先要分析的是原子性的概念。
1. 通常意義的原子性
通常意義上,我們說的原子性是指關(guān)系型數(shù)據(jù)庫 RDBMS(比如 MySQL)的原子性,也就是 ACID(Atomicity、Consistency、Isolation、Durability)中 Atomicity這一項特性。
ACID 中的原子性指:事務(wù)中的所有操作要么全部執(zhí)行,要么全部不執(zhí)行。
這里以銀行轉(zhuǎn)賬,賬戶A 給賬戶B 轉(zhuǎn)賬100元為例來解釋原子性:
- 賬戶A 減去100元;
- 賬戶B 增加100元;
原子性是指上面兩個過程,要么全部執(zhí)行,要么全部不執(zhí)行。也就是說,賬戶A 減去 100元的同時,賬戶B 必須增加100元,否則,該操作就不具備原子性。Java代碼簡要實現(xiàn)如下圖:
2. Lua 原子性
在分析 Lua的原子性之前,我們先看看 Lua是什么,下圖摘自 Lua官方描述:
從官方描述可以得知:Lua 是一種功能強大、高效、輕量級、可嵌入的腳本語言。它支持過程編程、面向?qū)ο缶幊?、函?shù)式編程、數(shù)據(jù)驅(qū)動編程和數(shù)據(jù)描述。 Lua 將簡單的過程語法與基于關(guān)聯(lián)數(shù)組和可擴(kuò)展語義的強大數(shù)據(jù)描述結(jié)構(gòu)相結(jié)合。Lua 是動態(tài)類型的,通過使用基于寄存器的虛擬機(jī)解釋字節(jié)碼來運行,并具有自動內(nèi)存管理和增量垃圾回收功能,使其成為配置、腳本編寫和快速原型設(shè)計的理想選擇。
Lua 本身并沒有提供對于原子性的直接支持,它只是一種腳本語言,通常是嵌入到其他宿主程序中運行,比如 Redis。
在 Redis中執(zhí)行 Lua的原子性是指:整個 Lua腳本在執(zhí)行期間,會被當(dāng)作一個整體,不會被其他客戶端的命令打斷。
為了對 Redis執(zhí)行 Lua的原子性有一個感官上的認(rèn)識,這里以 Lua腳本中需要完成 SET key1 value1 和 INCRBY key2 value2 和 SET key3 value3 三個命令為例:
上述例子,整個 luaScript 字符串腳本作為一個整體被執(zhí)行且不被其他事務(wù)打斷,這就是一個原子性的操作。
好了,總結(jié)下 ACID的原子性和 Redis執(zhí)行 Lua腳本原子性在概念上的差異:
- ACID的原子性是指:事務(wù)中的命令要么全執(zhí)行,要么全部不執(zhí)行;
- Redis中執(zhí)行 Lua腳本原子性是指:Lua腳本會作為一個整體執(zhí)行且不被其他客戶端打斷,至于 Lua腳本里面的命令是否必須全部成功,或者全部失敗,并不要求。關(guān)于這一點,在接下來的內(nèi)容也會詳細(xì)解釋;
在分析原子性概念時,我們可以發(fā)現(xiàn)“原子性”其實是事務(wù)中的一項特性,因此,接下來分析 Redis的事務(wù)。
二、Redis 事務(wù)
下圖是 Redis官方對事務(wù)描述的摘要:
文檔看起來很長,總結(jié)成一句話:Redis 事務(wù)允許執(zhí)行一批命令,通過執(zhí)行 MULTI命令開啟事務(wù),執(zhí)行 EXEC命令結(jié)束事務(wù),WATCH 和 DISCARD 配合事務(wù)一起使用,提供了一種 CAS(check-and-set) 樂觀鎖的機(jī)制。WATCH 用于監(jiān)聽 Key,如果被監(jiān)聽的 Key有任何一個發(fā)生變化,則中止事務(wù)(被動關(guān)閉事務(wù)),而 DISCARD 用于主動中止事務(wù)。
1. MULTI/EXEC
用一個示例來理解 MULTI/EXEC:
通過執(zhí)行的結(jié)果可以看出:Redis的事務(wù)是以 MULTI命令開啟,以 EXEC命令結(jié)束,期間所有的命令都是先進(jìn)入隊列,只有執(zhí)行 EXEC命令時,才會把隊列中的所有命令順序串行執(zhí)行,并且返回一個所有命令執(zhí)行結(jié)果的數(shù)組,包括命令執(zhí)行的錯誤信息。
需要注意的是:在 EXEC 執(zhí)行后,即使事務(wù)隊列中有命令執(zhí)行失敗,隊列中的所有其他命令也會被處理,Redis 不會停止執(zhí)行這些命令。
DISCARD 和 WATCH 也是 Redis 中用于事務(wù)的兩個命令,它們與 MULTI 和 EXEC 一起使用,提供更復(fù)雜的事務(wù)處理機(jī)制。
2. WATCH
WATCH 命令用于監(jiān)聽一個或多個 Key,如果在執(zhí)行事務(wù)期間這些 Key中任何一個Key的 value被其他事務(wù)修改,當(dāng)前整個事務(wù)將會被中止。(需要注意:低于 6.0.9 的 Redis 版本,Key過期不會中止事務(wù))
如下示例:事務(wù)1 watch key1 key2,事務(wù)2在事務(wù)1執(zhí)行期間修改 key2 = 10,當(dāng)事務(wù)1執(zhí)行 exec命令時,因為 watch監(jiān)聽到 key2被其他事務(wù)(事務(wù)2)修改了(value=10) , 因此事務(wù)1被取消,事務(wù)隊列中的所有命令被清除,即 set key1 value1 和 incrby key 2兩條命令都不執(zhí)行,key2的 value還是10;
事務(wù)1 | 事務(wù)2 |
watch key1 key2 | |
multi | |
set key1 value1 | |
incrby key2 2 | set key2 10 |
exec | |
keys * // 只有key2=10 | keys * // 只有key2=10DISCARD |
DISCARD 命令用于中止事務(wù)。
如下示例,執(zhí)行 DISCARD命令后,當(dāng)前事務(wù)被中止,因此,執(zhí)行 EXEC 時會報“ERR EXEC without MULTI”錯誤。
3. 事務(wù)中的錯誤
事務(wù)中主要會出現(xiàn)兩種類型的錯誤:
(1) 事務(wù)命令進(jìn)入事務(wù)隊列之前出錯。例如,命令語法錯誤(參數(shù)錯誤、命令名稱錯誤等),或者可能存在一些關(guān)鍵情況,比如內(nèi)存不足。如下示例,命令incr key2 1/0 在進(jìn)入事務(wù)隊列之前報錯,所以,當(dāng)前事務(wù)被中止,執(zhí)行 EXEC命令會報錯:
(2) 調(diào)用 EXEC 命令后,事務(wù)隊列中的命令執(zhí)行失敗。例如,對字符串值進(jìn)行加1操作。如下示例,key的 value是字符串,當(dāng)對 key 執(zhí)行incr key 操作時報錯,因此,該條命令執(zhí)行失?。?/p>
4. 事務(wù)回滾
Redis的事務(wù)不支持回滾。 官方說明如下:
Redis 不支持事務(wù)回滾,因為支持回滾會對 Redis 的簡單性和性能產(chǎn)生重大影響。
官方說明簡明扼要,其實,多加思考也能理解:"Redis" 是 "REmote DIctionary Server" 的縮寫,翻譯為“遠(yuǎn)程字典服務(wù)”,設(shè)計的初衷是用于緩存,追求快速高效。而了解過 ACID事務(wù)的小伙伴應(yīng)該能明白事務(wù)回滾的復(fù)雜度,因此,Redis不支持事務(wù)回滾似乎也合情合理。
到此,我們也對 Redis事務(wù)做個小結(jié):Redis的事務(wù)由 MULTI/EXEC 兩個命令完成,WATCH/DISCARD 兩個命令的加持,給 Redis事務(wù)提供了 CAS 樂觀鎖機(jī)制。Redis 事務(wù)不支持回滾,它和關(guān)系型數(shù)據(jù)庫(比如 MySQL)的事務(wù)(ACID)是不一樣的。
三、Redis 如何執(zhí)行 Lua?
分析完原子性和 Redis事務(wù)這些理論知識后,我們就得動手實操,看看 Redis是如何執(zhí)行 Lua的。
一般情況下,Redis執(zhí)行 Lua常用的方法有 2種:
- 原生命令,比如 EVAL/EVALSHA命令等;
- 編程工具,比如編程語言中提供的三方工具包或類庫;
在編寫 Lua腳本時,需要注意區(qū)分 redis.call() 和 redis.pcall() 兩個命令的使用。
1. EVAL
語法:
EVAL script numkeys [key [key ...]] [arg [arg ...]]
EVAL語法很簡單,EVAL script numkeys 是必填項,[key [key ...]] [arg [arg ...]]是選填項。
如下示例截圖,分別展示了不傳Key,傳 1個key 和 2個 key 3種場景:
下圖示例展示了 [key [key ...]] [arg [arg ...]] 和 numkeys 匹配錯誤時報錯的場景:
2. redis.call()
redis.call() 用于執(zhí)行 Redis的命令。當(dāng)命令執(zhí)行出錯時,會阻斷整個腳本執(zhí)行,并將錯誤信息返回給客戶端。
如下示例:當(dāng)執(zhí)行INCRBY key2 1/0 失敗時,會拋異常,后續(xù)流程被阻斷,即SET key3 value3沒有被執(zhí)行。
Redis原生命令執(zhí)行示例如下:
EVAL "redis.call('SET', 'key1', 'value1'); redis.call('INCRBY', 'key2', 1/0); redis.call('SET', 'key3', 'value3')" 0
使用 Jedis框架執(zhí)行 Lua示例如下:
查看 Lua執(zhí)行后各個key的值。
3. redis.pcall()
redis.pcall() 也用于執(zhí)行 Redis的命令。當(dāng)命令執(zhí)行出錯時,不會阻斷腳本的執(zhí)行,而是內(nèi)部捕獲錯誤,并繼續(xù)執(zhí)行后續(xù)的命令。
如下示例:當(dāng)執(zhí)行INCRBY key2 1/0 失敗時,不會拋異常,后續(xù)流程繼續(xù)執(zhí)行,即SET key3 value3 也被執(zhí)行。
Redis原生命令執(zhí)行示例:
EVAL "redis.pcall('SET', 'key1', 'value1'); redis.pcall('INCRBY', 'key2', 1/0); redis.pcall('SET', 'key3', 'value3')" 0
使用 Jedis框架執(zhí)行 Lua示例:
對于 Lua中 redis.call() 和 redis.pcall() 如何選擇,需要根據(jù)實際業(yè)務(wù)來判斷,標(biāo)準(zhǔn)是:當(dāng) Lua腳本中某條命令執(zhí)行出錯時,是否需要阻斷后續(xù)的命令執(zhí)行。
四、如何保證原子性?
首先,可以肯定的是:Redis執(zhí)行 Lua腳本可以保證原子性,不過這和 Redis Server的部署方式密不可分。
Redis是典型的 C/S(Client/Server) 模型,如下圖:
因此,Redis 通常有 3種不同的部署方式,部署方式不同,原子性的保證也不一樣。
1. 單機(jī)部署
不管 Lua腳本中操作的 key是不是同一個,都能保證原子性;
2. 主從部署
Redis 主從復(fù)制是用于將主節(jié)點的數(shù)據(jù)同步到從節(jié)點,以保持?jǐn)?shù)據(jù)的一致性。而Redis的所有寫操作都在主節(jié)點上,所以,不管 Lua腳本中操作的 key是不是同一個,都能保證原子性;
需要注意:當(dāng)主節(jié)點執(zhí)行寫命令時,從節(jié)點會異步地復(fù)制這些寫操作。在這個復(fù)制的過程中,從節(jié)點的數(shù)據(jù)可能與主節(jié)點存在一定的延遲。因此,如果在 Lua 腳本中包含讀操作,并且該腳本在主節(jié)點上執(zhí)行,可能會讀到最新的數(shù)據(jù),但如果在從節(jié)點上執(zhí)行,可能會讀到稍有延遲的數(shù)據(jù)。
3. Cluster集群部署
如果 Lua腳本操作的 key是同一個,能保證原子性;
如果操作的 Key不相同,可能被 hash 到不同的 slot,也可能 hash 到相同的 slot,所以不一定能保證原子性;
因此,在 Cluster集群部署的環(huán)境下使用 Lua腳本時一定要注意:Lua腳本中操作的是同一個 Key;
4. 原子性保證
這里以 Redis單機(jī)部署為例:當(dāng)客戶端向服務(wù)器發(fā)送一個帶有 Lua腳本的請求時,Redis會把該腳本當(dāng)作一個整體,然后加載到一個腳本緩存中,因為 Redis讀寫命令是單線程操作(關(guān)于 Redis的單線程模型和多路復(fù)用線程模型會在其他的文章中講解),最終,Lua腳本的讀寫在 Redis服務(wù)器上可以簡單地抽象成下圖,所有的 Lua腳本會按照進(jìn)入順序放入隊列中,然后串行進(jìn)行讀寫,這樣就保證每個 Lua不會被其他的客戶端打斷,從而保證了原子性:
五、面試該如何回答?
在面試中,Redis 執(zhí)行 Lua腳本時,能否保證原子性?這個問題如何作答?
- 第一步,需要解釋這里的原子性是什么?它和關(guān)系數(shù)據(jù)事務(wù) ACID中的一致性的差異是什么?消除原子性在具體載體(RDBMS/NoSQL)上概念的差異;
- 第二步,需要解釋 Redis的事務(wù),說明 RDBMS/NoSQL 在事務(wù)上的差異點;
- 第三步,需要解釋 Redis在不同部署方式下原子性能否保證。Redis部署方式有3種:單機(jī)部署,主從部署,Cluster集群部署,需要說明在哪些部署方式下能保證原子性,哪些不能保證原子性;
- 第四步,解釋 Redis 執(zhí)行 Lua腳本是如何保證原子性;
- 第五步,分析下 Redis的單線程模型 和 IO多路復(fù)用模型(加分項),這步是可選項;
六、Why Lua?
既然 Redis事務(wù)能保證原子性,為什么還需要 Lua腳本呢?
- Lua 是一種嵌入式語言,是 Redis官方推薦的腳本語言;
- Lua 腳本一般比 MULTI/EXEC 更快、更簡單;
- Redis 事務(wù)中,事務(wù)隊列中的所有命令都必須在 EXEC命令執(zhí)行才會被執(zhí)行,對于多個命令之間存在依賴關(guān)系,比如后面的命令需要依賴上一個命令結(jié)果的場景,Redis事務(wù)無法滿足,因此 Lua 腳本更適合復(fù)雜的場景;
- Redis 事務(wù)能做的 Lua能做,Redis事務(wù)做不到的 Lua也能做;
七、Lua注意事項
Redis執(zhí)行 Lua腳本時,Lua的編寫需要注意以下幾個點:
- 不要在 Lua腳本中使用阻塞命令(如BLPOP、BRPOP等)。因此這些命令可能會導(dǎo)致 Redis服務(wù)器在執(zhí)行腳本期間被阻塞,無法處理其他請求;
- 不要編寫過長的 Lua腳本。因為 Redis讀寫命令是單線程,過長的腳本,加載,解析,運行會比較耗時,導(dǎo)致其他命令的延遲延遲增加;
- 不要在 Lua腳本中進(jìn)行復(fù)雜耗時的邏輯;因為 Redis讀寫命令是單線程的,長時間運行腳本可能導(dǎo)致其他命令的延遲增加;
- Lua腳本中,需要注意區(qū)分 redis.call() 和 redis.pcall() 命令;
- Lua 索引表從索引 1 開始,而不是 0;
八、總結(jié)
- 原子性需要區(qū)分具體使用的載體,在關(guān)系型數(shù)據(jù)庫(比如 MySQL))和 No SQL(比如Redis)中,原子性的概念是不相同的;
- Redis的事務(wù)(MULTI/ESXEC)和關(guān)系型數(shù)據(jù)庫(比如 MySQL)的事務(wù)(ACID)也是不相同的;
- ACID的原子性指:命令要么全部執(zhí)行,要么全部不執(zhí)行;
- Redis執(zhí)行 Lua腳本的原子性指:Lua腳本會當(dāng)作一個整體被執(zhí)行且不被其他事務(wù)打斷,但是 Lua 腳本里面的命令無法保證“要么全部執(zhí)行,要么全部不執(zhí)行”;
- Lua腳本使用 redis.pcall() 執(zhí)行命令出錯時會被catch,后續(xù)命令會正常執(zhí)行;
- Lua腳本使用 redis.call() 執(zhí)行命令出錯時會拋給客戶端,后續(xù)命令會被阻斷;
- Lua 腳本一般比 MULTI/EXEC 更快、更簡單;
- Redis的部署方式?jīng)Q定了 Redis執(zhí)行 Lua腳本是否能保證原子性,編寫 Lua腳本時,特別需要注意在一個事務(wù)中是否要求操作同一個 key。