Redis系列之基礎篇【上】

Redis系列之基礎篇【上】

一. Redis單線程模型原理分析

在這裏插入圖片描述
Redis(全稱:Remote Dictionary Server 遠程字典服務)是一個開源的使用ANSI C語言編寫、支持網絡、可基於內存亦可持久化的日誌型、Key-Value數據庫,並提供多種語言的API。要明白Redis是單線程的,但是爲什麼單線程還可以效率這麼高並且支撐高併發呢?
如果想搞明白上面那個問題就要對上圖有一定理解,現在我就開始講講上圖的一些基本原理

  1. 首次連接,客戶端與服務端進行通信是通過Socket的,用戶請求與Redis的server socket 建立連接,當Redis的server socket收到連接請求時會產生一個AE_READABLE事件,Redis線程模型中的IO多路複用組件會監聽server socket產生的事件,將監聽到的事件壓入到隊列中,此時文件事件分派器得知隊列中有事件後會根據事件連接到對應的事件處理器即連接應答處理器中進行處理,將socket 01的AE_READABLE和命令請求處理器關聯。
  2. 由於上一步已經進行了連接,所以會維護一個socket01與客戶端進行會話保持。當用戶發送一個set key value 請求時,會產生一個AE_READABLE事件,Redis線程模型中的IO多路複用組件會監聽server socket產生的事件,將監聽到的事件壓入到隊列中,此時文件事件分派器得知隊列中有事件後會根據事件連接到對應的事件處理器即命令請求處理器進行處理,在socket 01中讀取出來的key和value,在自己的內存中完成keyhevalue的設置,並將socket 01的AE_WRITABLE和命令回覆處理器關聯。
  3. 當用戶準備好去讀取時,Redis的server socket會產生一個AE_WRITABLE事件,Redis線程模型中的IO多路複用組件會監聽server socket產生的事件,將監聽到的事件壓入到隊列中,此時文件事件分派器得知隊列中有事件後會根據事件連接到對應的事件處理器即命令回覆處理器進行處理,對socket 01輸出本次操作的一個結果:ok,並將socket 01的AE_WRITABLE和命令回覆處理器解除關聯。
  4. 最後將結果返回給用戶

上圖的流程大體介紹到這裏,現在我們來回答上面的那個問題,要真正搞懂上面的那個問題還需要理解一個概念:IO多路複用機制
IO多路複用機制可分爲四種:

  • 同步阻塞IO(Blocking IO):即傳統的IO模型。
  • 同步非阻塞IO(Non-blocking IO):默認創建的socket都是阻塞的,非阻塞IO要求socket被設置爲NONBLOCK。注意這裏所說的NIO並非Java的NIO(New IO)庫。
  • IO多路複用(IO Multiplexing):即經典的Reactor設計模式,有時也稱爲異步阻塞IO,Java中的Selector和Linux中的epoll都是這種模型。
  • 異步IO(Asynchronous IO):即經典的Proactor設計模式,也稱爲異步非阻塞IO。

現在是不是會有另外一個疑問什麼是同步,什麼是異步,什麼是阻塞,什麼是非阻塞
同步和異步的概念描述的是用戶線程與內核的交互方式:同步是指用戶線程發起IO請求後需要等待或者輪詢內核IO操作完成後才能繼續執行;而異步是指用戶線程發起IO請求後仍繼續執行,當內核IO操作完成後會通知用戶線程,或者調用用戶線程註冊的回調函數。
阻塞和非阻塞的概念描述的是用戶線程調用內核IO操作的方式:阻塞是指IO操作需要徹底完成後才返回到用戶空間;而非阻塞是指IO操作被調用後立即返回給用戶一個狀態值,無需等到IO操作徹底完成。

Redis的IO多路複用採用的是同步非阻塞(NIO),所以它可以輪詢多用戶產生的事件,並且它是非阻塞,並不會等待一個IO操作徹底完成才返回到用戶空間,它只是將用戶產生的事件壓入到隊列中,又因爲Redis是基於內存的單線程緩存,所以它不會像多線程一樣頻繁的切換上下文
解答上文問題:

  • 純內存操作
  • 核心是基於非阻塞的IO多路複用機制
  • 避免了頻繁的上下文切換

二. Redis的數據類型及內部數據結構解析

1.String

基本介紹
字符串類型是redis中最基本的數據類型,它能存儲任何形式的字符串,包括二進制數據。你可以用它存儲用戶的
郵箱、json化的對象甚至是圖片。一個字符類型鍵允許存儲的最大容量是512M
常用命令
set key value 設置key value
get key 查看當前key的值
del key 刪除key
append key value 如果key存在,則在指定的key末尾添加,如果key存在則類似set
strlen key 返回此key的長度

getrange key 0(開始位置) -1(結束位置) 獲取指定區間範圍內的值,類似between…and的關係 (0 -1)表示全部
setrange key 1(開始位置,從哪裏開始設置) 具體值 設置(替換)指定區間範圍內的值
setex 鍵 秒值 真實值 設置帶過期時間的key,動態設置。
setnx key value 只有在 key 不存在時設置 key 的值。
mset key1 value key2 value 同時設置一個或多個 key-value 對。
mget key1 key 2 獲取所有(一個或多個)給定 key 的值。
msetnx key1 value key2 value 同時設置一個或多個 key-value 對,當且僅當所有給定 key 都不存在。
getset key value 將給定 key 的值設爲 value ,並返回 key 的舊值(old value)。

以下幾個命令只有在key值爲數字的時候才能正常操作
incr key 爲執定key的值加一
decr key 爲指定key的值減一
incrby key 數值 爲指定key的值增加數值
decrby key 數值 爲指定key的值減數值

使用場景

  1. 計數器
      string類型的incr和decr命令的作用是將key中儲存的數字值加一/減一,這兩個操作具有原子性,總能安全地進行加減操作,因此可以用string類型進行計數,如微博的評論數、點贊數、分享數,抖音作品的收藏數,京東商品的銷售量、評價數等。
  2. 分佈式鎖
      string類型的setnx的作用是“當key不存在時,設值並返回1,當key已經存在時,不設值並返回0”,“判斷key是否存在”和“設值”兩個操作是原子性地執行的,因此可以用string類型作爲分佈式鎖,返回1表示獲得鎖,返回0表示沒有獲得鎖。例如,爲了保證定時任務的高可用,往往會同時部署多個具備相同定時任務的服務,但是業務上只希望其中的某一臺服務執行定時任務,當定時任務的時間點觸發時,多個服務同時競爭一個分佈式鎖,獲取到鎖的執行定時任務,沒獲取到的放棄執行定時任務。定時任務執行完時通過del命令刪除key即釋放鎖,如果擔心del命令操作失敗而導致鎖一直未釋放,可以通過expire命令給鎖設置一個合理的自動過期時間,確保即使del命令失敗,鎖也能被釋放。不過expire命令同樣存在失敗的可能性,如果你用的是Java語言,建議使用JedisCommands接口提供的String set(String key, String value, String nxxx, String expx, long time)方法,這個方法可以將setnx和expire原子性地執行,具體使用方式如下(相信其它語言的Redis客戶端也應當提供了類似的方法)。
jedisCommands.set("IAmAKey", "1", "NX", "EX", 60);//如果"IAmAKey"不存在,則將其設值爲1,同時設置60秒的自動過期時間
  1. 存儲對象
      利用JSON強大的兼容性、可讀性和易用性,將對象轉換爲JSON字符串,再存儲在string類型中,是個不錯的選擇,如用戶信息、商品信息等。

  2. 共享session
    出於負載均衡的考慮,分佈式服務會將用戶信息的訪問均衡到不同服務器上,用戶刷新一次訪問可能會需要重新登錄,爲避免這個問題可以用redis將用戶session集中管理,在這種模式下只要保證redis的高可用和擴展性的,每次獲取用戶更新或查詢登錄信息都直接從redis中集中獲取。

  3. 限速
    處於安全考慮,每次進行登錄時讓用戶輸入手機驗證碼,爲了短信接口不被頻繁訪問,會限制用戶每分鐘獲取驗證碼的頻率。

內部數據結構

在Redis內部,String類型通過 int、SDS(simple dynamic string)作爲結構存儲,int用來存放整型數據,sds存放字
節/字符串和浮點型數據。在C的標準字符串結構下進行了封裝,用來提升基本操作的性能,同時也充分利用已有的
C的標準庫,簡化實現邏輯。我們可以在redis的源碼中【sds.h】中看到sds的結構如下;
typedef char *sds;
redis3.2分支引入了五種sdshdr類型,目的是爲了滿足不同長度字符串可以使用不同大小的Header,從而節省內
存,每次在創建一個sds時根據sds的實際長度判斷應該選擇什麼類型的sdshdr,不同類型的sdshdr佔用的內存空
間不同。這樣細分一下可以省去很多不必要的內存開銷,下面是3.2的sdshdr定義

`struct __attribute__ ((__packed__)) sdshdr8 {8表示字符串最大長度是2^8-1 (長度爲255)`` uint8_t len;
//表示當前sds的長度(單位是字節)`` uint8_t alloc; 
//表示已爲sds分配的內存大小(單 位是字節)`` unsigned char flags; 
//用一個字節表示當前sdshdr的類型,因爲有sdshdr有五種類型,所 以至少需要3位來表示000:sdshdr5,001:sdshdr8,010:sdshdr16,011:sdshdr32,100:sdshdr64。高5位 用不到所以都爲0。`` char buf[];
//sds實際存放的位置``
};`

在這裏插入圖片描述

2.List

基本介紹
列表類型(list)可以存儲一個有序的字符串列表,常用的操作是向列表兩端添加元素或者獲得列表的某一個片段。
列表類型內部使用雙向鏈表實現,所以向列表兩端添加元素的時間複雜度爲O(1), 獲取越接近兩端的元素速度就越
快。一個列表最多可以儲存2的32次方-1個元素,這意味着即使是一個有幾千萬個元素的列表,獲取頭部或尾部的
10條記錄也是很快的

常用命令
lpush key value1 value2 將一個或多個值加入到列表頭部

rpush key value1 value2 將一個或多個值加入到列表底部

lrange key start end 獲取列表指定範圍的元素 (0 -1)表示全部

lpop key 移出並獲取列表第一個元素

rpop key 移出並獲取列表最後一個元素

lindex key index 通過索引獲取列表中的元素

llen 獲取列表長度

lrem key 0(數量) 值,表示刪除全部給定的值。零個就是全部值 從left往right刪除指定數量個值等於指定值的元素,返回的值爲實際刪除的數量

ltrim key start(從哪裏開始截) end(結束位置) 截取指定索引區間的元素,格式是ltrim list的key 起始索引 結束索引

使用場景

  1. 消息隊列
    list類型的lpop和rpush(或者反過來,lpush和rpop)能實現隊列的功能,故而可以用Redis的list類型實現簡單的點對點的消息隊列。不過我不推薦在實戰中這麼使用,因爲現在已經有Kafka、NSQ、RabbitMQ等成熟的消息隊列了,它們的功能已經很完善了,除非是爲了更深入地理解消息隊列,不然我覺得沒必要去重複造輪子。
  2. 文章列表
    每個用戶都有屬於自己的文章列表,現在需要分頁展示文章列表,此時可以考慮使用列表,列表不但有序,同時支持按照索引範圍獲取元素。
  3. 列表技巧
  • lpush+lpop=Stack(棧)
  • lpush+rpop=Queue(隊列)
  • lpush+ltrim=Capped Collection(有限集合)
  • lpush+brpop=Message Queue(消息隊列)

內部數據結構
redis3.2之前,List類型的value對象內部以linkedlist或者ziplist來實現, 當list的元素個數和單個元素的長度比較小
的時候,Redis會採用ziplist(壓縮列表)來實現來減少內存佔用。否則就會採用linkedlist(雙向鏈表)結構。
redis3.2之後,採用的一種叫quicklist的數據結構來存儲list,列表的底層都由quicklist實現。
這兩種存儲方式都有優缺點,雙向鏈表在鏈表兩端進行push和pop操作,在插入節點上複雜度比較低,但是內存開
銷比較大; ziplist存儲在一段連續的內存上,所以存儲效率很高,但是插入和刪除都需要頻繁申請和釋放內存;
quicklist仍然是一個雙向鏈表,只是列表的每個節點都是一個ziplist,其實就是linkedlist和ziplist的結合,quicklist
中每個節點ziplist都能夠存儲多個數據元素,在源碼中的文件爲【quicklist.c】,在源碼第一行中有解釋爲:A
doubly linked list of ziplists意思爲一個由ziplist組成的雙向鏈表;
在這裏插入圖片描述

3.Set

基本介紹
集合類型中,每個元素都是不同的,也就是不能有重複數據,同時集合類型中的數據是無序的。一個集合類型鍵可
以存儲至多232-1個 。集合類型和列表類型的最大的區別是有序性和唯一性
集合類型的常用操作是向集合中加入或刪除元素、判斷某個元素是否存在。由於集合類型在redis內部是使用的值
爲空的散列表(hash table),所以這些操作的時間複雜度都是O(1)
常用命令
sadd key value1 value 2 向集合中添加一個或多個成員

smembers key 返回集合中所有成員

sismembers key member 判斷member元素是否是集合key的成員

scard key 獲取集合裏面的元素個數

srem key value 刪除集合中指定元素

srandmember key 數值 從set集合裏面隨機取出指定數值個元素 如果超過最大數量就全部取出,

spop key 隨機移出並返回集合中某個元素

smove key1 key2 value(key1中某個值) 作用是將key1中執定的值移除 加入到key2集合中

sdiff key1 key2 在第一個set裏面而不在後面任何一個set裏面的項(差集)

sinter key1 key2 在第一個set和第二個set中都有的 (交集)

sunion key1 key2 兩個集合所有元素(並集)

使用場景

  1. 好友/關注/粉絲/感興趣的人集合
      set類型唯一的特點使得其適合用於存儲好友/關注/粉絲/感興趣的人集合,集合中的元素數量可能很多,每次全部取出來成本不小,set類型提供了一些很實用的命令用於直接操作這些集合,如
        a. sinter命令可以獲得A和B兩個用戶的共同好友
       b. sismember命令可以判斷A是否是B的好友
      c. scard命令可以獲取好友數量
      c. 關注時,smove命令可以將B從A的粉絲集合轉移到A的好友集合
      
      需要注意的是,如果你用的是Redis Cluster集羣,對於sinter、smove這種操作多個key的命令,要求這兩個key必須存儲在同一個slot(槽位)中,否則會報出 (error) CROSSSLOT Keys in request don’t hash to the same slot 錯誤。Redis Cluster一共有16384個slot,每個key都是通過哈希算法CRC16(key)獲取數值哈希,再模16384來定位slot的。要使得兩個key處於同一slot,除了兩個key一模一樣,還有沒有別的方法呢?答案是肯定的,Redis提供了一種Hash Tag的功能,在key中使用{}括起key中的一部分,在進行 CRC16(key) mod 16384 的過程中,只會對{}內的字符串計算,例如friend_set:{123456}和fans_set:{123456},分別表示用戶123456的好友集合和粉絲集合,在定位slot時,只對{}內的123456進行計算,所以這兩個集合肯定是在同一個slot內的,當用戶123456關注某個粉絲時,就可以通過smove命令將這個粉絲從用戶123456的粉絲集合移動到好友集合。相比於通過srem命令先將這個粉絲從粉絲集合中刪除,再通過sadd命令將這個粉絲加到好友集合,smove命令的優勢是它是原子性的,不會出現這個粉絲從粉絲集合中被刪除,卻沒有加到好友集合的情況。然而,對於通過sinter獲取共同好友而言,Hash Tag則無能爲力,例如,要用sinter去獲取用戶123456和456789兩個用戶的共同好友,除非我們將key定義爲{friend_set}:123456和{friend_set}:456789,否則不能保證兩個key會處於同一個slot,但是如果真這樣做的話,所有用戶的好友集合都會堆積在同一個slot中,數據分佈會嚴重不均勻,不可取,所以,在實戰中使用Redis Cluster時,sinter這個命令其實是不適合作用於兩個不同用戶對應的集合的(同理其它操作多個key的命令)。

  2. 黑名單/白名單
      經常有業務出於安全性方面的考慮,需要設置用戶黑名單、ip黑名單、設備黑名單等,set類型適合存儲這些黑名單數據,sismember命令可用於判斷用戶、ip、設備是否處於黑名單之中。

內部數據結構
Set在的底層數據結構以intset或者hashtable來存儲。當set中只包含整數型的元素時,採用intset來存儲,否則,
採用hashtable存儲,但是對於set來說,該hashtable的value值用於爲NULL。通過key來存儲元素

4.Zset

基本介紹
有序集合類型,顧名思義,和前面講的集合類型的區別就是多了有序的功能
在集合類型的基礎上,有序集合類型爲集合中的每個元素都關聯了一個分數,這使得我們不僅可以完成插入、刪除
和判斷元素是否存在等集合類型支持的操作,還能獲得分數最高(或最低)的前N個元素、獲得指定分數範圍內的元
素等與分數有關的操作。雖然集合中每個元素都是不同的,但是他們的分數卻可以相同

常用命令
zadd key score 值 score 值 向集合中添加一個或多個成員

zrange key 0 -1 表示所有 返回指定集合中所有value

zrange key 0 -1 withscores 返回指定集合中所有value和score

zrangebyscore key 開始score 結束score 返回指定score間的值

zrem key score某個對應值(value),可以是多個值 刪除元素

zcard key 獲取集合中元素個數

zcount key 開始score 結束score 獲取分數區間內元素個數

zrank key vlaue 獲取value在zset中的下標位置(根據score排序)

zscore key value 按照值獲得對應的分數

使用場景

  1. 排行榜

內部數據結構
zset類型的數據結構就比較複雜一點,內部是以ziplist或者skiplist+hashtable來實現,這裏面最核心的一個結構就
是skiplist,也就是跳躍表
在這裏插入圖片描述

5.Hash

基本介紹
常用命令
使用場景

  1. 購物車
    以用戶id爲key,商品id爲field,商品數量爲value,恰好構成了購物車的3個要素,如下圖所示。
    在這裏插入圖片描述
  2. 存儲對象
    hash類型的(key, field, value)的結構與對象的(對象id, 屬性, 值)的結構相似,也可以用來存儲對象。在介紹string類型的應用場景時有所介紹,string + json也是存儲對象的一種方式,那麼存儲對象時,到底用string + json還是用hash呢?兩種存儲方式的對比如下圖表所示。
    在這裏插入圖片描述
    當對象的某個屬性需要頻繁修改時,不適合用string+json,因爲它不夠靈活,每次修改都需要重新將整個對象序列化並賦值,如果使用hash類型,則可以針對某個屬性單獨修改,沒有序列化,也不需要修改整個對象。比如,商品的價格、銷量、關注數、評價數等可能經常發生變化的屬性,就適合存儲在hash類型裏。
      當然,不常變化的屬性存儲在hash類型裏也沒有問題,比如商品名稱、商品描述、上市日期等。但是,當對象的某個屬性不是基本類型或字符串時,使用hash類型就必須手動進行復雜序列化,比如,商品的標籤是一個標籤對象的列表,商品可領取的優惠券是一個優惠券對象的列表(如下圖所示)等,即使以coupons(優惠券)作爲field,value想存儲優惠券對象列表也還是要使用json來序列化,這樣的話序列化工作就太繁瑣了,不如直接用string + json的方式存儲商品信息來的簡單。
      
    內部數據結構
    map提供兩種結構來存儲,一種是hashtable、另一種是前面講的ziplist,數據量小的時候用ziplist. 在redis中,哈
    希表分爲三層,分別是,源碼地址【dict.h】
    dictEntry
    管理一個key-value,同時保留同一個桶中相鄰元素的指針,用來維護哈希桶的內部鏈;
typedef struct dictEntry {
 void *key; 
 union { //因爲value有多種類型,所以value用了union來存儲
 void *val;
 uint64_t u64;
 int64_t s64;
 double d; } v; 
 struct dictEntry *next;//下一個節點的地址,用來處理碰撞,所有分配到同一索引的元素通過next指針 鏈接起來形成鏈表key和v都可以保存多種類型的數據 } dictEntry;

dictht
實現一個hash表會使用一個buckets存放dictEntry的地址,一般情況下通過hash(key)%len得到的值就是buckets的
索引,這個值決定了我們要將此dictEntry節點放入buckets的哪個索引裏,這個buckets實際上就是我們說的hash
表。dict.h的dictht結構中table存放的就是buckets的地址

typedef struct dictht {
 dictEntry **table;//buckets的地址 
 unsigned long size;//buckets的大小,總保持爲 2^n 
 unsigned long sizemask;//掩碼,用來計算hash值對應的buckets索引 
 unsigned long used;//當前dictht有多少個dictEntry節點 
 } dictht;

dict
dictht實際上就是hash表的核心,但是隻有一個dictht還不夠,比如rehash、遍歷hash等操作,所以redis定義了
一個叫dict的結構以支持字典的各種操作,當dictht需要擴容/縮容時,用來管理dictht的遷移,以下是它的數據結
構,源碼在

typedef struct dict { 
dictType *type;
//dictType裏存放的是一堆工具函數的函數指針,
 void *privdata;//保存type中的某些函數需要作爲參數的數據 
 dictht ht[2];//兩個dictht,ht[0]平時用,ht[1] rehash時用 
 long rehashidx; //當前rehash到buckets的哪個索引,-1時表示非rehash狀態 
 int iterators; //安全迭代器的計數。 
 } dict;

比如我們要講一個數據存儲到hash表中,那麼會先通過murmur計算key對應的hashcode,然後根據hashcode取
模得到bucket的位置,再插入到鏈表中

注:上文對於底層的數據結構進行了部分展示,如果讀者想要深入研究可以去看Redis中對應的源碼

發佈了25 篇原創文章 · 獲贊 30 · 訪問量 1149
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章