天天看點

摸魚記 - Redis 總結Redis

Redis

能力有限,如有錯誤的地方盡情吐槽!如果感覺文章好像在哪裡看過也正常,因為我就是一名縫合怪,将看過的書籍,課程,文檔之内的進行一個總結!如果想要相關書籍,文檔的可以私信我。

存儲的資料結構

0.Redis 的 整體存儲結構

​ Redis 采用的是一個 哈希表 來存儲所有的鍵值對,這就是為什麼 Redis 可以這麼高效的從 key 找到 value 的原因,那我們平時所說的 Redis 的基本資料結構又是什麼?一般來說,Redis 哈希表存儲的Value不是一個值,而是一個指針,對于一些複雜的集合結構,例如 List ,Set 等,這些都是Value 存儲的指針指向另外一個對象去實作的。

// Redis 的哈希表的 dictEntry 結構
// 從結構上來看,Redis 解決 Hash 沖突應該是采用的拉鍊法
struct dictEntry {
    void* key ; // 鍵 - 8bytes
    // 值 - 8bytes
    union {
        void *val;
        uint64_t u64;
        int64_t s64;
    } v;
    
    struct dictEntry *next; // 指向下個 Hash dictEntry 節點 - 8 個位元組
}
           

​ 對于Redis的基本資料存儲結構來說 ,Redis作者 antirez 對于同一種資料存儲結構都會有不同的實作,去確定 Redis 在不同場景下能夠保障:查詢的效率和記憶體的使用率

1.String

​ String 在 redis 中是個最簡單的資料結構,在Redis中的實作是使用 動态字元串,如果熟悉 Java 的 StringBuilder,那就可以簡單了解 StringBuilder 就是一個動态字元串的實作;内部使用的是數組去存儲值,既然是動态字元串,那肯定支援append等操作,每次配置設定數組的時候都是有一個 capacity , 實際存儲的數量 length (length < capacity), 當 capacity 不足以添加 append 字元數時候,會進行一個擴容的操作(最大容量為512MB),一般擴容來說都是以之前的容量翻倍的(capacity * 2)避免多次的擴容影響效率,如果大于 1MB 則隻會在原有的 capacity + 1MB,避免大量的記憶體被浪費了。

// 動态資料的基本結構
struct SDS<T> {
	T capacity; // 數組的容量,數組的實際長度 : content.length
	T len; // 數組實際存儲的資料長度
	byte flag ; // 
	byte[] content; // 數組内容 
}
           

​ Redis 對 String 有三種内部編碼,會根據不同值的類型和長度決定使用不同的編碼:

  1. int :8個位元組的長整形 , 此時前面所說的 Redis 哈希表 value 存儲的就不是一個指針了,是一個實際的值,減少指針所占用的記憶體;
  2. embstr : 小于等于 44 個位元組 , 為什麼會是44個位元組呢,下面會簡單介紹
  3. raw: 大于44個位元組。

​ int的編碼,其實比較好了解,就是減少指針占用的記憶體;那對于 embstr 和 raw 呢 ? 為什麼是 44 個位元組作分界線呢?Redis 中其實每一個值我們都可以簡單的了解為一個對象,他都需要有額外的屬性:對象頭:

// (4bit + 4bit) + 3bytes + 4bytes + 8bytes = 16bytes
struct RedisObject {
    int4 type; // 對象類型 4bit 是Stinrg , 還是 List ..
    int4 encoding ; // 内部編碼類型 4bit , String 采用的是 embstr 或者 raw
    int24 lru; // LRU 計時鬧鐘-記錄對象最後一次被通路的時間 24bit - 3bytes
    int32 refcount; // 引用計數器-當 refcount = 0 時候對象可以被安全回收 : 32bit - 4bytes
    // ----- 分隔符 ----- 
    // 對于分隔符上面的我們一般稱之為 中繼資料, 隻是值的額外屬性
    // 而 *ptr 才是我們實際存儲的值 , 中繼資料 占用 8bytes , 實際資料占用 8bytes
    void *ptr; // 資料指針,如果是整數直接存儲值 - 64bit - 8bytes
}
// 結合上面動态字元串的結構來看 一個空數組可能都要占據 3bytes + 16bytes(RedisObject) = 19bytes
struct SDS<T> {
	int8 capacity; // 1bytes
	int8 len; // 1bytes
	int8 flag ; // 1bytes
	byte[] content;  
}
           

​ 從上面簡單的分析可以看出 String 最小都要占據 19bytes 的大小 , 而 記憶體配置設定中(malloc),單次配置設定是 2的次方 ,最大64bytes , 是以在一次記憶體配置設定中,可以将 RedisObject 和 SDS 的結構配置設定在一個記憶體塊中(64bytes)隻需要一次配置設定就可以完成,減少配置設定的次數, 是以除去 上面描述的 19bytes , 64 - 19 還有 45 bytes空間去存儲,但是每個存儲的數組最後一位都需要占用一位 結束符 \0 ,是以隻能夠存儲 44 bytes ,這種單次記憶體配置設定的編碼類型就是 embstr;(在Redis3.0之前好像是隻能夠存儲39bytes);

​ 而另外的 raw 就會使用多次記憶體配置設定存儲到其他的記憶體地方,效率較低,并且會導緻存儲的記憶體不連續,查詢效率相比 enbstr 較低;

​ RedisObject 中可以看出,當我們存儲大量資料的時候其實 中繼資料會占用大量的記憶體,導緻記憶體使用率并不高,例 如:一般我們存儲一個整數型的鍵值對,那麼實際上占用的記憶體 dictEntry (8 + 8 + 8 = 24 位元組) + RedisObject(中繼資料 8 + 實際存儲的值 8 ) = 50 位元組,但是上面我們提到單次記憶體配置設定是2的次方,是以實際占用的記憶體就是 64 的位元組,但是我們實際存儲的值明明就隻需要 8 位元組,額外所占用的記憶體其實簡單來說就是浪費了(并不是說這些屬性沒有,隻是有些場景沒必要用到)這種情況我們可以使用,二級編碼,利用其他資料結構去解決,例如 Hash的 zipList 壓縮清單;

2.List

3.Set

4.Sort Set

5.Hash

6.GEO

Redis 到底是不是單線程呢?

​ 在許多文章中介紹到,Redis 是個單線程模型的架構,但實際上呢,如果僅靠單線程是無法支撐起 Redis 的許多特性的,例如 Redis 的持久化(RDB 和 AOF), 主從複制等;那為什麼那麼多文章都強調 Redis 是個單線程模型呢?單線程模型到底有什麼好處呢?

​ 單線程模型避免了線程競争的問題,在多線程中,通路共享對象都需要有額外的同步機制確定隻有一個線程可以通路,同步機制就會引入額外的開銷;除此之外,在 CPU 密集型的操作中,随着線程的數量增加(小于 CPU 核心的數量),系統的吞吐量會增加,但是一旦線程數量大于 CPU 核心的數量,線程數量的增加,反而會影響到系統的吞吐量降低;簡單的來說,單線程模型的好處就是可以避免引入同步機制的開銷和避免系統吞吐量的降低。

​ 那我們所說的 Redis 單線程到底指的是什麼呢?這裡對 Redis 單線程主要特指的是 Redis的網絡 IO (NIO 模型 - 多路複用)和對鍵值對的讀寫操作都是由單線程完成的。Redis 會将所有用戶端的請求的指令,以到達的順序關聯到隊列裡 - 指令隊列(事件隊列),Redis 值需要監聽隊列的指令(事件),進行對應指令的操作即可,不需要 Redis 輪詢處理請求,避免 CPU 的浪費

多路複用 :

​ 多路複用機制就是指一個線程可以處理多個 IO 流。在傳統的阻塞 IO 中,每一個套接字的 read() 函數如果讀取不到位元組,就會阻塞目前的線程,如果有大量的 IO 流,我們就需要建立多個線程去處理IO,這樣會導緻浪費計算機的資源(線程)。是以存在 select 和 epoll 多路複用機制,大大降低了計算機資源的浪費,使用一個線程就可以處理大量的 IO。

​ 如果 Redis 單單隻是多線程,那麼 持久化 ,主從複制 , 異步删除就沒辦法執行了;如果在主線程中執行這類操作的話,會阻塞主線程幾秒甚至是幾十秒的時間,而 Redis 是基于記憶體的資料庫,如果超過了微妙級别的一般是不能被接受的,會阻塞許多的指令,為了完成這些特性,就必須引入 多線程\多程序 的架構。

Redis 持久化

​ Redis 提供了兩種持久化的方案,分别是 RDB 和 AOF ,針對不同的場景選用不同的方案,到了 Redis4.0提供了 RDB 和 AOF 的混合持久化的操作,很好的利用上了兩種方案的優點;

RDB

​ RDB 是一種通過快照實作的持久化方法,會生成一個 RDB 檔案,RDB是一個緊湊壓縮的二進制檔案,那麼二進制檔案有什麼好處呢?二進制資料相比我們一般的 json 資料,格式化的資料相比來說,占據的記憶體空間更小,并且在做資料恢複的時候效率更高(計算機識别的都是二進制資料),但是也引入了一個缺點就是可讀的效率太差了。

​ RDB 是基于快照的一種全量存儲的持久化方式,當 Redis 存儲的資料量太大的時候,如果我們執行 save 指令會阻塞目前的線程響應其他指令;對于 Redis 這種高性能的記憶體資料庫,我們是不允許主線程的阻塞而妨礙到了接下來的各種讀寫的操作,是以 Redis 也提供了另一種指令:bgsave。當使用 bgsave 的時候,主程序會 fork 出一個子程序(fork 也會短暫的阻塞主線程,取決于目前 Redis 資料庫的大小),然後将 持久化的操作由子程序完成。

快照簡單的說就是給目前的資料拍一張照片,然後根據目前的照片來進行持久化的操作,舉個例子就是如果在一個教室裡,學生們進進出出,如果想要統計目前人數我們就可以使用快照的思想,我直接給教室的情況拍個照,然後根據照片的來數人頭,我不管接下來學生的跑動,隻根據目前的照片來進行統計,這就是快照的思想。

fork 指令建立出一個子程序,我們可以将子程序和父程序看作是一個連體嬰,雖然它們的腦袋(記憶體頁表)不一樣,但是他們操作的都是同一個軀體(兩個不同的頁表指向的都是一個記憶體資料),這樣就可以避免大記憶體的複制(10GB的記憶體資料大約需要複制20MB的記憶體頁表)導緻主線程被阻塞了。

​ 這樣 fork 出來的程序效率高,但是由于父子程序共享同一記憶體,那麼如何避免在主線程在處理寫的操作時破壞快照,這種時候就要依賴作業系統提供的機制 COW(COPY ON WRITE) - 寫時複制,在主線程進行寫入的時候,會将要被修改的資料複制出來,然後對複制出來的頁面進行修改,這樣就可以避免破壞子程序的快照;

​ RDB 使用的是一種全量快照的持久化方法,fork 操作是一個重量級的操作,頻繁執行成本過高,而且在子程序進行資料備份的時候會導緻磁盤 IO 的壓力過大,是以就決定了 RDB 不适用于實時備份的一個場景,是以會存在資料丢失的情況;如果我們每半分鐘就進行一個 fork 操作,那麼同一時間可能存在多個子程序搶占磁盤 IO 的資源,導緻寫盤的效率變低。是以在實際的 Redis 也不允許建立多個子程序(如果存在一個子程序,那麼 fork 操作傳回失敗) 。

摸魚記 - Redis 總結Redis