Redis 事務簡介
???????Redis 通過 MULTI、EXEC、WATCH 等命令來實現事務功能。事務提供了一種將多個命令請求打包,然后一次性、按順序的執(zhí)行多個命令的機制,并且在事務執(zhí)行期間,服務器不會中斷事務而改去執(zhí)行其他客戶端的命令請求,它會將事務中的所有命令都執(zhí)行完畢,然后才去處理其他客戶端的命令請求。一個事務從開始到結束通常會經歷以下三個階段:
- 事務開始
- 命令入隊
- 事務執(zhí)行
文章也會重點圍繞上面三個步驟來講的。
事務組成
???????Redis要想了解 Redis 中的事務自然要了解 Redis 是如何實現事務的,那么我們肯定也就要知道 Redis 用了那些結構來存儲我們的事務的,下面我們就來看看事務的組成。
/*
* todo: client 結構體
*
* With multiplexing we need to take per-client state.
* Clients are taken in a linked list.
*/
typedef struct client {
...
// 標記,如果有事務進來 flags |= CLIENT_MULTI 追加事務狀態(tài)
int flags;
...
// 事務結構體
multiState mstate; /* MULTI/EXEC state */
...
// 使用 watch 監(jiān)控的所有 key
list *watched_keys;
...
} client;
???????Redis Redis 會為每個客戶端創(chuàng)建一個 client 結構體,client 結構體里會存儲當前客戶端的一些信息,而我們的事務信息也會保存在里面,下面我們詳細講解和事務相關的幾個字段。
- flags :字段采用位運算記錄很多狀態(tài),當我們標記事務狀態(tài)的時候只需要將 flags |= CLIENT_MULTI 即可追加事務狀態(tài)。
- mstate :事務狀態(tài)結構體,里面會存儲我們的命令列表
- watched_keys :看名字我們就知道,這個肯定是用監(jiān)控事務中 key 變化的列表
現在我們來看看 multiState 這個結構體里面到底存儲了哪些東西吧。
redis> multi
ok
redis> set "name" "practical common lisp"
queued
redis> get "name"
queue
redis> set "author" "peter seibel"
queued
redis> get "author"
queued
上面的命令服務器將為客戶端創(chuàng)建下圖所示的事務狀態(tài):

/**
* 事務狀態(tài)結構體
*/
typedef struct multiState {
/**
* 事務中命令列表
*/
multiCmd *commands;
/**
* 事務隊列里面命令的個數
*/
int count;
/**
* 用于同步復制
*/
int minreplicas;
/**
* 同步復制超時時間
*/
time_t minreplicas_timeout;
} multiState;
???????Redis 通過上面我們可以看到,multiState 保存了我們事務中所有的命令列表,一旦我們發(fā)送提交事務的命令,那么 Redis 就會從 multiState 拿到事務中所有的命令,然后依次執(zhí)行。上面 commands 保存的是 multiCmd 結構體,而這個結構體里面就保存了我們命令要執(zhí)行的一些信息。
/**
* 客戶端事務命令結構體
* Client MULTI/EXEC state
*/
typedef struct multiCmd {
/**
* 命令執(zhí)行的參數列表
*/
robj **argv;
/**
* 命令執(zhí)行的參數的個數
*/
int argc;
/**
* 具體要執(zhí)行的命令指針
*/
struct redisCommand *cmd;
} multiCmd;
???????Redis 為了更好的理解上面的結構,我們可以添加幾條命令,看看 Redis 到底是如何使用這幾個結構體來存儲我們的事務命令的。
事務開始
???????Redis 事務是從 multi 命令開始的,那么我們看看輸入 multi 命令,Redis 到底做了哪些操作。我們知道一個 multi 命令在 Redis 里面就對應了一個 multiCommand 方法,那么我就找到該方法一探究竟吧。
/**
* 開啟一個事務的命令
*/
void multiCommand(client *c) {
// 事務不支持嵌套(不支持事務里面再包含事務)
if (c->flags & CLIENT_MULTI) {
addReplyError(c,"MULTI calls can not be nested");
return;
}
// 將客戶端的 flags 標志添加一個事務標志
c->flags |= CLIENT_MULTI;
addReply(c,shared.ok);
}
???????Redis 從上面我們可以看出,我們使用 multi 開啟一個事務的時候,Redis 只是將當前 client 的 flags 追加一個事務標志。如果當前客戶端已經開啟了事務,那么在當前事務沒有結束之前是不允許再發(fā)送 multi 命令的。
事務入隊
???????Redis 從上面我們已經知道 multi 命令后 Redis 是如何開啟一個事務的,也許現在很多人又會會很好奇,為什么我們輸入一個 multi 命令后,redis 就會把 multi 之后的命令都加入命令隊列里面呢,下面我就來揭曉這個答案吧。我們來看一下所有 redis 命令的入口吧。
int processCommand(client *c) {
...
/* Exec the command 這里就是事務命令執(zhí)行的地方 */
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);
} else {
// todo: call 調用,這里面就會調用非事務命令的方法
call(c, CMD_CALL_FULL);
c->woff = server.master_repl_offset;
if (listLength(server.ready_keys))
handleClientsBlockedOnKeys();
}
return C_OK;
}
???????Redis 通過 processCommand 方法我們可以知道,當 client 狀態(tài) flags 為 CLIENT_MULTI 事務狀態(tài)的時候,并且,客戶端輸入的命令非 exec、discard、multi、watch 命令的時候,Redis 會將輸入的命令通過 queueMultiCommand 方法加入事務隊列,然后向客戶端返回 shared.queued("QUEUED") 字符串。如果不是事物狀態(tài),那么 Redis 會馬上執(zhí)行我們輸入的命令,看到這里就知道為什么 multi 之后的命令都會加入命令隊列了吧??吹竭@里是否有意猶未盡之意,我們繼續(xù)往下看,Redis 的 queueMultiCommand 方法具體是如何實現的。
/**
* 將新命令添加到MULTI命令隊列中
* 閱讀該方法一定要有 C 語言基礎,能看懂指針地址的賦值操作
*/
void queueMultiCommand(client *c) {
// 事務命令指針,里面會指向真正要執(zhí)行的命令
multiCmd *mc;
int j;
// 給新增的 multiCmd 計算內存起始地址,在 commands 鏈表中加入新事務命令
c->mstate.commands = zrealloc(c->mstate.commands,
sizeof(multiCmd) * (c->mstate.count + 1));
// 地址賦值,實際就是將 multiCmd 加入 mstate.commands 隊尾
mc = c->mstate.commands + c->mstate.count;
// 將客戶端的命令和參數賦值給事務命令結構體,方便后面執(zhí)行
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 就如上面這樣,Redis 會把 multi 之后的命令構造成一個 multiCmd 結構添加到
mstate.commands 鏈表后面,方便后續(xù)執(zhí)行。代碼看的不過癮,我們還可以看看執(zhí)行的流程圖,方便大家理解。下圖就是上面代碼執(zhí)行的流程圖。

事務執(zhí)行
???????Redis 在事務執(zhí)行之前,我們不得不提一下 watch 命令。Redis 的官方文檔上說,WATCH 命令是為了讓 Redis 擁有 check-and-set(CAS) 的特性。CAS 的意思是,一個客戶端在修改某個值之前,要檢測它是否更改;如果沒有更改,修改操作才能成功。通過上面的 client 結構體我們可以知道 watch 監(jiān)控的 key 是以鏈表的形式存儲在 Redis 的 client 結構體中。具體如下圖所示:

監(jiān)視鍵值的過程:
/**
* 這個就是 watch 命令執(zhí)行步驟
*/
void watchCommand(client *c) {
int j;
// 該命令只能出現在 multi 命令之前
if (c->flags & CLIENT_MULTI) {
addReplyError(c, "WATCH inside MULTI is not allowed");
return;
}
// 監(jiān)控指定的 key
for (j = 1; j < c->argc; j++)
// todo: 實際監(jiān)控 key 的操作
watchForKey(c, c->argv[j]);
// 像客戶端緩沖區(qū)返回 ok
addReply(c, shared.ok);
}
???????Redis 有一些前置操作,比如檢測 watch 命令是否在 multi 命令之前,如果不是則直接報錯,實際監(jiān)控 key 的還是 watchForKey 方法,下面我們重點講解該方法。
/*
* Watch for the specified key
*
* 監(jiān)控指定的 key
*/
void watchForKey(client *c, robj *key) {
list *clients = NULL;
listIter li;
listNode *ln;
watchedKey *wk;
/* Check if we are already watching for this key */
listRewind(c->watched_keys, &li);
while ((ln = listNext(&li))) {
wk = listNodeValue(ln);
// 條件滿足說明該 key 已經被 watched 了
if (wk->db == c->db && equalStringObjects(key, wk->key))
return; /* Key already watched */
}
/* This key is not already watched in this DB. Let's add it */
// 此DB中尚未監(jiān)視此 key 。 我們加上吧
// 先從 c->db->watched_keys 中取出該 key 對應的客戶端 client
clients = dictFetchValue(c->db->watched_keys, key);
// 如果該 client 為 null,則說明該key 沒有被 client 監(jiān)控
// 則需要在該key 后面創(chuàng)建一個 client list 列表,用來保存
// 監(jiān)控了該key 的客戶端 client
if (!clients) {
clients = listCreate();
dictAdd(c->db->watched_keys, key, clients);
incrRefCount(key);
}
// 尾插法。將客戶端添加到鏈表尾部,實際服務端也會保存一份
listAddNodeTail(clients, c);
/* Add the new key to the list of keys watched by this client */
// 將新 key 添加到此客戶端 watched(監(jiān)控) 的 key 列表中
wk = zmalloc(sizeof(*wk));
wk->key = key;
wk->db = c->db;
incrRefCount(key);
// 把 wk 賦值給指定的 client 的監(jiān)控key的結構體中
listAddNodeTail(c->watched_keys, wk);
}
???????Redis 當客戶端鍵值被修改的時候,監(jiān)視該鍵值的所有客戶端都會被標記為 REDISDIRTY-CAS,表示此該鍵值對被修改過,因此如果這個客戶端已經進入到事務狀態(tài),它命令隊列中的命令是不會被執(zhí)行的。
???????Redis touchWatchedKey() 是標記某鍵值被修改的函數,它一般不被 signalModifyKey() 函數包裝。下面是 touchWatchedKey() 的實現。
// 標記鍵值對的客戶端為REDIS_DIRTY_CAS,表示其所監(jiān)視的數據已經被修改過
/* "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;
// 獲取監(jiān)視key 的所有客戶端
if (dictSize(db->watched_keys) == 0) return;
clients = dictFetchValue(db->watched_keys, key);
if (!clients) return;
// 標記監(jiān)視key 的所有客戶端REDIS_DIRTY_CAS
/* Mark all the clients watching this key as REDIS_DIRTY_CAS */
/* Check if we are already watching for this key */
listRewind(clients,&li);
while((ln = listNext(&li))) {
redisClient *c = listNodeValue(ln);
// REDIS_DIRTY_CAS 更改的時候會設置此標記
c->flags |= REDIS_DIRTY_CAS;
}
}
???????Redis 當用戶發(fā)出 EXEC 的時候,在它 MULTI 命令之后提交的所有命令都會被執(zhí)行。從代碼的實現來看,如果客戶端監(jiān)視的數據被修改,它會被標記 REDIS_DIRTY_CAS,會調用 discardTransaction() 從而取消該事務。特別的,用戶開啟一個事務后會提交多個命令,如果命令在入隊過程中出現錯誤,譬如提交的命令本身不存在,參數錯誤和內存超額等,都會導致客戶端被標記 REDIS_DIRTY_EXEC,被標記 REDIS_DIRTY_EXEC 會導致事務被取消。
因此總結一下:
- REDIS_DIRTY_CAS 更改的時候會設置此標記
- REDIS_DIRTY_EXEC 命令入隊時出現錯誤,此標記會導致 EXEC 命令執(zhí)行失敗
下面是執(zhí)行事務的過程:
/**
* 執(zhí)行事務的命令
*/
void execCommand(client *c) {
...
// 是否需要將MULTI/EXEC命令傳播到slave節(jié)點/AOF
int must_propagate = 0;
int was_master = server.masterhost == NULL;
// 事務有可能會被取消
if (!(c->flags & CLIENT_MULTI)) {
// 沒事事務可以執(zhí)行
addReplyError(c, "EXEC without MULTI");
return;
}
/*
* 停止執(zhí)行事務命令的情況::
* 1. 有一些被監(jiān)控的 key 被修改了
* 2. 由于命令隊列里面出現了錯誤
*
* 第一種情況下失敗的EXEC返回一個多塊nil對象
* 技術上它不是錯誤,而是特殊行為,而在第二個中返回EXECABORT錯誤
*/
if (c->flags & (CLIENT_DIRTY_CAS | CLIENT_DIRTY_EXEC)) {
addReply(c, c->flags & CLIENT_DIRTY_EXEC ? shared.execaborterr :
shared.nullmultibulk);
discardTransaction(c);
goto handle_monitor;
}
/* 執(zhí)行所有排隊的命令 */
// 取消對所有key的監(jiān)控,否則會浪費CPU資源
// 因為 redis 是單線程。所以不用擔心 key 再被修改了
unwatchAllKeys(c);
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++) {
c->argc = c->mstate.commands[j].argc;
c->argv = c->mstate.commands[j].argv;
c->cmd = c->mstate.commands[j].cmd;
// todo: 遇到包含寫操作的命令需要將MULTI 命令寫入AOF 文件
if (!must_propagate && !(c->cmd->flags & (CMD_READONLY | CMD_ADMIN))) {
execCommandPropagateMulti(c);
must_propagate = 1;
}
// 執(zhí)行我們事務隊列里面的命令
call(c, CMD_CALL_FULL);
/* Commands may alter argc/argv, restore mstate. */
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;
// 清除事務狀態(tài)
discardTransaction(c);
...
handle_monitor:
if (listLength(server.monitors) && !server.loading)
replicationFeedMonitors(c, server.monitors, c->db->id, c->argv, c->argc);
}
如上所說,被監(jiān)視的鍵值被修改或者命令入隊出錯都會導致事務被取消:
/**
* 取消事務,比如遇到事務中的語法錯誤問題
*/
void discardTransaction(client *c) {
// 清空命令隊列
freeClientMultiState(c);
// 初始化命令隊列
initClientMultiState(c);
// 取消標記flag
c->flags &= ~(CLIENT_MULTI | CLIENT_DIRTY_CAS | CLIENT_DIRTY_EXEC);
// 取消對 client 所有被監(jiān)控的 key
unwatchAllKeys(c);
}
Redis 事務番外篇
你可能已經注意到「事務」這個詞。在學習數據庫原理的時候有提到過事務的 ACID,即原子性、一致性、隔離性、持久性。接下來,看看 Redis 事務是否支持 ACID。
原子性,即一個事務中的所有操作,要么全部完成,要么全部不完成,不會結束在中間某個環(huán)節(jié)。Redis 事務不支持原子性,最明顯的是 Redis 不支持回滾操作。一致性,在事務開始之前和事務結束以后,數據庫的完整性沒有被破壞。這一點,Redis 事務能夠保證。
隔離性,當兩個或者多個事務并發(fā)訪問(此處訪問指查詢和修改的操作)數據庫的同一數據時所表現出的相互關系。Redis 不存在多個事務的問題,因為 Redis 是單進程單線程的工作模式。
持久性,在事務完成以后,該事務對數據庫所作的更改便持久地保存在數據庫之中,并且是完全的。Redis 提供兩種持久化的方式,即 RDB 和 AOF。RDB 持久化只備份當前內存中的數據集,事務執(zhí)行完畢時,其數據還在內存中,并未立即寫入到磁盤,所以 RDB 持久化不能保證 Redis 事務的持久性。再來討論 AOF 持久化,Redis AOF 有后臺執(zhí)行和邊服務邊備份兩種方式。后臺執(zhí)行和 RDB 持久化類似,只能保存當前內存中的數據集;邊備份邊服務的方式中,因為 Redis 只是每間隔 2s 才進行一次備份,因此它的持久性也是不完整的!
一致性:待補充
???????還有一個亮點,就是 check-and-set CAS。一個修改操作不斷的判斷X 值是否已經被修改,直到 X 值沒有被其他操作修改,才設置新的值。Redis 借助 WATCH/MULTI 命令來實現 CAS 操作的。
???????實際操作中,多個線程嘗試修改一個全局變量,通常我們會用鎖,從讀取這個變量的時候就開始鎖住這個資源從而阻擋其他線程的修改,修改完畢后才釋放鎖,這是悲觀鎖的做法。相對應的有一種樂觀鎖,樂觀鎖假定其他用戶企圖修改你正在修改的對象的概率很小,直到提交變更的時候才加鎖,讀取和修改的情況都不加鎖。一般情況下,不同客戶端會訪問修改不同的鍵值對,因此一般 check 一次就可以 set 了,而不需要重復 check 多次。
???????注意:Redis 是不支持事務回滾的,據說作者認為回滾操作會影響 Redis 的性能,所以沒有事務回滾的功能。