【高頻 Redis 面試題】Redis 事務(wù)是否具備原子性?
一、事務(wù)的實現(xiàn)原理
一個事務(wù)從開始到結(jié)束通常會經(jīng)歷以下三個階段:
1、事務(wù)開始
客戶端發(fā)送 MULTI 命令,服務(wù)器執(zhí)行 MULTI 命令邏輯。
服務(wù)器會在客戶端狀態(tài)(redisClient)的 flags 屬性打開 REDIS_MULTI 標識,將客戶端從非事務(wù)狀態(tài)切換到事務(wù)狀態(tài)。
void multiCommand(redisClient *c) {// 不能在事務(wù)中嵌套事務(wù)if (c->flags & REDIS_MULTI) {addReplyError(c,"MULTI calls can not be nested");return;}// 打開事務(wù) FLAGc->flags |= REDIS_MULTI;addReply(c,shared.ok);}
2、命令入隊
接著,用戶可以在客戶端輸入當(dāng)前事務(wù)要執(zhí)行的多個命令。
當(dāng)客戶端切換到事務(wù)狀態(tài)時,服務(wù)器會根據(jù)客戶端發(fā)來的命令來執(zhí)行不同的操作。
如果客戶端發(fā)送的命令為 EXEC、DISCARD、WATCH、MULTI 四個命令的其中一個,那么服務(wù)器立即執(zhí)行這個命令。
與此相反,如果客戶端發(fā)送的命令是 EXEC、DISCARD、WATCH、MULTI 四個命令以外的其他命令,那么服務(wù)器并不立即執(zhí)行這個命令。
首先檢查此命令的格式是否正確,如果不正確,服務(wù)器會在客戶端狀態(tài)(redisClient)的
flags屬性打開 REDIS_MULTI 標識,并且返回錯誤信息給客戶端。如果正確將這個命令放入一個事務(wù)隊列里面,然后向客戶端返回 QUEUED 回復(fù)。
我們先看看事務(wù)隊列是如何實現(xiàn)的?
每個 Redis 客戶端都有自己的事務(wù)狀態(tài),對應(yīng)的是客戶端狀態(tài)(redisClient)的 mstate 屬性。
typeof struct redisClient{// 事務(wù)狀態(tài)multiState mstate;}redisClient;
事務(wù)狀態(tài)(mstate)包含一個事務(wù)隊列(FIFO 隊列),以及一個已入隊命令的計數(shù)器。
/** 事務(wù)狀態(tài)*/typedef struct multiState {// 事務(wù)隊列,F(xiàn)IFO 順序multiCmd *commands; /* Array of MULTI commands */// 已入隊命令計數(shù)int count; /* Total number of MULTI commands */int minreplicas; /* MINREPLICAS for synchronous replication */time_t minreplicas_timeout; /* MINREPLICAS timeout as unixtime. */} multiState;
事務(wù)隊列是一個 multiCmd 類型數(shù)組,數(shù)組中每個 multiCmd 結(jié)構(gòu)都保存了一個如入隊命令的相關(guān)信息:指向命令實現(xiàn)函數(shù)的指針,命令的參數(shù),以及參數(shù)的數(shù)量。
/** 事務(wù)命令*/typedef struct multiCmd {// 參數(shù)robj **argv;// 參數(shù)數(shù)量int argc;// 命令指針struct redisCommand *cmd;} multiCmd;
最后我們再看看入隊列的源碼:
/* Add a new command into the MULTI commands queue** 將一個新命令添加到事務(wù)隊列中*/void queueMultiCommand(redisClient *c) {multiCmd *mc;int j;// 為新數(shù)組元素分配空間c->mstate.commands = zrealloc(c->mstate.commands,sizeof(multiCmd)*(c->mstate.count+1));// 指向新元素mc = c->mstate.commands+c->mstate.count;// 設(shè)置事務(wù)的命令、命令參數(shù)數(shù)量,以及命令的參數(shù)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]);// 事務(wù)命令數(shù)量計數(shù)器增一c->mstate.count++;}
當(dāng)然了,還有我們上面提到的,如果命令入隊出錯時,會打開客戶端狀態(tài)的 REDIS_DIRTY_EXEC 標識。
/* Flag the transacation as DIRTY_EXEC so that EXEC will fail.** 將事務(wù)狀態(tài)設(shè)為 DIRTY_EXEC ,讓之后的 EXEC 命令失敗。** Should be called every time there is an error while queueing a command.** 每次在入隊命令出錯時調(diào)用*/void flagTransaction(redisClient *c) {if (c->flags & REDIS_MULTI)c->flags |= REDIS_DIRTY_EXEC;}
3、事務(wù)執(zhí)行
客戶端發(fā)送 EXEC 命令,服務(wù)器執(zhí)行 EXEC 命令邏輯。
如果客戶端狀態(tài)的 flags 屬性不包含
REDIS_MULTI標識,或者包含REDIS_DIRTY_CAS或者REDIS_DIRTY_EXEC標識,那么就直接取消事務(wù)的執(zhí)行。否則客戶端處于事務(wù)狀態(tài)(flags 有
REDIS_MULTI標識),服務(wù)器會遍歷客戶端的事務(wù)隊列,然后執(zhí)行事務(wù)隊列中的所有命令,最后將返回結(jié)果全部返回給客戶端;
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? */// 客戶端沒有執(zhí)行事務(wù)if (!(c->flags & REDIS_MULTI)) {addReplyError(c,"EXEC without MULTI");return;}/* Check if we need to abort the EXEC because:** 檢查是否需要阻止事務(wù)執(zhí)行,因為:** 1) Some WATCHed key was touched.* 有被監(jiān)視的鍵已經(jīng)被修改了** 2) There was a previous error while queueing commands.* 命令在入隊時發(fā)生錯誤* (注意這個行為是 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.** 第一種情況返回多個批量回復(fù)的空對象* 而第二種情況則返回一個 EXECABORT 錯誤*/if (c->flags & (REDIS_DIRTY_CAS|REDIS_DIRTY_EXEC)) {addReply(c, c->flags & REDIS_DIRTY_EXEC ? shared.execaborterr :shared.nullmultibulk);// 取消事務(wù)discardTransaction(c);goto handle_monitor;}/* Exec all the queued commands */// 已經(jīng)可以保證安全性了,取消客戶端對所有鍵的監(jiān)視unwatchAllKeys(c); /* Unwatch ASAP otherwise we'll waste CPU cycles */// 因為事務(wù)中的命令在執(zhí)行時可能會修改命令和命令的參數(shù)// 所以為了正確地傳播命令,需要現(xiàn)備份這些命令和參數(shù)orig_argv = c->argv;orig_argc = c->argc;orig_cmd = c->cmd;addReplyMultiBulkLen(c,c->mstate.count);// 執(zhí)行事務(wù)中的命令for (j = 0; j < c->mstate.count; j++) {// 因為 Redis 的命令必須在客戶端的上下文中執(zhí)行// 所以要將事務(wù)隊列中的命令、命令參數(shù)等設(shè)置給客戶端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.** 當(dāng)遇上第一個寫命令時,傳播 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.** 這可以確保服務(wù)器和 AOF 文件以及附屬節(jié)點的數(shù)據(jù)一致性。*/if (!must_propagate && !(c->cmd->flags & REDIS_CMD_READONLY)) {// 傳播 MULTI 命令execCommandPropagateMulti(c);// 計數(shù)器,只發(fā)送一次must_propagate = 1;}// 執(zhí)行命令call(c,REDIS_CALL_FULL);/* Commands may alter argc/argv, restore mstate. */// 因為執(zhí)行后命令、命令參數(shù)可能會被改變// 比如 SPOP 會被改寫為 SREM// 所以這里需要更新事務(wù)隊列中的命令和參數(shù)// 確保附屬節(jié)點和 AOF 的數(shù)據(jù)一致性c->mstate.commands[j].argc = c->argc;c->mstate.commands[j].argv = c->argv;c->mstate.commands[j].cmd = c->cmd;}// 還原命令、命令參數(shù)c->argv = orig_argv;c->argc = orig_argc;c->cmd = orig_cmd;// 清理事務(wù)狀態(tài)discardTransaction(c);/* Make sure the EXEC command will be propagated as well if MULTI* was already propagated. */// 將服務(wù)器設(shè)為臟,確保 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 事務(wù)不支持原子性?
1、Redis 事務(wù)不支持事務(wù)回滾機制
Redis 事務(wù)執(zhí)行過程中,如果一個命令執(zhí)行出錯,那么就返回錯誤,然后還是會接著繼續(xù)執(zhí)行下面的命令。
下面我們演示一下:

正是因為 Redis 事務(wù)不支持事務(wù)回滾機制,如果事務(wù)執(zhí)行中出現(xiàn)了命令執(zhí)行錯誤(例如對 String 類型的數(shù)據(jù)庫鍵執(zhí)行 LPUSH 操作),只會返回當(dāng)前命令執(zhí)行的錯誤給客戶端,并不會影響下面的命令的執(zhí)行。所以很多人覺得和關(guān)系型數(shù)據(jù)庫(MySQL) 不一樣,而 MySQL 的事務(wù)是具有原子性的,所以大家都認為 Redis 事務(wù)不支持原子性。
2、但是其實 Redis 意義上是支持原子性的。
正常情況下,它也是要不所有命令執(zhí)行成功,要不一個命令都不執(zhí)行。
我們下面演示一下:
全部執(zhí)行成功的:

一個都不執(zhí)行:

這就是上面提到的,在事務(wù)開始后,用戶可以輸入事務(wù)要執(zhí)行的命令;在命令入事務(wù)隊列前,會對命令進行檢查,如果命令不存在或者是命令參數(shù)不對,則會返回錯誤給客戶端,并且修改客戶端狀態(tài)。
當(dāng)后面客戶端執(zhí)行 EXEC 命令時,服務(wù)器就會直接拒絕執(zhí)行此事務(wù)了。
所以說,Redis 事務(wù)其實是支持原子性的!即使 Redis 不支持事務(wù)回滾機制,但是它會檢查每一個事務(wù)中的命令是否錯誤。
但是我們要注意一個點就是:Redis 事務(wù)不支持檢查那些程序員自己邏輯錯誤。例如對 String 類型的數(shù)據(jù)庫鍵執(zhí)行對 HashMap 類型的操作!
我很贊同 Redis 作者的想法:
首先,MySQL 和 Redis 的定位不一樣,一個是關(guān)系型數(shù)據(jù)庫,一個是 NoSQL。
MySQL 的 SQL 查詢是可以相當(dāng)復(fù)雜的,而且 MySQL 沒有事務(wù)隊列這種說法,SQL 真正開始執(zhí)行才會進行分析和檢查,MySQL 不可能提前知道下一條 SQL 是否正確。所以支持事務(wù)回滾是非常有必要的~
但是,Redis 使用了事務(wù)隊列來預(yù)先將執(zhí)行命令存儲起來,并且會對其進行格式檢查的,提前就知道命令是否可執(zhí)行了。所以如果只要有一個命令是錯誤的,那么這個事務(wù)是不能執(zhí)行的。
并且, Redis 作者認為基本只會出現(xiàn)在開發(fā)環(huán)境的編程錯誤其實在生產(chǎn)環(huán)境基本是不可能出現(xiàn)的(例如對 String 類型的數(shù)據(jù)庫鍵執(zhí)行 LPUSH 操作),所以他覺得沒必要為了這事務(wù)回滾機制而改變 Redis 追求簡單高效的設(shè)計主旨。
所以最后,其實 Redis 事務(wù)真正支持原子性的前提:開發(fā)者不要傻不拉幾的寫有邏輯問題的代碼!
推薦閱讀:
喜歡我可以給我設(shè)為星標哦
好文章,我“在看”
