【高頻 Redis 面試題】Redis 事務是否具備原子性?

一、Redis 事務的實現原理

一個事務從開始到結束通常會經歷以下三個階段:

1、事務開始

客戶端發送 MULTI 命令,服務器執行 MULTI 命令邏輯。

服務器會在客戶端狀態(redisClient)的 flags 屬性打開 REDIS_MULTI 標識,將客戶端從非事務狀態切換到事務狀態。

void multiCommand(redisClient *c) {

    // 不能在事務中嵌套事務
    if (c->flags & REDIS_MULTI) {
        addReplyError(c,"MULTI calls can not be nested");
        return;
    }

    // 打開事務 FLAG
    c->flags |= REDIS_MULTI;

    addReply(c,shared.ok);
}

2、命令入隊

接着,用戶可以在客戶端輸入當前事務要執行的多個命令。

當客戶端切換到事務狀態時,服務器會根據客戶端發來的命令來執行不同的操作。

  • 如果客戶端發送的命令爲 EXEC、DISCARD、WATCH、MULTI 四個命令的其中一個,那麼服務器立即執行這個命令。

  • 與此相反,如果客戶端發送的命令是 EXEC、DISCARD、WATCH、MULTI 四個命令以外的其他命令,那麼服務器並不立即執行這個命令。

    • 首先檢查此命令的格式是否正確,如果不正確,服務器會在客戶端狀態(redisClient)的 flags 屬性打開 REDIS_MULTI 標識,並且返回錯誤信息給客戶端。
    • 如果正確將這個命令放入一個事務隊列裏面,然後向客戶端返回 QUEUED 回覆。
我們先看看事務隊列是如何實現的?

每個 Redis 客戶端都有自己的事務狀態,對應的是客戶端狀態(redisClient)的 mstate 屬性。

typeof struct redisClient{
    // 事務狀態
    multiState mstate;
}redisClient;

事務狀態(mstate)包含一個事務隊列(FIFO 隊列),以及一個已入隊命令的計數器。

/*
 * 事務狀態
 */
typedef struct multiState {

    // 事務隊列,FIFO 順序
    multiCmd *commands;     /* Array of MULTI commands */
    // 已入隊命令計數
    int count;              /* Total number of MULTI commands */
    int minreplicas;        /* MINREPLICAS for synchronous replication */
    time_t minreplicas_timeout; /* MINREPLICAS timeout as unixtime. */
} multiState;

事務隊列是一個 multiCmd 類型數組,數組中每個 multiCmd 結構都保存了一個如入隊命令的相關信息:指向命令實現函數的指針,命令的參數,以及參數的數量。

/*
 * 事務命令
 */
typedef struct multiCmd {

    // 參數
    robj **argv;
    // 參數數量
    int argc;
    // 命令指針
    struct redisCommand *cmd;
} multiCmd;
最後我們再看看入隊列的源碼:
/* Add a new command into the MULTI commands queue 
 *
 * 將一個新命令添加到事務隊列中
 */
void queueMultiCommand(redisClient *c) {
    multiCmd *mc;
    int j;

    // 爲新數組元素分配空間
    c->mstate.commands = zrealloc(c->mstate.commands,
            sizeof(multiCmd)*(c->mstate.count+1));

    // 指向新元素
    mc = c->mstate.commands+c->mstate.count;

    // 設置事務的命令、命令參數數量,以及命令的參數
    mc->cmd = c->cmd;
    mc->argc = c->argc;
    mc->argv = zmalloc(sizeof(robj*)*c->argc);
    memcpy(mc->argv,c->argv,sizeof(robj*)*c->argc);
    for (j = 0; j < c->argc; j++)
        incrRefCount(mc->argv[j]);

    // 事務命令數量計數器增一
    c->mstate.count++;
}

當然了,還有我們上面提到的,如果命令入隊出錯時,會打開客戶端狀態的 REDIS_DIRTY_EXEC 標識。

/* Flag the transacation as DIRTY_EXEC so that EXEC will fail.
 *
 * 將事務狀態設爲 DIRTY_EXEC ,讓之後的 EXEC 命令失敗。
 *
 * Should be called every time there is an error while queueing a command. 
 *
 * 每次在入隊命令出錯時調用
 */
void flagTransaction(redisClient *c) {
    if (c->flags & REDIS_MULTI)
        c->flags |= REDIS_DIRTY_EXEC;
}

3、事務執行

客戶端發送 EXEC 命令,服務器執行 EXEC 命令邏輯。

  • 如果客戶端狀態的 flags 屬性不包含 REDIS_MULTI 標識,或者包含 REDIS_DIRTY_CAS 或者 REDIS_DIRTY_EXEC 標識,那麼就直接取消事務的執行。
  • 否則客戶端處於事務狀態(flags 有 REDIS_MULTI 標識),服務器會遍歷客戶端的事務隊列,然後執行事務隊列中的所有命令,最後將返回結果全部返回給客戶端;
void execCommand(redisClient *c) {
    int j;
    robj **orig_argv;
    int orig_argc;
    struct redisCommand *orig_cmd;
    int must_propagate = 0; /* Need to propagate MULTI/EXEC to AOF / slaves? */

    // 客戶端沒有執行事務
    if (!(c->flags & REDIS_MULTI)) {
        addReplyError(c,"EXEC without MULTI");
        return;
    }

    /* Check if we need to abort the EXEC because:
     *
     * 檢查是否需要阻止事務執行,因爲:
     *
     * 1) Some WATCHed key was touched.
     *    有被監視的鍵已經被修改了
     *
     * 2) There was a previous error while queueing commands.
     *    命令在入隊時發生錯誤
     *    (注意這個行爲是 2.6.4 以後才修改的,之前是靜默處理入隊出錯命令)
     *
     * A failed EXEC in the first case returns a multi bulk nil object
     * (technically it is not an error but a special behavior), while
     * in the second an EXECABORT error is returned. 
     *
     * 第一種情況返回多個批量回復的空對象
     * 而第二種情況則返回一個 EXECABORT 錯誤
     */
    if (c->flags & (REDIS_DIRTY_CAS|REDIS_DIRTY_EXEC)) {

        addReply(c, c->flags & REDIS_DIRTY_EXEC ? shared.execaborterr :
                                                  shared.nullmultibulk);

        // 取消事務
        discardTransaction(c);

        goto handle_monitor;
    }

    /* Exec all the queued commands */
    // 已經可以保證安全性了,取消客戶端對所有鍵的監視
    unwatchAllKeys(c); /* Unwatch ASAP otherwise we'll waste CPU cycles */

    // 因爲事務中的命令在執行時可能會修改命令和命令的參數
    // 所以爲了正確地傳播命令,需要現備份這些命令和參數
    orig_argv = c->argv;
    orig_argc = c->argc;
    orig_cmd = c->cmd;

    addReplyMultiBulkLen(c,c->mstate.count);

    // 執行事務中的命令
    for (j = 0; j < c->mstate.count; j++) {

        // 因爲 Redis 的命令必須在客戶端的上下文中執行
        // 所以要將事務隊列中的命令、命令參數等設置給客戶端
        c->argc = c->mstate.commands[j].argc;
        c->argv = c->mstate.commands[j].argv;
        c->cmd = c->mstate.commands[j].cmd;

        /* Propagate a MULTI request once we encounter the first write op.
         *
         * 當遇上第一個寫命令時,傳播 MULTI 命令。
         *
         * This way we'll deliver the MULTI/..../EXEC block as a whole and
         * both the AOF and the replication link will have the same consistency
         * and atomicity guarantees. 
         *
         * 這可以確保服務器和 AOF 文件以及附屬節點的數據一致性。
         */
        if (!must_propagate && !(c->cmd->flags & REDIS_CMD_READONLY)) {

            // 傳播 MULTI 命令
            execCommandPropagateMulti(c);

            // 計數器,只發送一次
            must_propagate = 1;
        }

        // 執行命令
        call(c,REDIS_CALL_FULL);

        /* Commands may alter argc/argv, restore mstate. */
        // 因爲執行後命令、命令參數可能會被改變
        // 比如 SPOP 會被改寫爲 SREM
        // 所以這裏需要更新事務隊列中的命令和參數
        // 確保附屬節點和 AOF 的數據一致性
        c->mstate.commands[j].argc = c->argc;
        c->mstate.commands[j].argv = c->argv;
        c->mstate.commands[j].cmd = c->cmd;
    }

    // 還原命令、命令參數
    c->argv = orig_argv;
    c->argc = orig_argc;
    c->cmd = orig_cmd;

    // 清理事務狀態
    discardTransaction(c);

    /* Make sure the EXEC command will be propagated as well if MULTI
     * was already propagated. */
    // 將服務器設爲髒,確保 EXEC 命令也會被傳播
    if (must_propagate) server.dirty++;

handle_monitor:
    /* Send EXEC to clients waiting data from MONITOR. We do it here
     * since the natural order of commands execution is actually:
     * MUTLI, EXEC, ... commands inside transaction ...
     * Instead EXEC is flagged as REDIS_CMD_SKIP_MONITOR in the command
     * table, and we do it here with correct ordering. */
    if (listLength(server.monitors) && !server.loading)
        replicationFeedMonitors(c,server.monitors,c->db->id,c->argv,c->argc);
}

二、爲什麼很多人說 Redis 事務爲何不支持原子性?

1、Redis 事務不支持事務回滾機制

Redis 事務執行過程中,如果一個命令執行出錯,那麼就返回錯誤,然後還是會接着繼續執行下面的命令。

下面我們演示一下:

正是因爲 Redis 事務不支持事務回滾機制,如果事務執行中出現了命令執行錯誤(例如對 String 類型的數據庫鍵執行 LPUSH 操作),只會返回當前命令執行的錯誤給客戶端,並不會影響下面的命令的執行。所以很多人覺得和關係型數據庫(MySQL) 不一樣,而 MySQL 的事務是具有原子性的,所以大家都認爲 Redis 事務不支持原子性。

2、但是其實 Redis 意義上是支持原子性的。

正常情況下,它也是要不所有命令執行成功,要不一個命令都不執行。

我們下面演示一下:

全部執行成功的:
在這裏插入圖片描述
一個都不執行:
在這裏插入圖片描述
這就是上面提到的,在事務開始後,用戶可以輸入事務要執行的命令;在命令入事務隊列前,會對命令進行檢查,如果命令不存在或者是命令參數不對,則會返回錯誤可客戶端,並且修改客戶端狀態。

當後面客戶端執行 EXEC 命令時,服務器就會直接拒絕執行此事務了。

所以說,Redis 事務其實是支持原子性的!即使 Redis 不支持事務回滾機制,但是它會檢查每一個事務中的命令是否錯誤。

但是我們要注意一個點就是:Redis 事務不支持檢查那些程序員自己邏輯錯誤。例如對 String 類型的數據庫鍵執行對 HashMap 類型的操作!

我很贊同 Redis 作者的想法:

首先,MySQL 和 Redis 的定位不一樣,一個是關係型數據庫,一個是 NoSQL。

MySQL 的 SQL 查詢是可以相當複雜的,而且 MySQL 沒有事務隊列這種說法,SQL 真正開始執行纔會進行分析和檢查,MySQL 不可能提前知道下一條 SQL 是否正確。所以支持事務回滾是非常有必要的~

但是,Redis 使用了事務隊列來預先將執行命令存儲起來,並且會對其進行格式檢查的,提前就知道命令是否可執行了。所以如果只要有一個命令是錯誤的,那麼這個事務是不能執行的。

Redis 作者認爲基本只會出現在開發環境的編程錯誤其實在生產環境基本是不可能出現的(例如對 String 類型的數據庫鍵執行 LPUSH 操作),所以他覺得沒必要爲了這事務回滾機制而改變 Redis 追求簡單高效的設計主旨。

所以最後,其實 Redis 事務真正支持原子性的前提:開發者不要傻不拉幾的寫有邏輯問題的代碼!

參考資料:《Redis 設計與實現》、《Redis 源碼》

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章