2020重新出發,NOSQL,Redis的事務

夜雨流雲發表於2020-09-04

Redis的基礎事務和常用操作

和其他大部分的 NoSQL 不同,Redis 是存在事務的,儘管它沒有資料庫那麼強大,但是它還是很有用的,尤其是在那些需要高併發的網站當中。

使用 Redis 讀/寫資料要比資料庫快得多,如果使用 Redis 事務在某種場合下去替代資料庫事務,則可以在保證資料一致性的同時,大幅度提高資料讀/寫的響應速度。網際網路系統面向的是公眾,很多使用者同時訪問伺服器的可能性很大,尤其在一些商品搶購、搶紅包等場合,對效能和資料的一致性有著很高的要求,而儲存系統的讀/寫響應速度對於這類場景的效能的提高是十分重要的。

在 Redis 中,也存在多個客戶端同時向 Redis 系統傳送命令的併發可能性,因此同一個資料,可能在不同的時刻被不同的執行緒所操縱,這樣就出現了併發下的資料一致的問題。為了保證異性資料的安全性,Redis 為提供了事務方案。而 Redis 的事務是使用 MULTI-EXEC 的命令組合,使用它可以提供兩個重要的保證:

  • 事務是一個被隔離的操作,事務中的方法都會被 Redis 進行序列化並按順序執行,事務在執行的過程中不會被其他客戶端發生的命令所打斷。
  • 事務是一個原子性的操作,它要麼全部執行,要麼就什麼都不執行。

在 Redis 的連線中,請注意要求是一個連線,所以更多的時候在使用 Spring 中會使用 SessionCallback 介面進行處理,在 Redis 中使用事務會經過 3 個過程:

  • 開啟事務。
  • 命令進入佇列。
  • 執行事務。

Redis 事務命令,如表所示。

命 令 說 明 備 注
multi 開啟事務命令,之後的命令就進入佇列,而不會馬上被執行 在事務生存期間,所有的 Redis 關於資料結構的命令都會入隊
watch key1 [key2......] 監聽某些鍵,當被監聽的鍵在事務執行前被修改,則事務會被回滾 使用樂觀鎖
unwatch key1 [key2......] 取消監聽某些鍵 ——
exec 執行事務,如果被監聽的鍵沒有被修改,則採用執行命令,否則就回滾命令 在執行事務佇列儲存的命令前,Redis 會檢測被監聽的鍵值對有沒有發生變化,如果沒有則執行命令, 否則就回滾事務
discard 回滾事務 回滾進入佇列的事務命令,之後就不能再用 exec 命令提交了

在 Redis 中開啟事務是 multi 命令,而執行事務是 exec 命令。multi 到 exec 命令之間的 Redis 命令將採取進入佇列的形式,直至 exec 命令的出現,才會一次性傳送佇列裡的命令去執行,而在執行這些命令的時候其他客戶端就不能再插入任何命令了,這就是 Redis 的事務機制。

Redis 命令執行事務的過程,如圖 1 所示。

Redis命令執行事務的過程

​ 圖 1 Redis命令執行事務的過程

從圖 1 中可以看到,先使用 multi 啟動了 Redis 的事務,因此進入了 set 和 get 命令,我們可以發現它並未馬上執行,而是返回了一個“QUEUED”的結果。

這說明 Redis 將其放入佇列中,並不會馬上執行,當命令執行到 exec 的時候它就會把佇列中的命令傳送給 Redis 伺服器,這樣儲存在佇列中的命令就會被執行了,所以才會有“OK”和“value1”的輸出返回。

如果回滾事務,則可以使用 discard 命令,它就會進入在事務佇列中的命令,這樣事務中的方法就不會被執行了,使用 discard 命令取消事務如圖 2 所示。

使用discard命令取消事務

​ 圖 2 使用 discard 命令取消事務

當我們使用了 discard 命令後,再使用 exec 命令時就會報錯,因為 discard 命令已經取消了事務中的命令,而到了 exec 命令時,佇列裡面已經沒有命令可以執行了,所以就出現了報錯的情況。

教程前面我們討論過,在 Spring 中要使用同一個連線操作 Redis 命令的場景,這個時候我們藉助的是 Spring 提供的 SessionCallback 介面,採用 Spring 去實現本節的命令,程式碼如下所示。

ApplicationContext applicationContext= new ClassPathXmlApplicationContext("applicationContext.xml");
RedisTemplate redisTemplate = applicationContext.getBean(RedisTemplate.class);
SessionCallback callBack = (SessionCallback) (RedisOperations ops)-> {
    ops.multi();
    ops.boundValueOps("key1").set("value1");
    //注意由於命令只是進入佇列,而沒有被執行,所以此處採用get命令,而value卻返回為null
    String value = (String) ops.boundValueOps("key1").get();
    System.out.println ("事務執行過程中,命令入佇列,而沒有被執行,所以value為空: value="+value);
    //此時list會儲存之前進入佇列的所有命令的結果
    List list = ops.exec(); //執行事務
    //事務結束後,獲取value1
    value = (String) redisTemplate.opsForValue().get("key1");
    return value;
};
//執行Redis的命令
String value = (String)redisTemplate.execute(callBack);
System.out.println(value);

這裡採用了 Lambda 表示式(注意,Java 8 以後才引入 Lambda 表示式)來為 SessionCallBack 介面實現了業務邏輯。從程式碼看,使用了 SessionCallBack 介面,從而保證所有的命令都是通過同一個 Redis 的連線進行操作的。

在使用 multi 命令後,要特別注意的是,使用 get 等返回值的方法一律返回為空,因為在 Redis 中它只是把命令快取到佇列中,而沒有去執行。使用 exec 後就會執行事務,執行完了事務後,執行 get 命令就能正常返回結果了。

最後使用 redisTemplate.execute(callBack); 就能執行我們在 SessionCallBack 介面定義的 Lambda 表示式的業務邏輯,並將獲得其返回值。執行程式碼後可以看到這樣的結果,如圖 3 所示:

執行結果

​ 圖 3 執行結果

需要再強調的是:這裡列印出來的 value=null,是因為在事務中,所有的方法都只會被快取到 Redis 事務佇列中,而沒有立即執行,所以返回為 null,這是在 Java 對 Redis 事務程式設計中開發者極其容易犯錯的地方,一定要十分注意才行。如果我們希望得到 Redis 執行事務各個命令的結果,可以用這行程式碼:

List list = ops.exec(); //執行事務

這段程式碼將返回之前在事務佇列中所有命令的執行結果,並儲存在一個 List 中,我們只要在 SessionCallback 介面的 execute 方法中將 list 返回,就可以在程式中獲得各個命令執行的結果了。

探索Redis事務回滾

對於 Redis 而言,不單單需要注意其事務處理的過程,其回滾的能力也和資料庫不太一樣,這也是需要特別注意的一個問題——Redis 事務遇到的命令格式正確而資料型別不符合,如圖所示。

Redis事務遇到命令格式正確而資料型別不符合

​ 圖 1 Redis事務遇到命令格式正確而資料型別不符合

從圖 1 中可知,我們將 key1 設定為字串,而使用命令 incr 對其自增,但是命令只會進入事務佇列,而沒有被執行,所以它不會有任何的錯誤發生,而是等待 exec 命令的執行。

當 exec 命令執行後,之前進入佇列的命令就依次執行,當遇到 incr 時發生命令操作的資料型別錯誤,所以顯示出了錯誤,而其之前和之後的命令都會被正常執行。注意,這裡命令格式是正確的,問題在於資料型別,對於命令格式是錯誤的卻是另外一種情形,如圖 2 所示。

Redis事務遇到命令格式錯誤的

​ 圖 2 Redis事務遇到命令格式錯誤的

從圖 2 中可以看到我們使用的 incr 命令格式是錯誤的,這個時候 Redis 會立即檢測出來併產生錯誤,而在此之前我們設定了 key1,在此之後我們設定了 key2。當事務執行的時候,我們發現 key2 的值為空,說明被 Redis 事務回滾了。

通過上面兩個例子,可以看出在執行事務命令的時候,在命令入隊的時候,Redis 就會檢測事務的命令是否正確,如果不正確則會產生錯誤。無論之前和之後的命令都會被事務所回滾,就變為什麼都沒有執行。

當命令格式正確,而因為運算元據結構引起的錯誤,則該命令執行出現錯誤,而其之前和之後的命令都會被正常執行。這點和資料庫很不一樣,這是需要讀者注意的地方。

對於一些重要的操作,我們必須通過程式去檢測資料的正確性,以保證 Redis 事務的正確執行,避免出現資料不一致的情況。Redis 之所以保持這樣簡易的事務,完全是為了保證移動網際網路的核心問題——效能。

Redis watch命令—監控事務

在 Redis 中使用 watch 命令可以決定事務是執行還是回滾。一般而言,可以在 multi 命令之前使用 watch 命令監控某些鍵值對,然後使用 multi 命令開啟事務,執行各類對資料結構進行操作的命令,這個時候這些命令就會進入佇列。

當 Redis 使用 exec 命令執行事務的時候,它首先會去比對被 watch 命令所監控的鍵值對,如果沒有發生變化,那麼它會執行事務佇列中的命令,提交事務;如果發生變化,那麼它不會執行任何事務中的命令,而去事務回滾。無論事務是否回滾,Redis 都會去取消執行事務前的 watch 命令,這個過程如圖所示。

Redis執行事務過程

​ 圖 1 Redis 執行事務過程

Redis 參考了多執行緒中使用的 CAS(比較與交換,Compare And Swap)去執行的。在資料高併發環境的操作中,我們把這樣的一個機制稱為樂觀鎖。

這句話還是比較抽象,也不好理解。

所以先簡要論述其操作的過程,當一條執行緒去執行某些業務邏輯,但是這些業務邏輯操作的資料可能被其他執行緒共享了,這樣會引發多執行緒中資料不一致的情況。

為了克服這個問題,首先,線上程開始時讀取這些多執行緒共享的資料,並將其儲存到當前程式的副本中,我們稱為舊值(old value),watch 命令就是這樣的一個功能。

然後,開啟執行緒業務邏輯,由 multi 命令提供這一功能。在執行更新前,比較當前執行緒副本儲存的舊值和當前執行緒共享的值是否一致,如果不一致,那麼該資料已經被其他執行緒操作過,此次更新失敗。

為了保持一致,執行緒就不去更新任何值,而將事務回滾;否則就認為它沒有被其他執行緒操作過,執行對應的業務邏輯,exec 命令就是執行“類似”這樣的一個功能。

注意,“類似”這個字眼,因為不完全是,原因是 CAS 原理會產生 ABA 問題。所謂 ABA 問題來自於 CAS 原理的一個設計缺陷,它可能引發 ABA 問題,如表 1 所示。

時間順序 執行緒1 執行緒2 說明
T1 X=A 執行緒 1 加入監控 X
T2 複雜運算開始 修改 X=B 執行緒 2 修改 X,此刻為 B
T3 處理簡單業務
T4 修改 X=A 執行緒 2 修改 X,此刻又變回 A
T5 結束執行緒 2 執行緒 2 結束
T6 檢測X=A,驗證通過,提交事務 CAS 原理檢測通過,因為和舊值保持一致

在處理複雜運算的時候,被執行緒 2 修改的 X 的值有可能導致執行緒 1 的運算出錯,而最後執行緒 2 將 X 的值修改為原來的舊值 A,那麼到了執行緒 1 運算結束的時間順序 T6,它將檢測 X 的值是否發生變化,就會拿舊值 A 和當前的 X 的值 A 比對,結果是一致的,於是提交事務。

然後在複雜計算的過程中 X 被執行緒 2 修改過了,這會導致執行緒 1 的運算出錯。在這個過程中,對於執行緒 2 而言,X 的值的變化為 A->B->A,所以 CAS 原理的這個設計缺陷被形象地稱為“ABA 問題”。

僅僅記錄一箇舊值去比較是不足夠的,還要通過其他方法避免 ABA 問題。常見的方法如 Hibernate 對快取的持久物件(PO)加入欄位 version 值,當每次操作一次該 PO,則 version=version+1,這樣採用 CAS 原理探測 version 欄位,就能在多執行緒的環境中,排除 ABA 問題,從而保證資料的一致性。

關於 CAS 和樂觀鎖的概念,本教程還會從更深層次討論它們,暫時討論到這裡,當討論完了 CAS 和樂觀鎖,讀者再回頭來看這個過程,就會有更深的理解了。

從上面的分析可以看出,Redis 在執行事務的過程中,並不會阻塞其他連線的併發,而只是通過比較 watch 監控的鍵值對去保證資料的一致性,所以 Redis 多個事務完全可以在非阻塞的多執行緒環境中併發執行,而且 Redis 的機制是不會產生 ABA 問題的,這樣就有利於在保證資料一致的基礎上,提高高併發系統的資料讀/寫效能。

下面演示一個成功提交的事務,如表 2 所示。

時刻 客戶端 說 明
T1 set key1 value1 初始化key1
T2 watch key1 監控 key1 的鍵值對
T3 multi 開啟事務
T4 set key2 value2 設定 key2 的值
T5 exec 提交事務,Redis 會在這個時間點檢測 key1 的值在 T2 時刻後,有沒有被其他命令修改過,如果沒有,則提交事務去執行

這裡我們使用了 watch 命令設定了一個 key1 的監控,然後開啟事務設定 key2,直至 exec 命令去執行事務,這個過程和圖 2 所演示的一樣。

Redis執行事務

​ 圖 2 執行結果

這裡我們看到了一個事務的過程,而 key2 也在事務中被成功設定。下面將演示一個提交事務的案例,如表 3 所示。

時刻 客戶端1 客戶端2 說 明
T1 set key1 value1 客戶端1:返回 OK
T2 watch key1 客戶端1:監控 key1
T3 multi 客戶端1:開啟事務
T4 set key2 value2 客戶端1:事務命令入列
T5 —— set key1 vall 客戶端2:修改 key1 的值
T6 exec —— 客戶端1:執行事務,但是事務會先檢査在 T2 時刻被監控的 key1 是否被 其他命令修改過。 因為客戶端 2 修改過,所以它會回滾事務,事實上如果客戶端執行的是 set key1 value1 命令,它也會認為 key1 被修改過,然後返回(nil),所以是不會產生 ABA 問題的

測試Redis事務回滾

​ 圖 3 測試 Redis 事務回滾

在表 3 中有比較詳盡的說明,注意 T2 和 T6 時刻命令的說明,使用 Redis 事務要掌握這些內容。

使用流水線(pipelined)提高Redis的命令效能

在事務中 Redis 提供了佇列,這是一個可以批量執行任務的佇列,這樣效能就比較高,但是使用 multi...exec 事務命令是有系統開銷的,因為它會檢測對應的鎖和序列化命令。

有時候我們希望在沒有任何附加條件的場景下去使用佇列批量執行一系列的命令,從而提高系統效能,這就是 Redis 的流水線(pipelined)技術。

而現實中 Redis 執行讀/寫速度十分快,而系統的瓶頸往往是在網路通訊中的延時,如圖 1 所示。

系統的瓶頸

​ 圖 1 系統的瓶頸

在實際的操作中,往往會發生這樣的場景,當命令 1 在時刻 T1 傳送到 Redis 伺服器後,伺服器就很快執行完了命令 1,而命令 2 在 T2 時刻卻沒有通過網路送達 Redis 伺服器,這樣就變成了 Redis 伺服器在等待命令 2 的到來,當命令 2 送達,被執行後,而命令 3 又沒有送達 Redis,Redis 又要繼續等待,依此類推,這樣 Redis 的等待時間就會很長,很多時候在空閒的狀態,而問題出在網路的延遲中,造成了系統瓶頸。

為了解決這個問題,可以使用 Redis 的流水線,但是 Redis 的流水線是一種通訊協議,通過 Java API 或者使用 Spring 操作它,測試一下它的效能,程式碼如下。

Jedis jedis = pool.getResource();
long start = System.currentTimeMillis();
// 開啟流水線
Pipeline pipeline = jedis.pipelined();
// 這裡測試10萬條的讀/寫2個操作
for (int i = 0; i < 100000; i++) {
    int j = i + 1;
    pipeline.set("pipeline_key_" + j, "pipeline_value_" + j);
    pipeline.get("pipeline_key_" + j);
}
// pipeline.sync(); //這裡只執行同步,但是不返回結果
// pipeline.syncAndReturnAll ();將返回執行過的命令返回的List列表結果
List result = pipeline.syncAndRetrunAll();
long end = System.currentTimeMillis();
// 計算耗時
System.err.println("耗時:" + (end - start) + "毫秒");

在電腦上測試這段程式碼,它的耗時在 550 毫秒到 700 毫秒之間,也就是不到 1 秒的時間就完成多達 10 萬次讀/寫,可見其效能遠超資料庫。筆者的測試是 1 秒 2 萬多次,可見使用流水線後其效能提高了數倍之多,效果十分明顯。執行過的命令的返回值都會放入到一個 List 中。

注意,這裡只是為了測試效能而已,當你要執行很多的命令並返回結果的時候,需要考慮 List 物件的大小,因為它會“吃掉”伺服器上許多的記憶體空間,嚴重時會導致記憶體不足,引發 JVM 溢位異常,所以在工作環境中,是需要讀者自己去評估的,可以考慮使用迭代的方式去處理。

在 Spring 中,執行流水線和執行事務的方法如出一轍都比較簡單,使用 RedisTemplate 提供的 executePipelined 方法即可。下面將上面程式碼的功能修改為 Spring 的形式供大家參考,程式碼如下所示。

public static void testPipeline() {
    Applicationcontext applicationcontext = new ClassPathXmlApplicationContext("applicationcontext.xml");
    RedisTemplate redisTemplate = applicationcontext.getBean(RedisTemplate.class);
    // 使用Java8的Lambda表示式
    SessionCallback callBack = (SessionCallback) (RedisOperations ops)-> {
        for (int i = 0; i<100000; i++)    {
            int j = i + 1;
            ops . boundValueOps ("pipeline_key_" + j ).set("piepeline_value_"+j);
            ops.boundValueOps("pipeline_key_" + j).get();
        }
        return null;
    };
    long start = System.currentTimeMillis();
    //執行Redis的流水線命令
    List resultList= redisTemplate.executePipelined(callBack);
    long end = System.currentTimeMillis();
    System.err.println(end-start);
}

這段程式碼進行測試,其效能慢於不用 RedisTemplate 的,測試消耗的時間大約在 1 100 毫秒到 1 300 毫秒之間,也就是消耗的時間大約是其兩倍,但也屬於完全可以接受的效能範圍,同樣的在執行很多命令的時候,也需要考慮其對執行環境記憶體空間的開銷。

Redis釋出訂閱模式

當使用銀行卡消費的時候,銀行往往會通過微信、簡訊或郵件通知使用者這筆交易的資訊,這便是一種釋出訂閱模式,這裡的釋出是交易資訊的釋出,訂閱則是各個渠道。這在實際工作中十分常用,Redis 支援這樣的一個模式。

釋出訂閱模式首先需要訊息源,也就是要有訊息釋出出來,比如例子中的銀行通知。首先是銀行的記賬系統,收到了交易的命令,成功記賬後,它就會把訊息傳送出來,這個時候,訂閱者就可以收到這個訊息進行處理了,觀察者模式就是這個模式的典型應用了。下面用圖 1 描述這樣的一個過程。

交易資訊釋出訂閱機制

​ 圖 1 交易資訊釋出訂閱機制

這裡建立了一個訊息渠道,簡訊系統、郵件系統和微信系統都在監聽這個渠道,一旦記賬系統把交易訊息傳送到訊息渠道,則監聽這個渠道的各個系統就可以拿到這個訊息,這樣就能處理各自的任務了。它也有利於系統的擴充,比如現在新增一個彩信平臺,只要讓彩信平臺去監聽這個訊息渠道便能得到對應的訊息了。

從上面的分析可以知道以下兩點:

  • 要有傳送的訊息渠道,讓記賬系統能夠傳送訊息。
  • 要有訂閱者(簡訊、郵件、微信等系統)訂閱這個渠道的訊息。

同樣的,Redis 也是如此。首先來註冊一個訂閱的客戶端,這個時候使用 SUBSCRIBE 命令。

比如監聽一個叫作 chat 的渠道,這個時候我們需要先開啟一個客戶端,這裡記為客戶端 1,然後輸入命令:

SUBSCRIBE chat

這個時候客戶端 1 就會訂閱了一個叫作 chat 渠道的訊息了。之後開啟另外一個客戶端,記為客戶端 2,輸入命令:

publish chat "let's go!!"

這個時候客戶端 2 就向渠道 chat 傳送訊息:

"let's go!!"

我們觀察客戶端 1,就可以發現已經收到了訊息,並有對應的資訊列印出來。Redis 的釋出訂閱過程如圖 2 和圖 3 所示。

Redis的釋出訂閱過程(1)

​ 圖 2 Redis的釋出訂閱過程(1)

Redis的釋出訂閱過程(2)

​ 圖 3 Redis的釋出訂閱過程(2)

其出現的先後順序為先出現圖 2 的上半部分,執行圖 3 命令之後執行結果為圖 2 所示,當釋出訊息的時候,對應的客戶端已經獲取到了這個資訊。

下面在 Spring 的工作環境中展示如何配置釋出訂閱模式。首先提供接收訊息的類,它將實現 org.springframework.data.redis.connection.MessageListener 介面,並實現介面定義的方法 public void onMessage(Message message,byte[]pattern),Redis 釋出訂閱監聽類程式碼如下所示。

/*** imports ***/
public class RedisMessageListener implements MessageListener {
    private RedisTemplate redisTemplate;

    /*** 此處省略redisTemplate的 setter和getter方法 ***/
    @Override
    public void onMessage(Message message, byte[] bytes) {
        // 獲取訊息
        byte[] body = message.getBody();
        // 使用值序列化器轉換
        String msgBody = (String) getRedisTemplate().getValueSerializer()
                .deserialize(body);
        System.err.println(msgBody);
        // 獲取 channel
        byte[] channel = message.getChannel();
        // 使用字串序列化器轉換
        String channelStr = (String) getRedisTemplate().getStringSerializer()
                .deserialize(channel);
        System.err.println(channelStr);
        // 渠道名稱轉換
        String bytesStr = new String(bytes);
        System.err.println(bytesStr);
    }
}

為了在 Spring 中使用這個類,需要對其進行配置。

<bean id="redisMsgListener" class="com.redis.listener.RedisMessageListener">
  <property name="redisTemplate" ref="redisTemplate"/>
</bean>

這樣就在 Spring 上下文中定義了監聽類。

有了監聽類還不能進行測試。為了進行測試,要給一個監聽容器,在 Spring 中已有類 org.springframework.data.redis.listener.RedisMessageListenerContainer。它可以用於監聽 Redis 的釋出訂閱訊息,下面的配置就是為了實現這個功能,讀者可以通過註釋來了解它的配置要點。

<bean id="topicContainer"
    class="org.springframework.data.redis.listener.RedisMessageListenerContainer" destroy-method="destroy">
    <!--Redis連線工廠 -->
    <property name="connectionFactory" ref="connectionFactory" />
    <!--連線池,這裡只要執行緒池生存,才能繼續監聽 -->
    <property name="taskExecutor">
        <bean
            class="org.springframework.scheduling.concurrent.ThreadPoolTaskScheduler">
            <property name="poolSize" value="3" />
        </bean>
    </property>
    <!--訊息監聽Map -->
    <property name="messageListeners">
        <map>
            <!-- 配置監聽者,key-ref和bean id定義一致 -->
            <entry key-ref="redisMsgListener">
                <!--監聽類 -->
                <bean class="org.springframework.data.redis.listener.ChannelTopic">
                    <constructor-arg value="chat" />
                </bean>
            </entry>
        </map>
    </property>
</bean>

這裡配置了執行緒池,這個執行緒池將會持續的生存以等待訊息傳入,而這裡配置了容器用 id 為 redisMsgListener的Bean 進行對渠道 chat 的監聽。當訊息通過渠道 chat 傳送的時候,就會使用 id 為 redisMsgListener 的 Bean 進行處理訊息。

通過以下程式碼測試 Redis 釋出訂閱。

public static void main(String[] args)    {
    ApplicationContext applicationContext = new ClassPathXmlApplicationContext("applicationContext.xml");
    RedisTemplate redisTemplate = applicationContext.getBean(RedisTemplate.class);
    String channel = "chat";
    redisTemplate.convertAndSend(channel, "I am lazy!!");
}

convertAndSend 方法就是向渠道 chat 傳送訊息的,當傳送後對應的監聽者就能監聽到訊息了。執行它,後臺就會打出對應的訊息。

相關文章