楔子
我們知道 Redis 是有事務功能的,盡管它不像關系型數據庫那樣常用,但是在面試中還是很容易被問到的,下面我們就來總結一下 Redis 的事務。
通過 Redis 事務的原理以及實際操作,來徹底攻略 Redis 中的事務。
事務介紹
Redis 事務是一組命令的集合,將多個命令進行打包,然后這些命令會被順序地添加到隊列中,並按照添加的順序依次執行。
「 Redis 事務中沒有像 MySQL 關系型數據庫事務隔離級別的概念,不能保證原子性操作,也沒有像 MySQL 那樣執行事務失敗時可以進行回滾的操作。 」
這個與 Redis 的特點:「 快速、高效 」有着緊密的聯系,因為回滾操作、以及像事務隔離級別那樣的加鎖解鎖,是非常消耗性能的。所以,Redis 中執行事務的流程只需要以下簡單的三個步驟:
1. MULTI:「 表示開啟一個事務 」,執行此命令后,后面執行的所有對 Redis 數據類型的操作命令「都會被順序地放入隊列中」。當執行 EXEC 命令后,隊列中的命令會被依次執行。
2. DISCARD:「 放棄執行隊列中的命令 」,可以類比為 MySQL 的回滾操作,「 並且將當前的狀態從事務狀態改為非事務狀態」。
3. EXEC:該命令表示要「 順序執行隊列中的命令 」,執行完之后並將結果顯示在客戶端,「 同時將當前狀態從事務狀態改為非事務狀態 」。若是執行該命令之前,有 key 被執行 WATCH 命令並且又被其它客戶端修改,那么就會放棄執行隊列中的所有命令,並在客戶端顯示報錯信息;如果沒有被修改,那么會繼續執行隊列中的所有命令。
除了以上三個命令之外,我們還有 WATCH 和 UNWATCH,我們先來介紹上面三個。
開啟事務
MULTI 命令表示開啟一個事務,當返回 OK 的時候表示已經進入事務狀態。
127.0.0.1:6379> multi
OK
127.0.0.1:6379>
該命令執行之后客戶端會將「 當前的狀態從非事務狀態修改為事務狀態 」,這一狀態的切換是通過打開客戶端 flags 屬性中的 REDIS_MULTI 來完成的,該命令可以理解為 MySQL 中的 BEGIN TRANSACTION 語句。
注意:multi 命令不能嵌套使用,如果已經開啟了事務的情況下,再執行 multi 命令,會提示如下錯誤:(error) ERR MULTI calls can not be nested,因為事務是不可重復的。
127.0.0.1:6379> multi
OK
127.0.0.1:6379> multi
(error) ERR MULTI calls can not be nested
127.0.0.1:6379>
當客戶端是非事務狀態時,使用 multi 命令,客戶端會返回結果 OK,如果客戶端已經是事務狀態,再執行 multi 命令則會報出 multi 命令不能嵌套的錯誤,但不會終止客戶端當然的事務狀態,如下圖所示:
不管是那種情況,最終都是處於事務開啟的一個狀態,因為在 MULTI 中執行 MULTI 雖然會報錯,但是不會結束事務。
命令入隊
客戶端進入事務狀態之后,執行的所有常規 Redis 操作命令(非觸發事務執行、或放棄以及導致入隊異常的命令)會依次入列,命令入列成功后會返回 QUEUED,這部分也是真正的業務邏輯的部分,代碼如下所示:
127.0.0.1:6379> multi
OK
127.0.0.1:6379> multi
(error) ERR MULTI calls can not be nested # 不會終止事務,完全可以將第二個 MULTI 忽略掉
127.0.0.1:6379> set name hanser
QUEUED
127.0.0.1:6379> get name
QUEUED
127.0.0.1:6379>
若是當前狀態處於事務狀態,那么 Redis 客戶端的命令執行后就會進入到隊列(FIFO)中,並且返回 QUEUED 字符串;否則的話,則會立即執行命令,並將結果返回給客戶端。流程圖如下:
我們說事務開啟之后,命令會進入到隊列中,而命令隊列中有如下參數:「 要執行的命令 」、「 命令的參數 」、「 參數的個數 」。以我們上面的事務為例:
127.0.0.1:6379> multi
OK
127.0.0.1:6379> set name hanser
QUEUED
127.0.0.1:6379> get name
QUEUED
127.0.0.1:6379>
那么對應的隊列中的參數如下:
執行事務、放棄事務
當客戶端執行 EXEC 命令的時候,隊列里面的命令就會按照先進先出的順序被執行;如果是 DISCARD,那么會放棄事務。
先來看看提交事務:
127.0.0.1:6379> multi
OK
127.0.0.1:6379> multi
(error) ERR MULTI calls can not be nested
127.0.0.1:6379> set name hanser
QUEUED
127.0.0.1:6379> get name
QUEUED
127.0.0.1:6379> exec # 隊列中的命令依次執行
1) OK
2) "hanser"
127.0.0.1:6379>
當執行 EXEC 的時候,先執行 SET 命令、再執行 GET 命令,並且執行后的結果也會進入一個隊列中保存,最后返回給客戶端:
所以最后你會在客戶端中看到 「 OK、hanser」這樣的結果顯示,這也是一個事務成功執行的過程。
至此一個事務就完整地執行完畢了,並且此時客戶端也從事務狀態更改為非事務狀態。
另外在事務中命令在提交事務之后,如果成功執行,那么影響是全局的,我們再舉個栗子:
127.0.0.1:6379> set name hanser # 設置 name 為 hanser
OK
127.0.0.1:6379> get name # 獲取 name,顯然沒問題
"hanser"
127.0.0.1:6379> multi # 開啟事務
OK
127.0.0.1:6379> set name yousa # 在事務中設置 name 為 yousa
QUEUED
127.0.0.1:6379> get name
QUEUED
127.0.0.1:6379> exec # 執行事務,get name 的結果為 yousa 顯然沒問題
1) OK
2) "yousa"
127.0.0.1:6379> get name
"yousa" # 但是我們說事務中的命令的影響是全局的,即便事務結束,里面執行的命令在外部也是生效的
127.0.0.1:6379>
然后是放棄事務:
127.0.0.1:6379> set name hanser
OK
127.0.0.1:6379> multi
OK
127.0.0.1:6379> set name yousa
QUEUED
127.0.0.1:6379> get name
QUEUED
127.0.0.1:6379> discard # 取消事務,里面的命令根本沒有執行
OK
127.0.0.1:6379> get name # 所以外部的 name 還是 hanser
"hanser"
127.0.0.1:6379>
DISCARD 命令取消事務的時候,會將命令隊列清空,並且將客戶端的狀態從事務狀態修改為非事務狀態。
事務錯誤&回滾
事務執行中的錯誤分為以下三類:
1. 執行時才會出現的錯誤(簡稱:執行時錯誤);
2. 入隊時錯誤,不會終止整個事務;
3. 入隊時錯誤,會終止整個事務;
1. 執行時錯誤:
127.0.0.1:6379> set name hanser # 設置 name
OK
127.0.0.1:6379> get name # 獲取 name
"hanser"
127.0.0.1:6379> multi # 開啟事務
OK
127.0.0.1:6379> incr name # name 自增1,顯然這是不合法的,因為 name 不是數字
QUEUED
127.0.0.1:6379> set name yousa # 再次設置name
QUEUED
127.0.0.1:6379> exec # 我們看到事務里面第一條命令執行失敗,但是第二條執行成功了
1) (error) ERR value is not an integer or out of range
2) OK
127.0.0.1:6379> get name # 事務結束后,獲取 name 發現被修改了
"yousa"
127.0.0.1:6379>
從以上結果來看,即使事務隊列中某個命令在執行期間出現了錯誤,事務也會繼續執行,直到事務隊列中所有命令都執行完成。
所以這樣就會導致,正確的命令被執行,而錯誤的命令不會被執行。而這也反映了 Redis 的事務不能保證數據的一致性,因為執行的途中出現了錯誤,但有些語句還是被執行了。因此最終的結果只能是程序猿根據之前的命令自己一步一步地回滾,所以自己的爛攤子自己收拾。
2. 不會導致事務結束的入隊時錯誤:
127.0.0.1:6379> set name hanser
OK
127.0.0.1:6379> get name
"hanser"
127.0.0.1:6379> multi
OK
127.0.0.1:6379> multi # 在入隊時就已經出現了錯誤,但是事務依舊沒有結束
(error) ERR MULTI calls can not be nested
127.0.0.1:6379> set name yousa # 修改 name
QUEUED
127.0.0.1:6379> exec
1) OK
127.0.0.1:6379> get name # name 被修改
"yousa"
127.0.0.1:6379>
可以看出,重復執行 multi 會導致入列錯誤,但不會終止事務,最終查詢的結果表示事務執行成功了。除了重復執行 multi 命令,還有在事務狀態下執行 watch 也是同樣的效果,下文會詳細講解關於 watch 的內容。
3. 會導致事務結束的入隊時錯誤:
127.0.0.1:6379> multi # 開啟一個事務
OK
127.0.0.1:6379> set name1 hanser # 設置 name1
QUEUED
127.0.0.1:6379> dadsadsadsa # 輸入一條不存在的命令
(error) ERR unknown command `dadsadsadsa`, with args beginning with:
127.0.0.1:6379> set name2 yousa # 設置 name2
QUEUED
127.0.0.1:6379> exec # 執行,提示我們由於前面的錯誤,導致整個事務被取消了
(error) EXECABORT Transaction discarded because of previous errors.
127.0.0.1:6379> get name1 # name1 為 nil
(nil)
127.0.0.1:6379> get name2 # name2 為 nil,所以不管錯誤在事務的哪個地方,只要出現了,整個事務就完蛋了
(nil)
127.0.0.1:6379>
所以我們看到錯誤主要可以分為兩種:一種是事務執行時才會發現的錯誤;另一種是在入隊的時候就能發現的錯誤。
執行時出現的錯誤,不會影響事務隊列中其它的命令;即使某條命令失敗,但其它命令依舊可以正常執行。
入隊發現的錯誤,如果是 multi、watch 這種錯誤也不會終止事務,只是不會讓它入隊;但如果是命令不符合 Redis 的規則,那么這種錯誤就屬於類似於編程語言的語法錯誤,直接編譯時報出語法錯誤,沒必要等到執行了,所以在 Redis 中的表現就是整個事務都廢棄掉,里面的命令一條也不會執行。
從執行時錯誤的例子中我們可以看到,Redis 是不支持事務回滾的。而不支持事務回滾的原因,Redis 作者提出了兩個理由:
作者認為 Redis 事務在執行時,錯誤通常是編程錯誤造成的,這種錯誤通常只會出現在開發環境中,而很少在生產環境中出現,所以它認為沒有必要為 Redis 開發事務回滾功能。
不支持事務回滾是因為這種復雜的功能和 Redis 追求的簡單高效的設計主旨不符合。
監控
Redis 的監控會使用到鎖機制,而鎖分為悲觀鎖和樂觀鎖。
類似於 MySQL 里面的 "表鎖" 和 "行鎖"。"表鎖" 就是為了保證數據的一致性,將整張表鎖上,這樣就只能一個人修改,好比進衛生間,進去之后就把大門鎖上了,但這樣的結果也可想而知,雖然數據的一致性、安全性好,但是並發性會極差,因為其他人進不去了。比如一張有 20 萬條記錄的表,但是你只修改第 520 行,而另一個哥們修改第 250 行,本來兩者不沖突,但是你把整個表都鎖了,那就意味這后面的老鐵只能排隊了,這樣顯然效率不高。於是就出現了 "行鎖","行鎖" 在 MySQL 中,就類似於表中有一個版本號的字段,假設有一條記錄的版本號為 1,A 和 B 同時修改這條記錄,那么一旦提交,就會改變那個版本號,假設變為 2。如果 A 先提交了,那么數據庫中對應記錄的版本號已經變了,但是 B 對應的版本號還是之前的,那么提交之后會立即報錯,這樣就知道這條記錄被人修改了,需要重新獲取對應版本號的記錄。
悲觀鎖:
pessimistic lock,顧名思義,就是很悲觀,每次拿數據的時候都會認為別人會修改,所以每次拿數據的時候都會上鎖,這樣別人想拿到這個數據就會 block 住,直到拿到鎖。
樂觀鎖:
optimistic lock,顧名思義,就是很樂觀,每次拿數據的時候都會認為別人不會修改,所以每次拿數據的時候都不會上鎖。但是在更新數據的時候會判斷一下在此期間別人有沒有去更新這條數據,可以使用版本號等機制。樂觀鎖使用於多讀的應用類型,這樣可以提高吞吐量。樂觀鎖策略就是:提交版本必須大於記錄的當前版本才能更新。
而 watch 命令則是用於客戶端並發情況下,為事務提供一個樂觀鎖(CAS,Check And Set),也就是可以用 watch 命令來監控一個或多個變量,如果在事務的過程中,某個監控項被修改了,那么整個事務就會終止執行。
WATCH:表示監視指定的 key,「 該命令只能在 MULTI 命令之前執行 」,如果監視的 key 被其它客戶端修改,那么 EXEC 將會放棄執行隊列中的所有命令。
下面就來演示一下,首先 watch 是需要搭配 multi 事務來使用的。一般是先 watch key,然后開啟事務對 key 操作。
127.0.0.1:6379> set money 100
OK
127.0.0.1:6379> watch money # 監控
OK
127.0.0.1:6379> multi # 開啟事務
OK
127.0.0.1:6379> decrby money 20 # money 自減 20
QUEUED
127.0.0.1:6379> exec # 執行
1) (integer) 80
127.0.0.1:6379> get money # 獲取
"80"
127.0.0.1:6379>
上面執行的結果顯然沒有問題,但是往下看。
127.0.0.1:6379> flushdb # 清空 db
OK
127.0.0.1:6379> set money 100 # 設置 money 為 100
OK
127.0.0.1:6379> watch money # 監控
OK
127.0.0.1:6379> set money 200 # 但是在開啟事務之前將 money 修改了
OK
127.0.0.1:6379> multi
OK
127.0.0.1:6379> incr money
QUEUED
127.0.0.1:6379> exec # 此時執行會返回一個nil
(nil)
127.0.0.1:6379> get money # money是我們開啟事務之前修改的 200
"200"
127.0.0.1:6379>
127.0.0.1:6379>
127.0.0.1:6379>
127.0.0.1:6379> get name
(nil)
127.0.0.1:6379> watch name # 監控一個不存在的key也是可以的
OK
127.0.0.1:6379> set name hanser # 開啟事務之前設置
OK
127.0.0.1:6379> multi
OK
127.0.0.1:6379> set name yousa
QUEUED
127.0.0.1:6379> exec # 執行已經不會成功
(nil)
127.0.0.1:6379> get name # name 依舊是之前的 hanser
"hanser"
127.0.0.1:6379>
因此我們可以得出一個結論,那就是一旦監視了 key,那么這個 key 如果想改變,則需要開啟一個事務,在事務中修改,然后 exec 執行來改變這個 key。如果在事務沒有執行之前,將 watch 監視的 key 修改了,那么不好意思,事務會失效。
那如果是先開啟的事務,再在另一個終端中把 key 修改了,會怎么樣呢?我們來試一下。
127.0.0.1:6379> flushdb
OK
127.0.0.1:6379> set money 100 # 設置 money 為 100
OK
127.0.0.1:6379> watch money # 開啟監控
OK
127.0.0.1:6379> multi # 開啟事務
OK
127.0.0.1:6379> set money 120 # 設置 money 為 120
QUEUED
127.0.0.1:6379> exec # 但是在事務開啟后、事務提交前,我在另一個終端將 money 設置成了 250
(nil) # 看到此時結果依舊為nil
127.0.0.1:6379> get money # 獲取 money,是我們在另一個終端中設置的250。
"250"
127.0.0.1:6379>
正如 MySQL 的行鎖一樣,兩個人都可以對同一條記錄做修改,但是一個人先改好之后,另一個人提交就會失敗,必須查找到對應的版本號,然后重新查找對應記錄,修改才能提交。這在 Redis 中如何實現呢,答案很簡單,如果開始事務之前被修改了,那么取消監視就好了。
UNWATCH:「 取消監視之前通過 WATCH 命令監視的 key 」,通過執行 EXEC、DISCARD 兩個命令,之前監視的 key 也會被取消監視。
127.0.0.1:6379> flushdb
OK
127.0.0.1:6379> set name hanser # 設置 name
OK
127.0.0.1:6379> watch name # 監控 name
OK
127.0.0.1:6379> set name yousa # 再次設置 name
OK
127.0.0.1:6379> get name # 從結果來看,這個 name 對應的值已經被修改了。如果此時開啟事務,那么事務必然無效。
"yousa"
127.0.0.1:6379> unwatch # 因此先取消監視
OK
127.0.0.1:6379> watch name # 然后重新監視
OK
127.0.0.1:6379> multi # 開啟事務
OK
127.0.0.1:6379> set name marblue # 設置name
QUEUED
127.0.0.1:6379> exec # 提交事務
1) OK
127.0.0.1:6379> get name # 執行成功
"marblue"
127.0.0.1:6379>
另外記住一點:一個 watch 對應一個事務,如果 watch 之后,執行了事務,那么對這個 key 的監視就算結束了。如果想繼續監視,那么必須再次 watch key。
127.0.0.1:6379> flushdb
OK
127.0.0.1:6379> watch name # 監視 name
OK
127.0.0.1:6379> set name hanser # 開始事務之前將其修改
OK
127.0.0.1:6379> multi # 開啟事務,顯然此時如果設置 name 的話必然不會成功,因為 name 在被監視的時候就已經被修改
OK
127.0.0.1:6379> exec # 直接提交事務
(nil)
127.0.0.1:6379> multi # 再次開啟事務
OK
127.0.0.1:6379> set name yousa # 設置
QUEUED
127.0.0.1:6379> exec # 提交
1) OK
127.0.0.1:6379> get name # 發現執行成功
"yousa"
127.0.0.1:6379>
所以原因就在於一個 watch 對應一個事務,watch 之后只要執行了事務,不管里面的命令是成功還是失敗,這個 watch 就算是結束了。再次開啟事務,設置的 key 就是不被監視的 key 了。
但如果在事務中使用了 watch,那么會報錯:(error) ERR WATCH inside MULTI is not allowed
,但事務不會終止。所以 watch 只可以在開啟事務之前使用。
Python實現Redis中的事務和監控
下面看看如何使用Python實現Redis中的事務和監控
import redis
client = redis.Redis(host="47.94.174.89", decode_responses="utf-8")
# 設置key
client.set("name", "古明地覺")
# 開啟事務, Python操作Redis開始事務需要創建一個管道
pipe = client.pipeline()
# 監視key
pipe.watch("name")
pipe.multi() # 此時事務算是開啟了
pipe.set("name", "古明地戀")
# 退出事務的話,使用pipe.exit()
pipe.execute() # 執行事務
# 獲取name
print(client.get("name")) # 古明地戀
小結
最后總結一下Redis中關於事務的特性:
單獨的隔離操作:事務中所有的命令都會被序列化,按照順序執行。事務在執行的過程中,不會被其他客戶端發送來的命令請求所打斷。
沒有隔離級別的狀態:隊列中的命令在沒有提交之前(exec),都不會被實際地執行,因為開啟事務之后、事務提交之前,任何指令都不會被實際地執行。也就不存在"事務內的查詢要看到更新,事務外查詢無法看到"這個讓人頭疼的問題。
不保證原子性:我們之前演示過,如果是在運行時出錯,那么后面的命令會繼續執行,不會回滾。
正常情況下 Redis 事務分為三個階段:開啟事務、命令入隊、執行事務。Redis 事務並不支持運行時錯誤的事務回滾,但在某些入隊錯誤,如 dasdasda
等命令本身錯誤 或者是 watch
監控項被修改時,提供整個事務回滾的功能(或者說直接就把事務給取消了)
。