1. 程式人生 > 實用技巧 >5. 詳解Redis中的事務

5. 詳解Redis中的事務

什麼是事務?

事務指的是提供一種將多個命令打包放在一個佇列裡,然後一次性按順序執行的機制,並且保證伺服器只有在執行完事務中的所有命令後,才會繼續處理此客戶端的其他命令,不會被其他的命令插隊。

一句話總結就是:一個佇列中,一次性、順序性、排他性地執行一系列命令

事務也是其他關係型資料庫所必備的基礎功能,以支付的場景為例,正常情況下只有正常消費完成之後,才會減去賬戶餘額。但如果沒有事務的保障,可能會發生消費失敗了,但依舊會把賬戶的餘額給扣減了,我想這種情況應該任何人都無法接受吧?所以事務是資料庫中一項非常重要的基礎功能。

事務基本使用

事務一般分為以下三個階段:

  • 1. 開啟事務:Begin Transaction
  • 2. 正確執行業務程式碼,提交事務:Commit Transaction
  • 3. 業務處理中出現異常,回滾事務:Rollback Transaction

Redis 中的事務從開始到結束也是要經歷三個階段:

  • 1. 開啟事務
  • 2. 命令依次進入佇列
  • 3. 提交事務/放棄事務

redis的事務的相關命令有以下幾種:

  • 1. multi:標記一個事務塊的開始,或者說開啟一個事務。然後輸出的所有命令都不會立刻執行,而是會按照順序進入佇列中。
  • 2. exec:按照順序執行佇列中的所有命令。
  • 3. discard:取消事務,放棄執行事務塊內的所有命令
  • 4. watch:監視一個或者多個key,如果在事務執行前(多個key的任意一個)key被其他命令所改動,那麼事務將被打斷。
  • 5. unwatch:取消對所有key的監視。

Redis中的基本事務操作

開啟一個事務

multi 命令用於開啟事務,實現程式碼如下:

127.0.0.1:6379> multi
OK
127.0.0.1:6379>

multi 命令可以讓客戶端從非事務模式狀態,變為事務模式狀態,如下圖所示:

注意: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 命令不能巢狀的錯誤,但不會終止客戶端為事務的狀態,如下圖所示:

此時依舊是處於事務開啟的一個狀態。

命令入隊

客戶端進入事務狀態之後,執行的所有常規 Redis 操作命令(非觸發事務執行或放棄和導致入列異常的命令)會依次入列,命令入列成功後會返回 QUEUED,如下程式碼所示:

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>

執行流程如下圖所示:

注意:命令會按照先進先出(FIFO)的順序出入列,也就是說事務會按照命令的入列順序,從前往後依次執行。

提交/放棄 事務

提交或者說執行事務的命令是exec,放棄事務的命令是discard。

exec提交事務:

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> 

另外在事務中命令在提交事務之後,如果成功執行,那麼影響是全域性的,我們再舉個栗子:

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> 

discard放棄事務:

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> 

執行流程如下圖所示:

事務錯誤&回滾

事務執行中的錯誤分為以下三類:

  • 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> 

從以上結果來看,即使事務佇列中某個命令在執行期間出現了錯誤,事務也會繼續執行,直到事務佇列中所有命令都執行完成。

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是需要搭配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中如何實現呢,答案很簡單,如果開始事務之前被修改了,那麼把取消監視就好了。

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", "古明地覺")

# 監視
client.watch("name")

# 開啟事務, Python操作Redis開始事務需要建立一個管道
pipe = client.pipeline()  # 此時事務算是開啟了
pipe.set("name", "古明地戀")
# 退出事務的話,使用pipe.exit()
pipe.execute()  # 執行事務

# 獲取name
print(client.get("name"))  # 古明地戀

小結

最後總結一下Redis中關於事務的特性:

  • 單獨的隔離操作:事務中所有的命令都會被序列化,按照順序執行。事務在執行的過程中,不會被其他客戶端傳送來的命令請求所打斷。
  • 沒有隔離級別的狀態:佇列中的命令在沒有提交之前(exec),都不會被實際地執行,因為開啟事務之後、事務提交之前,任何指令都不會被實際地執行。也就不存在"事務內的查詢要看到更新,事務外查詢無法看到"這個讓人頭疼的問題。
  • 不保證原子性:我們之前演示過,如果是在執行時出錯,那麼後面的命令會繼續執行,不會回滾。

正常情況下 Redis 事務分為三個階段:開啟事務、命令入隊、執行事務。Redis 事務並不支援執行時錯誤的事務回滾,但在某些入隊錯誤,如 dasdasda等命令本身錯誤 或者是 watch 監控項被修改時,提供整個事務回滾的功能(或者說直接就把事務給取消了)