【高頻 Redis 面試題】Redis 事務是否具備原子性?
阿新 • • 發佈:2020-04-25
### 一、Redis 事務的實現原理
一個事務從開始到結束通常會經歷以下三個階段:
#### 1、事務開始
客戶端傳送 **MULTI** 命令,伺服器執行 MULTI 命令邏輯。
伺服器會在客戶端狀態(redisClient)的 **`flags`** 屬性開啟 **REDIS_MULTI** 標識,將客戶端從非事務狀態切換到事務狀態。
```c
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`** 屬性。
```c
typeof struct redisClient{
// 事務狀態
multiState mstate;
}redisClient;
```
事務狀態(mstate)包含一個事務佇列(FIFO 佇列),以及一個已入隊命令的計數器。
```c
/*
* 事務狀態
*/
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`** 結構都儲存了一個如入隊命令的相關資訊:指向命令實現函式的指標,命令的引數,以及引數的數量。
```c
/*
* 事務命令
*/
typedef struct multiCmd {
// 引數
robj **argv;
// 引數數量
int argc;
// 命令指標
struct redisCommand *cmd;
} multiCmd;
```
##### 最後我們再看看入佇列的原始碼:
```c
/* 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`** 標識。
```c
/* 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` 標識),伺服器會遍歷客戶端的事務佇列,然後執行事務佇列中的所有命令,最後將返回結果全部返回給客戶端;
```c
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 事務執行過程中,如果一個命令執行出錯,那麼就返回錯誤,然後還是會接著繼續執行下面的命令。
下面我們演示一下:
![](https://imgconvert.csdnimg.cn/aHR0cHM6Ly9naXRlZS5jb20vSG93aW5mdW4vaW1hZ2UvcmF3L21hc3Rlci9yZWRpcyVFOCVBRSVCRSVFOCVBRSVBMSVFNCVCOCU4RSVFNSVBRSU5RSVFNyU4RSVCMC9tdWx0aS5wbmc?x-oss-process=image/format,png)
正是因為 Redis 事務不支援事務回滾機制,如果事務執行中出現了命令執行錯誤(例如對 String 型別的資料庫鍵執行 LPUSH 操作),只會返回當前命令執行的錯誤給客戶端,並不會影響下面的命令的執行。所以很多人覺得和關係型資料庫(MySQL) 不一樣,而 MySQL 的事務是具有原子性的,所以大家都認為 Redis 事務不支援原子性。
#### 2、但是其實 Redis 意義上是支援原子性的。
正常情況下,它也是要不所有命令執行成功,要不一個命令都不執行。
##### 我們下面演示一下:
全部執行成功的:
![在這裡插入圖片描述](https://img-blog.csdnimg.cn/20200425175827774.png?x-oss-process=image/watermark,type_ZmFuZ3poZW5naGVpdGk,shadow_10,text_aHR0cHM6Ly9ibG9nLmNzZG4ubmV0L0hvd2luZnVu,size_16,color_FFFFFF,t_70)
一個都不執行:
![在這裡插入圖片描述](https://img-blog.csdnimg.cn/20200425175859198.png?x-oss-process=image/watermark,type_ZmFuZ3poZW5naGVpdGk,shadow_10,text_aHR0cHM6Ly9ibG9nLmNzZG4ubmV0L0hvd2luZnVu,size_16,color_FFFFFF,t_70)
這就是上面提到的,在事務開始後,使用者可以輸入事務要執行的命令;在命令入事務佇列前,會對命令進行檢查,如果命令不存在或者是命令引數不對,則會返回錯誤可客戶端,並且修改客戶端狀態。
當後面客戶端執行 EXEC 命令時,伺服器就會直接拒絕執行此事務了。
所以說,Redis 事務其實是支援原子性的!即使 Redis 不支援事務回滾機制,但是它會檢查每一個事務中的命令是否錯誤。
但是我們要注意一個點就是:Redis 事務不支援檢查那些程式設計師自己邏輯錯誤。例如對 String 型別的資料庫鍵執行對 HashMap 型別的操作!
#### 我很贊同 Redis 作者的想法:
首先,MySQL 和 Redis 的定位不一樣,一個是關係型資料庫,一個是 NoSQL。
MySQL 的 SQL 查詢是可以相當複雜的,而且 MySQL 沒有事務佇列這種說法,SQL 真正開始執行才會進行分析和檢查,MySQL 不可能提前知道下一條 SQL 是否正確。所以支援事務回滾是非常有必要的~
但是,Redis 使用了事務佇列來預先將執行命令儲存起來,並且會對其進行格式檢查的,提前就知道命令是否可執行了。所以如果只要有一個命令是錯誤的,那麼這個事務是不能執行的。
**Redis 作者認為基本只會出現在開發環境的程式設計錯誤其實在生產環境基本是不可能出現的(例如對 String 型別的資料庫鍵執行 LPUSH 操作),所以他覺得沒必要為了這事務回滾機制而改變 Redis 追求簡單高效的設計主旨。**
**所以最後,其實 Redis 事務真正支援原子性的前提:開發者不要傻不拉幾的寫有邏輯問題的程式碼!**
參考資料:《Redis 設計與實現》、《Redis 原始碼》