天天看點

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

文章收錄在 GitHub JavaKeeper ,N線網際網路開發必備技能兵器譜
不支援原子性的 Redis 事務也叫事務嗎?
假設現在有這樣一個業務,使用者擷取的某些資料來自第三方接口資訊,為避免頻繁請求第三方接口,我們往往會加一層緩存,緩存肯定要有時效性,假設我們要存儲的結構是 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 的監視

指令用于開啟一個事務,它總是傳回 OK 。

MULTI 執行之後, 用戶端可以繼續向伺服器發送任意多條指令, 這些指令不會立即被執行, 而是被放到一個隊列中, 當

指令被調用時, 所有隊列中的指令才會被執行。

另一方面, 通過調用

, 用戶端可以清空事務隊列, 并放棄執行事務。

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

一帆風順

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

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

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

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

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

事務中的錯誤

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

  • 事務在執行

    EXEC

    之前,入隊的指令可能會出錯。比如說,指令可能會産生文法錯誤(參數數量錯誤,參數名錯誤等等),或者其他更嚴重的錯誤,比如記憶體不足(如果伺服器使用

    maxmemory

    設定了最大記憶體限制的話)。
  • 指令可能在

    EXEC

    調用之後失敗。舉個例子,事務中的指令可能處理了錯誤類型的鍵,比如将清單指令用在了字元串鍵上面,諸如此類。

Redis 針對如上兩種錯誤采用了不同的處理政策,對于發生在

EXEC

執行之前的錯誤,伺服器會對指令入隊失敗的情況進行記錄,并在用戶端調用

EXEC

指令時,拒絕執行并自動放棄這個事務(Redis 2.6.5 之前的做法是檢查指令入隊所得的傳回值:如果指令入隊時傳回 QUEUED ,那麼入隊成功;否則,就是入隊失敗)

對于那些在

EXEC

指令執行之後所産生的錯誤, 并沒有對它們進行特别處理: 即使事務中有某個/某些指令在執行時産生了錯誤, 事務中的其他指令仍然會繼續執行。

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

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

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

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

為什麼 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 事務也叫事務嗎?

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

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

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

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

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

你可能沒看明白 watch 有啥用,我們再來看下,如果還是同樣的場景,我們沒有

watch balance

,事務不會失敗,儲蓄卡成負數,是不不太符合業務呢

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

使用無參數的

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 操作。

參考資料

[1] Redis設計與實作:

https://redisbook.readthedocs.io/en/latest/feature/transaction.html#id3