Redis 事务
文章目錄
- Redis 事務概述
- Redis 事務原理
- Redis 事務命令
- multi
- exec
- discard
- watch
- WATCH 命令的實現
- WATCH 的觸發
- unwatch
- Redis 事務特征
- Redis 錯誤處理
- Redis 不支持回滾(roll back),為什么?
- Redis 使用 check-and-set 操作實現樂觀鎖
- 事務的 ACID 性質
- 原子性(Atomicity)
- 一致性(Consistency)
- 入隊錯誤
- 執行錯誤
- Redis 進程被終結
- 隔離性(Isolation)
- 持久性(Durability)
- PHP 操作 Redis 事務示例
Redis 事務概述
Redis 中的事務是一組命令的集合。
Redis 事務提供了一種“將多個命令打包, 然后一次性、按順序地執行”的機制, 并且事務在執行的期間不會主動中斷 —— 服務器在執行完事務中的所有命令之后, 才會繼續處理其他客戶端的其他命令。
Redis 事務原理
- 先發送 MULTI 命令給 Redis,告訴 Redis ,在我之后的命令都是同一個事務中的,你先不要執行,找個地方存起來。
- Redis 回復 “OK”。
- 然后客戶端可以繼續向服務器發送任意多條命令, 這些命令不會立即被執行, 而是被放到一個命令隊列中。
- 最后發送 EXEC 命令,告訴 Redis,可以順序執行隊列里排隊的所有命令了,得到返回值就是這些命令的返回值組成的列表,返回值順序和命令的順序相同。
Redis 事務命令
multi
聲明事務開始,后續命令將排隊按順序等待 exec執行。
exec
順序執行 multi 之后的命令,如果 multi 之前使用 watch 命令監視的鍵的值發生變化,執行將失敗。
執行成功時返回數組包含每個命令執行結果,失敗時原生命令返回 null,php-redis 擴展方法返回 false。
discard
事務會被放棄, 事務隊列中的命令會被清空, 并且客戶端會從事務狀態中退出。
watch
我們已經知道在一個事務中只有當所有命令都依次執行完后才能得到每個結果的返回值,但是有些情況下需要先獲得一條命令的返回值,然后再根據這個值執行下一條命令。例如,當使用 GET 和 SET 命令自己實現 incr 函數時,會出現靜態條件:
<?php $redis = new Redis(); $redis->connect('127.0.0.1', 6379); function selfIncr($key, $redis) {$value = $redis->get($key);if (empty($value)) {$value = 1;} else {$value += 1;}$redis->set($key, $value);return $value; }了解完 Redis 事務后,我們可以想到用事務來實現 incr 函數以防止競態條件,但是因為事務中的每個命令的執行結果都是最后一起返回的,所以無法將前一條命令的結果作為下一條命令的參數,即在執行SET命令時無法獲得GET命令的返回值,也就無法做到增1的功能了。
為了解決這個問題,我們需要換一種思路。即在GET獲得鍵值后保證該鍵值不被其他客戶端修改,直到函數執行完成后才允許其他客戶端修改該鍵鍵值,這樣也可以防止競態條件。要實現這一思路需要請出事務家族的另一位成員:WATCH。
WATCH 命令可以監控一個或多個鍵,一旦其中有一個鍵被修改(或刪除),之后的事務就不會執行。監控一直持續到 EXEC 命令(事務中的命令是在EXEC之后才執行的,所以在 MULTI 命令后可以修改 WATCH 監控的鍵值)
redis>SET key 1 OK redis>WATCH key OK redis>SET key 2 OK redis> MULTI OK redis>SET key 3 QUEUED redis>EXEC (nil) redis>GET key "2"在執行 WATCH 命令后、事務執行前修改了 key 的值(即SET key 2),所以最后事務中的命令 SET key 3 沒有執行,EXEC 命令返回空結果。
了解 WATCH 命令以后,就可以通過 WARCH命令 配合事務自己實現 incr 函數了。
<?php $redis = new Redis(); $redis->connect('127.0.0.1', 6379); function selfIncr($key, $redis) {try {$redis->watch([$key]);$value = $redis->get($key);if (empty($value)) {$value = 1;} else {$value += 1;}$redis->multi();$redis->set($key, $value);$result = $redis->exec();if (!$result) {$redis->discard();throw new Exception('transaction error');}return $value;} catch (Exception $e){echo $e->getMessage();die;} }注意:由于WATCH命令的作用只是當被監控的鍵值被修改后阻止之后一個事務的執行,而不能保證其他客戶端不修改這一鍵值,所以我們需要在EXEC執行失敗后重新執行整個函數。
執行EXEC命令后會取消對所有鍵的監控,如果不想執行事務中的命令也可以使用UNWATCH命令來取消監控。
另外, 當客戶端斷開連接時, 該客戶端對鍵的監視也會被取消。使用無參數的 UNWATCH 命令可以手動取消對所有鍵的監視。
對于一些需要改動多個鍵的事務, 有時候程序需要同時對多個鍵進行加鎖, 然后檢查這些鍵的當前值是否符合程序的要求。 當值達不到要求時, 就可以使用 UNWATCH 命令來取消目前對鍵的監視, 中途放棄這個事務, 并等待事務的下次嘗試。
WATCH 命令的實現
在每個代表數據庫的 redis.h/redisDb 結構類型中, 都保存了一個 watched_keys 字典, 字典的鍵是這個數據庫被監視的鍵, 而字典的值則是一個鏈表, 鏈表中保存了所有監視這個鍵的客戶端。
比如說,以下字典就展示了一個 watched_keys 字典的例子:
其中, 鍵 key1 正在被 client2 、 client5 和 client1 三個客戶端監視, 其他一些鍵也分別被其他別的客戶端監視著。
WATCH 命令的作用, 就是將當前客戶端和要監視的鍵在 watched_keys 中進行關聯。
舉個例子, 如果當前客戶端為 client10086 , 那么當客戶端執行 WATCH key1 key2 時, 前面展示的 watched_keys 將被修改成這個樣子:
通過 watched_keys 字典, 如果程序想檢查某個鍵是否被監視, 那么它只要檢查字典中是否存在這個鍵即可; 如果程序要獲取監視某個鍵的所有客戶端, 那么只要取出鍵的值(一個鏈表), 然后對鏈表進行遍歷即可。
WATCH 的觸發
在任何對數據庫鍵空間(key space)進行修改的命令成功執行之后 (比如 FLUSHDB 、 SET 、 DEL 、 LPUSH 、 SADD 、 ZREM ,諸如此類), multi.c/touchWatchedKey 函數都會被調用 —— 它檢查數據庫的 watched_keys 字典, 看是否有客戶端在監視已經被命令修改的鍵, 如果有的話, 程序將所有監視這個/這些被修改鍵的客戶端的 REDIS_DIRTY_CAS 選項打開:
當客戶端發送 EXEC 命令、觸發事務執行時, 服務器會對客戶端的狀態進行檢查:
如果客戶端的 REDIS_DIRTY_CAS 選項已經被打開,那么說明被客戶端監視的鍵至少有一個已經被修改了,事務的安全性已經被破壞。服務器會放棄執行這個事務,直接向客戶端返回空回復,表示事務執行失敗。
如果 REDIS_DIRTY_CAS 選項沒有被打開,那么說明所有監視鍵都安全,服務器正式執行事務。
可以用一段偽代碼來表示這個檢查:
舉個例子,假設數據庫的 watched_keys 字典如下圖所示
如果某個客戶端對 key1 進行了修改(比如執行 DEL key1 ), 那么所有監視 key1 的客戶端, 包括 client2 、 client5 和 client1 的 REDIS_DIRTY_CAS 選項都會被打開, 當客戶端 client2 、 client5 和 client1 執行 EXEC 的時候, 它們的事務都會以失敗告終。
最后,當一個客戶端結束它的事務時,無論事務是成功執行,還是失敗, watched_keys 字典中和這個客戶端相關的資料都會被清除。
unwatch
取消 watch 對所有 key 的監視,如果 watch 監視之后執行了 exec 或 discard ,會自動取消監視,不必再 unwatch。
Redis 事務特征
- 批量操作在發送 EXEC 命令前被放入隊列緩存。
- 收到 EXEC 命令后進入事務執行,事務中任意命令執行失敗,其余的命令依然被執行。
- 在事務執行過程,其他客戶端提交的命令請求不會插入到事務執行命令序列中。
Redis 錯誤處理
如果一個事務中的某個命令執行出錯,Redis 會怎樣處理呢?要回答這個問題,首先需要知道什么原因會導致命令執行出錯。
(1)語法錯誤。語法錯誤指命令不存在或者命令參數的個數不對。比如:
redis>MULTI OK redis>SET key value QUEUED redis>SET key (error)ERR wrong number of arguments for 'set' command redis> EXEC (error) EXECABORT Transaction discarded because of previous errors.MULTI 命令后執行了2個命令:一個是正確的命令,成功地加入事務隊列;其余命令有語法錯誤。而只要有一個命令有語法錯誤,執行EXEC命令后Redis就會直接返回錯誤,連語法正確的命令也不會執行。
注意:Redis 2.6.5之前的版本會忽略有語法錯誤的命令,然后執行事務中其他語法正確的命令。就此例而言,SET key value會被執行,EXEC命令會返回一個結果:1) OK。不過,從 Redis 2.6.5 開始,服務器會對命令入隊失敗的情況進行記錄,并在客戶端調用 EXEC命令時,拒絕執行并自動放棄這個事務。
(2)運行錯誤。運行錯誤指在命令執行時出現的錯誤,比如使用散列類型的命令操作集合類型的鍵,這種錯誤在實際執行之前Redis是無法發現的,所以在事務里這樣的命令是會被Redis接受并執行的。如果事務里的一條命令出現了運行錯誤,事務里其他的命令依然會繼續執行(包括出錯命令之后的命令),示例如下:
redis>MULTI OK redis>SET key 1 QUEUED redis>SADD key 2 QUEUED redis>SET key 3 QUEUED redis>EXEC 1) OK 2) (error) ERR Operation against a key holding the wrong kind of value 3) OKredis>GET key "3"可見雖然 SADD key 2 出現了錯誤,但是 SET key 3 依然執行了。Redis的事務沒有關系數據庫事務提供的回滾(rollback)功能。為此開發者必須在事務執行出錯后自己收拾剩下的攤子(將數據庫復原回事務執行前的狀態等)。
Redis 不支持回滾(roll back),為什么?
如果你有使用關系式數據庫的經驗, 那么 “Redis 在事務失敗時不進行回滾,而是繼續執行余下的命令”這種做法可能會讓你覺得有點奇怪。
以下是這種做法的優點:
- Redis 命令只會因為錯誤的語法而失敗(并且這些問題不能在入隊時發現),或是命令用在了錯誤類型的鍵上面:這也就是說,從實用性的角度來說,失敗的命令是由編程錯誤造成的,而這些錯誤應該在開發的過程中被發現,而不應該出現在生產環境中。
- 因為不需要對回滾進行支持,所以 Redis 的內部可以保持簡單且快速。
有種觀點認為 Redis 處理事務的做法會產生 bug , 然而需要注意的是, 在通常情況下, 回滾并不能解決編程錯誤帶來的問題。 舉個例子, 如果你本來想通過 INCR 命令將鍵的值加上 1 , 卻不小心加上了 2 , 又或者對錯誤類型的鍵執行了 INCR , 回滾是沒有辦法處理這些情況的。
鑒于沒有任何機制能避免程序員自己造成的錯誤, 并且這類錯誤通常不會在生產環境中出現, 所以 Redis 選擇了更簡單、更快速的無回滾方式來處理事務。
Redis 使用 check-and-set 操作實現樂觀鎖
WATCH 命令可以為 Redis 事務提供 check-and-set (CAS)行為。
悲觀鎖
悲觀鎖(Pessimistic Lock),顧名思義,就是很悲觀,每次去拿數據的時候都認為別人會修改,所以每次在拿數據的時候都會上鎖,這樣別人想拿這個數據就會 block 直到它拿到鎖。傳統的關系型數據庫里邊就用到了很多這種鎖機制,比如行鎖,表鎖等,讀鎖,寫鎖等,都是在做操作之前先上鎖。
樂觀鎖
樂觀鎖(Optimistic Lock),顧名思義,就是很樂觀,每次去拿數據的時候都認為別人不會修改,所以不會上鎖,但是在更新的時候會判斷一下在此期間別人有沒有去更新這個數據,可以使用版本號等機制。樂觀鎖適用于多讀的應用類型,這樣可以提高吞吐量。樂觀鎖策略:提交版本必須大于記錄當前版本才能執行更新。
被 WATCH 的鍵會被監視,并會發覺這些鍵是否被改動過了。 如果有至少一個被監視的鍵在 EXEC 執行之前被修改了, 那么整個事務都會被取消,EXEC 返回 nil-reply 來表示事務已經失敗。
舉個例子, 假設我們需要原子性地為某個值進行增 1 操作(假設 INCR 不存在)。
首先我們可能會這樣做:
val = GET mykey val = val + 1 SET mykey $val上面的這個實現在只有一個客戶端的時候可以執行得很好。 但是, 當多個客戶端同時對同一個鍵進行這樣的操作時, 就會產生競爭條件。舉個例子, 如果客戶端 A 和 B 都讀取了鍵原來的值, 比如 10 , 那么兩個客戶端都會將鍵的值設為 11 , 但正確的結果應該是 12 才對。
有了 WATCH , 我們就可以輕松地解決這類問題了:
WATCH mykey val = GET mykey val = val + 1 MULTI SET mykey $val EXEC使用上面的代碼, 如果在 WATCH 執行之后, EXEC 執行之前, 有其他客戶端修改了 mykey 的值, 那么當前客戶端的事務就會失敗。 程序需要做的, 就是不斷重試這個操作, 直到沒有發生碰撞為止。
這種形式的鎖被稱作樂觀鎖, 它是一種非常強大的鎖機制。 并且因為大多數情況下, 不同的客戶端會訪問不同的鍵, 碰撞的情況一般都很少, 所以通常并不需要進行重試。
例子同上面 incr 函數的實現。
事務的 ACID 性質
在傳統的關系式數據庫中,常常用 ACID 性質 來檢驗事務功能的安全性。
Redis 事務保證了其中的一致性(C)和隔離性(I),但并不保證原子性(A)和持久性(D)。
原子性(Atomicity)
單個 Redis 命令的執行是原子性的,但 Redis 沒有在事務上增加任何維持原子性的機制,所以 Redis 事務的執行并不是原子性的。
如果一個事務隊列中的所有命令都被成功地執行,那么稱這個事務執行成功。
另一方面,如果 Redis 服務器進程在執行事務的過程中被停止 —— 比如接到 KILL 信號、宿主機器停機,等等,那么事務執行失敗。
當事務失敗時,Redis 也不會進行任何的重試或者回滾動作。
一致性(Consistency)
Redis 的一致性問題可以分為三部分來討論:入隊錯誤、執行錯誤、Redis 進程被終結。
入隊錯誤
在命令入隊的過程中,如果客戶端向服務器發送了錯誤的命令,比如命令的參數數量不對,等等, 那么服務器將向客戶端返回一個出錯信息, 并且將客戶端的事務狀態設為 REDIS_DIRTY_EXEC 。
當客戶端執行 EXEC] 命令時, Redis 會拒絕執行狀態為 REDIS_DIRTY_EXEC 的事務, 并返回失敗信息。
redis 127.0.0.1:6379> MULTI OKredis 127.0.0.1:6379> set key (error) ERR wrong number of arguments for 'set' commandredis 127.0.0.1:6379> EXISTS key QUEUEDredis 127.0.0.1:6379> EXEC (error) EXECABORT Transaction discarded because of previous errors.因此,帶有不正確入隊命令的事務不會被執行,也不會影響數據庫的一致性。
執行錯誤
如果命令在事務執行的過程中發生錯誤,比如說,對一個不同類型的 key 執行了錯誤的操作, 那么 Redis 只會將錯誤包含在事務的結果中, 這不會引起事務中斷或整個失敗,不會影響已執行事務命令的結果,也不會影響后面要執行的事務命令, 所以它對事務的一致性也沒有影響。
Redis 進程被終結
如果 Redis 服務器進程在執行事務的過程中被其他進程終結,或者被管理員強制殺死,那么根據 Redis 所使用的持久化模式,可能有以下情況出現:
-
內存模式:如果 Redis 沒有采取任何持久化機制,那么重啟之后的數據庫總是空白的,所以數據總是一致的。
-
RDB 模式:在執行事務時,Redis 不會中斷事務去執行保存 RDB 的工作,只有在事務執行之后,保存 RDB 的工作才有可能開始。所以當 RDB 模式下的 Redis 服務器進程在事務中途被殺死時,事務內執行的命令,不管成功了多少,都不會被保存到 RDB 文件里?;謴蛿祿煨枰褂矛F有的 RDB 文件,而這個 RDB 文件的數據保存的是最近一次的數據庫快照(snapshot),所以它的數據可能不是最新的,但只要 RDB 文件本身沒有因為其他問題而出錯,那么還原后的數據庫就是一致的。
-
AOF 模式:因為保存 AOF 文件的工作在后臺線程進行,所以即使是在事務執行的中途,保存 AOF 文件的工作也可以繼續進行,因此,根據事務語句是否被寫入并保存到 AOF 文件,有以下兩種情況發生:
1)如果事務語句未寫入到 AOF 文件,或 AOF 未被 SYNC 調用保存到磁盤,那么當進程被殺死之后,Redis 可以根據最近一次成功保存到磁盤的 AOF 文件來還原數據庫,只要 AOF 文件本身沒有因為其他問題而出錯,那么還原后的數據庫總是一致的,但其中的數據不一定是最新的。
2)如果事務的部分語句被寫入到 AOF 文件,并且 AOF 文件被成功保存,那么不完整的事務執行信息就會遺留在 AOF 文件里,當重啟 Redis 時,程序會檢測到 AOF 文件并不完整,Redis 會退出,并報告錯誤。需要使用 redis-check-aof 工具將部分成功的事務命令移除之后,才能再次啟動服務器。還原之后的數據總是一致的,而且數據也是最新的(直到事務執行之前為止)。
隔離性(Isolation)
Redis 是單進程程序,并且它保證在執行事務時,不會對事務進行中斷,事務可以運行直到執行完所有事務隊列中的命令為止。因此,Redis 的事務是總是帶有隔離性的。
持久性(Durability)
因為事務不過是用隊列包裹起了一組 Redis 命令,并沒有提供任何額外的持久性功能,所以事務的持久性由 Redis 所使用的持久化模式決定:
-
在單純的內存模式下,事務肯定是不持久的。
-
在 RDB 模式下,服務器可能在事務執行之后、RDB 文件更新之前的這段時間失敗,所以 RDB 模式下的 Redis 事務也是不持久的。
-
在 AOF 的“總是 SYNC ”模式下,事務的每條命令在執行成功之后,都會立即調用 fsync 或 fdatasync 將事務數據寫入到 AOF 文件。但是,這種保存是由后臺線程進行的,主線程不會阻塞直到保存成功,所以從命令執行成功到數據保存到硬盤之間,還是有一段非常小的間隔,所以這種模式下的事務也是不持久的。
其他 AOF 模式也和“總是 SYNC ”模式類似,所以它們都是不持久的。
PHP 操作 Redis 事務示例
<?php try {$redis = new Redis();$redis->connect('192.168.188.105', 6379);$key1 = 'test1';$key2 = 'test2';$redis->set($key1, '111');$redis->set($key2, '222');// 監視一個(或多個)key,如果在事務執行之前這個(或這些) key 被其他命令所改動,那么事務將被打斷$redis->watch([$key1, $key2]);// 模擬監視 key 被打斷// $redis->set($key1, '11223344');$redis->multi();$redis->set($key1, '333');$redis->set($key2, '444');// 執行事務中的所有命令,執行成功返回數組,執行失敗返回false$result = $redis->exec();// 失敗則取消事務if (!$result) {$redis->discard();} } catch (Exception $e){echo $e->getMessage();die; }var_dump($result); echo $redis->get($key1) . '-' .$redis->get($key2);參考書籍:
《Redis入門指南》
《Redis設計與實現》
參考鏈接
總結
- 上一篇: Qt 如何处理密集型耗时的事情
- 下一篇: 【MySQL】Java对SQL时间类型的