深入理解Redis事務、事務異常、樂觀鎖、管道

小松聊PHP进阶發表於2024-06-01

Redis事務與MySQL事務

  • 不一樣。
  • 原子性:MySQL有Undo Log機制,支援強原子性,和回滾。Redis只能保證事務內指令可以不被干擾的在同一批次執行,且沒有機制保證全部成功則提交,部分失敗則回滾。
  • 隔離性:MySQL的隔離性指多個事務可以併發執行,MySQL有MVCC機制。而Redis沒有,Redis是事務提交前的指令不會被執行,單執行緒的環境下,也就不存在事務未提交時,事務內外資料不一致的隔離性問題了。
  • 永續性:MySQL事務先寫Undo Log,並有Redo Log的兩階段提交機制,可以保證永續性。但是Redis持久化機制只有RDB和AOF持久化策略,若事務成功執行且資料剛好被儲存,則可以滿足永續性。
  • 一致性:MySQL是指資料庫從一個合法(指符合業務預期)狀態轉換成另一個合法狀態,這種只要Redis執行不出錯,可以保證。

Redis事務

  • 官方文件:https://redis.io/docs/latest/develop/interact/transactions/
  • 極簡概括:將一批要執行的Redis指令,放入Redis的執行佇列中,事務執行時(不包含事務未提交時) 使其不被併發過來的任務干擾執行。(無法做到嚴格意義上的ACID 4特性)。
  • 適用場景:
    • 效能最佳化:10條命令傳輸10次執行10次,與1次批次執行10條命令,效能有差異。
    • 樂觀鎖實現:結合Watch可以實現樂觀鎖。
  • 優點:如上的應用場景就是優點。
  • 缺點:無法像MySQL那樣保證原子性、永續性。
  • 關鍵字:mutli(開啟事務),discard(停止事務)、exec(執行事務)、watch(監視指定key)、unwatch(取消監視所有key)。

事務操作實操

測試multi與exec,常規執行

127.0.0.1:6379> multi
OK
127.0.0.1:6379> set a a
QUEUED
127.0.0.1:6379> set b b
QUEUED
127.0.0.1:6379> exec
1) OK
2) OK

測試discard,事務未提交,強行終止,則修改不會生效

127.0.0.1:6379> multi
OK
127.0.0.1:6379> set a a1
QUEUED
127.0.0.1:6379> set b b1
QUEUED
127.0.0.1:6379> discard
OK
127.0.0.1:6379> get a
"a"
127.0.0.1:6379> get b
"b"

Redis事務異常(語法錯誤導致整個事務執行失敗,非回滾操作)

127.0.0.1:6379> multi
OK
127.0.0.1:6379> set a a2
QUEUED
127.0.0.1:6379> sset b b2
(error) ERR unknown command `sset`, with args beginning with: `b`, `b2`, 
127.0.0.1:6379> exec
(error) EXECABORT Transaction discarded because of previous errors.
127.0.0.1:6379> get a
"a"
127.0.0.1:6379> get b
"b"

Redis事務異常(非語法錯誤引起的部分失敗,無法保證ACID中的A,無回滾機制)

127.0.0.1:6379> multi
OK
127.0.0.1:6379> set a aa
QUEUED
127.0.0.1:6379> incr a
QUEUED
127.0.0.1:6379> set b bb
QUEUED
127.0.0.1:6379> exec
1) OK
2) (error) ERR value is not an integer or out of range
3) OK
127.0.0.1:6379> get a
"aa"
127.0.0.1:6379> get b
"bb"

有Redis事務,為什麼又出來了Lua?

  • Redis事務和Lua機制並不衝突,並且要比Redis事務更加強大。
  • 應對併發安全問題:雖然有了Lua的加持,仍不支援事務回滾或者,強原子性(要麼都成功,要麼都回滾),但是Lua可以保證當前的操作不被打斷(無間隙執行),應對併發(例如超賣)問題,Lua能妥善解決。
  • Redis事務不支援流程控制,只支援函式呼叫:配合Lua用於實現無間隙執行的複雜邏輯,這樣的用法非常多。因為高併發下,若單純利用程式語言多次調Redis,實現判斷或迴圈邏輯,這中間有間隙,會有併發問題發生。
    Lua是一門高效能指令碼語言,Lua由標準C編寫而成,幾乎在所有作業系統和平臺上都可以編譯、執行。Lua指令碼可以很容易的被C/C++程式碼呼叫,也可以反過來呼叫C/C++的函式,這使得Lua在應用程式中可以被廣泛應用。

關於Redis+Lua是否是原子性執行的爭議問題

https://redis.io/docs/latest/develop/interact/programmability/eval-intro/
對Redis官網進行搜尋,出現了原子性的字眼。
原話是:
Blocking semantics that ensure the script’s atomic execution.
Lua lets you run part of your application logic inside Redis. Such scripts can perform conditional updates across multiple keys, possibly combining several different data types atomically.

但是我想了想有矛盾的地方:
MySQL使用了undo log來保證原子性,要麼成功全部執行,要麼失敗全部回滾。
眾所周知,Redis不支援回滾的,那麼ACID的A就沒辦法全部保證,最多是沒有執行期間沒有間隙,不被其它過來的請求影響,引起併發問題。

然後我又看了看阿里某架構師對此的剖析,跟我設想的一樣:
Redis會把Lua指令碼當做一個整體去執行,中間不會被其它的命令插入,但是如果執行過程中出現了錯誤,事務是不會回滾的。
也就意味著執行Lua指令碼的過程不可被拆分,不可被中斷,但是遇到錯誤不會回滾。

Redis樂觀鎖

  • 悲觀鎖:很悲觀,認為資料大機率會有併發一致性問題,首次請求過來時加具有互斥性的鎖阻塞其它併發請求,但是Redis是高效能元件,阻塞會帶來效能問題,所以不用悲觀鎖。
  • 樂觀鎖:樂觀,認為資料小機率有併發一致性問題,所以讀資料時不上鎖,但是寫資料時,會判斷一下這個資料是否被改動,從而在舊值的基礎上做修改,如果資料被改動,則失敗掉此次執行。
  • 注意:redis在事務exec或者discard,都會取消對key的watch操作。
  • 解決問題:高併發讀多寫少場景下Redis資料一致性問題。
  • 演變:

假設使用者a賬戶有100元,此時要新增10元

127.0.0.1:6379> set a_money 100
OK
127.0.0.1:6379> multi
OK
127.0.0.1:6379> incrby a_money 10
QUEUED
127.0.0.1:6379> exec
1) (integer) 110
127.0.0.1:6379> get a_money
"110"

假設使用者a賬戶有110元,此時要新增20元,但是事務未提交期間,已經被其它請求改為了115,然後事務內加了20。
由於是加法,所以值正確,但是事務內的資料一般是不讓改的,很多情況下的自增或者自減,是需要以原資料為基礎基礎為準的(這也是MySQL隔離級別的用意,所以有了當前讀和快照讀的區分)。

終端1
127.0.0.1:6379> get a_money
"110"
127.0.0.1:6379> multi
OK
127.0.0.1:6379> incrby a_money 20
QUEUED

終端2
127.0.0.1:6379> get a_money
"110"
127.0.0.1:6379> incrby a_money 5
(integer) 115


終端1
127.0.0.1:6379> get a_money
"110"
127.0.0.1:6379> multi
OK
127.0.0.1:6379> incrby a_money 20
QUEUED
127.0.0.1:6379> exec
1) (integer) 135
127.0.0.1:6379> get a_money
"135"

Redis沒有事務的隔離機制怎麼辦?使用watch加鎖。

終端一
127.0.0.1:6379> watch a_money
OK
127.0.0.1:6379> get a_money
"135"
127.0.0.1:6379> multi
OK
127.0.0.1:6379> incrby a_money 20
QUEUED

終端二模擬其它併發使用者
127.0.0.1:6379> incrby a_money 5
(integer) 140

終端1
127.0.0.1:6379> exec
(nil)
127.0.0.1:6379> get a_money
"140"
事務沒有成功被執行,因為watch監控了a_money的值,一旦事務執行期間,被事務外的請求鎖修改,則失敗掉此次事務。
樂觀鎖,在此處的體現就是,利用watch監控一下事務執行期間,a_money的值是否被改動。

unwatch 使用

終端1
127.0.0.1:6379> set a a
OK
127.0.0.1:6379> watch a
OK
127.0.0.1:6379> unwatch
OK
127.0.0.1:6379> multi
OK
127.0.0.1:6379> set a a1

終端2,模擬併發過來的使用者請求
127.0.0.1:6379> set a a2
OK

終端1,執行unwatch後,取消了對所有key的監控,執行exec時,就不是nil了。
127.0.0.1:6379> exec
1) OK
127.0.0.1:6379> get a
"a1

watch部分key,其餘key的反應

終端1
127.0.0.1:6379> set a a
OK
127.0.0.1:6379> set b b
OK
127.0.0.1:6379> watch a
OK
127.0.0.1:6379> multi
OK
127.0.0.1:6379> set a a1
QUEUED
127.0.0.1:6379> set b b1
QUEUED

終端2
127.0.0.1:6379> set a a2
OK
127.0.0.1:6379> set b b2
OK

終端1,watch a,沒有watch b,事務提交時,被watch的key,可以影響沒有被watch的key。
127.0.0.1:6379> exec
(nil)
127.0.0.1:6379> get a
"a2"
127.0.0.1:6379> get b
"b2"

管道

  • 官方文件:https://redis.io/docs/latest/develop/use/pipelining/
  • 極簡概括:將多個指令的操作,一次性傳送給Redis,進行批次處理。
  • 解決問題:減少網路開銷,減少頻繁接收命令的開銷(10輪request->exec->response,精簡為1次request->10次exec->1次response),避免多條Redis指令通訊往返時間。避免Redis伺服器頻繁的從使用者態到核心態的呼叫,減少上下文通訊時間。
  • 與事務對比:批次處理指令的行為,類似事務。
  • 注意:redis-cli會話內部並未提供管道命令,(但是使用Linux Shell端支援STDIN標準輸入到redis-cli實現管道,例如echo -e "set a aa \n set b bb" | redis-cli --pipe),但redis-server提供了這個機制,管道機制最好用程式語言的客戶端演示。
若在redis-cli會話內部實現管道,會有如下提示:
127.0.0.1:6379> pipe
(error) ERR unknown command `pipe`, with args beginning with: 
127.0.0.1:6379> pipeline
(error) ERR unknown command `pipeline`, with args beginning with:
  • PHP實現:
<?php
$redis = new Redis();
$redis->connect('127.0.0.1', 6379);

$pipe = $redis->pipeline();

$pipe->set('key1', 'value1');
$pipe->set('key2', 'value2');
$pipe->get('key1');
$pipe->get('key2');

$responses = $pipe->exec();

var_dump($responses);

$redis->close();

返回執行的結果
array(4) {
  [0]=>
  bool(true)
  [1]=>
  bool(true)
  [2]=>
  string(6) "value1"
  [3]=>
  string(6) "value2"
}

管道異常情況(Redis語法錯誤)

以PHP為例,經實際測試(set函式缺少引數2),Redis呼叫語法錯誤(非PHP語法錯誤),會升級為PHP出現致命錯誤,管道流程走不下去。

Fatal error: Uncaught ArgumentCountError: Redis::set() expects at least 2 arguments, 1 given in E:\Host\test\t1.php:7
Stack trace:
#0 E:\Host\test\t1.php(7): Redis->set('a')
#1 {main}
  thrown in E:\Host\test\t1.php on line 7

管道異常情況(Redis執行異常)

經過實測,對字串進行遞增操作,除了incr返回false外,其餘上下文程式碼執行不受影響。

<?php
$redis = new Redis();
$redis->connect('127.0.0.1', 6379);

$pipe = $redis->pipeline();

$pipe->set('a', 'a');
$pipe->incr('a');
$pipe->set('b', 'b');
$pipe->get('a');
$pipe->get('b');

$responses = $pipe->exec();

var_dump($responses);

$redis->close();


array(5) {     
  [0]=>        
  bool(true)
  [1]=>
  bool(false)
  [2]=>
  bool(true)
  [3]=>
  string(1) "a"
  [4]=>
  string(1) "b"
}

相關文章