文章收錄在 GitHub JavaKeeper ,N線網際網路開發必備技能兵器譜
假設現在有這樣一個業務,使用者獲取的某些資料來自第三方介面資訊,為避免頻繁請求第三方介面,我們往往會加一層快取,快取肯定要有時效性,假設我們要儲存的結構是 hash(沒有String的'SET anotherkey "will expire in a minute" EX 60'這種原子操作),我們既要批量去放入快取,又要保證每個 key 都加上過期時間(以防 key 永不過期),這時候事務操作是個比較好的選擇
為了確保連續多個操作的原子性,我們常用的資料庫都會有事務的支援,Redis 也不例外。但它又和關係型資料庫不太一樣。
每個事務的操作都有 begin、commit 和 rollback,begin 指示事務的開始,commit 指示事務的提交,rollback 指示事務的回滾。它大致的形式如下
begin();
try {
command1();
command2();
....
commit();
} catch(Exception e) {
rollback();
}
Redis 在形式上看起來也差不多,分為三個階段
- 開啟事務(multi)
- 命令入隊(業務操作)
- 執行事務(exec)或取消事務(discard)
> multi
OK
> incr star
QUEUED
> incr star
QUEUED
> exec
(integer) 1
(integer) 2
上面的指令演示了一個完整的事務過程,所有的指令在 exec 之前不執行,而是快取在伺服器的一個事務佇列中,伺服器一旦收到 exec 指令,才開執行整個事務佇列,執行完畢後一次性返回所有指令的執行結果。
Redis 事務可以一次執行多個命令,本質是一組命令的集合。一個事務中的所有命令都會序列化,按順序地序列化執行而不會被其它命令插入,不許加塞。
可以保證一個佇列中,一次性、順序性、排他性的執行一系列命令(Redis 事務的主要作用其實就是串聯多個命令防止別的命令插隊)
官方文件是這麼說的
事務可以一次執行多個命令, 並且帶有以下兩個重要的保證:
- 事務是一個單獨的隔離操作:事務中的所有命令都會序列化、按順序地執行。事務在執行的過程中,不會被其他客戶端傳送來的命令請求所打斷。
- 事務是一個原子操作:事務中的命令要麼全部被執行,要麼全部都不執行
這個原子操作,和關係型 DB 的原子性不太一樣,它不能完全保證原子性,後邊會介紹。
Redis 事務的幾個命令
命令 | 描述 |
---|---|
MULTI | 標記一個事務塊的開始 |
EXEC | 執行所有事務塊內的命令 |
DISCARD | 取消事務,放棄執行事務塊內的所有命令 |
WATCH | 監視一個(或多個)key,如果在事務執行之前這個(或多個)key被其他命令所改動,那麼事務將被打斷 |
UNWATCH | 取消 WATCH 命令對所有 keys 的監視 |
MULTI 命令用於開啟一個事務,它總是返回 OK 。
MULTI 執行之後, 客戶端可以繼續向伺服器傳送任意多條命令, 這些命令不會立即被執行, 而是被放到一個佇列中, 當 EXEC 命令被呼叫時, 所有佇列中的命令才會被執行。
另一方面, 通過呼叫 DISCARD , 客戶端可以清空事務佇列, 並放棄執行事務。
廢話不多說,直接操作起來看結果更好理解~
一帆風順
正常執行(可以批處理,挺爽,每條操作成功的話都會各取所需,互不影響)
放棄事務(discard 操作表示放棄事務,之前的操作都不算數)
思考個問題:假設我們有個有過期時間的 key,在事務操作中 key 失效了,那執行 exec 的時候會成功嗎?
事務中的錯誤
上邊規規矩矩的操作,看著還挺好,可是事務是為解決資料安全操作提出的,我們用 Redis 事務的時候,可能會遇上以下兩種錯誤:
- 事務在執行
EXEC
之前,入隊的命令可能會出錯。比如說,命令可能會產生語法錯誤(引數數量錯誤,引數名錯誤等等),或者其他更嚴重的錯誤,比如記憶體不足(如果伺服器使用maxmemory
設定了最大記憶體限制的話)。 - 命令可能在
EXEC
呼叫之後失敗。舉個例子,事務中的命令可能處理了錯誤型別的鍵,比如將列表命令用在了字串鍵上面,諸如此類。
Redis 針對如上兩種錯誤採用了不同的處理策略,對於發生在 EXEC
執行之前的錯誤,伺服器會對命令入隊失敗的情況進行記錄,並在客戶端呼叫 EXEC
命令時,拒絕執行並自動放棄這個事務(Redis 2.6.5 之前的做法是檢查命令入隊所得的返回值:如果命令入隊時返回 QUEUED ,那麼入隊成功;否則,就是入隊失敗)
對於那些在 EXEC
命令執行之後所產生的錯誤, 並沒有對它們進行特別處理: 即使事務中有某個/某些命令在執行時產生了錯誤, 事務中的其他命令仍然會繼續執行。
全體連坐(某一條操作記錄報錯的話,exec 後所有操作都不會成功)
冤頭債主(示例中 k1 被設定為 String 型別,decr k1 可以放入操作佇列中,因為只有在執行的時候才可以判斷出語句錯誤,其他正確的會被正常執行)
為什麼 Redis 不支援回滾
如果你有使用關係式資料庫的經驗,那麼 “Redis 在事務失敗時不進行回滾,而是繼續執行餘下的命令”這種做法可能會讓你覺得有點奇怪。
以下是官方的自誇:
- Redis 命令只會因為錯誤的語法而失敗(並且這些問題不能在入隊時發現),或是命令用在了錯誤型別的鍵上面:這也就是說,從實用性的角度來說,失敗的命令是由程式設計錯誤造成的,而這些錯誤應該在開發的過程中被發現,而不應該出現在生產環境中。
- 因為不需要對回滾進行支援,所以 Redis 的內部可以保持簡單且快速。
有種觀點認為 Redis 處理事務的做法會產生 bug , 然而需要注意的是, 在通常情況下, 回滾並不能解決程式設計錯誤帶來的問題。 舉個例子, 如果你本來想通過
INCR
命令將鍵的值加上 1 , 卻不小心加上了 2 , 又或者對錯誤型別的鍵執行了INCR
, 回滾是沒有辦法處理這些情況的。鑑於沒有任何機制能避免程式設計師自己造成的錯誤, 並且這類錯誤通常不會在生產環境中出現, 所以 Redis 選擇了更簡單、更快速的無回滾方式來處理事務。
帶 Watch 的事務
WATCH
命令用於在事務開始之前監視任意數量的鍵: 當呼叫 EXEC 命令執行事務時, 如果任意一個被監視的鍵已經被其他客戶端修改了, 那麼整個事務將被打斷,不再執行, 直接返回失敗。
WATCH命令可以被呼叫多次。 對鍵的監視從 WATCH 執行之後開始生效, 直到呼叫 EXEC 為止。
使用者還可以在單個 WATCH 命令中監視任意多個鍵, 就像這樣:
redis> WATCH key1 key2 key3
OK
當 EXEC
被呼叫時, 不管事務是否成功執行, 對所有鍵的監視都會被取消。另外, 當客戶端斷開連線時, 該客戶端對鍵的監視也會被取消。
我們看個簡單的例子,用 watch 監控我的賬號餘額(一週100零花錢的我),正常消費
但這個卡,還繫結了我媳婦的支付寶,如果在我消費的時候,她也消費了,會怎麼樣呢?
犯困的我去樓下 711 買了包煙,買了瓶水,這時候我媳婦在超市直接刷了 100,此時餘額不足的我還在挑口香糖來著,,,
這時候我去結賬,發現刷卡失敗(事務中斷),尷尬的一批
你可能沒看明白 watch 有啥用,我們再來看下,如果還是同樣的場景,我們沒有 watch balance
,事務不會失敗,儲蓄卡成負數,是不不太符合業務呢
使用無引數的 UNWATCH
命令可以手動取消對所有鍵的監視。 對於一些需要改動多個鍵的事務,有時候程式需要同時對多個鍵進行加鎖, 然後檢查這些鍵的當前值是否符合程式的要求。 當值達不到要求時, 就可以使用 UNWATCH
命令來取消目前對鍵的監視, 中途放棄這個事務, 並等待事務的下次嘗試。
watch指令,類似樂觀鎖,事務提交時,如果 key 的值已被別的客戶端改變,比如某個 list 已被別的客戶端push/pop 過了,整個事務佇列都不會被執行。(當然也可以用 Redis 實現分散式鎖來保證安全性,屬於悲觀鎖)
通過 watch 命令在事務執行之前監控了多個 keys,倘若在 watch 之後有任何 key 的值發生變化,exec 命令執行的事務都將被放棄,同時返回 Null 應答以通知呼叫者事務執行失敗。
悲觀鎖
悲觀鎖(Pessimistic Lock),顧名思義,就是很悲觀,每次去拿資料的時候都認為別人會修改,所以每次在拿資料的時候都會上鎖,這樣別人想拿這個資料就會 block 直到它拿到鎖。傳統的關係型資料庫裡邊就用到了很多這種鎖機制,比如行鎖,表鎖等,讀鎖,寫鎖等,都是在做操作之前先上鎖
樂觀鎖
樂觀鎖(Optimistic Lock),顧名思義,就是很樂觀,每次去拿資料的時候都認為別人不會修改,所以不會上鎖,但是在更新的時候會判斷一下在此期間別人有沒有去更新這個資料,可以使用版本號等機制。樂觀鎖適用於多讀的應用型別,這樣可以提高吞吐量。樂觀鎖策略:提交版本必須大於記錄當前版本才能執行更新
WATCH 命令的實現原理
在代表資料庫的 server.h/redisDb
結構型別中, 都儲存了一個 watched_keys
字典, 字典的鍵是這個資料庫被監視的鍵, 而字典的值是一個連結串列, 連結串列中儲存了所有監視這個鍵的客戶端,如下圖。
typedef struct redisDb {
dict *dict; /* The keyspace for this DB */
dict *expires; /* Timeout of keys with a timeout set */
dict *blocking_keys; /* Keys with clients waiting for data (BLPOP)*/
dict *ready_keys; /* Blocked keys that received a PUSH */
dict *watched_keys; /* WATCHED keys for MULTI/EXEC CAS */
int id; /* Database ID */
long long avg_ttl; /* Average TTL, just for stats */
unsigned long expires_cursor; /* Cursor of the active expire cycle. */
list *defrag_later; /* List of key names to attempt to defrag one by one, gradually. */
} redisDb;
list *watched_keys; /* Keys WATCHED for MULTI/EXEC CAS */
WATCH
命令的作用, 就是將當前客戶端和要監視的鍵在 watched_keys
中進行關聯。
舉個例子, 如果當前客戶端為 client99
, 那麼當客戶端執行 WATCH key2 key3
時, 前面展示的 watched_keys
將被修改成這個樣子:
通過 watched_keys
字典, 如果程式想檢查某個鍵是否被監視, 那麼它只要檢查字典中是否存在這個鍵即可; 如果程式要獲取監視某個鍵的所有客戶端, 那麼只要取出鍵的值(一個連結串列), 然後對連結串列進行遍歷即可。
在任何對資料庫鍵空間(key space)進行修改的命令成功執行之後 (比如 FLUSHDB、SET 、DEL、LPUSH、 SADD,諸如此類), multi.c/touchWatchedKey
函式都會被呼叫 —— 它會去 watched_keys
字典, 看是否有客戶端在監視已經被命令修改的鍵, 如果有的話, 程式將所有監視這個/這些被修改鍵的客戶端的 REDIS_DIRTY_CAS
選項開啟:
void multiCommand(client *c) {
// 不能在事務中巢狀事務
if (c->flags & CLIENT_MULTI) {
addReplyError(c,"MULTI calls can not be nested");
return;
}
// 開啟事務 FLAG
c->flags |= CLIENT_MULTI;
addReply(c,shared.ok);
}
/* "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;
// 字典為空,沒有任何鍵被監視
if (dictSize(db->watched_keys) == 0) return;
// 獲取所有監視這個鍵的客戶端
clients = dictFetchValue(db->watched_keys, key);
if (!clients) return;
// 遍歷所有客戶端,開啟他們的 CLIENT_DIRTY_CAS 標識
listRewind(clients,&li);
while((ln = listNext(&li))) {
client *c = listNodeValue(ln);
c->flags |= CLIENT_DIRTY_CAS;
}
}
當客戶端傳送 EXEC 命令、觸發事務執行時, 伺服器會對客戶端的狀態進行檢查:
- 如果客戶端的
CLIENT_DIRTY_CAS
選項已經被開啟,那麼說明被客戶端監視的鍵至少有一個已經被修改了,事務的安全性已經被破壞。伺服器會放棄執行這個事務,直接向客戶端返回空回覆,表示事務執行失敗。 - 如果
CLIENT_DIRTY_CAS
選項沒有被開啟,那麼說明所有監視鍵都安全,伺服器正式執行事務。
小總結:
3 個階段
- 開啟:以 MULTI 開始一個事務
- 入隊:將多個命令入隊到事務中,接到這些命令並不會立即執行,而是放到等待執行的事務佇列裡面
- 執行:由 EXEC 命令觸發事務
3 個特性
- 單獨的隔離操作:事務中的所有命令都會序列化、按順序地執行。事務在執行的過程中,不會被其他客戶端傳送來的命令請求所打斷。
- 沒有隔離級別的概念:佇列中的命令沒有提交之前都不會實際的被執行,因為事務提交前任何指令都不會被實際執行,也就不存在”事務內的查詢要看到事務裡的更新,在事務外查詢不能看到”這個讓人萬分頭痛的問題
- 不保證原子性:Redis 同一個事務中如果有一條命令執行失敗,其後的命令仍然會被執行,沒有回滾
在傳統的關係式資料庫中,常常用 ACID 性質來檢驗事務功能的安全性。Redis 事務保證了其中的一致性(C)和隔離性(I),但並不保證原子性(A)和永續性(D)。
最後
Redis 事務在傳送每個指令到事務快取佇列時都要經過一次網路讀寫,當一個事務內部的指令較多時,需要的網路 IO 時間也會線性增長。所以通常 Redis 的客戶端在執行事務時都會結合 pipeline 一起使用,這樣可以將多次 IO 操作壓縮為單次 IO 操作。