Redis的事務功能詳解
MULTI、EXEC、DISCARD和WATCH命令是Redis事務功能的基礎。Redis事務允許在一次單獨的步驟中執行一組命令,並且可以保證如下兩個重要事項:
>Redis會將一個事務中的所有命令序列化,然后按順序執行。Redis不可能在一個Redis事務的執行過程中插入執行另一個客戶端發出的請求。這樣便能保證Redis將這些命令作為一個單獨的隔離操作執行。 > 在一個Redis事務中,Redis要么執行其中的所有命令,要么什么都不執行。因此,Redis事務能夠保證原子性。EXEC命令會觸發執行事務中的所有命令。因此,當某個客戶端正在執行一次事務時,如果它在調用MULTI命令之前就從Redis服務端斷開連接,那么就不會執行事務中的任何操作;相反,如果它在調用EXEC命令之后才從Redis服務端斷開連接,那么就會執行事務中的所有操作。當Redis使用只增文件(AOF:Append-only File)時,Redis能夠確保使用一個單獨的write(2)系統調用,這樣便能將事務寫入磁盤。然而,如果Redis服務器宕機,或者系統管理員以某種方式停止Redis服務進程的運行,那么Redis很有可能只執行了事務中的一部分操作。Redis將會在重新啟動時檢查上述狀態,然后退出運行,並且輸出報錯信息。使用redis-check-aof工具可以修復上述的只增文件,這個工具將會從上述文件中刪除執行不完全的事務,這樣Redis服務器才能再次啟動。從2.2版本開始,除了上述兩項保證之外,Redis還能夠以樂觀鎖的形式提供更多的保證,這種形式非常類似於“檢查再設置”(CAS:Check And Set)操作。本文稍后會對Redis的樂觀鎖進行描述。
一、相關命令
1. MULTI
用於標記事務塊的開始。Redis會將后續的命令逐個放入隊列中,然后才能使用EXEC命令原子化地執行這個命令序列。
這個命令的運行格式如下所示:
MULTI這個命令的返回值是一個簡單的字符串,總是OK。
2. EXEC
在一個事務中執行所有先前放入隊列的命令,然后恢復正常的連接狀態。
當使用WATCH命令時,只有當受監控的鍵沒有被修改時,EXEC命令才會執行事務中的命令,這種方式利用了檢查再設置(CAS)的機制。
這個命令的運行格式如下所示:
EXEC這個命令的返回值是一個數組,其中的每個元素分別是原子化事務中的每個命令的返回值。 當使用WATCH命令時,如果事務執行中止,那么EXEC命令就會返回一個Null值。
3. DISCARD
清除所有先前在一個事務中放入隊列的命令,然后恢復正常的連接狀態。
如果使用了WATCH命令,那么DISCARD命令就會將當前連接監控的所有鍵取消監控。
這個命令的運行格式如下所示:
DISCARD
這個命令的返回值是一個簡單的字符串,總是OK。
4. WATCH
當某個事務需要按條件執行時,就要使用這個命令將給定的鍵設置為受監控的。
這個命令的運行格式如下所示:
WATCH key [key ...]
這個命令的返回值是一個簡單的字符串,總是OK。
對於每個鍵來說,時間復雜度總是O(1)。
5. UNWATCH
清除所有先前為一個事務監控的鍵。
如果你調用了EXEC或DISCARD命令,那么就不需要手動調用UNWATCH命令。
這個命令的運行格式如下所示:
UNWATCH
這個命令的返回值是一個簡單的字符串,總是OK。
時間復雜度總是O(1)。
二、使用方法
使用MULTI命令便可以進入一個Redis事務。這個命令的返回值總是OK。此時,用戶可以發出多個Redis命令。Redis會將這些命令放入隊列,而不是執行這些命令。一旦調用EXEC命令,那么Redis就會執行事務中的所有命令。
相反,調用DISCARD命令將會清除事務隊列,然后退出事務。
以下示例會原子化地遞增foo鍵和bar鍵的值:

正如從上面的會話所看到的一樣,EXEC命令的返回值是一個數組,其中的每個元素都分別是事務中的每個命令的返回值,返回值的順序和命令的發出順序是相同的。
當一個Redis連接正處於MULTI請求的上下文中時,通過這個連接發出的所有命令的返回值都是QUEUE字符串(從Redis協議的角度來看,返回值是作為狀態回復(Status Reply)來發送的)。當調用EXEC命令時,Redis會簡單地調度執行事務隊列中的命令。
三、事務內部的錯誤
在一個事務的運行期間,可能會遇到兩種類型的命令錯誤:
一個命令可能會在被放入隊列時失敗。因此,事務有可能在調用EXEC命令之前就發生錯誤。例如,這個命令可能會有語法錯誤(參數的數量錯誤、命令名稱錯誤,等等),或者可能會有某些臨界條件(例如:如果使用maxmemory指令,為Redis服務器配置內存限制,那么就可能會有內存溢出條件)。
在調用EXEC命令之后,事務中的某個命令可能會執行失敗。例如,我們對某個鍵執行了錯誤類型的操作(例如,對一個字符串(String)類型的鍵執行列表(List)類型的操作)。
可以使用Redis客戶端檢測第一種類型的錯誤,在調用EXEC命令之前,這些客戶端可以檢查被放入隊列的命令的返回值:如果命令的返回值是QUEUE字符串,那么就表示已經正確地將這個命令放入隊列;否則,Redis將返回一個錯誤。如果將某個命令放入隊列時發生錯誤,那么大多數客戶端將會中止事務,並且丟棄這個事務。
然而,從Redis 2.6.5版本開始,服務器會記住事務積累命令期間發生的錯誤。然后,Redis會拒絕執行這個事務,在運行EXEC命令之后,便會返回一個錯誤消息。最后,Redis會自動丟棄這個事務。
在Redis 2.6.5版本之前,如果發生了上述的錯誤,那么在客戶端調用了EXEC命令之后,Redis還是會運行這個出錯的事務,執行已經成功放入事務隊列的命令,而不會關心先前發生的錯誤。從2.6.5版本開始,Redis在遭遇上述錯誤時,會采用先前描述的新行為,這樣便能輕松地混合使用事務和管道。在這種情況下,客戶端可以一次性地將整個事務發送至Redis服務器,稍后再一次性地讀取所有的返回值。
相反,在調用EXEC命令之后發生的事務錯誤,Redis不會進行任何特殊處理:在事務運行期間,即使某個命令運行失敗,所有其他的命令也將會繼續執行。
這種行為在協議層面上更加清晰。在以下示例中,當事務正在運行時,有一條命令將會執行失敗,即使這條命令的語法是正確的:

上述示例的EXEC命令的返回值是批量的字符串,包含兩個元素,一個是OK代碼,另一個是-ERR錯誤消息。客戶端會根據自身的程序庫,選擇一種合適的方式,將錯誤信息提供給用戶
需要注意的是,即使某個命令執行失敗,事務隊列中的所有其他命令仍然會執行 —— Redis不會停止執行事務中的命令。
再看另一個示例,再次使用telnet通信協議,觀察命令的語法錯誤是如何盡快報告給用戶的:

這一次,由於INCR命令的語法錯誤,Redis根本就沒有將這個命令放入事務隊列。
四、為什么Redis不支持回滾?
如果你具備關系型數據庫的知識背景,你就會發現一個事實:在事務運行期間,雖然Redis命令可能會執行失敗,但是Redis仍然會執行事務中余下的其他命令,而不會執行回滾操作,你可能會覺得這種行為很奇怪。
然而,這種行為也有其合理之處:
只有當被調用的Redis命令有語法錯誤時,這條命令才會執行失敗(在將這個命令放入事務隊列期間,Redis能夠發現此類問題),或者對某個鍵執行不符合其數據類型的操作:實際上,這就意味着只有程序錯誤才會導致Redis命令執行失敗,這種錯誤很有可能在程序開發期間發現,一般很少在生產環境發現。
Redis已經在系統內部進行功能簡化,這樣可以確保更快的運行速度,因為Redis不需要事務回滾的能力。
對於Redis事務的這種行為,有一個普遍的反對觀點,那就是程序有可能會有缺陷(bug)。但是,你應當注意到:事務回滾並不能解決任何程序錯誤。例如,如果某個查詢會將一個鍵的值遞增2,而不是1,或者遞增錯誤的鍵,那么事務回滾機制是沒有辦法解決這些程序問題的。請注意,沒有人能解決程序員自己的錯誤,這種錯誤可能會導致Redis命令執行失敗。正因為這些程序錯誤不大可能會進入生產環境,所以我們在開發Redis時選用更加簡單和快速的方法,沒有實現錯誤回滾的功能。
五、丟棄命令隊列
DISCARD命令可以用來中止事務運行。在這種情況下,不會執行事務中的任何命令,並且會將Redis連接恢復為正常狀態。示例如下所示:

六、通過CAS操作實現樂觀鎖
Redis使用WATCH命令實現事務的“檢查再設置”(CAS)行為。
作為WATCH命令的參數的鍵會受到Redis的監控,Redis能夠檢測到它們的變化。在執行EXEC命令之前,如果Redis檢測到至少有一個鍵被修改了,那么整個事務便會中止運行,然后EXEC命令會返回一個Null值,提醒用戶事務運行失敗。
例如,設想我們需要將某個鍵的值自動遞增1(假設Redis沒有INCR命令)。
首次嘗試的偽碼可能如下所示:
val = GET mykey
val = val + 1
SET mykey $val
如果我們只有一個Redis客戶端在一段指定的時間之內執行上述偽碼的操作,那么這段偽碼將能夠可靠的工作。如果有多個客戶端大約在同一時間嘗試遞增這個鍵的值,那么將會產生競爭狀態。例如,客戶端-A和客戶端-B都會讀取這個鍵的舊值(例如:10)。這兩個客戶端都會將這個鍵的值遞增至11,最后使用SET命令將這個鍵的新值設置為11。因此,這個鍵的最終值是11,而不是12。
現在,我們可以使用WATCH命令完美地解決上述的問題,偽碼如下所示:
WATCH mykey
val = GET mykey
val = val + 1
MULTI
SET mykey $val
EXEC
由上述偽碼可知,如果存在競爭狀態,並且有另一個客戶端在我們調用WATCH命令和EXEC命令之間的時間內修改了val變量的結果,那么事務將會運行失敗。
我們只需要重復執行上述偽碼的操作,希望此次運行不會再出現競爭狀態。這種形式的鎖就被稱為樂觀鎖,它是一種非常強大的鎖。在許多用例中,多個客戶端可能會訪問不同的鍵,因此不太可能發生沖突 —— 也就是說,通常沒有必要重復執行上述偽碼的操作。
七、WATCH命令詳解
那么WATCH命令實際做了些什么呢?這個命令會使得EXEC命令在滿足某些條件時才會運行事務:我們要求Redis只有在所有受監控的鍵都沒有被修改時,才會執行事務。(但是,相同的客戶端可能會在事務內部修改這些鍵,此時這個事務不會中止運行。)否則,Redis根本就不會進入事務。(注意,如果你使用WATCH命令監控一個易失性的鍵,然后在你監控這個鍵之后,Redis再使這個鍵過期,那么EXEC命令仍然可以正常工作。)
WATCH命令可以被調用多次。簡單說來,所有的WATCH命令都會在被調用之時立刻對相應的鍵進行監控,直到EXEC命令被調用之時為止。你可以在單條的WATCH命令之中,使用任意數量的鍵作為命令參數。
當調用EXEC命令時,所有的鍵都會變為未受監控的狀態,Redis不會管事務是否被中止。當一個客戶單連接被關閉時,所有的鍵也都會變為未受監控的狀態。
你還可以使用UNWATCH命令(不需要任何參數),這樣便能清除所有的受監控鍵。當我們對某些鍵施加樂觀鎖之后,這個命令有時會非常有用。因為,我們可能需要運行一個用來修改這些鍵的事務,但是在讀取這些鍵的當前內容之后,我們可能不打算繼續進行操作,此時便可以使用UNWATCH命令,清除所有受監控的鍵。在運行UNWATCH命令之后,Redis連接便可以再次自由地用於運行新事務。
如何使用WATCH命令實現ZPOP操作呢?
本文將通過一個示例,說明如何使用WATCH命令創建一個新的原子化操作(Redis並不原生支持這個原子化操作),此處會以實現ZPOP操作為例。這個命令會以一種原子化的方式,從一個有序集合中彈出分數最低的元素。以下源碼是最簡單的實現方式:
WATCH zset
element = ZRANGE zset 0 0
MULTI
ZREM zset element
EXEC
如果偽碼中的EXEC命令執行失敗(例如,返回Null值),那么我們只需要重復運行這個操作即可。
八、Redis腳本和事務
根據定義,Redis腳本也是事務型的。因此,你可以通過Redis事務實現的功能,同樣也可以通過Redis腳本來實現,而且通常腳本更簡單、更快速。
由於Redis從2.6版本才開始引入腳本特性,而事務特性是很久以前就已經存在的,所以目前的版本才有兩個看起來重復的特性。但是,我們不太可能在短時間內移除對事務特性的支持。因為,即使不用求助於Redis腳本,用戶仍然能夠規避競爭狀態,這從語義上來看是適宜的。還有另一個更重要的原因,Redis事務特性的實現復雜度是最小的。
但是,在相當長的一段時間之內,我們不大可能看到整個用戶群體都只使用Redis腳本。如果發生這種情況,那么我們可能會廢棄,甚至最終移除Redis事務。