資料庫事務( transaction )是存取並可能操作各種資料項的一個資料庫操作序列,這些操作要麼全部執行,要麼全部不執行,是一個不可分割的工作單位。事務由事務開始與事務結束之間執行的全部資料庫操作組成。
事務必須滿足所謂的ACID屬性
事務中的全部操作在資料庫中是不可分割的,要麼全部完成,要麼全部不執行;
整個資料庫事務是不可分割的工作單位;
只有使資料庫中所有的資料庫操作都執行成功,才算整個事務成功;
事務中任何一個 SQL 執行失敗,已經執行成功的 SQL 也必須撤回,資料庫應該退回到執行事務之前的狀態;
事務的執行使資料從一個狀態轉換為另一個狀態,在事務開始之前和事務結束之後,資料庫的完整性約束沒有被破壞。
有點繞,這裡舉個栗子
如果一個名字欄位,在資料庫中是唯一屬性,執行了事務之後,涉及到了對該欄位的修改,事務執行過程中發生了回滾,之後該欄位變的不唯一了,這種情況下就是破壞了事務的一致性要求。
因為上面事務執行的過程中,導致裡面名字欄位屬性的前後不一致,即資料庫的狀態從一種狀態變成了一種不一致的狀態。
上面的這個栗子就是資料庫沒有遵循一致性的表現。
事務的隔離性要求每個讀寫事務的物件對其他事務的操作物件相互分離,即該事務提交前對其他事務都不可見。
通常使用鎖來實現,資料庫系統中會提供一種粒度鎖的策略,允許事務僅鎖住一個實體物件的子集,以此來提高事務之間的並行度。
對於任意已提交事務,系統必須保證該事務對資料庫的改變不被丟失,即使資料庫出現故障。
當時如果一些人為的或者自然災害導致資料庫機房被破壞,比如火災,機房爆炸等。這種情況下提交的資料可能會丟失。
因此可以理解,永續性保證的事務系統的高可靠性,而不是高可用性。
Redis 中提供了 MULTI、EXEC
這兩個命令來進行事務的操作
# 初始化一個值
127.0.0.1:6379> set test-mult-key 100
OK
# 開啟事務
127.0.0.1:6379> MULTI
OK
127.0.0.1:6379> DECR test-mult-key
QUEUED
127.0.0.1:6379> DECR test-mult-key
QUEUED
127.0.0.1:6379> DECR test-mult-key
QUEUED
# 提交事務
127.0.0.1:6379> EXEC
1) (integer) 99
2) (integer) 98
3) (integer) 97
從上面的執行過程可以看出,事務的執行可以分成三個步驟
1、使用 MULTI 開啟一個事務;
2、當開啟一個事務之後,之後所有的命令不會馬上被執行,而是會被放入到一個事務佇列中,然後返回 QUEUED, 表示命令已入隊;
3、那麼當 EXEC 命令執行時, 伺服器根據使用者端所儲存的事務佇列, 以先進先出(FIFO)的方式執行事務佇列中的命令:最先入隊的命令最先執行,而最後入隊的命令最後執行。
如果命令正常執行,事務中的原子性是可以得到保證的。
在執行命令的過程中如果有命令失敗了呢
關於失敗命令,可分成下面三種情況
比如執行一個不存在的命令,或者命令的寫錯了
來個栗子
127.0.0.1:6379> set test-mult-key 100
OK
127.0.0.1:6379> MULTI
OK
127.0.0.1:6379> DECR test-mult-key
QUEUED
# DECR 命令拼寫錯了
127.0.0.1:6379> DECRR test-mult-key
(error) ERR unknown command `DECRR`, with args beginning with: `test-mult-key`,
127.0.0.1:6379> DECR test-mult-key
QUEUED
127.0.0.1:6379> EXEC
(error) EXECABORT Transaction discarded because of previous errors.
可以看到事務中 DECR 的命令拼寫錯了,寫成了 DECRR。這時候事務是不能執行的,在執行 EXEC 的時候,Redis 丟擲了錯誤,整個事務的執行被丟棄了。
對於這種情況,在命令入隊時,Redis就會報錯並且記錄下這個錯誤。此時,我們還能繼續提交命令操作。等到執行了EXEC命令之後,Redis就會拒絕執行所有提交的命令操作,返回事務失敗的結果。這樣一來,事務中的所有命令都不會再被執行了,保證了原子性。
這種情況,就是我們操作 Redis 命令時候,命令的型別不匹配。
慄如:我們對一個 value 為 string 型別的 key,執行 DECR 操作。
127.0.0.1:6379> set test-mult-key 100
OK
127.0.0.1:6379> set test-mult-key-string 's100'
OK
127.0.0.1:6379> MULTI
OK
127.0.0.1:6379> DECR test-mult-key
QUEUED
127.0.0.1:6379> DECR test-mult-key
QUEUED
# 對 value 為 string 的,執行 DECR 操作,結果會報錯
# 模擬錯誤的命令
127.0.0.1:6379> DECR test-mult-key-string
QUEUED
127.0.0.1:6379> DECR test-mult-key
QUEUED
127.0.0.1:6379> EXEC
1) (integer) 99
2) (integer) 98
3) (error) ERR value is not an integer or out of range
4) (integer) 97
這種情況下,雖然錯誤的命令會報錯,但是還是會把正確的命令執行完成。
這種情況下,命令的原子性就無法得到保證了。Redis 中沒有提供事務的回滾機制。
如果 Redis 開啟了 AOF 紀錄檔,那麼,只會有部分的事務操作被記錄到 AOF 紀錄檔中。
機器範例恢復後,我們可以使用 redis-check-aof 工具檢查 AOF 紀錄檔檔案,這個工具可以把已完成的事務操作從 AOF 檔案中去除。這樣一來,我們使用 AOF 恢復範例後,事務操作不會再被執行,從而保證了原子性。
所以關於 Redis 中事務原子性的總結,就是下面幾點
1、命令入隊時就報錯,會放棄事務執行,保證原子性;
2、命令入隊時沒報錯,實際執行時報錯,不保證原子性;
3、EXEC 命令執行時範例故障,如果開啟了 AOF 紀錄檔,可以保證原子性。
看下 Redis 事務中的幾個命令
子命令 | 功能說明 |
---|---|
DISCARD | 取消事務,放棄執行事務塊內的所有命令 |
EXEC | 執行所有事務塊內的命令 |
MULTI | 標記一個事務塊的開始 |
UNWATCH | 取消 WATCH 命令對所有 key 的監視 |
WATCH key [key ...] | 監視一個(或多個) key ,如果在事務執行之前這個(或這些) key 被其他命令所改動,那麼事務將被打斷 |
關於一致性的分析還是從上面三個點來展開
事務本身就不會執行,一致性可以得到保證
有錯誤的命令不會被執行,正確的命令可以正常執行,也不會改變資料庫的一致性。
如果沒有開啟持久化,那麼範例故障重啟後,資料都沒有了,資料庫是一致的。
如果使用 RDB 快照,因為 RDB 快照不會在事務執行時執行,所以事務執行的結果不會儲存到 RDB 快照中,使用 RDB 快照進行恢復時,資料庫中的資料也是一致性的。
如果我們使用了 AOF 紀錄檔,而事務操作還沒有被記錄到 AOF 紀錄檔時,範例就發生了故障,那麼,使用 AOF 紀錄檔恢復的資料庫資料是一致的。如果只有部分操作被記錄到了 AOF 紀錄檔,我們可以使用 redis-check-aof 清除事務中已經完成的操作,資料庫恢復後也是一致的。
總體看下來,Redis 中對於資料一致性屬性還是有保證的。
事務的隔離性要求每個讀寫事務的物件對其他事務的操作物件相互分離,即該事務提交前對其他事務都不可見。
這裡分析下 Redis 中事務的隔離性,Redis 中事務的隔離性將從下面兩個方面進行分析
因為 Redis 在事務提交之前只是把命令,放入到了佇列中,所以如果在命令入隊,EXEC執行之前,有並行操作,這種情況下,事務是沒有隔離性的。
這種情況下,可以藉助於 watch 實現,來個栗子,看下 watch 如何使用
1、使用者端 1 首先,使用 watch 監聽一個 key,然後開始一個事務,在事務中寫入一些命令;
127.0.0.1:6379> set test-mult-key 100
OK
127.0.0.1:6379> watch test-mult-key
OK
127.0.0.1:6379> MULTI
OK
127.0.0.1:6379> DECR test-mult-key
QUEUED
127.0.0.1:6379> DECR test-mult-key
QUEUED
2、使用者端 2 在使用者端 1 事務提交之前,操作修改該鍵值;
127.0.0.1:6379> DECR test-mult-key
(integer) 99
3、使用者端 1 提交事務;
127.0.0.1:6379> EXEC
(nil)
從上面的結果可以看到如果使用 watch 之後,如果當前鍵值,在事務之外有了修改,那麼當前事務就會放棄本次事務的執行。這樣就實現了事務的隔離性。
這種情況下是沒有問題的,Redis 會先把事務中的命令執行完成,然後再去執行後續的命令,因為 Redis 對於命令的執行是單執行緒的,這種情況下,可以保證事務的隔離性。
Redis 是會存在丟資料的情況的,如果在資料持久化之前,資料庫宕機,那麼就會有一部分資料沒有及時持久化,而丟失。
所以,Redis 中不能保證事務的永續性。
Redis 中為什麼沒有提供事務的回滾,有下面兩個方面的考量
1、支援回滾會對 Redis 的簡單性和效能有很大的影響;
2、Redis 中只有在 語法錯誤或者鍵值的型別操作錯誤 中才會出錯,這些問題應該在開發中解決,不應該出現在生產中。
基於上面兩點的考慮,目前 Redis 中不支援事務的回滾。
這裡來簡單分析下 Redis 中事務的實現過程
Redis 中使用 MULTI 命令來宣告和開啟一個事務
// https://github.com/redis/redis/blob/7.0/src/multi.c#L104
void multiCommand(client *c) {
// 判斷是否已經開啟了事務
// 不持之事務的巢狀
if (c->flags & CLIENT_MULTI) {
addReplyError(c,"MULTI calls can not be nested");
return;
}
// 設定事務標識
c->flags |= CLIENT_MULTI;
addReply(c,shared.ok);
}
1、首先會判斷當前使用者端是是否已經開啟了事務,Redis 中的事務不支援巢狀;
2、給 flags 設定事務標識 CLIENT_MULTI。
開始事務之後,後面所有的命令都會被新增到事務佇列中
// https://github.com/redis/redis/blob/7.0/src/multi.c#L59
/* Add a new command into the MULTI commands queue */
void queueMultiCommand(client *c) {
multiCmd *mc;
// 這裡有兩種情況的判斷
// 1、如果命令在入隊是有問題就不入隊了,CLIENT_DIRTY_EXEC 表示入隊的時候,命令有語法的錯誤
// 2、如果 watch 的鍵值有更改也不用入隊了, CLIENT_DIRTY_CAS 表示該使用者端監聽的鍵值有變動
if (c->flags & (CLIENT_DIRTY_CAS|CLIENT_DIRTY_EXEC))
return;
// 在原commands後面設定空間以存放新命令
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 = c->argv;
mc->argv_len = c->argv_len;
...
}
入隊的時候會做個判斷:
1、如果命令在入隊時有語法錯誤不入隊了,CLIENT_DIRTY_EXEC 表示入隊的時候,命令有語法的錯誤;
2、如果 watch 的鍵值有更改也不用入隊了, CLIENT_DIRTY_CAS 表示該使用者端監聽的鍵值有變動;
3、client watch 的 key 有更新,當前使用者端的 flags 就會被標記成 CLIENT_DIRTY_CAS,CLIENT_DIRTY_CAS 是在何時被標記,可繼續看下文。
命令入隊之後,再來看下事務的提交
// https://github.com/redis/redis/blob/7.0/src/multi.c#L140
void execCommand(client *c) {
...
// 判斷下是否開啟了事務
if (!(c->flags & CLIENT_MULTI)) {
addReplyError(c,"EXEC without MULTI");
return;
}
// 事務中不能 watch 有過期時間的鍵值
if (isWatchedKeyExpired(c)) {
c->flags |= (CLIENT_DIRTY_CAS);
}
// 檢查是否需要中退出事務,有下面兩種情況
// 1、 watch 的 key 有變化了
// 2、命令入隊的時候,有語法錯誤
if (c->flags & (CLIENT_DIRTY_CAS | CLIENT_DIRTY_EXEC)) {
if (c->flags & CLIENT_DIRTY_EXEC) {
addReplyErrorObject(c, shared.execaborterr);
} else {
addReply(c, shared.nullarray[c->resp]);
}
// 取消事務
discardTransaction(c);
return;
}
uint64_t old_flags = c->flags;
/* we do not want to allow blocking commands inside multi */
// 事務中不允許出現阻塞命令
c->flags |= CLIENT_DENY_BLOCKING;
/* Exec all the queued commands */
unwatchAllKeys(c); /* Unwatch ASAP otherwise we'll waste CPU cycles */
server.in_exec = 1;
orig_argv = c->argv;
orig_argv_len = c->argv_len;
orig_argc = c->argc;
orig_cmd = c->cmd;
addReplyArrayLen(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->argv_len = c->mstate.commands[j].argv_len;
c->cmd = c->realcmd = c->mstate.commands[j].cmd;
// 許可權檢查
int acl_errpos;
int acl_retval = ACLCheckAllPerm(c,&acl_errpos);
if (acl_retval != ACL_OK) {
...
} else {
// 執行命令
if (c->id == CLIENT_ID_AOF)
call(c,CMD_CALL_NONE);
else
call(c,CMD_CALL_FULL);
serverAssert((c->flags & CLIENT_BLOCKED) == 0);
}
// 命令執行後可能會被修改,需要更新操作
c->mstate.commands[j].argc = c->argc;
c->mstate.commands[j].argv = c->argv;
c->mstate.commands[j].cmd = c->cmd;
}
// restore old DENY_BLOCKING value
if (!(old_flags & CLIENT_DENY_BLOCKING))
c->flags &= ~CLIENT_DENY_BLOCKING;
// 恢復原命令
c->argv = orig_argv;
c->argv_len = orig_argv_len;
c->argc = orig_argc;
c->cmd = c->realcmd = orig_cmd;
// 清除事務
discardTransaction(c);
server.in_exec = 0;
}
事務提交的時候,命令的執行邏輯還是比較簡單的
1、首先會進行一些檢查;
檢查事務有沒有巢狀;
watch 監聽的鍵值是否有變動;
事務中命令入佇列的時候,是否有語法錯誤;
2、迴圈執行,事務佇列中的命令。
通過原始碼可以看到語法錯誤的時候事務才會結束執行,如果命令操作的型別不對,事務是不會停止的,還是會把正確的命令執行。
WATCH 命令用於在事務開始之前監視任意數量的鍵: 當呼叫 EXEC 命令執行事務時, 如果任意一個被監視的鍵已經被其他使用者端修改了, 那麼整個事務不再執行, 直接返回失敗。
看下 watch 的鍵值對是如何和使用者端進行對映的
// https://github.com/redis/redis/blob/7.0/src/server.h#L918
typedef struct redisDb {
...
dict *watched_keys; /* WATCHED keys for MULTI/EXEC CAS */
...
} redisDb;
// https://github.com/redis/redis/blob/7.0/src/server.h#L1083
typedef struct client {
...
list *watched_keys; /* Keys WATCHED for MULTI/EXEC CAS */
...
} client;
// https://github.com/redis/redis/blob/7.0/src/multi.c#L262
// 伺服器端中每一個db 中都有一個 hash table 來記錄使用者端和 watching key 的對映,當這些 key 修改,可以標識監聽這些 key 的使用者端。
//
// 每個使用者端中也有一個被監聽的鍵值對的列表,當用戶端被釋放或者 un-watch 被呼叫,可以取消監聽這些 key .
typedef struct watchedKey {
// 鍵值
robj *key;
// 鍵值所在的db
redisDb *db;
// 使用者端
client *client;
// 正在監聽過期key 的標識
unsigned expired:1; /* Flag that we're watching an already expired key. */
} watchedKey;
分析完資料結構,看下 watch 的程式碼實現
// https://github.com/redis/redis/blob/7.0/src/multi.c#L441
void watchCommand(client *c) {
int j;
if (c->flags & CLIENT_MULTI) {
addReplyError(c,"WATCH inside MULTI is not allowed");
return;
}
/* No point in watching if the client is already dirty. */
if (c->flags & CLIENT_DIRTY_CAS) {
addReply(c,shared.ok);
return;
}
for (j = 1; j < c->argc; j++)
watchForKey(c,c->argv[j]);
addReply(c,shared.ok);
}
// https://github.com/redis/redis/blob/7.0/src/multi.c#L270
/* Watch for the specified key */
void watchForKey(client *c, robj *key) {
list *clients = NULL;
listIter li;
listNode *ln;
watchedKey *wk;
// 檢查是否正在 watch 傳入的 key
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 */
}
// 沒有監聽,新增監聽的 key 到 db 中的 watched_keys 中
clients = dictFetchValue(c->db->**watched_keys**,key);
if (!clients) {
clients = listCreate();
dictAdd(c->db->watched_keys,key,clients);
incrRefCount(key);
}
// 新增 key 到 client 中的 watched_keys 中
wk = zmalloc(sizeof(*wk));
wk->key = key;
wk->client = c;
wk->db = c->db;
wk->expired = keyIsExpired(c->db, key);
incrRefCount(key);
listAddNodeTail(c->watched_keys,wk);
listAddNodeTail(clients,wk);
}
1、伺服器端中每一個db 中都有一個 hash table 來記錄使用者端和 watching key 的對映,當這些 key 修改,可以標識監聽這些 key 的使用者端;
2、每個使用者端中也有一個被監聽的鍵值對的列表,當用戶端被釋放或者 un-watch 被呼叫,可以取消監聽這些 key ;
3、當用 watch 命令的時候,過期鍵會被分別新增到 redisDb 中的 watched_keys 中,和 client 中的 watched_keys 中。
上面事務的執行的時候,使用者端有一個 flags, CLIENT_DIRTY_CAS 標識當前使用者端 watch 的鍵值對有更新,那麼 CLIENT_DIRTY_CAS 是在何時被標記的呢?
// https://github.com/redis/redis/blob/7.0/src/db.c#L535
/*-----------------------------------------------------------------------------
* Hooks for key space changes.
*
* Every time a key in the database is modified the function
* signalModifiedKey() is called.
*
* Every time a DB is flushed the function signalFlushDb() is called.
*----------------------------------------------------------------------------*/
// 每次修改資料庫中的一個鍵時,都會呼叫函數signalModifiedKey()。
// 每次DB被重新整理時,函數signalFlushDb()被呼叫。
/* Note that the 'c' argument may be NULL if the key was modified out of
* a context of a client. */
// 當 鍵值對有變動的時候,會呼叫 touchWatchedKey 標識對應的使用者端狀態為 CLIENT_DIRTY_CAS
void signalModifiedKey(client *c, redisDb *db, robj *key) {
touchWatchedKey(db,key);
trackingInvalidateKey(c,key,1);
}
// https://github.com/redis/redis/blob/7.0/src/multi.c#L348
/* "Touch" a key, so that if this key is being WATCHed by some client the
* next EXEC will fail. */
// 修改 key 對應的使用者端狀態為 CLIENT_DIRTY_CAS,當前使用者端 watch 的 key 已經發生了更新
void touchWatchedKey(redisDb *db, robj *key) {
list *clients;
listIter li;
listNode *ln;
// 如果 redisDb 中的 watched_keys 為空,直接返回
if (dictSize(db->watched_keys) == 0) return;
// 通過傳入的 key 在 redisDb 的 watched_keys 中找到監聽該 key 的使用者端資訊
clients = dictFetchValue(db->watched_keys, key);
if (!clients) return;
/* Mark all the clients watching this key as CLIENT_DIRTY_CAS */
/* Check if we are already watching for this key */
// 將監聽該 key 的所有使用者端資訊標識成 CLIENT_DIRTY_CAS 狀態
listRewind(clients,&li);
while((ln = listNext(&li))) {
watchedKey *wk = listNodeValue(ln);
client *c = wk->client;
if (wk->expired) {
/* The key was already expired when WATCH was called. */
if (db == wk->db &&
equalStringObjects(key, wk->key) &&
dictFind(db->dict, key->ptr) == NULL)
{
/* Already expired key is deleted, so logically no change. Clear
* the flag. Deleted keys are not flagged as expired. */
wk->expired = 0;
goto skip_client;
}
break;
}
c->flags |= CLIENT_DIRTY_CAS;
/* As the client is marked as dirty, there is no point in getting here
* again in case that key (or others) are modified again (or keep the
* memory overhead till EXEC). */
// 這個使用者端應該被表示成 dirty,這個使用者端就不需要在判斷監聽了,取消這個使用者端監聽的 key
unwatchAllKeys(c);
skip_client:
continue;
}
}
Redis 中 redisClient 的 flags 設定被設定成 REDIS_DIRTY_CAS 位,有下面兩種情況:
1、每次修改資料庫中的一個鍵值時;
2、每次DB被 flush 時,整個 Redis 的鍵值被清空;
上面的這兩種情況發生,redis 就會修改 watch 對應的 key 的使用者端 flags 為 CLIENT_DIRTY_CAS 表示該使用者端 watch 有更新,事務處理就能通過這個狀態來進行判斷。
幾乎所有對 key 進行操作的函數都會呼叫 signalModifiedKey 函數,比如 setKey、delCommand、hsetCommand
等。也就所有修改 key 的值的函數,都會去呼叫 signalModifiedKey 來檢查是否修改了被 watch 的 key,只要是修改了被 watch 的 key,就會對 redisClient 的 flags 設定 REDIS_DIRTY_CAS 位。
1、事務的使用只有在最後提交事務,並且執行完成獲取到執行的結果;
2、事務的隔離性,需要引入 watch 機制的使用,會增加事務使用的複雜度;
1、命令執行的過程中,整個 Lua 指令碼的執行都是原子性的,所以不會存在事務中的隔離性問題;
2、Lua 的執行中,在執行的過程中,就能獲取到執行的結果,可以使用前面命令的執行結果,做後續的操作;
3、因為 Lua 執行過程中是原子性的,所以不推薦用來執行耗時的命令;
除了上面幾個使用場景的限制,這裡看下官方檔案對此的描述
Something else to consider for transaction like operations in redis are redis scripts which are transactional. Everything you can do with a Redis Transaction, you can also do with a script, and usually the script will be both simpler and faster.
翻譯下來就是
Redis Lua指令碼的定義是事務性的,所以你可以用 Redis 事務做的所有事情,你也可以用 Lua 指令碼來做,通常指令碼會更簡單和更快。
所以可以知道,相比於事務,還是更推薦去使用 Lua 指令碼。
1、事務在執行過程中不會被中斷,所有事務命令執行完之後,事務才能結束;
2、多個命令會被入隊到事務佇列中,然後按先進先出(FIFO)的順序執行;
3、事務本身沒有實現隔離性,可以藉助於 watch 命令來實現;
4、Redis 事務在執行的過程中,發生語法問題,整個事務才會報錯不執行,如果僅僅是型別操作的錯誤,事務還是正常執行,還是會把正確的命令執行完成;
5、Redis 中為什麼沒有提供事務的回滾,有下面兩個方面的考量;
1、支援回滾會對 Redis 的簡單性和效能有很大的影響;
2、Redis 中只有在 語法錯誤或者鍵值的型別操作錯誤 中才會出錯,這些問題應該在開發中解決,不應該出現在生產中。
6、Redis 中的 Lua 指令碼也是事務性的,相比於事務,還是更推薦去使用 Lua 指令碼。
【Redis核心技術與實戰】https://time.geekbang.org/column/intro/100056701
【Redis設計與實現】https://book.douban.com/subject/25900156/
【Redis 的學習筆記】https://github.com/boilingfrog/Go-POINT/tree/master/redis
【資料庫事務】https://baike.baidu.com/item/資料庫事務/9744607
【transactions】https://redis.io/docs/manual/transactions/
【Redis中的事務分析】https://boilingfrog.github.io/2022/06/19/Redis中的事務分析/