聊一聊redis奇葩數據類型與集群知識

多樣的數據類型

string 類型簡單方便,支持空間預分配,也就是每次會多分配點空間,這樣 string 如果下次變長的話,就不需要額外的申請空瞭,當然前提是剩餘的空間夠用。

List 類型可以實現簡單的消息隊列,但是註意可能存在消息丟失哦,它並不持 ACK 模式。

Hash 表有點像關系型數據庫,但是當 hash 表越來越大的時候,請註意,避免使用 hgetall 之類的語句,因為請求大量的數據會導致redis阻塞,這樣後面的兄弟們就得等待瞭。

set 集合類型可以幫你做一些統計,比如你要統計某天活躍的用戶,可以直接把用戶ID扔到集合裡,集合支持一些騷操作,比如 sdiff 可以獲取集合之間的差集,sunion 可以獲取集合之間的並集,功能很多,但是一定需要謹慎,因為牛逼的功能是有代價的,這些操作需要耗費一些 CPU 和IO 資源,可能會導致阻塞,因此大集合之間的騷操作要慎用,

zset 可以說是最閃耀的星,可以做排序,因為可以排序,因此應用場景挺多,比如點贊前xx名用戶,延時隊列等等。

bitmap 位圖的好處就是在於節省空間,特別在做一些統計類的方面,比如要統計某一天有多少個用戶簽到瞭並且某個用戶是否簽到瞭,如果不用bitmap的話,你可能會想到用set。

SADD day 1234//簽到就添加到集合
SISMEMBER day 1234//判斷1234是否簽到
SCARD day   //有多少個簽到的

set 在功能上可以滿足,但是相比bitmap的話,set要更耗費存儲空間,set的底層主要是由整數集合或者 hashtable 組成,整數集合隻有在數據量非常小的情況下才會使用,一般是小於512個元素,同時元素必須都是整數,對於set來說,整數集合的數據更加緊湊,他們在內存是上連續的,查詢的話隻能是二分查找瞭,時間復雜度是O(logN),而 hashtable 就不同瞭,這裡的 hashtable 和 redis 的5大數據類型中的hash是一樣的,隻不過沒有 value 而已,value 指向個 null,同時也不存在沖突,因為這裡是集合,但是需要考慮 rehash 相關問題。ok,扯的有點遠,我們說的用戶簽到問題,在用戶非常多的情況下,set 的話肯定會用到 hashtable,hashtable 的話,其實每個元素都是個 dictEntry 結構體

typedef struct dictEntry {
    // 鍵
    void *key;
    // 值
    union {
        void *val;
        uint64_t u64;
        int64_t s64;
    } v;
    // 指向下個哈希表節點,形成鏈表
    struct dictEntry *next;
​​​​​​​} dictEntry;

從這個結構體可以看到什麼呢?首先雖然值 union(沒有 value)和 next(沒有沖突)是空的,但是結構體本身需要空間,還需要加上個 key,這個占用空間是實打實的,而如果用 bitmap 的話,一個bit位就可以代表一個數字,很省空間,我們來看看 bitmap 的方式如何設置和統計。

SETBIT day 1234 1//簽到
GETBIT day 1234//判斷1234是否簽到
BITCOUNT day//有多少個簽到的

bf 這是 redis4.0 之後支持的佈隆過濾器 RedisBloom,但是需要單獨加載對應的 module,當然我們也可以基於上述的 bitmap 來實現自己的佈隆過濾器,不過既然 redis 已經支持瞭,通過 RedisBloom 可以減少我們的開發時間,佈隆過濾器是幹嘛的,我這裡就不贅述瞭,直接來看看 RedisBloom 相關的用法吧。

# 可以通過docker的方式快速拉取鏡像來玩耍
docker run -p 6379:6379 --name redis-redisbloom redislabs/rebloom:latest
docker exec -it redis-redisbloom bash
redis-cli
# 相關操作
bf.reserve sign 0.001 10000
bf.add sign 99 //99這個用戶加入
bf.add exists 99//判斷99這個用戶是否存在

因為佈隆過濾器是存在誤判的,所有 bf 支持自定義誤判率,0.001就代表誤判率,10000 代表佈隆過濾器可以存儲的元素個數,當實際存儲的元素個數超過這個值的時候,誤判率會提高。

HyperLogLog 可以用於統計,它的優點就是占用的存儲空間極小,隻需要 12KB 的內存就可以統計 2^64 個元素,那它主要統計什麼呢?其實主要就是基數統計,比如像 UV 這種,從功能上來說 UV 可以用 set 或者 hash 來存儲,但是缺點就是耗費存儲,容易使之變成大 key,如果想要節省空間,bitmap 也可以,12KB 空間的 bitmap 隻能統計 12*1024*8=98304個元素,而 HyperLogLog 卻可以統計 2^64 個元素,但是這麼牛逼的技術其實是有誤差的,HyperLogLog 是基於概率來統計的,標準誤算率是 0.81%,在統計海量數據並且對精度要求不那麼高的場景下,HyperLogLog 在節省空間這塊還是很優秀的。

PFADD uv 1 2 3 //1 2 3是活躍用戶
PFCOUNT uv //統計

GEO 是可以應用在地理位置的業務上,比如微信附近的人或者附近的車輛等等,先來看一下如果沒有GEO 這種數據結構,你如何知道你附近的人?首先得上報自己的地理位置信息吧,比如經度 116.397128,緯度 39.916527,此時可以用 string、hash 數據類型存儲,但是如果要查找你附近的人,string 和 hash 這種就無能為例瞭,你不可能每次都要遍歷全部的數據來判斷,這樣太耗時瞭,當然你也不可能通過 zset 這種數據結構來把經緯度信息當成權重,但是如果我們能把經緯度信息通過某種方式轉換成一個數字,然後當成權重好像也可以,這時我們隻需通過zrangebyscore key v1 v2也可以找到附近的人。真的需要這麼麻煩嗎?於是 GEO 出現瞭,GEO 轉換經緯度為數字的方法是“二分區間,區間編碼”,這是什麼意思呢?以經度為例,它的范圍是[-180,180],如果要采用3位編碼值,那麼就是需要二分3次,二分後落在左邊的用0表示,右邊的用1表示,以經度是121.48941 來說,第一次是在[0,180]這個區間,因此記1,第二次是在[90,180],因此再記1,第三次是在[90,135],因此記0。緯度也是同樣的邏輯,假設此時對應的緯度編碼後是010,最後把經緯度合並在一起,需要註意的是經度的每個值在偶數位,緯度的每個值在奇數位。

1 1 0   //經度
 0 1 0  //緯度
------------
101100 //經緯度對應的數值

原理是這樣,我們再來看看 redis 如何使用 GEO:

GEOADD location 112.123456 41.112345 99 //上報用戶99的地理位置信息
GEORADIUS location  112.123456 41.112345 1 km ASC COUNT 10 //獲取附近1KM的人

搞懂集群

生產環境用單實例 redis 的應該比較少,單實例的風險在於:

  1. 單點故障即服務故障,沒有backup
  2. 單實例壓力大,又要提供讀,又要提供寫

於是我們首先想到的就是經典的主從模式,而且往往是一主多從,這是因為大部分應用都是讀多寫少的情況,我們的主負責更新,從負責提供讀,就算我們的主宕機瞭,我們也可以選擇一個從來充當主,這樣整個應用依然可以提供服務。

復制過程的細節

當一個 redis 實例首次成為某個主的從的時候,這時主得把數據發給它,也就是 rdb 文件,這個過程 master 是要 fork 一個子進程來處理的,這個子進程會執行 bgsave 把當前的數據重新保存一下,然後準備發給新來的從,bgsave 的本質是讀取當前內存中的數據然後保存到 rdb 文件中,這個過程涉及大量的 IO,如果直接在主進程中來處理的話,大概率會阻塞正常的請求,因此使用個子進程是個明智的選擇。

那 fork 的子進程在 bgsave 過程中如果有新的變更請求會怎麼辦?

嚴格來說子進程出來的一瞬間,要保存的數據應該就是當時那個點的快照數據,所以是直接把當時的內存再復制一份嗎?不復制的話,如果這期間又有變更改怎麼辦?其實這要說到寫實復制(COW)機制,首先從表象上來看內存是一整塊空間,其實這不太好維護,因此操作系統會把內存分成一小塊一小塊的,也就是內存分頁管理,一頁的大小一般是4K、8K或者16K等等,redis 的數據都是分佈在這些頁面上的,出於效率問題,fork 出來的子進程是和主進程是共享同一塊的內存的,並不會復制內存,如果這期間主進程有數據變更,那麼為瞭區分,這時最快捷的做法就是把對應的數據頁重新復制一下,然後主的變更就在這個新的數據頁上修改,並不會修改來的數據頁,這樣就保證瞭子進程處理的還是當時的快照。

以上說的變更是從快照的角度來考慮的,如果從數據的一致性來說,當快照的 rdb 被從庫應用之後,這期間的變更該如何同步給從庫?答案是緩沖區,這個緩沖區叫做 replication buffer,主庫在收到需要同步的命令之後,會把期間的變更都先保存在這個緩沖區中,這樣在把 rdb 發給從庫之後,緊接著會再把 replication buffer 的數據也發給從庫,最終主從就保持瞭一致。

replication buffer不是萬能的補給劑

我們來看看 replication buffer 持續寫入的時間有多長。

  1. 我們知道主從同步的時候,主庫會執行 fork 來讓子進程完成相應地工作,因此子進程從開始執行 bgsave 到執行完畢這期間,變更是要寫入 replication buffer 的。
  2. rdb 生成好之後,需要把它發送給從庫,這個網絡傳輸是不是也需要耗點時間,這期間也是要寫入 replication buffer 的。
  3. 從庫在收到 rdb 之後需要把 rdb 應用到內存裡,這期間從庫是阻塞的,無法提供服務,因此這期間也是要寫入 replication buffer 的。

replication buffer 既然是個 buffer,那麼它的大小就是有限的,如果說上面3個步驟中,隻要有一個耗時長,就會導致 replication buffer 快速增長(前提是有正常的寫入),當 replication buffer 超過瞭限制之後就會導致主庫和從庫之間的連接斷開,斷開之後如果從庫再次連接上來就會導致重新開始復制,然後重復同樣的漫長的復制步驟,因此這個 replication buffer 的大小還是很關鍵的,一般需要根據寫入的速度、每秒寫入的量和網絡傳輸的速度等因素來綜合判斷。

從庫網絡不好和主庫斷瞭該怎麼辦?

正常來說,隻要主從之間的連接建立好瞭,後面主庫的變更可以直接發給從庫,讓從庫直接回放,但是我們並不能保證網絡環境是百分百的通暢的,因此也要考慮從庫和主庫之間的斷聯問題。

應該是在 redis2.8 以前,隻要從庫斷聯,哪怕隻有很短的時間,後面從庫再次連接上來的時候,主庫也會直接無腦的進行全量同步。在 2.8 版本及以後,開始支持增量復制瞭,增量復制的原理就是得有個緩沖區來保存變更的記錄,這裡這個緩沖區叫做repl_backlog_buffer,這個緩沖區從邏輯上來說是個環形緩沖區,寫滿瞭就會從頭開始覆蓋,所以也有大小限制。在從庫重新連接上來的時候,從庫會告訴主庫:“我當前已經復制到瞭xx位置”,主庫收到從庫的消息之後開始查看xx位置的數據是否還在 repl_backlog_buffer 中,如果在的話,直接把xx後面的數據發給從庫即可,如果不在的話,那無能為力瞭,隻能再次進行全量同步。

需要一個管理者

在主從模式下,如果主庫掛瞭,我們可以把一個從庫升級成主庫,但是這個過程是手動的,靠人力來操作,不能使損失降到最低,還是需要一套自動管理和選舉的機制,這就是哨兵,哨兵它本身也是個服務,隻不過它不處理數據的讀寫而已,它隻負責管理所有的 redis 實例,哨兵每隔一段時間會和各個 redis 通信(ping 操作),每個 redis 實例隻要在規定的時間內及時回復,就可以表明自己的立場。當然哨兵本身也可能存在宕機或者網絡不通的情況,因此一般哨兵也會搭建個哨兵集群,這個集群的個數最好是奇數,比如3個或者5這個這種,奇數的目的主要就是為瞭選舉(少數服從多數)。

當某個哨兵在發起 ping 後沒有及時收到 pong,那麼就會把這個 redis 實例標記下線,此時它還是不是真正的下線,這時其他的哨兵也會判定當前這個哨兵是不是真正的下線,當大多數哨兵都認定這個 redis 是下線狀態,那麼就會把它從集群中踢出去,如果下線的是從庫,那麼還好,直接踢出去就ok,如果是主庫還要觸發選舉,選舉也不是盲目選舉,肯定是要選出最合適的那個從來充當新的主庫。這個最合適充當主庫的庫,一般會按照以下優先級來確定:

  1. 權重,每個從庫其實都可以設置一個權重,權重越高的從庫會被優先選擇
  2. 復制的進度,每個從庫復制的進度可能是不一樣的,優先選擇當前和主庫數據差距最小的那個
  3. 服務的 ID,其實每個 redis 實例都有自己的 ID,如果以上條件都一樣,那麼會選擇 ID 最小的那個庫來充當主庫

更強的橫向伸縮性

主從模式解決瞭單點故障問題,同時讀寫分離技術使得應用支撐能力更強,哨兵模式可以自動監管集群,實現自動選主,自動剔除故障節點的能力。

正常來說隻要讀的壓力越來越大,我們可以添加從庫來緩解,那如果主庫壓力很大怎麼辦?這就得提到接下來要說的分片技術瞭,我們隻需要把主庫切成幾片,部署到不同的機器上即可。這個分片就是 redis 中的槽概念瞭,當分片的時候,redis 會默認分成 0~16383 也就是一共 16384 個槽,然後把這些槽平均分到每個分片節點上就可以起到負載均衡的作用瞭。每個 key 具體該分到哪個槽中,主要是先 CRC16 得到一個 16bit 的數字,然後這個數字再對 16384 取模即可:

crc16(key)%16384

然後客戶端會緩存槽信息,這樣每當一個 key 到來時,隻要通過計算就知道該發給哪個實例來處理來瞭。但是客戶端緩存的槽信息並不是一成不變的,比如在增加實例的時候,這時候會導致重新分片,那麼原來客戶端緩存的信息就會不準確,一般這時候會發生兩個常見的錯誤,嚴格來說也不是錯誤,更像一種信息,一個叫做MOVED,一個叫做ASK。moved的意思就說,原來是實例A負責的數據,現在被遷移到瞭實例B,MOVED 代表的是遷移完成的,但是 ASK 代表的是正在遷移過程中,比如原來是實例A負責的部分數據,現在被遷移到瞭實例B,剩下的還在等待遷移中,當數據遷移完畢之後 ASK 就會變成 MOVED,然後客戶端收到 MOVED 信息之後就會再次更新下本地緩存,這樣下次就不會出現這兩個錯誤瞭。

總結

到此這篇關於redis奇葩數據類型與集群知識的文章就介紹到這瞭,更多相關redis數據類型與集群內容請搜索WalkonNet以前的文章或繼續瀏覽下面的相關文章希望大傢以後多多支持WalkonNet!

推薦閱讀: