如何在Redis中實現事務

bobo2發表於2019-02-16

事務介紹

事務(Transaction) ,是指作為單個邏輯工作單元執行的一系列操作。事務必須滿足ACID原則(原子性、一致性、隔離性和永續性)。
簡單來說,事務可能包括1~N條命令,當這些命令被作為事務處理時,將會順序執行這些命令直到完成,並返回結果,如果中途有命令失敗,則會回滾所有操作。
舉個例子:

  1. 我們到銀行ATM機取一筆錢,我們的操作可能是如下:

  2. 插卡(輸入密碼)

  3. 輸入要取的金額

  4. ATM吐鈔
    後臺在你的戶頭上扣掉相應金額

整個操作是一個順序,不可分割的整體。上一步完成後才會執行下一步,如果ATM沒吐鈔卻扣了使用者的錢,銀行可是要關門了。

Redis中的事務

先來看一下事務相關的命令

命令原型 命令描述
MULTI 用於標記事務的開始,其後執行的命令都將被存入命令佇列,直到執行EXEC時,這些命令才會被原子的執行。
EXEC 執行在一個事務內命令佇列中的所有命令,同時將當前連線的狀態恢復為正常狀態,即非事務狀態。如果在事務中執行了WATCH命令,那麼只有當WATCH所監控的Keys沒有被修改的前提下,EXEC命令才能執行事務佇列中的所有命令,否則EXEC將放棄當前事務中的所有命令。
DISCARD 回滾事務佇列中的所有命令,同時再將當前連線的狀態恢復為正常狀態,即非事務狀態。如果WATCH命令被使用,該命令將UNWATCH所有的Keys。
WATCH key [key …] 在MULTI命令執行之前,可以指定待監控的Keys,然而在執行EXEC之前,如果被監控的Keys發生修改,EXEC將放棄執行該事務佇列中的所有命令。
UNWATCH 取消當前事務中指定監控的Keys,如果執行了EXEC或DISCARD命令,則無需再手工執行該命令了,因為在此之後,事務中所有被監控的Keys都將自動取消。

和關係型資料庫中的事務相比,在Redis事務中如果有某一條命令執行失敗,其後的命令仍然會被繼續執行。
我們可以通過MULTI命令開啟一個事務,有關係型資料庫開發經驗的人可以將其理解為BEGIN TRANSACTION語句。在該語句之後執行的命令都將被視為事務之內的操作,最後我們可以通過執行EXEC/DISCARD命令來提交/回滾該事務內的所有操作。這兩個Redis命令可被視為等同於關係型資料庫中的COMMIT/ROLLBACK語句。
在事務開啟之前,如果客戶端與伺服器之間出現通訊故障並導致網路斷開,其後所有待執行的語句都將不會被伺服器執行。然而如果網路中斷事件是發生在客戶端執行EXEC命令之後,那麼該事務中的所有命令都會被伺服器執行。
當使用Append-Only模式時,Redis會通過呼叫系統函式write將該事務內的所有寫操作在本次呼叫中全部寫入磁碟。然而如果在寫入的過程中出現系統崩潰,如電源故障導致的當機,那麼此時也許只有部分資料被寫入到磁碟,而另外一部分資料卻已經丟失。Redis伺服器會在重新啟動時執行一系列必要的一致性檢測,一旦發現類似問題,就會立即退出並給出相應的錯誤提示。此時,我們就要充分利用Redis工具包中提供的redis-check-aof工具,該工具可以幫助我們定位到資料不一致的錯誤,並將已經寫入的部分資料進行回滾。修復之後我們就可以再次重新啟動Redis伺服器了。

樣例

@Test
public void test2Trans() { 
  Jedis jedis = new Jedis("localhost"); 
  long start = System.currentTimeMillis(); 
  Transaction tx = jedis.multi(); 
  for (int i = 0; i < 100000; i++) { 
    tx.set("t" + i, "t" + i); 
  } 
  List<Object> results = tx.exec(); 
  long end = System.currentTimeMillis(); 
  System.out.println("Transaction SET: " + ((end - start)/1000.0) + " seconds"); 
  jedis.disconnect(); 
}

得到事務結果result之後,可以檢查當中是否有非OK的返回值,如果存在則說明中間執行錯誤,可以使用DISCARD來回滾執行結果。

WATCH命令

WATCHMULTI執行之前的某個Key提供監控(樂觀鎖)的功能,如果Key的值變化了,就會放棄事務的執行。
當事務EXEC執行完成之後,就會自動UNWATCH

Session 1 Session 2
(1)第1步
redis 127.0.0.1:6379> get age
“10”
redis 127.0.0.1:6379> watch age
OK
redis 127.0.0.1:6379> multi
OK
redis 127.0.0.1:6379>
(2)第2步
redis 127.0.0.1:6379> set age 30
OK
redis 127.0.0.1:6379> get age
“30”
redis 127.0.0.1:6379>
(3)第3步
redis 127.0.0.1:6379> set age 20
QUEUED
redis 127.0.0.1:6379> exec
(nil)
redis 127.0.0.1:6379> get age
“30”
redis 127.0.0.1:6379>

樣例

<?php  
header("content-type:text/html;charset=utf-8");  
$redis = new redis();  
$result = $redis->connect(`localhost`, 6379);  
$mywatchkey = $redis->get("mywatchkey");  
$rob_total = 100;   //搶購數量  
if($mywatchkey<$rob_total){  
    $redis->watch("mywatchkey");  
    $redis->multi();  
       
    //設定延遲,方便測試效果。  
    sleep(5);  
    //插入搶購資料  
    $redis->hSet("mywatchlist","user_id_".mt_rand(1, 9999),time());  
    $redis->set("mywatchkey",$mywatchkey+1);  
    $rob_result = $redis->exec();  
    if($rob_result){  
        $mywatchlist = $redis->hGetAll("mywatchlist");  
        echo "搶購成功!<br/>";  
        echo "剩餘數量:".($rob_total-$mywatchkey-1)."<br/>";  
        echo "使用者列表:<pre>";  
        var_dump($mywatchlist);  
    }else{  
        echo "手氣不好,再搶購!";exit;  
    }  
}  
?>

在上例是一個秒殺的場景,該部分搶購的功能會被並行執行。
通過已銷售數量(mywatchkey)的監控,達到了控制庫存,避免超賣的作用。
WATCH是一個樂觀鎖,有利於減少併發中的衝突, 提高吞吐量。

樂觀鎖與悲觀鎖

樂觀鎖(Optimistic Lock)又叫共享鎖(S鎖),每次去拿資料的時候都認為別人不會修改,所以不會上鎖,但是在更新的時候會判斷一下在此期間別人有沒有去更新這個資料,可以使用版本號等機制。樂觀鎖適用於多讀的應用型別,這樣可以提高吞吐量。

悲觀鎖(Pessimistic Lock)又叫排他鎖(X鎖),每次去拿資料的時候都認為別人會修改,所以每次在拿資料的時候都會上鎖,這樣別人想拿這個資料就會block直到它拿到鎖。傳統的關係型資料庫裡邊就用到了很多這種鎖機制,比如行鎖,表鎖等,都是在做操作之前先上鎖。

Lua指令碼與事務

Lua 以可嵌入,輕量,高效著稱,Redis於2.6版本之後,增加了Lua語言解析模組,可以用於一些簡單的事務與邏輯運算。

命令原型 命令描述
EVAL script numkeys key[key …] arg [arg…] 傳入並執行一段Lua指令碼,script為指令碼內容,numkeys表示傳入引數數量,key表示指令碼要訪問的key,arg為傳入引數
EVALSHA sha1 通過SHA1序列呼叫lua_scripts字典預存的指令碼
SCRIPT FLUSH 用於清除伺服器中lua有關的指令碼,釋放lua_scripts字典,關閉現有的lua環境,並重新建立
SCRIPT EXISTS sha1 輸入SHA1校驗和,判斷是否存在
SCRIPT LOAD script 與EVAL相同,建立對應的lua函式,存放到字典中
SCRIPT KILL 殺掉正在執行的指令碼。正在執行的指令碼會中斷並返回錯誤,指令碼中的寫操作已被執行則不能殺死,因為違反原子性原則。此時只有手動回滾或shutdown nosave來還原資料

應用原理

客戶端將Lua指令碼作為命令傳給服務端,服務端讀取並解析後,執行並返回結果

127.0.0.1:6379> eval `return redis.call("zrange", "name2", 0 , -1);` 0
1) "1"

Redis啟動時會建立一個內建的lua_script雜湊表,客戶端可以將指令碼上傳到該表,並得到一個SHA1序列。之後可以通過該序列來呼叫指令碼。(類似儲存過程)

redis> SCRIPT LOAD "return `dlrow olleh`"
"d569c48906b1f4fca0469ba4eee89149b5148092"
 
redis> EVALSHA d569c48906b1f4fca0469ba4eee89149b5148092 0
"dlrow olleh"

約束

Redis會把Lua指令碼作為一個整體執行,由於Redis是單執行緒,因此在指令碼執行期間,其他指令碼或命令是無法插入執行,這個特性符合事務的原子性。
TIP

  1. 表是Lua中的表示式,與很多流行語言不同。KEYS中的第一個元素是KEYS[1],第二個是KEYS[2](譯註:不是0開始)

  2. nil是表的結束符,[1,2,nil,3]將自動變為[1,2],因此在表中不要使用nil。

  3. redis.call會觸發Lua中的異常,redis.pcall將自動捕獲所有能檢測到的錯誤並以表的形式返回錯誤內容。

  4. Lua數字都將被轉換為整數,發給Redis的小數點會丟失,返回前把它們轉換成字串型別。

  5. 確保在Lua中使用的所有KEY都在KEY表中,否則在將來的Redis版中你的指令碼都有不能被很好支援的危險。

  6. 指令碼要保持精簡,以免阻塞其他客戶端操作

一致性

為了保證指令碼執行結果的一致性,重複執行同一段指令碼,應該得到相同的結果。Redis做了如下約束:

  • Lua沒有訪問系統時間或者其他內部狀態的命令。

  • Lua指令碼在解析階段,如果發現RANDOMKEYSRANDMEMBERTIME這類返回隨機性結果的命令,且指令碼中有寫指令(SET)類,則會返回錯誤,不允許執行。

  • Lua指令碼中呼叫返回無序元素的命令時,如SMEMBERS,Redis會在後臺將命令的結果排序後傳回指令碼

  • Lua中的偽隨機數生成函式math.randommath.randomseed會被替換為Redis內建的函式來執行,以保證指令碼執行時的seed值不變。

樣例

private static String getSCRIPT() {
        return "local key = KEYS[1]
" +
                "local localIp = ARGV[1]
" +
                "
" +
                "local gateIp = redis.call("HGET", key, "gateIp")
" +
                "if gateIp == localIp then
" +
                "    redis.call("HSET", key, "userStatus", "false")
" +
                "    return 1
" +
                "else
" +
                "    return 0
" +
                "end";
    }
 
@Test
public void testTrans() { 
  ......
  Jedis jedis = new Jedis("localhost"); 
  result = jedis.evalsha(getSCRIPT, keys, args);
  ......
}

相關文章