1. 程式人生 > >redis的底層原理總結

redis的底層原理總結

redis單點吞吐量

單點TPS達到8萬/秒,QPS達到10萬/秒。

redis的5中儲存型別

string、list、set、map(hash)、stored-set

redis的string型別

能表達3中型別:字串、整數和浮點數。根據場景相互間自動轉型,並且根據需要選取底層的承載方式
value內部以int、sds作為結構儲存。int存放整型資料,sds存放位元組/字串和浮點型資料
sds內部結構:
用buf陣列儲存字串的內容,但陣列的長度會大於所儲存內容的長度。會有一格專門存放”\0”(C標準庫)作為結尾,還有預留多幾個空的(即free區域),當append字串的長度小於free區域,則sds不會重新申請記憶體,直接使用free區域
擴容:當對字串的操作完成後預期的串長度小於1M時,擴容後的buf陣列大小=預期長度*2+1;若大於1M,則buf總是會預留出1M的free空間
value物件通常具有兩個記憶體部分:redisObject部分和redisObject的ptr指向的sds部分。建立value物件時,通常需要為redisObject和sds申請兩次記憶體。單對於短小的字串,可以把兩者連續存放,所以可以一次性把兩者的記憶體一起申請了
redis的list型別

list型別的value物件內部以linkedlist或ziplist承載。當list的元素個數和單個元素的長度較小時,redis會採用ziplist實現以減少記憶體佔用,否則採用linkedlist結構
linkedlist內部實現是雙向連結串列。在list中定義了頭尾元素指標和列表的長度,是的pop/push操作、llen操作的複雜度為O(1)。由於是連結串列,lindex類的操作複雜度仍然是O(N)
ziplist的內部結構
所有內容被放置在連續的記憶體中。其中zlbytes表示ziplist的總長度,zltail指向最末元素,zllen表示元素個數,entry表示元素自身內容,zlend作為ziplist定界符
rpush、rpop、llen,複雜度為O(1);lpush/pop操作由於涉及全列表元素的移動,複雜度為O(N)
redis的map型別

map又叫hash。map內部的key和value不能再巢狀map了,只能是string型別:整形、浮點型和字串
map主要由hashtable和ziplist兩種承載方式實現,對於資料量較小的map,採用ziplist實現
hashtable內部結構
主要分為三層,自底向上分別是dictEntry、dictht、dict
dictEntry:管理一個key-value對,同時保留同一個桶中相鄰元素的指標,一次維護雜湊桶的內部連
dictht:維護雜湊表的所有桶鏈
dict:當dictht需要擴容/縮容時,用於管理dictht的遷移
雜湊表的核心結構是dictht,它的table欄位維護著hash桶,它是一個數組,每個元素指向桶的第一個元素(dictEntry)
set值的流程:先通過MurmurHash演算法求出key的hash值,再對桶的個數取模,得到key對應的桶,再進入桶中,遍歷全部entry,判定是否已有相同的key,如果沒有,則將新key對應的鍵值對插入到桶頭,並且更新dictht的used數量,used表示hash表中已經存了多少元素。由於每次插入都要遍歷hash桶中的全部entry,所以當桶中entry很多時,效能會線性下降
擴容:通過負載因子判定是否需要增加桶數。負載因子=雜湊表中已有元素/雜湊桶數的比值。有兩個閾值,小於1一定不擴容;大於5一定擴容。擴容時新的桶數目是現有桶的2n倍
縮容:負載因子的閾值是0.1
擴/縮容通過新建雜湊表的方式實現。即擴容時,會並存兩個雜湊表,一個是源表,一個是目標表。通過將源表的桶逐步遷移到目標表,以資料遷移的方式實現擴容,遷移完成後目標表覆蓋源表。遷移過程中,首先訪問源表,如果發現key對應的源表桶已完成遷移,則重新訪問目標表,否則在源表中操作
redis是單執行緒處理請求,遷移和訪問的請求在相同執行緒內進行,所以不會存在併發性問題
ziplist內部結構
和list的ziplist實現類似。不同的是,map對應的ziplist的entry個數總是2的整數倍,奇數存放key,偶數存放value
ziplist實現下,由雜湊遍歷變成了連結串列的順序遍歷,複雜度變成O(N)
redis的set型別

set以intset或hashtable來儲存。hashtable中的value永遠為null,當set中只包含整數型的元素時,則採用intset
intset的內部結構
核心元素是一個位元組陣列,從小到大有序存放著set的元素
由於元素有序排列,所以set的獲取操作採用二分查詢方式實現,複雜度O(log(N))。進行插入時,首先通過二分查詢得到本次插入的位置,再對元素進行擴容,再將預計插入位置之後的所有元素向右移動一個位置,最後插入元素,插入複雜度為O(N)。刪除類似
redis的sorted-set型別

類似map是一個key-value對,但是有序的。value是一個浮點數,稱為score,內部是按照score從小到大排序
內部結構以ziplist或skiplist+hashtable來實現
redis客戶端與伺服器的互動模式

序列的請求/響應模式
每一次請求的傳送都依賴於上一次請求的相應結果完全接收,同一個連線的每秒吞吐量低
redis對單個請求的處理時間通常比區域網的延遲小一個數量級,所以序列模式下,單鏈接的大部分時間都處於網路等待
雙工的請求/相應模式(pipeline)
適用於批量的獨立寫入操作。即可將請求資料批量傳送到伺服器,再批量地從伺服器連線的位元組流中一次讀取每個響應資料,減少了網路延遲,所以單連線吞吐量較序列會提高一個數量級
原子化的批量請求/響應模式(事務)
客戶端通過和redis伺服器兩階段的互動做到批量命令原子執行的事務效果:入隊操作(即伺服器端先將客戶端傳送過來的連線物件暫存在請求佇列中)和執行階段(依次執行請求佇列中的所有請求)
一個連線的請求在執行批量請求的過程中,不會執行其他客戶端的請求
redis的事務不是一致的,沒有回滾機制。如果中途失敗,則返回錯誤資訊,但已經成功執行的命令不會回滾
事務裡面有可能會帶有讀操作作為條件,由於批量請求只會先入佇列,再批量一起執行,所以一般讀操作不會跟批量寫請求一起執行,這時候就有可能會導致批量寫之前和之後讀到的資料不一致,這種可以通過樂觀鎖的可序列化來解決,redis通過watch機制實現樂觀鎖。具體實現過程看下一題
釋出/訂閱模式
釋出端和訂閱者通過channel關聯
channel的訂閱關係,維護在reids例項級別,獨立於redisDB的key-value體系。所有的channel都由一個map維護,鍵是channel的名字,value是它所有訂閱者client的指標連結串列
指令碼化的批量執行(指令碼模式)
redis通過watch機制實現樂觀鎖流程

將本次事務涉及的所有key註冊為觀察模式
執行只讀操作
根據只讀操作的結果組裝寫操作命令併發送到伺服器端入隊
傳送原子化的批量執行命令EXEC試圖執行連線的請求佇列中的命令
如果前面註冊為觀察模式的key中有一個貨多個,在EXEC之前被修改過,則EXEC將直接失敗,拒絕執行;否則順序執行請求佇列中的所有請求
redis沒有原生的悲觀鎖或者快照實現,但可通過樂觀鎖繞過。一旦兩次讀到的操作不一樣,watch機制觸發,拒絕了後續的EXEC執行
redis的網路協議

redis協議位於TCP層之上,即客戶端和redis例項保持雙工的連線,互動的都是序列化後的協議資料

redis處理命令的主要邏輯

redis伺服器對命令的處理都是單執行緒的,但是I/O層面卻面向多個客戶端併發地提供服務,併發到內部單執行緒的轉化通過多路複用框架來實現
首先從多路服用框架(epoll、evport、kqueue)中select出已經ready的檔案描述符(fileDescriptor)
ready的標準是已有資料到達核心(kernel)、已準備好寫入資料
對於上一步已經ready的fd,redis會分別對每個fd上已ready的事件進行處理,處理完相同fd上的所有事件後,再處理下一個ready的fd。有3中事件型別
acceptTcpHandler:連線請求事件
readQueryFromClient:客戶端的請求命令事件
sendReplyToClient:將暫存的執行結果寫回客戶端
對來自客戶端的命令執行結束後,接下來處理定時任務(TimeEvent)
aeApiPoll的等待時間取決於定時任務處理(TimeEvent)邏輯
本次主迴圈完畢,進入下一次主迴圈的beforeSleep邏輯,後者負責處理資料過期、增量持久化的檔案寫入等任務
redis的持久化機制

redis主要提供了兩種持久化機制:RDB和AOF;
RDB
預設開啟,會按照配置的指定時間將記憶體中的資料快照到磁碟中,建立一個dump.rdb檔案,redis啟動時再恢復到記憶體中。
redis會單獨建立fork()一個子程序,將當前父程序的資料庫資料複製到子程序的記憶體中,然後由子程序寫入到臨時檔案中,持久化的過程結束了,再用這個臨時檔案替換上次的快照檔案,然後子程序退出,記憶體釋放。
需要注意的是,每次快照持久化都會將主程序的資料庫資料複製一遍,導致記憶體開銷加倍,若此時記憶體不足,則會阻塞伺服器執行,直到複製結束釋放記憶體;都會將記憶體資料完整寫入磁碟一次,所以如果資料量大的話,而且寫操作頻繁,必然會引起大量的磁碟I/O操作,嚴重影響效能,並且最後一次持久化後的資料可能會丟失;
AOF
以日誌的形式記錄每個寫操作(讀操作不記錄),只需追加檔案但不可以改寫檔案,redis啟動時會根據日誌從頭到尾全部執行一遍以完成資料的恢復工作。包括flushDB也會執行。
主要有兩種方式觸發:有寫操作就寫、每秒定時寫(也會丟資料)。
因為AOF採用追加的方式,所以檔案會越來越大,針對這個問題,新增了重寫機制,就是當日志文件大到一定程度的時候,會fork出一條新程序來遍歷程序記憶體中的資料,每條記錄對應一條set語句,寫到臨時檔案中,然後再替換到舊的日誌檔案(類似rdb的操作方式)。預設觸發是當aof檔案大小是上次重寫後大小的一倍且檔案大於64M時觸發;
當兩種方式同時開啟時,資料恢復redis會優先選擇AOF恢復。一般情況下,只要使用預設開啟的RDB即可,因為相對於AOF,RDB便於進行資料庫備份,並且恢復資料集的速度也要快很多。
開啟持久化快取機制,對效能會有一定的影響,特別是當設定的記憶體滿了的時候,更是下降到幾百reqs/s。所以如果只是用來做快取的話,可以關掉持久化。
redis記憶體分析的設計思路

主要有3種方式可以實現
keys命令:獲取到所有的key,再根據key獲取所有的內容。缺點是如果key數量特別多,則會導致redis卡住影響業務
aof:通過aof檔案獲取到所有資料。缺點是有一些redis例項寫入頻繁,不適合開啟aof,並且檔案可能特別大,傳輸、解析效率差
rdb:使用bgsave獲取rdb檔案,然後解析。缺點是bgsave在fork子程序時有可能會卡住主程序。當對於其他兩種,在低峰期在從節點做bgsave獲取rdb檔案,相對安全可靠。
設計思路:
在訪問低峰期時根據redis獲取rdb檔案
解析rdb檔案
根據相對應的資料結構及內容,估算內容消耗等
統計並生成報表
開源框架:https://github.com/xueqiu/rdr
redis記憶體估算

基礎的資料型別:sds、dict、intset、zipmap、adlist、quicklist、skiplist
舉例:以key為hello,value為world,型別是string,它的記憶體使用:
一個dictEntry的消耗(有2個指標,一個int64的記憶體消耗),RedisDB就是一個大dict,每對kv都是其中的一個entry;
一個robj的消耗(有1指標,一個int,以及幾個使用位域的欄位共消耗4位元組),robj是為了在同一個dict內能夠儲存不同型別的value,而使用的一個通用的資料結構,全名是RedisObject;
儲存key的sds消耗(儲存header以及字串長度+1的空間,header長度根據字串長度不同也會有所不同),sds是Redis中儲存字串使用的資料結構;
儲存過期時間消耗(也是儲存為一個dictEntry,時間戳為int64);
儲存value的sds消耗,根據資料結構不同而不同;
前四項基本是儲存任何一個key都需要消耗的,最後一項根據value的資料結構不同而不同;
redis叢集(redis cluster)

redis3以後,節點之間提供了完整的sharding(分片)、replication(主備感知能力)、failover(故障轉移)的特性
配置一致性:每個節點(Node)內部都儲存了叢集的配置資訊,儲存在clusterState中,通過引入自增的epoch變數來使得叢集配置在各個節點間保持一致
sharding資料分片
將所有資料劃分為16384個分片(slot),每個節點會對應一部分slot,每個key都會根據分佈演算法對映到16384個slot中的一個,分佈演算法為slotId=crc16(key)%16384
當一個client訪問的key不在對應節點的slots中,redis會返回給client一個moved命令,告知其正確的路由資訊從而重新發起請求。client會根據每次請求來快取本地的路由快取資訊,以便下次請求直接能夠路由到正確的節點
分片遷移:分片遷移的觸發和過程控制由外部系統完成,redis只提供遷移過程中需要的原語支援。主要包含兩種:一種是節點遷移狀態設定,即遷移錢標記源、目標節點;另一種是key遷移的原子化命令
failover故障轉移
故障發現:節點間兩兩通過TCP保持連線,週期性進行PING、PONG互動,若對方的PONG相應超時未收到,則將其置為PFAIL狀態,並傳播給其他節點
故障確認:當叢集中有一半以上的節點對某一個PFAIL狀態進行了確認,則將起改為FAIL狀態,確認其故障
slave選舉:當有一個master掛掉了,則其slave重新競選出一個新的master。主要根據各個slave最後一次同步master資訊的時間,越新表示slave的資料越新,競選的優先順序越高,就更有可能選中。競選成功之後將訊息傳播給其他節點。
叢集不可用的情況:
叢集中任意master掛掉,且當前master沒有slave。
叢集中超過半數以上master掛掉。
普通雜湊演算法和一致性雜湊演算法對比

普通雜湊:也稱硬雜湊,採用簡單取模的方式,將機器進行雜湊,這在cache環境不變的情況下能取得讓人滿意的結果,但是當cache環境動態變化時,這種靜態取模的方式顯然就不滿足單調性的要求(當增加或減少一臺機子時,幾乎所有的儲存內容都要被重新雜湊到別的緩衝區中)。
一致性雜湊:將機器節點和key值都按照一樣的hash演算法對映到一個0~232的圓環上。當有一個寫入快取的請求到來時,計算Key值k對應的雜湊值Hash(k),如果該值正好對應之前某個機器節點的Hash值,則直接寫入該機器節點,如果沒有對應的機器節點,則順時針查詢下一個節點,進行寫入,如果超過232還沒找到對應節點,則從0開始查詢(因為是環狀結構)。為了更可能的滿足平衡性,可以引入虛擬節點,即一個實體節點對映到多個虛擬節點。
參考:http://blog.huanghao.me/?p=14
快取雪崩,快取穿透,快取併發,快取預熱,快取演算法

快取雪崩:可能是因為資料未載入到快取中,或者快取同一時間大面積的失效,從而導致所有請求都去查資料庫,導致資料庫CPU和記憶體負載過高,甚至宕機。解決思路:
加鎖計數(即限制併發的數量,可以用semphore)或者起一定數量的佇列來避免快取失效時大量請求併發到資料庫。但這種方式會降低吞吐量。
分析使用者行為,然後失效時間均勻分佈。或者在失效時間的基礎上再加1~5分鐘的隨機數。
如果是某臺快取伺服器宕機,則考慮做主備。
快取穿透:指使用者查詢資料,在資料庫沒有,自然在快取中也不會有。這樣就導致使用者查詢的時候,在快取中找不到,每次都要去資料庫中查詢。解決思路:
如果查詢資料庫也為空,直接設定一個預設值存放到快取,這樣第二次到緩衝中獲取就有值了,而不會繼續訪問資料庫。設定一個過期時間或者當有值的時候將快取中的值替換掉即可。
可以給key設定一些格式規則,然後查詢之前先過濾掉不符合規則的Key。
快取併發:如果網站併發訪問高,一個快取如果失效,可能出現多個程序同時查詢DB,同時設定快取的情況,如果併發確實很大,這也可能造成DB壓力過大,還有快取頻繁更新的問題。解決思路:
對快取查詢加鎖,如果KEY不存在,就加鎖,然後查DB入快取,然後解鎖;其他程序如果發現有鎖就等待,然後等解鎖後返回資料或者進入DB查詢。
快取預熱:目的就是在系統上線前,將資料載入到快取中。解決思路:
資料量不大的話,在系統啟動的時候直接載入。
自己寫個簡單的快取預熱程式。
快取演算法:
FIFO演算法:First in First out,先進先出。原則:一個數據最先進入快取中,則應該最早淘汰掉。也就是說,當快取滿的時候,應當把最先進入快取的資料給淘汰掉。
LFU演算法:Least Frequently Used,最不經常使用演算法。
LRU演算法:Least Recently Used,近期最少使用演算法。
LRU和LFU的區別。LFU演算法是根據在一段時間裡資料項被使用的次數選擇出最少使用的資料項,即根據使用次數的差異來決定。而LRU是根據使用時間的差異來決定的。
用redis實現分散式鎖

主要使用的命令:
setnx key val。當且僅當key不存在時,set一個key為val的字串,返回1;若key存在,則什麼都不做,返回0。
expire key timeout。為key設定一個超時時間,單位為second,超過這個時間鎖會自動釋放,避免死鎖。
delete key。刪除鎖
實現思想:
使用setnx加鎖,如果返回1,則說明加鎖成功,並設定超時時間,避免系統掛了,鎖沒法釋放。在finally中delete刪除鎖釋放。
如果需要設定超時等待時間,則可以加個while迴圈,在獲取不到鎖的情況下,進行迴圈獲取鎖,超時了則退出。