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文件
- 根據相對應的數據結構及內容,估算內容消耗等
- 統計並生成報表
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~2^32的圓環上。當有一個寫入緩存的請求到來時,計算Key值k對應的哈希值Hash(k),如果該值正好對應之前某個機器節點的Hash值,則直接寫入該機器節點,如果沒有對應的機器節點,則順時針查找下一個節點,進行寫入,如果超過2^32還沒找到對應節點,則從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循環,在獲取不到鎖的情況下,進行循環獲取鎖,超時了則退出。