天天看點

Redis系列:深刻了解高性能Redis的本質

作者:JAVA後端架構
Redis系列:深刻了解高性能Redis的本質

1 背景

分布式系統繞不開的核心之一的就是資料緩存,有了緩存的支撐,系統的整體吞吐量會有很大的提升。通過使用緩存,我們把頻繁查詢的資料由磁盤排程到緩存中,保證資料的高效率讀寫。

當然,除了在記憶體内運作還遠遠不夠,我們今天就以具有代表性的緩存中間件Redis為例子,分析下,它是如何達到飛起的效率。

2 Redis高效性能分析

Redis之是以能夠提供超高的執行效率,主要從以下幾個次元來實作的:

Redis系列:深刻了解高性能Redis的本質
  • 存儲模式:基于記憶體實作,而非磁盤
  • 資料結構:基于不同業務場景的高效資料結構動态字元串(REDIS_STRING):整數(REDIS_ENCODING_INT)、字元串(REDIS_ENCODING_RAW)雙端清單(REDIS_ENCODING_LINKEDLIST)壓縮清單(REDIS_ENCODING_ZIPLIST)跳躍表(REDIS_ENCODING_SKIPLIST)哈希表(REDIS_HASH)整數集合(REDIS_ENCODING_INTSET)
  • 線程模型: Redis 的網絡 IO 以及鍵值對指令讀寫是由單個線程來執行的,避免了不必要的contextswitch和競選
  • I/O 模型: 基于I/O多路複用模型,非阻塞的I/O模型
  • 恰單的資料編碼: 根據實際資料類型,選擇合理的資料編碼

2.1 官網的性能報告

Redis官方站點中,有對Redis性能做了比較詳細的壓測,可以參考官方這一篇 How fast is Redis?,

在較高的配置基準下(比如 8C 16G +),在連接配接數為0~10000的時候,最高QPS可達到120000。Redis以超過60000個連接配接為基準,仍然能夠在這些條件下維持50000個q/s,展現了超高的性能。下圖中橫軸是連接配接數,縱軸是QPS。

Redis系列:深刻了解高性能Redis的本質

下面這張圖為data size 與整體吞吐量之間的趨向關系:

Redis系列:深刻了解高性能Redis的本質

這個大概可以得出一個容量預估,比如你的服務使用者量是多少,預估峰值QPS是多少,叢集需要配置多少個執行個體(雖然執行個體的多少不能線性計算),可以大緻推算出去。

2.2 基于記憶體實作

Redis的讀寫操作都是在記憶體中實作了,相對其他的持久化存儲(如MySQL、File等,資料持久化在磁盤上),性能會高很多。因為在們在操作資料的時候,需要通過 IO 操作先将資料讀取到記憶體裡,增加工作成本。

Redis系列:深刻了解高性能Redis的本質

上面那張圖來源于網絡,可以看看他的金字塔模型,越往上執行效率越高,價格也就越貴。下面給出每一層的執行耗時對比:

  • 寄存器:0.3 ns
  • L1高速緩存:0.9 ns
  • L2高速緩存:2.8 ns
  • L3高速緩存:12.9 ns
  • 主存:120 ns
  • 本地二級存儲(SSD):50~150 us
  • 遠端二級存儲:30 ms

這樣可能不直覺,我們舉個L1和SSD的對比,如果L1耗時1s的話,SSD中差不多要15~45小時。

因為 CPU 内部內建了記憶體控制器,是以CPU直接控制了記憶體,給予通信上的最優帶寬。

2.3 适配多元場景的高效資料結構

在 Redis 緩存中,常用的主要資料類型有五種,如下:

  • 字元串/REDIS_STRING:适用于 緩存、計數、共享Session、IP統計、分布式鎖等。
  • 清單/REDIS_LIST: 連結清單、消息隊列、棧、有序的對象清單(如朋友圈的點贊順序清單、評論順序清單)。
  • 哈希表/REDIS_HASH: 購物車資訊、使用者資訊、Hash類型的(key, field, value)存儲對象等。
  • 集合/REDIS_SET:無序的唯一的鍵值結構: 好友、關注、粉絲、感興趣的人集合等。
  • 有序集合/REDIS_ZSET:通路排行榜、點贊排行、粉絲數排行等。

上面這5種Redis 支援的資料類型,能夠滿足不同業務場景下的資料結構需求。而對于這幾類資料類型的區分和支援,目的無非也是為了效率,具體的業務中使用恰當的資料結構才能保證得到應有的效率。

這5種資料類型都有一種或者多種資料結構來支撐,底層資料結構有 7 種。關系如下:

Redis系列:深刻了解高性能Redis的本質

限免我們對這些資料結構一個個的分析。

2.3.1 SDS 簡單動态字元串

Redis使用簡單動态字元串(simple dynamic string,SDS)來表示字元串,Redis中字元串類型包含的資料結構有:整數(R_INT) 、 字元串(R_RAW)。我們以字元串為例子,正常的字元串,如 "Brand",如果要擷取他的長度,需要從頭開始周遊,直至遇到 \0 空字元代表結尾,如 C字元串。

C 字元串結構與 SDS 字元串結構 對比圖 參照如下:

Redis系列:深刻了解高性能Redis的本質
  • free屬性的值為0,表示這個SDS沒有配置設定任何未使用空間。
  • len屬性的值為5,表示這個SDS儲存了一個5位元組長度的字元串。
  • buf是一個char類型的數組,存儲真實的字元串,數組的前五個位元組分别儲存了'B'、'r'、'a'、'n'、'd'五個字元,而最後一個位元組則儲存了空字元'\0',代表結尾。

    注意:SDS遵循C字元串的慣例以空字元結尾,儲存空字元的1位元組不計算在SDS的len屬性中。

比起C字元串,SDS具有以下優點:

  1. 度擷取字元串長度時間複雜度為O(1) 。

    C字元串不記錄自身長度,擷取C字元串長度時必須周遊整個字元串計數得到,複雜度是O(N)

    SDS字元串自身記錄維護len長度屬性,獲得SDS字元串長度的複雜度是O(1)

  2. 杜絕緩沖區溢出。

    C字元串不記錄長度,由于兩個C字元串在記憶體存儲上緊鄰,在執行字元串拼接strcat時,如果不提前配置設定足夠空間,很可能發生修改s1的資料溢出到s2所在的空間中(緩沖區溢出)。

    SDS杜絕了緩沖區溢出問題,它記錄了長度,當修改SDS字元串之前,API都會檢查SDS的空間是否滿足修改的要求,不滿足API會自動進行空間擴充。

  3. 空間預配置設定,減少修改時的記憶體重配置設定次數

    SDS 被修改後,程式不僅會為 SDS 配置設定所需要的空間,還會配置設定額外的未使用空間。這樣,Redis可以減少連續執行字元串增長操作所需的記憶體重配置設定次數。

    具體配置設定未使用空間如下2種方式:

  • 如修改後長度len小于1MB,就配置設定和len屬性相同大小的未使用空間:free=len。
  • 如修改後長度len大于等于1MB,就配置設定1M的未使用空間:free=1MB。
  1. 惰性空間釋放

    惰性空間釋放,縮短操作時:SDS避免了縮短字元串時所需的記憶體重配置設定操作,并為将來可能有的增長操作提供了優化。當SDS做縮短操作,不會立刻使用記憶體重配置設定來收回縮短後多出來的位元組,而是保持在free屬性裡。将來如果需要 append 操作,則直接使用 free 中未使用的空間,減少了記憶體的配置設定步驟。

    另外,SDS也提供了API手動進行釋放SDS未使用空間,避免惰性釋放政策會造成記憶體浪費。

  2. 二進制安全

    C字元串的字元必須符合某種編碼,除結尾空字元以外,字元串内部不允許有空字元串,存儲有局限性。而在 Redis 中,不僅可以存儲 String 類型的資料,也可能存儲一些二進制資料。

    二進制資料并不是規則的字元串格式,其中會包含一些特殊的字元如 '\0'。在 C 中遇到 '\0' 則表示字元串的結束,但SDS不是,它是以len長度辨別結尾。

  3. 相容部分C字元串函數。

    SDS雖然是二進制安全的,但還是秉承C字元串以空字元結尾的特性,很多函數與C字元串一緻不需要重寫。

2.3.2 zipList 壓縮清單

通過上面的資料結構關系圖,可以看出,壓縮清單是 List 、Hash、 Set 三種資料類型底層實作之一。

當我們的list清單資料量比較少的時候,且存儲的資料輕量的(如小整數值、短字元串)時候, Redis 就會通過壓縮清單來進行底層實作。

ziplist 是由一系列特殊編碼的連續記憶體塊組成的順序型的資料結構,在清單頭有三個字段 zlbytes、zltail 和 zllen,清單中有多個entry,表尾還有一個 zlend,我們來具體拆解下:

  • zlbytes:表示清單占用位元組數
  • zltail:清單尾的偏移量
  • zllen:清單尾的偏移量:清單中的 entry 個數
  • entry:存儲區,可以包含多個節點,每個節點可以存放整數或者字元串。
  • zlend:表示清單結束。

參考代碼如下:

struct ziplist<T> {
    // 清單占用位元組數
    int32 zlbytes;
	// 清單尾的偏移量,用于快速定位到最後一個節點
    int32 zltail_offset; 
	// 清單entry元素個數
    int16 zllength; 
	// 元素内容清單
    T[] entries; 
	// 标志壓縮清單的結束,值恒為 0xFF
    int8 zlend; 
}
           
Redis系列:深刻了解高性能Redis的本質

如果查找定位首個元素或最後1個元素,可以通過表頭zlbytes、zltail_offset元素快速擷取,複雜度是 O(1)。但是查找其他元素時,就沒有這麼高效了,隻能逐個查找下去,比如 entry n 的複雜度就是 O(N)。

2.3.3 linklist 雙端清單

Redis List 資料類型經常使用在連結清單、消息隊列、棧、有序的對象清單(如朋友圈的點贊順序清單、評論順序清單、關注時間線)等場景,無論是隊列(先進先出),還是棧(先進後出),雙端清單都能很好的支援。

參考代碼如下:

typedef struct list {
	// 表頭
	listnode * head;
	// 表尾
	listnode * tail;
	// 連結清單所包含的節點數量
	unsigned long len;
	// 函數:複制節點值
	void *(*dup)(void *ptr);
	// 函數:釋放節點值
	void (*free)(void *ptr);
	// 函數:對比節點值
	int (*match)(void *ptr, void *key);
} list;
           

Redis 的連結清單實作的特性可以總結如下:

  • 雙端:連結清單節點帶有 prev 和 next 指針,擷取某個節點的前一節點和後一節點的複雜度都是 O(1)。
  • 無環:表頭節點的 prev 指針和表尾節點的 next 指針都指向 NULL,對連結清單的通路以 NULL 為終點。
  • 表頭指針/表尾指針:通過 list 結構的 head 指針和 tail 指針,擷取連結清單的表頭節點和表尾節點的複雜度為 O(1)。
  • 連結清單長度計數器:通過 list 結構的 len 屬性來對 list 的連結清單節點進行計數,擷取節點數量的複雜度為O(1)。
  • 多态:連結清單節點使用 void* 指針來儲存節點值,并通過 list 結構的 dup、free、match 三個屬性為節點值設定類型特定函數,是以連結清單可以用于儲存各種不同類型的值。

    使用連結清單的附加空間相對太高,因為64bit系統中指針是8個位元組,是以prev和next指針需要占據16個位元組,且連結清單節點在記憶體中單獨配置設定,會加劇記憶體的碎片化,影響記憶體管理效率。

    考慮到連結清單的以上缺點,Redis後續版本對清單資料結構進行改造,使用quicklist代替了ziplist和linkedlist。 作為ziplist 和 linkedlist 的混合體,它将 linkedlist 按段切分,每一段使用 ziplist 來緊湊存儲,多個 ziplist 之間使用雙向指針串接起來。

這樣,性能就的得到了更大的提升。

2.3.4 Hhash 字典

無論何種類型(string、list、hash、set、zset),Redis都是以一個Hash結構的形式來儲存鍵值對的。整體是一個數組,數組中的每個元素都是一個獨立的對象,被稱為哈系桶,比如圖中1 ~ n, 對應的entry儲存着實際具體值的指針。

Redis系列:深刻了解高性能Redis的本質

上圖中的全局哈希表,它的時間複雜度是 O(1),隻需要計算每個鍵的哈希值,便知道對應的哈希桶位置,定位桶中的 entry ,并找到對應資料。這個執行效率就很高了。

為了解決可能存在的沖突,采用了鍊式哈希的做法,也就是同一個桶裡面的元素使用連結清單儲存。

2.3.5 intset 整數集合

如果你的集合隻有整數值元素,并且數量是輕量的,這時候Redis會使用使用整數集合作為Redis集合的底層資料結構。參考如下代碼:

typedef struct intset{
     // 編碼格式
     uint32_t encoding;
     // 集合中的元素個數
     uint32_t length;
     // 儲存元素資料
     int8_t contents[];
} intset;
           

我們拆解下:

  • encoding: 編碼方式
  • length: 數組中元素個數,也就是數組的整體長度
  • contents[]: 整數集合,集合的每個元素都是數組的一個數組項(item)。具有以下特點:按值的大小增序排列不包含任何重複項

2.3.6 skipList 跳躍表

skiplist(即跳躍表)是一種有序資料結構,是以它也是ZSet資料類型中的一種,通過在每個節點中維持多個指向其他節點的指針,達到快速定位的目标。

跳躍表的平均的節點搜尋,平均時間複雜度是 O(logN)、最差時間複雜度是 O(N),還可以通過順序性操作來批量處理節點。 跳躍表是基于連結清單的改良,在它基礎上,增加了多層級索引,通過索引不斷跳轉,最終定好位到真實的資料項。這個方式是不是讓大家想到b+tree,理念上有點接近,如下圖所示:

Redis系列:深刻了解高性能Redis的本質

可以看出,需要擷取 68 這個元素需要經曆3次查找,需要擷取 97則需要經曆4次查找。

2.4 單線程模型

Redis 的單線程主要是指Redis的網絡IO和鍵值對讀寫是由一個線程來完成的,Redis在處理用戶端的請求時包括擷取 (socket 讀)、解析、執行、内容傳回 (socket 寫) 等都由一個順序串行的主線程處理,這就是所謂的“單線程”。這也是Redis對外提供鍵值存儲服務的主要流程。

但Redis的其他功能, 比如持久化、異步删除、叢集資料同步等等,其實是由額外的線程執行的。 可以這麼說,Redis工作線程是單線程的。但是,整個Redis來說,是多線程的。

2.4.1 為何是單線程?

那在主流程中使用單線程,主要是出于什麼原因呢?

  • 整體吞吐量降低

    适當的擴增線程,是為了有效的利用cpu的性能,讓它跟記憶體達到一個利用的最優值。但頻繁的Redis讀寫,如果沒有對線程進行有效管理,不但對系統的吞吐量沒有提升,反而可能導緻下降。

  • CPU上下文切換

    在運作任務的時候,CPU需要把任務加載到CPU寄存器中進行計算,當切換到其他thread時,需要将目前上下文存儲在系統核心中,以便後續重新執行計算時再次加載。

    就像你做專心做一件事時,頻繁切換,頻繁被打斷,這個代價是非常高的。

    如圖中,切換上下文時,我們需要完成一系列工作,save context、switch、restore context等,這種操作越頻繁越是耗費資源。

  • 共享資源的并發控制問題

    引入了程式執行順序的不确定性,帶來了并發讀寫的一系列問題,增加了系統複雜度。同時可能存線上程切換、甚至加鎖解鎖、死鎖造成的性能損耗。

  • 記憶體才是核心關注點

    對于 Redis 架構來說, 主要的性能瓶頸是記憶體或者網絡帶寬,而非 CPU。

2.4.2 單線程的好處

  1. 避免線程建立過多導緻的性能消耗,反而降低整體吞吐能力。
  2. 避免上下文切換引起的 CPU 額外的開銷。
  3. 避免了線程之間的競争問題,如加鎖、解鎖、死鎖等,都會造成性能損耗。
  4. 無需額外考慮多線程帶來的程式複雜度,代碼更清晰,處理邏輯簡單。

2.4.3 單線程是否有效利用CPU

官方這麼說

It’s not very frequent that CPU becomes your bottleneck with Redis, as usually Redis is either memory or network bound. For instance, using pipelining Redis running on an average Linux system can deliver even 1 million requests per second, so if your application mainly uses O(N) or O(log(N)) commands, it is hardly going to use too much CPU.

大概意思是,Redis是完全的純記憶體操作,執行速度是非常快的,CPU通常不會是瓶頸,因為大多數請求不會是CPU密集型的。參考

Redis真正的性能瓶頸在網絡IO,也就是用戶端和服務端之間的網絡傳輸延遲,是以Redis選擇了單線程的IO多路複用來實作它的核心網絡模型。

2.5 I/O 多路複用模型

服務端網絡程式設計常見的 I/O 模型有四種:同步阻塞IO(Blocking IO)、同步非阻塞IO(Non-blocking IO)、IO多路複用(IO Multiplexing)、異步IO(Asynchronous IO)。

Redis 采用的是 I/O 多路複用技術,并發的去處理連接配接,它的多路複用程式函數有 select、poll、epoll、kqueue。以 epoll (目前最新的也是最好的多路複用技術)函數為例,當客服端執行 read、write、accept、close 等操作指令時,它會将指令封裝成一個個事件,然後利用 epoll 多路複用的特性來避免 I/O 阻塞。

下面我們看看普通 I/O 模型 和 Redis的 I/O 多路複模型的的差別,來分析Redis高頻請求下如何保持高效執行。

2.5.1 普通 I/O 模型

先來看一下傳統的阻塞 I/O 模型到底是如何工作的:當使用 read 或者 write 對某一個檔案描述符(File Descriptor:FD)進行讀寫時,如果目前 FD 不可讀或不可寫,整個 Redis 服務就不會對其它的操作作出響應,導緻整個服務不可用。

這也就是傳統意義上的,也就是我們在程式設計中使用最多的阻塞模型:

Redis系列:深刻了解高性能Redis的本質

阻塞模型雖然開發中非常常見也非常易于了解,但是由于它會影響其他 FD 對應的服務,是以在需要處理多個用戶端任務的時候,往往都不會使用阻塞模型。

2.5.2 I/O 多路複用

Redis系列:深刻了解高性能Redis的本質

多路 複用指的是:多個socket連接配接複用一個線程。這種模式下,核心不會去監視應用程式的連接配接,而是監視檔案描述符。

當用戶端發起請求的時候,會生成不同僚件類型的套接字。而在服務端,因為使用了 I/O 多路複用技術,是以不是阻塞式的同步執行,而是将消息放入 socket 隊列(參考下圖的 I/O Multiplexing module),然後通過 File event Dispatcher 将其轉發到不同的事件處理器上,如accept、read、send。

Redis系列:深刻了解高性能Redis的本質

綜上,我們得出如下特性:

  • 單線程模式,核心持續監聽 socket 上的連接配接及資料請求,一監聽就交予Redis線程處理,達到單個線程處理多個I/O 流的效果。
  • epoll 提供了基于事件的回調機制。不同僚件調用對應的事件處理器。Redis可以持續性的高效處理事件,性能同步提升。
  • Redis 不阻塞任一用戶端發起的請求,是以可以同時和多個用戶端連接配接并處理請求,聚币并發執行的能力。

3 高性能Redis總結

  • 基于記憶體實作,而非磁盤,大都是簡單的存取操作,資源主要消耗在 IO 上,是以讀取速度快。
  • 資料結構:基于不同業務場景的高效資料結構動态字元串(REDIS_STRING):整數(REDIS_ENCODING_INT)、字元串(REDIS_ENCODING_RAW)雙端清單(REDIS_ENCODING_LINKEDLIST)壓縮清單(REDIS_ENCODING_ZIPLIST)跳躍表(REDIS_ENCODING_SKIPLIST)哈希表(REDIS_HASH)整數集合(REDIS_ENCODING_INTSET)
  • 線程模型:Redis 的網絡 IO 以及鍵值對指令讀寫是由單個線程來執行的,避免了不必要的contextswitch和競選
  • I/O 模型:基于I/O多路複用模型,非阻塞的I/O模型
  • 恰單的資料編碼:根據實際資料類型,選擇合理的資料編碼
  • Redis 本身是一個全局 哈希表,他的時間複雜度是 O(1),另外為了防止哈希沖突導緻連結清單過長,執行 rehash 操作進行擴充,減少哈希沖突。

為幫助開發者們提升面試技能、有機會入職BATJ等大廠公司,特别制作了這個專輯——這一次整體放出。

大緻内容包括了: Java 集合、JVM、多線程、并發程式設計、設計模式、Spring全家桶、Java、MyBatis、ZooKeeper、Dubbo、Elasticsearch、Memcached、MongoDB、Redis、MySQL、RabbitMQ、Kafka、Linux、Netty、Tomcat等大廠面試題等、等技術棧!

Redis系列:深刻了解高性能Redis的本質

歡迎大家關注公衆号【Java爛豬皮】,回複【666】,擷取以上最新Java後端架構VIP學習資料以及視訊學習教程,然後一起學習,一文在手,面試我有。

每一個專欄都是大家非常關心,和非常有價值的話題,如果我的文章對你有所幫助,還請幫忙點贊、好評、轉發一下,你的支援會激勵我輸出更高品質的文章,非常感謝!

繼續閱讀