Redis的線程模型和事務

1. 前言

我本來只是想學習Redis的事務,但後來發現,Redis和傳統關係型數據庫的事務在ACID的表現上差別很大。而要想詳細瞭解其中的原因,就離不開Redis獨特的單線程模型,所以本文將兩者聯繫在一塊兒講解。java

下面先會補充一些知識儲備,包括解答幾個常犯錯的問題,分析Redis的線程模型,爲後面的章節打好基礎。隨後再講解Redis的事務實現,和關係型數據庫的事務作對比,以及會附上springboot中實現事務的代碼。redis

2. 常見問題

2.1. 高併發不等於高並行

咱們最多聽到的就是併發,但實際上不少時候並不嚴謹,有些狀況應該被定義爲並行spring

  • 併發,是指在一個時間段內有多個進程在執行。只不過在人的角度看,由於這個計算機角度的時間實在是過短暫了,人根本就感覺不到是多個進程,看起來像是同時進行,這種是併發。
  • 並行,指的是在同一時刻有多個進程在同時執行。

一個是時間段內發生的,一個是某一時刻發生的,若是是在只有單核CPU的狀況下,是沒法實現並行的,由於同一時刻只能有一個進程被調度執行,若是此時同時要執行其餘進程則必須上下文切換,這種只能稱之爲併發,而若是是多個CPU的狀況下,就能夠同時調度多個進程,這種就能夠稱之爲並行。數據庫

2.2. 何時該用多線程

咱們首先要明確,多線程不必定比單線程快,由於多線程還涉及到CPU上下文切換的消耗,和頻繁建立、銷燬線程的消耗 。那麼多線程是爲了優化什麼而使用的呢?我所瞭解的有兩點:數組

1.充分利用多核CPU的資源,實現並行

由於多核cpu每個核心均可以獨立執行一個線程,因此多核cpu能夠真正實現多線程的並行。
但這點優化算不上什麼,一臺服務器上通常部署了不少的應用,哪有那麼多空閒的CPU核心空閒着。安全

2.應對CPU的「阻塞」

我認爲這纔是主要緣由。「阻塞」包括網絡io、磁盤io等這類io的阻塞,還包括一些執行很慢的邏輯操做等。例如:某個接口的方法中,按照執行順序分紅A、B、C三個獨立的部分。springboot

若是每一個部分執行的都很慢(如:查詢數據庫視圖,將數據導出excel文件),都要10秒。那麼方法執行完成,單線程要用30秒,多線程分別執行只須要10秒。優化了20秒,線程建立和CPU上下文切換的影響,和20秒比起來不算什麼。服務器

若是每一個部分執行的都很快,都只須要10毫秒。按照上面的計算方式,理論上優化了20毫秒,可線程建立和CPU上下文切換的影響,但是要大於20毫秒的。網絡

所以整體來講,多線程開發對於程序的優化,主要體如今應對致使CPU「阻塞」的點。多線程

3. 線程模型

Redis服務端經過單進程單線程,處理全部客戶端的請求。

Redis官方數據是說支持100000+ 的QPS(峯值時間的每秒請求),很難相信這是靠單線程來支撐的。所以咱們要探究一下,Redis的線程模型爲啥能支持它執行這麼快?

3.1. 性能瓶頸

官方表示,Redis是基於內存操做,CPU不是Redis的性能瓶頸,Redis的性能瓶頸是機器的內存和網絡帶寬。

看到這句話,我有個疑惑,爲啥 「Redis是基於內存操做,CPU不是Redis的性能瓶頸」

這就聯繫到第二章中「2.多線程不必定快」的知識點了-- 在多線程開發對於程序的優化,主要體如今應對致使CPU「阻塞」的點。普通數據庫的瓶頸在於磁盤io,可Redis是基於內存操做,沒有磁盤io的瓶頸,並且基於Reactor模型,也沒有網絡io的阻塞。沒有多線程的必要,CPU也就不是Redis的性能瓶頸。

另外Redis是將全部的數據所有放在內存中的,全部說使用單線程去操做執行效率就是最高的,多線程在執行過程當中須要進行 CPU 的上下文切換,這個是耗時操做。對於內存系統來講,若是沒有上下文切換效率就是最高的,屢次讀寫都是在一個 CPU 上的,在內存狀況下,這個就是最佳方案。

咱們能夠理解成,由於Redis做爲內存數據庫,又有個很好的線程模型,並不存在io阻塞和CPU等性能瓶頸。再日後能夠提高Redis空間的,就在於機器的內存和網絡帶寬了。

3.2. 線程模型

我以前的不少篇文章都提到了Reactor線程模型,像Tomcat、Netty等,都使用了Reactor線程模型來實現IO多路複用,此次再加上Redis。還記得以前有介紹Reactor模型有三種:單線程Reactor模型,多線程Reactor模型,主從Reactor模型。

一般來講,主從Reactor模型是最健壯的,Tomcat和Netty都是使用這種,可是 Redis是使用單線程Reactor模型

image

上圖描述了Redis工做的線程模型,模擬了服務端處理客戶端命令的過程:

  1. 文件事件處理器使用 I/O 多路複用(multiplexing)程序來同時監聽多個套接字,即將套接字的fd註冊到epoll上,當被監聽的套接字準備好執行鏈接應答(accept)、讀取(read)、寫入(write)、關閉(close)等操做時,與操做相對應的文件事件就會產生。
  2. 儘管多個文件事件可能會併發地出現,但I/O多路複用程序老是會將全部產生事件的套接字都推到一個隊列裏面,而後經過這個隊列,以有序(sequentially)、同步(synchronously)、每次一個套接字的方式向文件事件分派器傳送套接字。
  3. 此時文件事件處理器就會調用套接字以前關聯好的事件處理器來處理這些事件。文件事件處理器以單線程方式運行,這就是以前一直提到的Redis線程模型中,效率很高的那個單線程。

值得注意的是,在執行命令階段,因爲Redis是單線程來處理命令的,全部每一條到達服務端的命令不會馬上執行,全部的命令都會進入一個隊列中,而後逐個被執行。而且多個客戶端發送的命令的執行順序是不肯定的。可是能夠肯定的是,不會有兩條命令被同時執行,不會產生並行問題,這也是後面咱們討論Redis事務的基礎

3.3. 分析

爲何不怕Reactor單線程模型的弊端?

咱們回顧以前的文章,Reactor單線程模型的最大缺點在於:Acceptor和Handlers都共用一個線程,只要某個環節發生阻塞,就會阻塞全部。整個尤爲是Handlers是執行業務方法的,最容易發生阻塞,像Tomcat就默認使用200容量大線程池來執行。那Redis爲何就不怕呢?

緣由就在於Redis做爲內存數據庫,它的Handlers是可預知的,不會出現像Tomcat那樣的自定義業務方法。不過也建議不要在Reids中執行要佔用大量時間的命令。

總結:Redis單線程效率高的緣由
  • 純內存訪問:數據存放在內存中,內存的響應時間大約是100納秒,這是Redis每秒萬億級別訪問的重要基礎。
  • 非阻塞I/O:Redis採用epoll作爲I/O多路複用技術的實現,再加上Redis自身的事件處理模型將epoll中的鏈接,讀寫,關閉都轉換爲了時間,不在I/O上浪費過多的時間。
  • 單線程避免了線程切換和競態產生的消耗。

4. 事務

前面說過,因爲Redis單線程的特性,全部的命令都是進入一個隊列中,依次執行。所以不會有兩條命令被同時執行,不會產生並行問題。這點和傳統關係型數據庫不同,沒有並行問題,也就沒有像表鎖、行鎖這類鎖競爭的問題了。

4.1. 概念

那麼Redis的事務是爲了處理什麼狀況?

假設,客戶端A提交的命令有A一、A2和A3 這三條,客戶端B提交的命令有B一、B2和B3,在進入服務端隊列後的順序實際上很大部分是隨機。假設是:A一、B一、B三、A三、B二、A2,可客戶端A指望本身提交的是按照順序一塊兒執行的,它就可使用事務實現:B二、A一、A二、A三、B一、B3,客戶端B的命令執行順序仍是隨機的,可是客戶端A的命令執行順序就保證了。

Redis 事務的本質是一組命令的集合。事務支持一次執行多個命令,一個事務中全部命令都會被序列化。在事務執行過程,會按照順序串行化執行隊列中的命令,其餘客戶端提交的命令請求不會插入到事務執行命令序列中。

總結說:redis事務就是一次性、順序性、排他性的執行一個隊列中的一系列命令。  

Redis事務相關命令
  • watch key1 key2 ... : 監視一或多個key,若是在事務執行以前,被監視的key被其餘命令改動,則事務被打斷 ( 相似樂觀鎖 )
  • multi : 標記一個事務塊的開始( queued )
  • exec : 執行全部事務塊的命令 ( 一旦執行exec後,以前加的監控鎖都會被取消掉 ) 
  • discard : 取消事務,放棄事務塊中的全部命令
  • unwatch : 取消watch對全部key的監控
事務執行過程

multi命令能夠將執行該命令的客戶端從非事務狀態切換至事務狀態,執行後,後續的普通命令(非multi、watch、exec、discard的命令)都會被放在一個事務隊列中,而後向客戶端返回QUEUED回覆。

事務隊列是一個以先進先出(FIFO)的方式保存入隊的命令,較先入隊的命令會被放到數組的前面,而較後入隊的命令則會被放到數組的後面。

當一個處於事務狀態的客戶端向服務器發送exec命令時,這個exec命令將當即被服務器執行。服務器會遍歷這個客戶端的事務隊列,執行隊列中保存的全部的命令,最後將執行命令所得的結果返回給客戶端。

當一個處於事務狀態的客戶端向服務器發送discard命令時,表示事務取消,客戶端從事務狀態切換回非事務狀態,對應的事務隊列清空。

watch

watch命令可被用做樂觀鎖。它能夠在exec命令執行前,監視任意數量的數據庫鍵,並在exec命令執行時,檢查監視的鍵是否至少有一個已經被其餘客戶端修改過了,若是修改過了,服務器將拒絕執行事務,並向客戶端返回表明事務執行失敗的空回覆。而unwatch命令用於取消對全部鍵的監視。

要注意,watch是監視鍵被其餘客戶端修改過,即其餘的會話鏈接中。若是你在同一個會話下本身watch本身改,是不生效的。

4.2. ACID分析

在傳統關係型數據庫中,事務都是遵循ACID四個特性的,那麼Redis的事務遵循嗎?

原子性(Atomicity)
原子性是指事務包含的全部操做要麼所有成功,要麼所有失敗回滾。

Redis 開始事務 multi 命令後,Redis 會爲這個事務生成一個隊列,每次操做的命令都會按照順序插入到這個隊列中。這個隊列裏面的命令不會被立刻執行,直到 exec 命令提交事務,全部隊列裏面的命令會被一次性,而且排他的進行執行。

可是呢,當事務隊列裏面的命令執行報錯時,會有兩種狀況:(1)一種錯誤相似於Java中的CheckedException,Redis執行器會檢測出來,若是某個命令出現了這種錯誤,會自動取消事務,這是符合原子性的;(2)另外一種錯誤相似於Java中的RuntimeExcpetion,Redis執行器檢測不出來,當執行報錯了已經來不及了,錯誤命令後續的命令依然會執行完畢,並不會回滾,所以不符合原子性。

一致性(Consistency)
一致性是指事務必須使數據庫從一個一致性狀態變換到另外一個一致性狀態,也就是說一個事務執行以前和執行以後都必須處於一致性狀態。

由於達不成原子性,其實嚴格上來說,也就達不成一致性。

隔離性(Isolation)
隔離性是當多個用戶併發訪問數據庫時,好比操做同一張表時,數據庫爲每個用戶開啓的事務,不能被其餘事務的操做所幹擾,多個併發事務之間要相互隔離。

回顧前面的基礎,Redis 由於是單線程依次執行隊列中的命令的,沒有併發的操做,因此在隔離性上有天生的隔離機制。,當 Redis 執行事務時,Redis 的服務端保證在執行事務期間不會對事務進行中斷,因此,Redis 事務老是以串行的方式運行,事務也具有隔離性。

持久性(Durability)
持久性是指一個事務一旦被提交了,那麼對數據庫中的數據的改變就是永久性的,即使是在數據庫系統遇到故障的狀況下也不會丟失提交事務的操做。

Redis 是否具有持久化,這個取決於 Redis 的持久化模式:

  • 純內存運行,不具有持久化,服務一旦停機,全部數據將丟失。
  • RDB 模式,取決於 RDB 策略,只有在知足策略纔會執行 Bgsave,異步執行並不能保證 Redis 具有持久化。
  • AOF 模式,只有將 appendfsync 設置爲 always,程序纔會在執行命令同步保存到磁盤,這個模式下,Redis 具有持久化。(將 appendfsync 設置爲 always,只是在理論上持久化可行,但通常不會這麼操做)

簡單總結:

  • Redis 具有了必定的原子性,但不支持回滾。
  • Redis 不具有 ACID 中一致性的概念。(或者說 Redis 在設計時就無視這點)
  • Redis 具有隔離性。
  • Redis 經過必定策略能夠保證持久性。

固然,咱們也不該該拿傳統關係型數據庫事務的ACID特性去要求Redis,Redis設計更多的是追求簡單與高性能,不會受制於傳統 ACID 的束縛。

4.3. 代碼

這裏結合springboot代碼作示例,加深咱們對Redis事務的應用開發。在springboot中構建Redis客戶端,通常經過spring-boot-starter-data-redis來實現。

jedis 和 lettuce

Lettuce和Jedis的都是鏈接Redis Server的客戶端程序。Jedis在實現上是直連redis server,多線程環境下非線程安全,除非使用鏈接池,爲每一個Jedis實例增長物理鏈接。Lettuce基於Netty的鏈接實例(StatefulRedisConnection),能夠在多個線程間併發訪問,且線程安全,知足多線程環境下的併發訪問,同時它是可伸縮的設計,一個鏈接實例不夠的狀況也能夠按需增長鏈接實例。

可見Lettuce是要優於Jedis的,在spring-boot-starter-data-redis早期版本都是使用Jedis鏈接的,但到了2.x版本,Jedis就直接被替換成Lettuce。

下面直接看代碼吧。

pom

pom文件主要是引入了spring-boot-starter-data-redis

<dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-data-redis</artifactId>
        </dependency>
controller

controller中定義了兩個接口:

  • 接口1 watch:watch鍵A,在事務中修改鍵A和B的值,在阻塞3秒後,提交事務。
  • 接口2 change:修改鍵A。
@RestController
public class DemoController {
    public final static String STR_KEY_A="key_a";
    public final static String STR_KEY_B="key_b";

    private final StringRedisTemplate stringRedisTemplate;

    public DemoController(StringRedisTemplate stringRedisTemplate) {
        this.stringRedisTemplate = stringRedisTemplate;
    }

    @GetMapping("/watch")
    public void watch(){
        stringRedisTemplate.setEnableTransactionSupport(true);
        stringRedisTemplate.watch(STR_KEY_A);
        stringRedisTemplate.multi();
        try {
            stringRedisTemplate.opsForValue().set(STR_KEY_A, "watch_a");
            stringRedisTemplate.opsForValue().set(STR_KEY_B, "watch_b");
            Thread.sleep(3000);
        }catch (Exception e){
            e.printStackTrace();
            stringRedisTemplate.discard();
        }
        stringRedisTemplate.exec();
        stringRedisTemplate.unwatch();
    }

    @GetMapping("/change")
    public void change(){
        stringRedisTemplate.opsForValue().set(STR_KEY_A,"change_a");
    }

}
測試用例

咱們寫一個測試用例,大體邏輯是:先調用接口1,0.5秒後(爲了保證接口1先於接口2執行,由於線程實際執行順序不必定按照業務代碼順序來),再調用接口2,而且在兩個接口的線程中,都會將鍵A和B的值打印出來。

由於接口1的事務是延遲3秒提交的,所以執行順序是:

接口1 watch 鍵A ->接口1 multi開始事務 -> 接口2 修改鍵A -> 接口1 提交事務

結果也符合咱們預想的,由於在接口1 watch的鍵值,被接口2修改了,因此接口1 的事務執行失敗了,最終輸出的日誌是:

2020-10-11 23:32:14.133  Thread2執行結果:
key_a:change_a
key_b:null
2020-10-11 23:32:16.692  Thread1執行結果:
key_a:change_a
key_b:null
@RunWith(SpringRunner.class)
@SpringBootTest
@AutoConfigureMockMvc
public class DemoControllerTest {
    private final Logger logger = LoggerFactory.getLogger(DemoControllerTest.class);

    @Autowired
    private MockMvc mockMvc;
    @Autowired
    private StringRedisTemplate stringRedisTemplate;
    
    @Test
    public void transactionTest() throws InterruptedException{
        /**
         * 清空數據,刪除 A、B 鍵
         */
        stringRedisTemplate.delete(DemoController.STR_KEY_A);
        stringRedisTemplate.delete(DemoController.STR_KEY_B);
        /**
         * 線程1:watch A 鍵
         * 事務:修改A、B 鍵值,阻塞10秒後exec、unwatch
         * 輸出:A、B鍵值
         */
        Thread thread1 = new Thread(() -> {
            try {
                mockMvc.perform(MockMvcRequestBuilders.get("/watch"));
                logger.info(new StringBuffer(Thread.currentThread().getName()).append("執行結果:\n")
                        .append(DemoController.STR_KEY_A).append(":").append(stringRedisTemplate.opsForValue().get(DemoController.STR_KEY_A))
                        .append("\n").append(DemoController.STR_KEY_B).append(":").append(stringRedisTemplate.opsForValue().get(DemoController.STR_KEY_B))
                        .toString());
            } catch (Exception e) {
                logger.error("/watch",e);
            }
        });
        thread1.setName("Thread1");
        /**
         * 線程2:修改 A 鍵
         * 事務:無事務,無阻塞
         * 輸出:A、B 鍵值
         */
        Thread thread2 = new Thread(() -> {
            try {
                mockMvc.perform(MockMvcRequestBuilders.get("/change"));
                logger.info(new StringBuffer(Thread.currentThread().getName()).append("執行結果:\n")
                        .append(DemoController.STR_KEY_A).append(":").append(stringRedisTemplate.opsForValue().get(DemoController.STR_KEY_A))
                        .append("\n").append(DemoController.STR_KEY_B).append(":").append(stringRedisTemplate.opsForValue().get(DemoController.STR_KEY_B))
                        .toString());
            } catch (Exception e) {
                logger.error("/change",e);
            }
        });
        thread2.setName("Thread2");
        /**
         * 線程1 比 線程2 先執行
         */
        thread1.start();
        Thread.sleep(500);
        thread2.start();
        /**
         * 主線程,等待 線程一、線程2 執行完成
         */
        thread1.join();
        thread2.join();
    }
}
相關文章
相關標籤/搜索