Redis 事務實現和樂觀鎖
1. 事務的介紹
Redis
事務(transaction)提供了以下五個命令,用於用戶操作事務功能,其分別是:
命令 | 功能 |
---|---|
MULTI | 標記一個事務塊的開始 |
DISCARD | 放棄執行事務 |
EXEC | 執行事務中的所有命令 |
WATCH | 監視一個或多個key,如果至少有一個key在EXEC之前被修改,則放棄執行事務 |
UNWATCH | 取消WATCH命令對所有鍵的監視 |
- 事務提供了一種將多個命令請求打包,然後一次性、按順序地執行多個命令的機制,並且在事務的執行期間,服務器不會打斷事務而改去執行其他客戶端的命令請求,它會將事務中的所有命令都執行完畢,然後纔去執行其他客戶端的命令請求。
- Redis
中的事務(transaction)是一組命令的集合。事務同命令一樣都是Redis
中的最小執行單位,一個事務中的命令要麼都執行,要麼都不執行。
事務命令的使用方法請看:Redis 事務命令 。
Redis 事務源碼詳細註釋
2. 事務的實現
執行事務的過程分爲以下的幾個階段:
- 開始事務
- 命令入隊
- 執行事務
2.1 開始事務
在客戶端執行一個MULTI
命令,標記一個事務塊的開始。該命令會被封裝成Redis
協議的格式發送給服務器,服務器接收到該命令會調用multiCommand()
函數來執行。函數源碼如下:
void multiCommand(client *c) {
// 客戶端已經處於事務狀態,回覆錯誤後返回
if (c->flags & CLIENT_MULTI) {
addReplyError(c,"MULTI calls can not be nested");
return;
}
// 打開客戶的的事務狀態標識
c->flags |= CLIENT_MULTI;
// 回覆OK
addReply(c,shared.ok);
}
該函數首先先會判斷當前客戶端是否處於事務狀態(CLIENT_MULTI),如果沒有處於事務狀態,那麼會打開客戶端的事務狀態標識,並且回覆客戶端一個OK
。
執行完MULTI
命令,表示着一個事務的開始。
2.2 命令入隊
由於事務的命令是一次性、按順序的執行,因此,需要將客戶端中的所有事務命令事前保存起來。Redis 事務源碼詳細註釋
在每個描述客戶端狀態的結構struct client
中,保存着有關事務狀態的成員變量,他的定義如下:
typedef struct client {
// 事物狀態
multiState mstate;
} client;
multiState
是一個結構體,定義如下:
typedef struct multiState {
// 事務命令隊列數組
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;
重點關注前兩個屬性。
commands
成員是一個指針,保存的是一個成員類型爲multiCmd
的數組首地址,每一個成員保存的都是事務命令。count
成員保存的是以commands
爲地址的數組的成員個數。
對於每一個事務命令,都使用multiCmd
類型來描述,定義如下:
// 事務命令狀態
typedef struct multiCmd {
// 命令的參數列表
robj **argv;
// 命令的參數個數
int argc;
// 命令函數指針
struct redisCommand *cmd;
} multiCmd;
在客戶端連接到服務器的時候,服務器會爲客戶端創建一個
client
,當客戶端發送命令給服務器,會觸發客戶端和服務器網絡連接的套接字產生讀事件,然後會調用在創建client
時所設置的回調函數readQueryFromClient()
,來執行處理客戶端發來的請求。該函數首先要讀取被包裝爲協議的命令,然後調用processInputBuffer()
函數將協議格式命令轉換爲參數列表的形式,最後會調用processCommand()
函數執行命令。
由於之前執行了MULTI
命令,因此客戶端打開了CLIENT_MULTI
標識,表示當前處於事務狀態。因此在processCommand()
函數中,最後執行命令的代碼如下:
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 {
// 調用 call() 函數執行命令....
}
如果當前客戶端處於事務狀態,並且當前執行的命令不是EXEC
、DISCARD
、MULTI
和WATCH
命令,那麼調用queueMultiCommand()
函數將當前命令入隊。該函數源碼如下:
void queueMultiCommand(client *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]);
// 事務命令個數加1
c->mstate.count++;
}
2.3 執行事務
當事務命令全部入隊後,在客戶端執行EXEC
命令,就可以執行事務。服務器會調用execCommand()
函數來執行EXEC
命令。Redis 事務源碼詳細註釋
void execCommand(client *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 & CLIENT_MULTI)) {
addReplyError(c,"EXEC without MULTI");
return;
}
// 檢查是否需要中斷EXEC的執行因爲:
/*
1. 被監控的key被修改
2. 入隊命令時發生了錯誤
*/
// 第一種情況返回空回覆對象,第二種情況返回一個EXECABORT錯誤
// 如果客戶的處於 1.命令入隊時錯誤或者2.被監控的key被修改
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;
}
// 執行隊列數組中的命令
// 因爲所有的命令都是安全的,因此取消對客戶端的所有的鍵的監視
unwatchAllKeys(c); /* Unwatch ASAP otherwise we'll waste CPU cycles */
// 備份EXEC命令
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;
// 當執行到第一個寫命令時,傳播事務狀態
if (!must_propagate && !(c->cmd->flags & CMD_READONLY)) {
// 發送一個MULTI命令給所有的從節點和AOF文件
execCommandPropagateMulti(c);
// 設置已經傳播過的標識
must_propagate = 1;
}
// 執行該命令
call(c,CMD_CALL_FULL);
// 命令可能會被修改,重新存儲在事務命令隊列中
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);
// 如果傳播了EXEC命令,表示執行了寫命令,更新數據庫髒鍵數
if (must_propagate) server.dirty++;
handle_monitor:
// 如果服務器設置了監控器,並且服務器不處於載入文件的狀態
if (listLength(server.monitors) && !server.loading)
// 將參數列表中的參數發送給監控器
replicationFeedMonitors(c,server.monitors,c->db->id,c->argv,c->argc);
}
如果當前客戶端不處於事務狀態會直接返回。
如果在按順序執行事務命令的過程中,被WATCH
命令監視的鍵發生修改,會被設置CLIENT_DIRTY_CAS
狀態;或者在執行processCommand()
命令,該函數會在執行命令時會事先判斷命令的合法性,如果不合法,會調用flagTransaction()
函數來設置客戶端CLIENT_DIRTY_EXEC
狀態。如果執行事務命令時,客戶端處於以上兩種狀態,那麼會調用discardTransaction()
函數取消執行事務。
如果不處於以上兩種狀態,那麼表示可以執行事務命令,但是先調用unwatchAllKeys()
函數,解除當前客戶端所監視的所有命令。
然後遍歷事務隊列中的所有命令,調用call()
函數執行命令。如果事務狀態中有寫命令被執行,那麼要將MULTI
命令傳播給從節點和AOF
文件。並且會在最後更新數據庫的髒鍵值。
執行完畢會調用discardTransaction()
函數取消當前客戶端的事務狀態。
取消客戶端的事務狀態,即使釋放客戶端當前事務狀態所有佔用的資源,並且取消CLIENT_MULTI
、 CLIENT_DIRTY_CAS
、 CLIENT_DIRTY_EXEC
三種狀態。
3. WATCH命令的實現
WATCH
命令可以監視一個(或多個) key ,如果在事務執行之前這個(或這些) key 被其他命令所改動,那麼事務將被打斷。Redis 事務源碼詳細註釋
服務器調用watchCommand()
函數執行WATCH
命令。代碼如下:
void watchCommand(client *c) {
int j;
// 如果已經處於事務狀態,則回覆錯誤後返回,必須在執行MULTI命令執行前執行WATCH
if (c->flags & CLIENT_MULTI) {
addReplyError(c,"WATCH inside MULTI is not allowed");
return;
}
// 遍歷所有的參數
for (j = 1; j < c->argc; j++)
// 監控當前key
watchForKey(c,c->argv[j]);
// 回覆OK
addReply(c,shared.ok);
}
如果執行WATCH
命令時,函數處於事務狀態,則直接返回。必須在執行MULTI
命令執行前執行WATCH
。
該函數會調用watchForKey()
函數來監控所有指定的鍵。該函數代碼如下:
void watchForKey(client *c, robj *key) {
list *clients = NULL;
listIter li;
listNode *ln;
watchedKey *wk;
listRewind(c->watched_keys,&li);
// 遍歷客戶端監視的鍵的鏈表,檢查是否已經監視了指定的鍵
while((ln = listNext(&li))) {
wk = listNodeValue(ln);
// 如果鍵已經被監視,則直接返回
if (wk->db == c->db && equalStringObjects(key,wk->key))
return; /* Key already watched */
}
// 如果數據庫中該鍵沒有被client監視則添加它
clients = dictFetchValue(c->db->watched_keys,key);
// 沒有被client監視
if (!clients) {
// 創建一個空鏈表
clients = listCreate();
// 值是被client監控的key,鍵是client,添加到數據庫的watched_keys字典中
dictAdd(c->db->watched_keys,key,clients);
incrRefCount(key);
}
// 將當前client添加到監視該key的client鏈表的尾部
listAddNodeTail(clients,c);
// 將新的被監視的key和與該key關聯的數據庫加入到客戶端的watched_keys中
wk = zmalloc(sizeof(*wk));
wk->key = key;
wk->db = c->db;
incrRefCount(key);
listAddNodeTail(c->watched_keys,wk);
}
首先,客戶端會保存一個監視鏈表,定義爲list *watched_keys
。這個鏈表每一個節點保存一個watchedKey
類型的指針,該結構代碼如下:
typedef struct watchedKey {
// 被監視的key
robj *key;
// 被監視的key所在的數據庫
redisDb *db;
} watchedKey;
這個結構只要用來保存當前客戶端監視的鍵和該鍵所在的數據庫的映射關係。
因此,首先遍歷客戶端的watched_keys
鏈表,如果當前鍵已經被監視,則直接返回。
其次,客戶端當前操作的數據庫中保存有一個監視字典,定義爲dict *watched_keys
,該字典的鍵是數據庫中被監視的鍵,字典的值是所有監視該鍵的客戶端鏈表。
因此,如果當前鍵沒有被監視的話,就將當前客戶端添加到該監視該鍵的客戶端鏈表的尾部。
然後新建一個watchedKey
結構,將當前被監視的鍵和該鍵所在的數據庫關聯起來,保存到客戶端的watched_keys
鏈表中。
這樣就將一個鍵監視起來,當數據庫中的鍵被修改,例如執行DEL
、RENAME
、等等命令時,會調用signalModifiedKey()
函數來處理數據庫中的鍵被修改的情況,該函數直接調用touchWatchedKey()
函數,來處理是否該鍵處於被監視的狀態。
void touchWatchedKey(redisDb *db, robj *key) {
list *clients;
listIter li;
listNode *ln;
// 如果數據庫中沒有被監視的key,直接返回
if (dictSize(db->watched_keys) == 0) return;
// 找出監視該key的client鏈表
clients = dictFetchValue(db->watched_keys, key);
// 沒找到返回
if (!clients) return;
listRewind(clients,&li);
// 遍歷所有監視該key的client
while((ln = listNext(&li))) {
client *c = listNodeValue(ln);
// 設置CLIENT_DIRTY_CAS標識
c->flags |= CLIENT_DIRTY_CAS;
}
}
該函數首先判斷數據庫中的watched_keys
字典是否爲空,如果爲空,表示該鍵沒有被客戶端監視,那麼直接返回。
否則會從數據庫中的watched_keys
字典中找到監視該鍵的客戶端鏈表,遍歷所有的客戶端,將所有的客戶端設置爲CLIENT_DIRTY_CAS
狀態標識。
該標識在標題2.3
中,執行EXEC
命令時,如果客戶端設置了CLIENT_DIRTY_CAS
標識,那麼會取消事務狀態,執行事務失敗。
4. 樂觀鎖
樂觀鎖(又名樂觀併發控制,Optimistic Concurrency Control,縮寫“OCC”),是一種併發控制的方法。它假設多用戶併發的事務在處理時不會彼此互相影響,各事務能夠在不產生鎖的情況下處理各自影響的那部分數據。在提交數據更新之前,每個事務會先檢查在該事務讀取數據後,有沒有其他事務又修改了該數據。
與樂觀所相對的,就是悲觀鎖(又名悲觀併發控制,Pessimistic Concurrency Control,縮寫“PCC”),它可以阻止一個事務以影響其他用戶的方式來修改數據。如果一個事務執行的操作都某行數據應用了鎖,那只有當這個事務把鎖釋放,其他事務才能夠執行與該鎖衝突的操作。
通俗的說,就是悲觀鎖就是“先取鎖在訪問”,因爲悲觀鎖會“悲觀”地認爲訪問會產生衝突,因此這種保守的策略雖然在數據處理的安全行上提供了保障,但是在效率方面會讓數據庫產生極大的開銷,而且還有可能出現死鎖的情況。
在Redis
中WATCH
命令的實現是基於樂觀鎖,即,假設訪問不會產生衝突,但是在提交數據之前會先檢查該事務該事物讀取數據後,其他事務是否修改數據,如果其他事務修改了數據,像MySQL
提供了回滾操作,而Redis
不支持回滾,因爲antirez
認爲這與Redis
簡單高效的設計主旨不相符,並且Redis
事務執行時錯誤在開發環境時是可以避免的。
樂觀鎖控制的事務一般包括三個階段:
- 讀取:當執行完
MULTI
命令後,客戶端進入事務模式,客戶端接下來輸入的命令會讀入到事務隊列中,入隊過程中出錯會設置CLIENT_DIRTY_EXEC
標識。 - 校驗:如果數據庫有鍵被修改,那麼會檢測被修改的鍵是否是被
WATCH
命令監視的命令,如果是則會設置對應的標識(CLIENT_DIRTY_CAS),並且在命令執行前會檢測這兩個標識,如果檢測到該標識,則會取消事務的執行。 - 寫入:如果沒有設置以上兩種標識,那麼會執行事務的命令,而
Redis
是單進程模型,因此可以避免執行事務命令時其他請求可能修改數據庫鍵的可能。
Redis
的樂觀鎖不是通常實現樂觀鎖的一般方法:檢測版本號,而是在執行完一個寫命令後,會進行檢查,檢查是否是被WATCH
監視的鍵。
Redis 事務源碼詳細註釋