Redis 事務詳解

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

假設如今有這樣一個業務,用戶獲取的某些數據來自第三方接口信息,爲避免頻繁請求第三方接口,咱們每每會加一層緩存,緩存確定要有時效性,假設咱們要存儲的結構是 hash(沒有String的' SET anotherkey "will expire in a minute" EX 60'這種原子操做),咱們既要批量去放入緩存,又要保證每一個 key 都加上過時時間(以防 key 永不過時),這時候事務操做是個比較好的選擇

爲了確保連續多個操做的原子性,咱們經常使用的數據庫都會有事務的支持,Redis 也不例外。但它又和關係型數據庫不太同樣。html

每一個事務的操做都有 begin、commit 和 rollback,begin 指示事務的開始,commit 指示事務的提交,rollback 指示事務的回滾。它大體的形式以下java

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

Redis 在形式上看起來也差很少,分爲三個階段git

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

上面的指令演示了一個完整的事務過程,全部的指令在 exec 以前不執行,而是緩存在服務器的一個事務隊列中,服務器一旦收到 exec 指令,纔開執行整個事務隊列,執行完畢後一次性返回全部指令的運行結果。程序員

Redis 事務能夠一次執行多個命令,本質是一組命令的集合。一個事務中的全部命令都會序列化,按順序地串行化執行而不會被其它命令插入,不準加塞。github

能夠保證一個隊列中,一次性、順序性、排他性的執行一系列命令(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 操做。

相關文章
相關標籤/搜索