不能回滾的Redis事務還能用嗎

雙子孤狼發表於2021-02-23

前言

事務是關係型資料庫的特徵之一,那麼作為 Nosql 的代表 Redis 中有事務嗎?如果有,那麼 Redis 當中的事務又是否具備關係型資料庫的 ACID 四大特性呢?

Redis 有事務嗎

這個答案可能會令很多人感到意外,Redis 當中是存在“事務”的。這裡我把 Redis 的事務帶了引號,原因在後面分析。

Redis 當中的單個命令都是原子操作,但是如果我們需要把多個命令組合操作又需要保證資料的一致性時,就可以考試使用 Redis 提供的事務(或者使用前面介紹的 Lua 指令碼)。

Redis 當中,通過下面 4 個命令來實現事務:

  • multi:開啟事務
  • exec:執行事務
  • discard:取消事務
  • watch:監視

Redis 的事務主要分為以下 3 步:

  1. 執行命令 multi 開啟一個事務。
  2. 開啟事務之後執行的命令都會被放入一個佇列,如果成功之後會固定返回 QUEUED
  3. 執行命令 exec 提交事務之後,Redis 會依次執行佇列裡面的命令,並依次返回所有命令結果(如果想要放棄事務,可以執行 discard 命令)。

接下來讓我們依次執行以下命令來體會一下 Redis 當中的事務:

multi //開啟事務
set name lonely_wolf //設定 name,此時 Redis 會將命令放入佇列
set age 18  //設值 age,此時 Redis 會將命令放入佇列
get name  //獲取 name,此時 Redis 會將命令放入佇列
exec //提交事務,此時會依次執行佇列裡的命令,並依次返回結果

執行完成之後得到如下效果:

Redis 事務實現原理

Redis 中每個客戶端都有記錄當前客戶端的事務狀態 multiState ,下面就是一個客戶端 client 的資料結構定義:

typedef struct client {
    uint64_t id;//客戶端唯一 id
    multiState mstate; //MULTI 和 EXEC 狀態(即事務狀態)
    //...省略其他屬性
} client;

multiState 資料結構定義如下:

typedef struct multiState {
    multiCmd *commands;//儲存命令的 FIFO 佇列
    int count;//命令總數
    //...省略了其他屬性
} multiState;

multiCmd 是一個佇列,用來接收並儲存開啟事務之後傳送的命令,其資料結構定義如下:

typedef struct multiCmd {
    robj **argv;//用來儲存引數的陣列
    int argc;//引數的數量
    struct redisCommand *cmd;//命令指標
} multiCmd;

我們以上面事務的示例截圖中事務為例,可以得到如下所示的一個簡圖:

Redis 事務 ACID 特性

傳統的關係型資料庫中,一個事務一般都具有 ACID 特性。那麼現在就讓我們來分析一下 Redis 是否也滿足這 ACID 四大特性。

A - 原子性

在討論事務的原子性之前,我們先來看 2 個例子。

  • 模擬事務在執行命令前發生異常。依次執行以下命令:
multi //開啟事務
set name lonely_wolf //設定 name,此時 Redis 會將命令放入佇列
get  //執行一個不完成的命令,此時會報錯
exec //在發生異常後提交事務

最終得到了如下圖所示的結果,我們可以看到,當命令入隊的時候報錯時,事務已經被取消了:

  • 模擬事務在執行命令前發生異常。依次執行以下命令:
flushall //為了防止影響,先清空資料庫
multi //開啟事務
set name lonely_wolf //設定 name,此時 Redis 會將命令放入佇列
incr name  //這個命令只能用於 value 為整數的字串物件,此時執行會報錯
exec //提交事務,此時在執行第一條命令成功,執行第二條命令失敗
get name //獲取 name 的值

最終得到了如下圖所示的結果,我們可以看到,當執行事務報錯的時候,之前已經成功的命令並沒有被回滾,也就是說在執行事務的時候某一個命令失敗了,並不會影響其他命令的執行,即 Redis 的事務並不會回滾

Redis 中的事務為什麼不會滾

這個問題的答案在 Redis 官網中給出了明確的解釋:

總結起來主要就是 3 個原因:

  • Redis 作者認為發生事務回滾的原因大部分都是程式錯誤導致,這種情況一般發生在開發和測試階段,而生產環境很少出現。
  • 對於邏輯性錯誤,比如本來應該把一個數加 1 ,但是程式邏輯寫成了加 2,那麼這種錯誤也是無法通過事務回滾來進行解決的。
  • Redis 追求的是簡單高效,而傳統事務的實現相對比較複雜,這和 Redis 的設計思想相違背。

C - 一致性

一致性指的就是事務執行前後的資料符合資料庫的定義和要求。這一點 Redis 中的事務是符合要求的,上面講述原子性的時候已經提到,不論是發生語法錯誤還是執行時錯誤,錯誤的命令均不會被執行。

I - 隔離性

事務中的所有命令都會按順序執行,在執行 Redis 事務的過程中,另一個客戶端發出的請求不可能被服務,這保證了命令是作為單獨的獨立操作執行的。所以 Redis 當中的事務是符合隔離性要求的。

D - 永續性

如果 Redis 當中沒有被開啟持久化,那麼就是純記憶體執行的,一旦重啟,所有資料都會丟失,此時可以認為 Redis 不具備事務的永續性;而如果 Redis 開啟了持久化,那麼可以認為 Redis 在特定條件下是具備永續性的。

watch 命令

上面我們講述 Redis 中事務時,提到的的常用命令還有一個 watch 命令,這個又是做什麼用的呢?我們還是先來看一個例子。

首先開啟一個客戶端一,依次執行以下命令:

flushall  //清空資料庫
multi     //開啟事務
get name  //獲取 name,此時正常返回 nil
set name lonely_wolf //設定 name
get name //獲取 name,此時正常應該返回 lonely_wolf

得到如下效果圖:

這時候我們先不執行事務,開啟另一個客戶端二,來執行一個命令 set name zhangsan

客戶端二執行成功了,這時候再返回到客戶端一執行 exec 命令:

可以發現,第一句話返回了 zhangsan。也就是說,name 這個 key 值在入隊之後到 exec 之前發生了變化,一旦發生這種情況,可能會引起很嚴重的問題,所以在關係型資料庫可以通過鎖來解決這種問題,那麼 Redis 當中試如何解決的呢?

是的,在 Redis 當中就是通過 watch 命令來處理這種場景的。

watch 命令的作用

watch 命令可以為 Redis 事務提供 CAS 樂觀鎖行為,它可以在 exec 命令執行之前,監視任意 key 值的變化,也就是說當多個執行緒更新同一個 key 值的時候,會跟原值做比較,一旦發現它被修改過,則拒絕執行命令,並且會返回 nil 給客戶端。
下面還是讓我們通過一個示例來演示一下。

開啟一個客戶端一,依次執行如下命令:

flushall  //清空資料庫
watch name //監視 name
multi     //開啟事務
set name lonely_wolf //設定 name
set age 18 // 設定 age
get name   //獲取 name
get age    //獲取 age

執行之後得到如下效果圖:

這時候再開啟一個客戶端二,執行 set name zhangsan命令:

然後再回到客戶端一執行 exec命令。這時候會發現直接返回了 nil,也就是事務中所有的命令都沒有被執行(即:只要檢測到一個 key 值被修改過,那麼整個事務都不會被執行):

watch 原理分析

下面是一個 Redis 服務的資料結構定義:

typedef struct redisDb {
    dict *watched_keys;  //被 watch 命令監視的 key
    int id;           //Database ID
    //...省略了其他屬性
} redisDb;

可以看到,redisDb 中的 watched_keys 儲存了一個字典,這個字典當中的 key 存的就是被監視的 key ,然後字典的值存的就是客戶端 id。然後每個客戶端還有一個標記屬性 CLIENT_DIRTY_CAS,一旦我們執行了一些如 setsadd 等能修改 key 值對應 value 的命令,那麼客戶端的 CLIENT_DIRTY_CAS 標記屬性將會被修改,後面執行事務提交命令 exec 時發現客戶端的標記屬性被修改過(樂觀鎖的體現),則會拒絕執行事務。

總結

本文主要介紹了 Redis 當中的事務機制,在介紹事務實現原理的同時從傳統關係型資料庫的 ACID 四大特性對比分析了 Redis 當中的事務,並最終了解到了 Redis 的事務似乎並不是那麼“完美”。

相關文章