不支援原子性的 Redis 事務也叫事務嗎?

不假發表於2020-09-07

文章收錄在 GitHub JavaKeeper ,N線網際網路開發必備技能兵器譜

假設現在有這樣一個業務,使用者獲取的某些資料來自第三方介面資訊,為避免頻繁請求第三方介面,我們往往會加一層快取,快取肯定要有時效性,假設我們要儲存的結構是 hash(沒有String的'SET anotherkey "will expire in a minute" EX 60'這種原子操作),我們既要批量去放入快取,又要保證每個 key 都加上過期時間(以防 key 永不過期),這時候事務操作是個比較好的選擇

為了確保連續多個操作的原子性,我們常用的資料庫都會有事務的支援,Redis 也不例外。但它又和關係型資料庫不太一樣。

每個事務的操作都有 begin、commit 和 rollback,begin 指示事務的開始,commit 指示事務的提交,rollback 指示事務的回滾。它大致的形式如下

begin();
try {
    command1();
    command2();
    ....
    commit();
} catch(Exception e) {
    rollback();
}

Redis 在形式上看起來也差不多,分為三個階段

  1. 開啟事務(multi)
  2. 命令入隊(業務操作)
  3. 執行事務(exec)或取消事務(discard)
> multi
OK
> incr star
QUEUED
> incr star
QUEUED
> exec
(integer) 1
(integer) 2

上面的指令演示了一個完整的事務過程,所有的指令在 exec 之前不執行,而是快取在伺服器的一個事務佇列中,伺服器一旦收到 exec 指令,才開執行整個事務佇列,執行完畢後一次性返回所有指令的執行結果。

Redis 事務可以一次執行多個命令,本質是一組命令的集合。一個事務中的所有命令都會序列化,按順序地序列化執行而不會被其它命令插入,不許加塞。

可以保證一個佇列中,一次性、順序性、排他性的執行一系列命令(Redis 事務的主要作用其實就是串聯多個命令防止別的命令插隊)

官方文件是這麼說的

事務可以一次執行多個命令, 並且帶有以下兩個重要的保證:

  • 事務是一個單獨的隔離操作:事務中的所有命令都會序列化、按順序地執行。事務在執行的過程中,不會被其他客戶端傳送來的命令請求所打斷。
  • 事務是一個原子操作:事務中的命令要麼全部被執行,要麼全部都不執行

這個原子操作,和關係型 DB 的原子性不太一樣,它不能完全保證原子性,後邊會介紹。

Redis 事務的幾個命令

命令 描述
MULTI 標記一個事務塊的開始
EXEC 執行所有事務塊內的命令
DISCARD 取消事務,放棄執行事務塊內的所有命令
WATCH 監視一個(或多個)key,如果在事務執行之前這個(或多個)key被其他命令所改動,那麼事務將被打斷
UNWATCH 取消 WATCH 命令對所有 keys 的監視

MULTI 命令用於開啟一個事務,它總是返回 OK 。

MULTI 執行之後, 客戶端可以繼續向伺服器傳送任意多條命令, 這些命令不會立即被執行, 而是被放到一個佇列中, 當 EXEC 命令被呼叫時, 所有佇列中的命令才會被執行。

另一方面, 通過呼叫 DISCARD , 客戶端可以清空事務佇列, 並放棄執行事務。

廢話不多說,直接操作起來看結果更好理解~

一帆風順

正常執行(可以批處理,挺爽,每條操作成功的話都會各取所需,互不影響)

redis-transaction-case1.png

放棄事務(discard 操作表示放棄事務,之前的操作都不算數)

redis-transaction-case2.png

思考個問題:假設我們有個有過期時間的 key,在事務操作中 key 失效了,那執行 exec 的時候會成功嗎?

事務中的錯誤

上邊規規矩矩的操作,看著還挺好,可是事務是為解決資料安全操作提出的,我們用 Redis 事務的時候,可能會遇上以下兩種錯誤:

  • 事務在執行 EXEC 之前,入隊的命令可能會出錯。比如說,命令可能會產生語法錯誤(引數數量錯誤,引數名錯誤等等),或者其他更嚴重的錯誤,比如記憶體不足(如果伺服器使用 maxmemory 設定了最大記憶體限制的話)。
  • 命令可能在 EXEC 呼叫之後失敗。舉個例子,事務中的命令可能處理了錯誤型別的鍵,比如將列表命令用在了字串鍵上面,諸如此類。

Redis 針對如上兩種錯誤採用了不同的處理策略,對於發生在 EXEC 執行之前的錯誤,伺服器會對命令入隊失敗的情況進行記錄,並在客戶端呼叫 EXEC 命令時,拒絕執行並自動放棄這個事務(Redis 2.6.5 之前的做法是檢查命令入隊所得的返回值:如果命令入隊時返回 QUEUED ,那麼入隊成功;否則,就是入隊失敗)

對於那些在 EXEC 命令執行之後所產生的錯誤, 並沒有對它們進行特別處理: 即使事務中有某個/某些命令在執行時產生了錯誤, 事務中的其他命令仍然會繼續執行。

全體連坐(某一條操作記錄報錯的話,exec 後所有操作都不會成功)

redis-transaction-case3.png

冤頭債主(示例中 k1 被設定為 String 型別,decr k1 可以放入操作佇列中,因為只有在執行的時候才可以判斷出語句錯誤,其他正確的會被正常執行)

redis-transaction-case4.png

為什麼 Redis 不支援回滾

如果你有使用關係式資料庫的經驗,那麼 “Redis 在事務失敗時不進行回滾,而是繼續執行餘下的命令”這種做法可能會讓你覺得有點奇怪。

以下是官方的自誇

  • Redis 命令只會因為錯誤的語法而失敗(並且這些問題不能在入隊時發現),或是命令用在了錯誤型別的鍵上面:這也就是說,從實用性的角度來說,失敗的命令是由程式設計錯誤造成的,而這些錯誤應該在開發的過程中被發現,而不應該出現在生產環境中。
  • 因為不需要對回滾進行支援,所以 Redis 的內部可以保持簡單且快速。

有種觀點認為 Redis 處理事務的做法會產生 bug , 然而需要注意的是, 在通常情況下, 回滾並不能解決程式設計錯誤帶來的問題。 舉個例子, 如果你本來想通過 INCR 命令將鍵的值加上 1 , 卻不小心加上了 2 , 又或者對錯誤型別的鍵執行了 INCR , 回滾是沒有辦法處理這些情況的。

鑑於沒有任何機制能避免程式設計師自己造成的錯誤, 並且這類錯誤通常不會在生產環境中出現, 所以 Redis 選擇了更簡單、更快速的無回滾方式來處理事務。

帶 Watch 的事務

WATCH 命令用於在事務開始之前監視任意數量的鍵: 當呼叫 EXEC 命令執行事務時, 如果任意一個被監視的鍵已經被其他客戶端修改了, 那麼整個事務將被打斷,不再執行, 直接返回失敗。

WATCH命令可以被呼叫多次。 對鍵的監視從 WATCH 執行之後開始生效, 直到呼叫 EXEC 為止。

使用者還可以在單個 WATCH 命令中監視任意多個鍵, 就像這樣:

redis> WATCH key1 key2 key3 
OK 

EXEC 被呼叫時, 不管事務是否成功執行, 對所有鍵的監視都會被取消。另外, 當客戶端斷開連線時, 該客戶端對鍵的監視也會被取消。

我們看個簡單的例子,用 watch 監控我的賬號餘額(一週100零花錢的我),正常消費

redis-transaction-watch1.png

但這個卡,還繫結了我媳婦的支付寶,如果在我消費的時候,她也消費了,會怎麼樣呢?

犯困的我去樓下 711 買了包煙,買了瓶水,這時候我媳婦在超市直接刷了 100,此時餘額不足的我還在挑口香糖來著,,,

redis-transaction-watch2

這時候我去結賬,發現刷卡失敗(事務中斷),尷尬的一批

redis-transaction-watch3

你可能沒看明白 watch 有啥用,我們再來看下,如果還是同樣的場景,我們沒有 watch balance ,事務不會失敗,儲蓄卡成負數,是不不太符合業務呢

redis-transaction-watch4

使用無引數的 UNWATCH 命令可以手動取消對所有鍵的監視。 對於一些需要改動多個鍵的事務,有時候程式需要同時對多個鍵進行加鎖, 然後檢查這些鍵的當前值是否符合程式的要求。 當值達不到要求時, 就可以使用 UNWATCH 命令來取消目前對鍵的監視, 中途放棄這個事務, 並等待事務的下次嘗試。

watch指令,類似樂觀鎖,事務提交時,如果 key 的值已被別的客戶端改變,比如某個 list 已被別的客戶端push/pop 過了,整個事務佇列都不會被執行。(當然也可以用 Redis 實現分散式鎖來保證安全性,屬於悲觀鎖)

通過 watch 命令在事務執行之前監控了多個 keys,倘若在 watch 之後有任何 key 的值發生變化,exec 命令執行的事務都將被放棄,同時返回 Null 應答以通知呼叫者事務執行失敗。

悲觀鎖

悲觀鎖(Pessimistic Lock),顧名思義,就是很悲觀,每次去拿資料的時候都認為別人會修改,所以每次在拿資料的時候都會上鎖,這樣別人想拿這個資料就會 block 直到它拿到鎖。傳統的關係型資料庫裡邊就用到了很多這種鎖機制,比如行鎖,表鎖等,讀鎖,寫鎖等,都是在做操作之前先上鎖

樂觀鎖

樂觀鎖(Optimistic Lock),顧名思義,就是很樂觀,每次去拿資料的時候都認為別人不會修改,所以不會上鎖,但是在更新的時候會判斷一下在此期間別人有沒有去更新這個資料,可以使用版本號等機制。樂觀鎖適用於多讀的應用型別,這樣可以提高吞吐量。樂觀鎖策略:提交版本必須大於記錄當前版本才能執行更新

WATCH 命令的實現原理

在代表資料庫的 server.h/redisDb 結構型別中, 都儲存了一個 watched_keys 字典, 字典的鍵是這個資料庫被監視的鍵, 而字典的值是一個連結串列, 連結串列中儲存了所有監視這個鍵的客戶端,如下圖。

Redis設計與實現

typedef struct redisDb {
    dict *dict;                 /* The keyspace for this DB */
    dict *expires;              /* Timeout of keys with a timeout set */
    dict *blocking_keys;        /* Keys with clients waiting for data (BLPOP)*/
    dict *ready_keys;           /* Blocked keys that received a PUSH */
    dict *watched_keys;         /* WATCHED keys for MULTI/EXEC CAS */
    int id;                     /* Database ID */
    long long avg_ttl;          /* Average TTL, just for stats */
    unsigned long expires_cursor; /* Cursor of the active expire cycle. */
    list *defrag_later;         /* List of key names to attempt to defrag one by one, gradually. */
} redisDb;

list *watched_keys;     /* Keys WATCHED for MULTI/EXEC CAS */

WATCH 命令的作用, 就是將當前客戶端和要監視的鍵在 watched_keys 中進行關聯。

舉個例子, 如果當前客戶端為 client99 , 那麼當客戶端執行 WATCH key2 key3 時, 前面展示的 watched_keys 將被修改成這個樣子:

圖:Redis設計與實現

通過 watched_keys 字典, 如果程式想檢查某個鍵是否被監視, 那麼它只要檢查字典中是否存在這個鍵即可; 如果程式要獲取監視某個鍵的所有客戶端, 那麼只要取出鍵的值(一個連結串列), 然後對連結串列進行遍歷即可。

在任何對資料庫鍵空間(key space)進行修改的命令成功執行之後 (比如 FLUSHDB、SET 、DEL、LPUSH、 SADD,諸如此類), multi.c/touchWatchedKey 函式都會被呼叫 —— 它會去 watched_keys 字典, 看是否有客戶端在監視已經被命令修改的鍵, 如果有的話, 程式將所有監視這個/這些被修改鍵的客戶端的 REDIS_DIRTY_CAS 選項開啟:

圖:Redis設計與實現

void multiCommand(client *c) {
    // 不能在事務中巢狀事務
    if (c->flags & CLIENT_MULTI) {
        addReplyError(c,"MULTI calls can not be nested");
        return;
    }
    // 開啟事務 FLAG
    c->flags |= CLIENT_MULTI;
    addReply(c,shared.ok);
}

/* "Touch" a key, so that if this key is being WATCHed by some client the
 * next EXEC will fail. */
void touchWatchedKey(redisDb *db, robj *key) {
    list *clients;
    listIter li;
    listNode *ln;
	// 字典為空,沒有任何鍵被監視
    if (dictSize(db->watched_keys) == 0) return;
    // 獲取所有監視這個鍵的客戶端
    clients = dictFetchValue(db->watched_keys, key);
    if (!clients) return;

    // 遍歷所有客戶端,開啟他們的 CLIENT_DIRTY_CAS 標識
    listRewind(clients,&li);
    while((ln = listNext(&li))) {
        client *c = listNodeValue(ln);

        c->flags |= CLIENT_DIRTY_CAS;
    }
}

當客戶端傳送 EXEC 命令、觸發事務執行時, 伺服器會對客戶端的狀態進行檢查:

  • 如果客戶端的 CLIENT_DIRTY_CAS 選項已經被開啟,那麼說明被客戶端監視的鍵至少有一個已經被修改了,事務的安全性已經被破壞。伺服器會放棄執行這個事務,直接向客戶端返回空回覆,表示事務執行失敗。
  • 如果 CLIENT_DIRTY_CAS 選項沒有被開啟,那麼說明所有監視鍵都安全,伺服器正式執行事務。

小總結:

3 個階段

  • 開啟:以 MULTI 開始一個事務
  • 入隊:將多個命令入隊到事務中,接到這些命令並不會立即執行,而是放到等待執行的事務佇列裡面
  • 執行:由 EXEC 命令觸發事務

3 個特性

  • 單獨的隔離操作:事務中的所有命令都會序列化、按順序地執行。事務在執行的過程中,不會被其他客戶端傳送來的命令請求所打斷。
  • 沒有隔離級別的概念:佇列中的命令沒有提交之前都不會實際的被執行,因為事務提交前任何指令都不會被實際執行,也就不存在”事務內的查詢要看到事務裡的更新,在事務外查詢不能看到”這個讓人萬分頭痛的問題
  • 不保證原子性:Redis 同一個事務中如果有一條命令執行失敗,其後的命令仍然會被執行,沒有回滾

在傳統的關係式資料庫中,常常用 ACID 性質來檢驗事務功能的安全性。Redis 事務保證了其中的一致性(C)和隔離性(I),但並不保證原子性(A)和永續性(D)。

最後

Redis 事務在傳送每個指令到事務快取佇列時都要經過一次網路讀寫,當一個事務內部的指令較多時,需要的網路 IO 時間也會線性增長。所以通常 Redis 的客戶端在執行事務時都會結合 pipeline 一起使用,這樣可以將多次 IO 操作壓縮為單次 IO 操作。

相關文章