Redis核心原理與實踐--事務實踐與原始碼分析

binecy發表於2021-11-10

Redis支援事務機制,但Redis的事務機制與傳統關係型資料庫的事務機制並不相同。
Redis事務的本質是一組命令的集合(命令佇列)。事務可以一次執行多個命令,並提供以下保證:
(1)事務中的所有命令都按順序執行。事務命令執行過程中,其他客戶端提交的命令請求需要等待當前事務所有命令執行完成後再處理,不會插入當前事務命令佇列中。
(2)事務中的命令要麼都執行,要麼都不執行,即使事務中有些命令執行失敗,後續命令依然被執行。因此Redis事務也是原子的。
注意Redis不支援回滾,如果事務中有命令執行失敗了,那麼Redis會繼續執行後續命令而不是回滾。
可能有讀者疑惑Redis是否支援ACID?筆者認為,ACID概念起源於傳統的關係型資料庫,而Redis是非關係型資料庫,而且Redis並沒有宣告是否支援ACID,所以本文不討論該問題。

事務的應用示例

Redis提供了MULTI、EXEC、DISCARD和WATCH命令來實現事務功能:

> MULTI
OK
> SET points 1
QUEUED
> INCR points
QUEUED
> EXEC
1) (integer) 1
2) (integer) 1
  • MULTI命令可以開啟一個事務,後續的命令都會被放入事務命令佇列。
  • EXEC命令可以執行事務命令佇列中的所有命令,DISCARD命令可以拋棄事務命令佇列中的命令,這兩個命令都會結束當前事務。
  • WATCH命令可以監視指定鍵,當後續事務執行前發現這些鍵已修改時,則拒絕執行事務。

表17-1展示了一個WATCH命令的簡單使用示例。
picture 1
可以看到,在執行EXEC命令前如果WATCH的鍵被修改,則EXEC命令不會執行事務,因此WATCH常用於實現樂觀鎖。

事務的實現原理

server.h/multiState結構體負責存放事務資訊:

typedef struct multiState {
    multiCmd *commands;
    ...
} multiState;
  • commands:事務命令佇列,存放當前事務所有的命令。
    客戶端屬性client.mstate指向一個multiState變數,該multiState作為客戶端的事務上下文,負責存放該客戶端當前的事務資訊。
    下面看一下MULTI、EXEC和WATCH命令的實現。

WATCH命令的實現

提示:本章程式碼如無特殊說明,均在multi.c中。
WATCH命令的實現邏輯較獨立,我們先分析該命令的實現邏輯。
redisDb中定義了字典屬性watched_keys,該字典的鍵是資料庫中被監視的Redis鍵,字典的值是監視字典鍵的所有客戶端列表,如圖17-1所示。
picture 2

client中也定義了列表屬性watched_keys,記錄該客戶端所有監視的鍵。
watchCommand函式負責處理WATCH命令,該函式會呼叫watchForKey函式處理相關邏輯:

void watchForKey(client *c, robj *key) {
    ...
    // [1]
    clients = dictFetchValue(c->db->watched_keys,key);
    ...
    listAddNodeTail(clients,c);
    
    // [2]
    wk = zmalloc(sizeof(*wk));
    wk->key = key;
    wk->db = c->db;
    incrRefCount(key);
    listAddNodeTail(c->watched_keys,wk);
}

【1】將客戶端新增到redisDb.watched_keys字典中該Redis鍵對應的客戶端列表中。
【2】初始化watchedKey結構體(wk變數),該結構體可以儲存被監視鍵和對應的資料庫。 將wk變數新增到client.watched_keys中。
Redis中每次修改資料時,都會呼叫signalModifiedKey函式,將該資料標誌為已修改。
signalModifiedKey函式會呼叫touchWatchedKey函式,通知監視該鍵的客戶端資料已修改:

void touchWatchedKey(redisDb *db, robj *key) {
    ...
    clients = dictFetchValue(db->watched_keys, key);
    if (!clients) return;

    listRewind(clients,&li);
    while((ln = listNext(&li))) {
        client *c = listNodeValue(ln);

        c->flags |= CLIENT_DIRTY_CAS;
    }
}

從redisDb.wzatched_keys中獲取所有監視該鍵的客戶端,給這些客戶端新增CLIENT_ DIRTY_CAS標誌,該標誌代表客戶端監視的鍵已被修改。

MULTI、EXEC命令的實現

MULTI命令由multiCommand函式處理,該函式的處理非常簡單,就是開啟客戶端CLIENT_MULTI標誌,代表該客戶端已開啟事務。
前面說過,processCommand函式執行命令時,會檢查客戶端是否已開啟事務。如果客戶端已開啟事務,則呼叫queueMultiCommand函式,將命令請求新增到客戶端事務命令佇列client.mstate.commands中:

int processCommand(client *c) {
    ...
    if (c->flags & CLIENT_MULTI &&
        c->cmd->proc != execCommand && c->cmd->proc != discardCommand &&
        c->cmd->proc != multiCommand && c->cmd->proc != watchCommand)
    {
        queueMultiCommand(c);
        addReply(c,shared.queued);
    } ...
    return C_OK;
}

可以看到,如果當前客戶端開啟了事務,則除了MULTI、EXEC、DISCARD和WATCH命令,其他命令都會放入到事務命令佇列中。
EXEC命令由execCommand函式處理:

void execCommand(client *c) {
    ...

    // [1]
    if (c->flags & (CLIENT_DIRTY_CAS|CLIENT_DIRTY_EXEC)) {
        addReply(c, c->flags & CLIENT_DIRTY_EXEC ? shared.execaborterr : shared.nullarray[c->resp]);
        discardTransaction(c);
        goto handle_monitor;
    }


    // [2]
    unwatchAllKeys(c);
    ...
    addReplyArrayLen(c,c->mstate.count);
    for (j = 0; j < c->mstate.count; j++) {
        c->argc = c->mstate.commands[j].argc;
        c->argv = c->mstate.commands[j].argv;
        c->cmd = c->mstate.commands[j].cmd;

        // [3]
        if (!must_propagate &&
            !server.loading &&
            !(c->cmd->flags & (CMD_READONLY|CMD_ADMIN)))
        {
            execCommandPropagateMulti(c);
            must_propagate = 1;
        }
        // [4]
        int acl_keypos;
        int acl_retval = ACLCheckCommandPerm(c,&acl_keypos);
        if (acl_retval != ACL_OK) {
            ...
        } else {
            call(c,server.loading ? CMD_CALL_NONE : CMD_CALL_FULL);
        }
        ...
    }
    // [5]
    ...
    discardTransaction(c);

    // [6]
    if (must_propagate) {
        int is_master = server.masterhost == NULL;
        server.dirty++;
        ...
    }    
    ...
}

【1】當客戶端監視的鍵被修改(客戶端存在CLIENT_DIRTY_CAS標誌)或者客戶端已拒絕事務中的命令(客戶端存在CLIENT_DIRTY_EXEC標誌)時,直接拋棄事務命令佇列中的命令,並進行錯誤處理。
當伺服器處於異常狀態(如記憶體溢位)時,Redis將拒絕命令,並給開啟了事務的客戶端新增CLIENT_DIRTY_EXEC標誌。
【2】取消當前客戶端對所有鍵的監視,所以WATCH命令只能作用於後續的一個事務。
【3】在執行事務的第一個寫命令之前,傳播MULTI命令到AOF檔案和從節點。MULTI命令執行完後並不會被傳播(MULTI命令並不屬於寫命令),如果事務中執行了寫命令,則在這裡傳播MULTI命令。
【4】檢查使用者的ACL許可權,檢查通過後執行命令。
【5】執行完所有命令,呼叫discardTransaction函式重置客戶端事務上下文client.mstate,並刪除CLIENT_MULTI、CLIENT_DIRTY_CAS、CLIENT_DIRTY_EXEC標誌,代表當前事務已經處理完成。
【6】如果事務中執行了寫命令,則修改server.dirty,這樣會使server.c/call函式將EXEC命令傳播到AOF檔案和從節點,從而保證一個事務的MULTI、EXEC命令都被傳播。
關於Redis不支援回滾機制,Redis在官網中給出瞭如下解釋:
(1)僅當使用了錯誤語法(並且該錯誤無法在命令加入佇列期間檢測)或者Redis命令運算元據型別錯誤(比如對集合型別使用了HGET命令)時,才可能導致事務中的命令執行失敗,這意味著事務中失敗的命令是程式設計錯誤的結果,所以這些問題應該在開發過程中發現並處理,而不是依賴於在生產環境中的回滾機制來規避。
(2)不支援回滾,Redis事務機制實現更簡單並且效能更高。
Redis的事務非常簡單,即在一個原子操作內執行多條命令。Redis的Lua指令碼也是事務性的,所以使用者也可以使用Lua指令碼實現事務。Redis Lua指令碼會在後續章節詳細分析。
總結:

  • Redis事務保證多條命令在一個原子操作內執行。
  • Redis提供了MULTI、EXEC、DISCARD和WATCH命令來實現事務功能。
  • 使用WATCH命令可以實現樂觀鎖機制。

本文內容摘自作者新書《Redis核心原理與實踐》。本書通過深入分析Redis 6.0原始碼,總結了Redis核心功能的設計與實現。通過閱讀本書,讀者可以深入理解Redis內部機制及最新特性,並學習到Redis相關的資料結構與演算法、Unix程式設計、儲存系統設計,分散式系統架構等一系列知識。
經過該書編輯同意,我會繼續在個人技術公眾號(binecy)釋出書中部分章節內容,作為書的預覽內容,歡迎大家查閱,謝謝。

語雀平臺預覽:《Redis核心原理與實踐》
京東連結

相關文章