1. 程式人生 > >Java Jedis操作Redis示例(五)——Redis的事務、管道和指令碼

Java Jedis操作Redis示例(五)——Redis的事務、管道和指令碼

一 Redis的事務

在資料庫系統中,一個事務是指:由一系列資料庫操作組成的一個完整的邏輯過程。例如銀行轉帳,從原賬戶扣除金額,以及向目標賬戶新增金額,這兩個資料庫操作的總和,構成一個完整的邏輯過程,不可拆分。這個過程被稱為一個事務,具有ACID特性。

0. ACID/CAP/BASE

ACID:是指在資料庫管理系統(DBMS)中,事務(transaction)所具有的四個特性:原子性(Atomicity)、一致性(Consistency)、隔離性(Isolation,又稱獨立性)、永續性(Durability)。
l  原子性:一個事務(transaction)中的所有操作,要麼全部完成,要麼全部不完成,不會結束在中間某個環節。事務在執行過程中發生錯誤,會被回滾(Rollback)到事務開始前的狀態,就像這個事務從來沒有執行過一樣。
l  一致性

:在事務開始之前和事務結束以後,資料庫的完整性限制沒有被破壞。
l  隔離性:當兩個或者多個事務併發訪問(此處訪問指查詢和修改的操作)資料庫的同一資料時所表現出的相互關係。事務隔離分為不同級別,包括讀未提交(Read uncommitted)、讀提交(read committed)、可重複讀(repeatable read)和序列化(Serializable)。
l  永續性:在事務完成以後,該事務對資料庫所作的更改便持久地儲存在資料庫之中,並且是完全的。

CAP:CAP原理指的是,一致性(Consistency)可用性(Availability)分割槽容忍性(Partitiontolerance)

這三個要素最多隻能同時實現兩點,不可能三者兼顧。這是Brewer教授於2000年提出的,後人也論證了CAP理論的正確性。

l  一致性(Consistency) :對於分散式的儲存系統,一個數據往往會存在多份。簡單的說,一致性會讓客戶對資料的修改操作(增/刪/改),要麼在所有的資料副本(replica)全部成功,要麼全部失敗。即,修改操作對於一份資料的所有副本(整個系統)而言,是原子(atomic)的操作。如果一個儲存系統可以保證一致性,那麼則客戶讀寫的資料完全可以保證是最新的。不會發生兩個不同的客戶端在不同的儲存節點中讀取到不同副本的情況。
l  可用性(Availability) :可用性很簡單,顧名思義,就是指在客戶端想要訪問資料的時候,可以得到響應。但是注意,系統可用(Available)並不代表儲存系統所有節點提供的資料是一致的。這種情況,我們仍然說系統是可用的。往往我們會對不同的應用設定一個最長響應時間,超過這個響應時間的服務我們仍然稱之為不可用的。
l  分割槽容忍性

(Partition Tolerance) :如果你的儲存系統只執行在一個節點上,要麼系統整個崩潰,要麼全部執行良好。一旦針對同一服務的儲存系統分佈到了多個節點後,整個儲存系統就存在分割槽的可能性。比如,兩個儲存節點之間聯通的網路斷開(無論長時間或者短暫的),就形成了分割槽。一般來講,為了提高服務質量,同一份資料放置在不同城市非常正常的。因此節點之間形成分割槽也很正常。

BASE:BASE全稱是BasicallyAvailable(基本可用), Soft-state(軟狀態/柔性事務), Eventually Consistent(最終一致性)。BASE模型在理論邏輯上是相反於ACID(原子性Atomicity、一致性Consistency、隔離性Isolation、永續性Durability)模型的概念,它犧牲高一致性,獲得可用性和分割槽容忍性。

l  最終一致性 (Eventually Consistent)。最終一致性是指:經過一段時間以後,更新的資料會到達系統中的所有相關節點。這段時間就被稱之為最終一致性的時間視窗

| 軟狀態(Soft-state)。軟狀態是指允許系統存在中間狀態,而該中間狀態不會影響系統整體可用性。分散式儲存中一般一份資料至少會有三個副本,允許不同節點間副本同步的延時就是軟狀態的體現。mysql replication的非同步複製也是一種體現。

| 基本可用(Basically Available)。基本可用是指分散式系統在出現故障的時候,允許損失部分可用性,即保證核心可用。電商大促時,為了應對訪問量激增,部分使用者可能會被引導到降級頁面,服務層也可能只提供降級服務。這就是損失部分可用性的體現。

1. Redis事務定義

Redis中的事務(transaction)是一組命令的集合。事務同命令一樣都是Redis的最小執行單位,一個事務中的命令要麼都執行,要麼都不執行。
事務的原理是先將屬於一個事務的命令傳送給Redis,然後再讓Redis依次執行這些命令。

  1. Redis保證一個事務中的所有命令要麼都執行,要麼都不執行。如果在傳送EXEC命令前客戶端斷線了,則Redis會清空事務佇列,事務中的所有命令都不會執行。而一旦客戶端傳送了EXEC命令,所有的命令就都會被執行,即使此後客戶端斷線也沒關係,因為Redis中已經記錄了所有要執行的命令。
  2. 除此之外,Redis的事務還能保證一個事務內的命令依次執行而不被其他命令插入。試想客戶端A需要執行幾條命令,同時客戶端B傳送了一條命令,如果不使用事務,則客戶端B的命令可能會插入到客戶端A的幾條命令中執行。如果不希望發生這種情況,也可以使用事務。

2. Redis事務的錯誤和回滾

Redis的事務沒有隔離級別的概念(CAID中的I),在事務執行前所有的命令都未執行。對於執行過程中的錯誤按照型別分為兩種。

1. 語法錯誤

語法錯誤指命令不存在或者命令引數的個數不對,這個命令可能會有語法錯誤(引數的數量錯誤、命令名稱錯誤,等等),或者可能會有某些臨界條件(例如:如果使用maxmemory指令,為Redis伺服器配置記憶體限制,那麼就可能會有記憶體溢位條件)。 

可用Redis客戶端檢測第一種型別的錯誤,在呼叫EXEC命令之前,這些客戶端可以檢查被放入佇列的命令的返回值:如果命令的返回值是QUEUE字串,那麼就表示已經正確地將這個命令放入佇列;否則,Redis將返回一個錯誤。如果將某個命令放入佇列時發生錯誤,那麼大多數客戶端將會中止事務,並且丟棄這個事務。
在Redis 2.6.5版本之前,如果發生了上述的錯誤,那麼在客戶端呼叫了EXEC命令之後,Redis還是會執行這個出錯的事務,執行已經成功放入事務佇列的命令,而不會關心先前發生的錯誤。從2.6.5版本開始,Redis在遭遇上述錯誤時,伺服器會記住事務積累命令期間發生的錯誤。然後,Redis會拒絕執行這個事務,在執行EXEC命令之後,便會返回一個錯誤訊息。最後,Redis會自動丟棄這個事務,這樣便能輕鬆地混合使用事務和管道。在這種情況下,客戶端可以一次性地將整個事務傳送至Redis伺服器,稍後再一次性地讀取所有的返回值。

2. 執行錯誤

執行錯誤指在命令執行時出現的錯誤,比如使用雜湊型別的命令操作集合型別的鍵,這種錯誤在實際執行之前Redis是無法發現的,所以在事務裡這樣的命令是會被Redis接受並執行的。如果事務裡的一條命令出現了執行錯誤,事務裡其他的命令依然會繼續執行(包括出錯命令之後的命令)

Redis的回滾機制

Redis的事務沒有關係資料庫事務提供的回滾(rollback)功能。為此開發者必須在事務執行出錯後自己收拾剩下的攤子(將資料庫復原回事務執行前的狀態等,這裡我們一般採取日誌記錄然後業務補償的方式來處理,但是一般情況下,在redis做的操作不應該有這種強一致性要求的需求,我們認為這種需求為不合理的設計)。

3. Redis的樂觀鎖和Watch

Watch命令描述:
WATCH命令可以監控一個或多個鍵,一旦其中有一個鍵被修改(或刪除),之後的事務就不會執行。監控一直持續到EXEC命令(事務中的命令是在EXEC之後才執行的,所以在MULTI命令後可以修改WATCH監控的鍵值)

在Redis的事務中,WATCH命令可用於提供CAS(check-and-set)功能,且是基於樂觀鎖的思想。假設我們通過WATCH命令在事務執行之前監控了多個Keys,倘若在WATCH之後有任何Key的值發生了變化,EXEC命令執行的事務都將被放棄,同時返回Null multi-bulk應答以通知呼叫者事務執行失敗。例如,我們再次假設Redis中並未提供incr命令來完成鍵值的原子性遞增,如果要實現該功能,我們只能自行編寫相應的程式碼。其偽碼如下:

      val = GET mykey
      val = val + 1
      SET mykey $val

以上程式碼只有在單連線的情況下才可以保證執行結果是正確的,因為如果在同一時刻有多個客戶端在同時執行該段程式碼,那麼就會出現多執行緒程式中經常出現的一種錯誤場景--競態爭用(race condition)。比如,客戶端A和B都在同一時刻讀取了mykey的原有值,假設該值為10,此後兩個客戶端又均將該值加一後set回Redis伺服器,這樣就會導致mykey的結果為11,而不是我們認為的12。為了解決類似的問題,我們需要藉助WATCH命令的幫助,見如下程式碼:
      WATCH mykey
      val = GET mykey
      val = val + 1
      MULTI
      SET mykey $val
      EXEC

和此前程式碼不同的是,新程式碼在獲取mykey的值之前先通過WATCH命令監控了該鍵,此後又將set命令包圍在事務中,這樣就可以有效的保證每個連線在執行EXEC之前,如果當前連接獲取的mykey的值被其它連線的客戶端修改,那麼當前連線的EXEC命令將執行失敗。這樣呼叫者在判斷返回值後就可以獲悉val是否被重新設定成功。

由於WATCH命令的作用只是當被監控的鍵值被修改後阻止之後一個事務的執行,而不能保證其他客戶端不修改這一鍵值,所以在一般的情況下我們需要在EXEC執行失敗後重新執行整個函式。執行EXEC命令後會取消對所有鍵的監控,如果不想執行事務中的命令也可以使用UNWATCH命令來取消監控。

實現一個hsetNX函式
我們實現的hsetNX這個功能是:僅當欄位存在時才賦值。為了避免競態條件我們使用watch和事務來完成這一功能(虛擬碼):

    WATCH key  
    isFieldExists = HEXISTS key, field  
    if isFieldExists is 1  
    MULTI  
    HSET key, field, value  
    EXEC  
    else  
    UNWATCH  
    return isFieldExists

在程式碼中會判斷要賦值的欄位是否存在,如果欄位不存在的話就不執行事務中的命令,但需要使用UNWATCH命令來保證下一個事務的執行不會受到影響。

4. Jedis的事務

Jedis對Redis的事務機制給出了具體實現,示例程式碼如下:

   public static void testWach(){
        Jedis jedis = RedisCacheClient.getInstrance().getClient();
        String watch = jedis.watch("testabcd");
        System.out.println(Thread.currentThread().getName()+"--"+watch);
        Transaction multi = jedis.multi();
        multi.set("testabcd", "23432");
        try {
            Thread.sleep(3000);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        List<Object> exec = multi.exec();
        System.out.println("---"+exec);
        jedis.unwatch();
    }
    public static void testWatch2(){
        Jedis jedis = RedisCacheClient.getInstrance().getClient();
        String watch = jedis.watch("testabcd2");
        System.out.println(Thread.currentThread().getName()+"--"+watch);
        Transaction multi = jedis.multi();
        multi.set("testabcd", "125");
        List<Object> exec = multi.exec();
        System.out.println("--->>"+exec);
    }

三 Redis的管道

Redis是一個響應式的服務,當客戶端傳送一個請求後,就處於阻塞狀態等待Redis返回結果。這樣一次命令消耗的時間就包括三個部分:請求從客戶端到伺服器的時間結果從伺服器到客戶端的時間命令真正執行時間,前兩個部分消耗的時間總和稱為RTT(Round Trip Time),當客戶端與伺服器存在網路延時時,RTT就可能會很大,這樣就會導致效能問題。

管道(Pipeline)就是為了改善這個情況的,利用管道,客戶端可以一次性發送多個請求而不用等待伺服器的響應,待所有命令都發送完後再一次性讀取服務的響應,這樣可以極大的降低RTT時間從而提升效能。需要注意到是用pipeline方式打包命令傳送,redis必須在處理完所有命令前先快取起所有命令的處理結果。打包的命令越多,快取消耗記憶體也越多。所以並不是打包的命令越多越好。

pipeline和“事務”是兩個完全不同的概念,pipeline只是表達“互動”中操作的傳遞的方向性,pipeline也可以在事務中執行,也可以不在。無論如何,pipeline中傳送的每個command都會被server立即執行,如果執行失敗,將會在此後的相應中得到資訊;也就是pipeline並不是表達“所有command都一起成功”的語義;但是如果pipeline的操作被封裝在事務中,那麼將有事務來確保操作的成功與失敗。Pipeline的示例程式碼如下:

private static void usePipeline(int count){
    Jedis jr = null;
    try {
        jr = new Jedis("10.10.224.44", 6379);
        Pipeline pl = jr.pipelined();
        for(int i =0; i<count; i++){
             pl.incr("testKey2");
        }
            pl.sync();
    } catch (Exception e) {
        e.printStackTrace();
    }
    finally{
        if(jr!=null){
            jr.disconnect();
        }
    }
}

使pipeline完成操作需要更低的耗時即可。

四 Redis Lua指令碼

Redis在2.6推出了指令碼功能,允許開發者使用Lua語言編寫指令碼傳到Redis中執行。使用指令碼的好處如下:
1.減少網路開銷:本來5次網路請求的操作,可以用一個請求完成,原先5次請求的邏輯放在redis伺服器上完成。使用指令碼,減少了網路往返時延。
2.原子操作:Redis會將整個指令碼作為一個整體執行,中間不會被其他命令插入。
3.複用:客戶端傳送的指令碼會永久儲存在Redis中,意味著其他客戶端可以複用這一指令碼而不需要使用程式碼完成同樣的邏輯。

在實際工作過程中,可以使用lua指令碼來解決一些需要保證原子性的問題,而且lua指令碼可以快取在redis伺服器上,勢必會增加效能。

Lua語法

Eval命令

從Redis2.6.0版本開始,通過內建的Lua直譯器,可以使用EVAL命令對Lua指令碼進行求值。EVAL命令的格式如下:

EVAL script numkeys key [key ...] arg [arg ...]  

script引數是一段Lua指令碼程式,它會被執行在Redis伺服器上下文中,這段指令碼不必(也不應該)定義為一個Lua函式。numkeys引數用於指定鍵名引數的個數。鍵名引數 key [key ...] 從EVAL的第三個引數開始算起,表示在指令碼中所用到的那些Redis鍵(key),這些鍵名引數可以在 Lua中通過全域性變數KEYS陣列,用1為基址的形式訪問( KEYS[1] , KEYS[2] ,以此類推)。

在命令的最後,那些不是鍵名引數的附加引數 arg [arg ...] ,可以在Lua中通過全域性變數ARGV陣列訪問,訪問的形式和KEYS變數類似( ARGV[1] 、 ARGV[2] ,諸如此類)。例如

> eval "return {KEYS[1],KEYS[2],ARGV[1],ARGV[2]}" 2 key1 key2 first second
1) "key1"
2) "key2"
3) "first"
4) "second"

其中 "return {KEYS[1],KEYS[2],ARGV[1],ARGV[2]}" 是被求值的Lua指令碼,數字2指定了鍵名引數的數量, key1和key2是鍵名引數,分別使用 KEYS[1] 和 KEYS[2] 訪問,而最後的 first 和 second 則是附加引數,可以通過 ARGV[1] 和 ARGV[2] 訪問它們。

Call和pcall

在 Lua 指令碼中,可以使用兩個不同函式來執行Redis命令,它們分別是:

  1. redis.call()
  2. redis.pcall()

這兩個函式的唯一區別在於它們使用不同的方式處理執行命令所產生的錯誤。當 redis.call() 在執行命令的過程中發生錯誤時,指令碼會停止執行,並返回一個指令碼錯誤,錯誤的輸出資訊會說明錯誤造成的原因,redis.pcall() 出錯時並不引發(raise)錯誤,而是返回一個帶 err 域的 Lua 表(table),用於表示錯誤。

Jedis呼叫

Jedis中呼叫指令碼需要以字串形式給出指令碼主體,並遵從EVAL的資料規範,示例程式碼如下:

String script ="local result={} " + 
                " for i,v in ipairs(KEYS) do " + 
                " result[i] = redis.call('get',v) " + 
                " end " + 
                " return result ";

Jedis jedis = new Jedis(ip,port);

jedis.eval(script,keyCount,String … params);

注意,不要再Lua指令碼中出現死迴圈和耗時的運算,否則redis將不接受其他的命令,這個redis就掛了,只能script kill,如果有寫入的話,只能shutdown nosave。 所以使用時要注意不能出現死迴圈、耗時的運算。redis是單程序、單執行緒執行指令碼。

五 Redis事務、管道和指令碼的區別

1. 事務和指令碼從原子性上來說都能滿足原子性的要求,其區別在於指令碼可藉助Lua語言可在伺服器端儲存的便利性定製和簡化操作,但指令碼無法處理長耗時的操作。

2. 管道是無狀態操作集合,使用管道可能在效率上比使用script要好,但是有的情況下只能使用script。因為在執行後面的命令時,無法得到前面命令的結果,就像事務一樣,所以如果需要在後面命令中使用前面命令的value等結果,則只能使用script或者事務+watch。