Redis之資料結構原理
一、Redis是單執行緒的嗎?(面試題)
1.Redis是單執行緒的,Redis是指處理使用者請求的執行緒是單執行緒,請求過程:獲取 (socket 讀)、解析、執行、內容返回 (socket 寫)。
2.Redis還有後臺任務執行緒,例如定時刪除過期key執行緒、AOF持久化策略刷盤、非同步刪除大key(unlink命令)的記憶體清理等。
3.多個db之間也是共享的單執行緒(db之間是相互影響的)。
二、Redis的資料結構
String
String 是 Redis 的基礎資料型別,redis 沒有 int Float ,Boolean 等資料型別的概念,所有的基本型別在Redis 都可以通過String 體現
String的 常用命令
set
為一個 key 設定 value,可以配合 ex/px 引數指定key 的有效期,通過nx/xx 引數對key 是否存在的情況進行區別操作,時間複雜度為 O(1)get
獲取 key 對應的 value 時間複雜度為 O(1)getset
為一個key 設定value 並返回key 的原 value ,時間複雜度為 O(1)mset
為 多個 key 設定 value ,時間複雜度為 O(n)msetnx
同 mset ,如果指定的key 中有任意一個已經存在,則不進行任何操作,時間複雜度為 O(n)
List
redis 的 list 是連結串列型的資料結構,可以使用 lpush、rpush、lpop、rpop 等命令在List 的兩端執行插入和移除元素操作, List 而已支援在特定的 index 上插入和讀取元素的功能,但是其時間複雜度就比較高裡的,謹慎使用。
lpush
向指定 List 的左側(也就是頭部)插入一個或者多個元素,返回插入一戶list 的長度,時間複雜度為 O(n),n 為插入元素的數量rpush
向 List 的的右側,也就是尾部,插入一個或者多個元素lpop
是從 List 的頭部移除一個元素,就是取最後面新增的元素,並返回該元素,時間複雜度為 O(1)rpop
從 List 的尾部移除一個元素並返回lpushx
、rpushx
和lpush rpush
類似,區別就是,如果 操作的 key 不存在,則不會進行任務號的操作llen
是返回 指定list 的長度,時間複雜度為 O(1)lrange
返回指定 list 中 指定範圍的元素,時間複雜度為 O(n), 這個命令要控制獲取 list 元素的個數,太大會導致 redis 延遲響應,另外對於不可預知長度的list 禁止 lrange key 0 -1 這樣的遍歷操作。- 注意謹慎使用 命令
lindex
返回指定的下標的元素,如果下標越界 返回 nil, index 數值是迴環的,也就是說 -1 代表最後一個位置,-2 代表倒數第二個位置,該命令的時間複雜度為 O(n) ;lset
命令將指定 list 指定 index 的元素設定為 value ,如果下標越界,則返回錯誤,其時間複雜度為 O(n),操作 首尾的話 複雜度為 O(1);linsert
向指定 List 中指定元素之前或者之後新增一個新元素,並返回操作後的list長度,如果指定的元素不存在,返回 -1,如果指定的 key 不存在,則不進行任何操作 ,時間複雜度也為 O(n)
Hash
雜湊表,redis 的hash 和傳統的 hash 一樣,是一種 field-value 的資料結構,Hash 適合用於表現物件型別的資料, hash 中的 field 對應 物件的 field 即可。
Hash 的優點包括
- 可以實現二元查詢
- 比起將整個物件序列化後作為String 儲存,hash 能夠有效的減少網路傳輸的消耗
- 當使用 hash 維護一個集合的時候,提供了比 List 效率更高的隨機訪問命令
與Hash 相關常用的命令
hset
將key 對應的 Hash 中的 field 設定為 value ,如果該 hash 不存在,則會自動建立一個 時間複雜度為 O(1)hget
返回指定Hash 中 field 欄位的值,時間複雜度為 O(1)hmset/hmget
和 hset 和 hget 類似,可以批量操作同一個key 下的多個 field,jedis 中你可以 使用一個key 儲存一個 map物件進去,其時間複雜度是 O(n),n為一次操作的 field 的數量hsetnx
通hset
如果 field 存在,則不會進行任何的操作,O(1) 複雜度hexists
是否存在 field,存在返回 -1 ,不存在返回0 O(1)複雜度hdel
刪除指定 hash 中的 field (一個或者 多個),時間複雜度為O(n),n為操作的 field 數量hincrby
同incrby
,對指定Hash 中的 field 進行incrby
操作,O(1) 複雜度需要謹慎使用的命令
hgetall
返回指定hash中所有的 field-value,返回結果是陣列,陣列中field 和value 交替出現,O(n) 複雜度hkeys/hvals
返回指定 Hash 中 所有的 field/value O(n) 複雜度。
上面 3 個命令都會將 hahs 整個遍歷,儘量使用hscan
進行遊標式遍歷。
Set
redis set 是無序的,不可重複的String 集合
與 set 相關常用命令
- sadd 向指定的 set 中新增一個或者多個 member ,如果指定的set 不存在,自會自動建立一個,時間複雜度為 O(n),n 為新增的個數
- srem 從指定的 set 移除 1 個或者多個 member,時間複雜度為O(n), n為返回的member個數
- srandmember 從指定的set 隨機返回 1 個或者多個 member ,時間複雜度為 O(n), n為返回的member 的個數
- spop 從指定的set 移除並返回 n個 member 時間複雜度為 O(n),n為移除的member的個數
- scard 返回指定 set 中 member的格式 ,時間複雜度為O(1)
- sismember 判斷指定的 value 是否存在於指定的 set 中,時間複雜度為O(1)
- smove 將指定的member 從一個 set 移動到另外的 set
慎用的set 命令
smembers 返回指定 hash 中所有的 member ,O(n)
sunion / sunionstore 計算多個 set 的並集並返回/儲存到另外一個 set 中,O(n)
sinter/sinterstore 計算多個set 的交集並返回/ 儲存至另外一個set 中,O(n)
sdiff / sdiffstore 計算 1 個 set 與 1 個或者多個 set 的並集並返回儲存到另外的 set 中,O(n)
上面的幾個命令計算量大,特別是在 set 尺寸不可知的情況下,避免使用,可以考慮 sscan 命令遍歷獲取相關的 set 的全部 member 。
其他命令
exists 判斷是否存在某個 key 存在 返回 1,不存在 返回 0 O(1) del 刪除指定 key 及其對應的 value ,時間複雜度為 O(n) , n 為刪除 key 的數量 expire/pexpire 為一個 key 設定有效期,單位為 秒 或者 毫秒,時間複雜度為 O(1) TTL/PTTL 返回一個 key 的有效時間,單位為秒或者毫秒。O(1) rename / renamenx 將 key 重新命名為 newkey,使用 rename 時候,如果 newkey 已經 存在其值會被覆蓋 ,使用 renamenx 時,如果 newkey 已經存在,則不會進行任何的操作。O(1) type 返回 key 的型別,O(1) config get 獲得 redis 某配置項當前的值,可以使用 * 萬用字元 O(1) config set 為 redis 某配置項設定新值,時間複雜度為 O(1) config rewrite 讓 redis 重新載入 redis.conf 的配置
三、分散式快取寫緩寫步驟/失效步驟
寫快取:先查詢快取是否存在,再決定是否需要寫入快取
失效快取:寫快取的時候指定失效時間;刪/改-刪除快取
四、常見的快取問題
》快取穿透:指快取和資料庫中都沒有的資料,而使用者不斷髮起請求,造成資料庫壓力過大(攻擊手段)
解決辦法1-快取空值:針對資料庫不存在值也快取
優點:簡單 缺點:攻擊者可以短時間不斷更換查詢id,把Redis記憶體佔滿。
解決辦法2-布隆過濾器:一種用於“檢索一個元素是否在一個集合中”高效的佔用空間小的資料結構 特點:存在一定的錯誤率(返回true不一定存在,但返回false一定不存在)
優點:有效 缺點:麻煩,適用場景上要求資料不得刪除
解決辦法3:(推薦)兩者一起使用,先通過“布隆過濾器”,再針對返回false的查詢,快取空。
》快取擊穿:快取中沒有但資料庫中有的資料(一般是快取時間到期),這時由於併發使用者特別多,同時讀快取沒讀到資料,又同時去資料庫去取資料,引起資料庫壓力瞬間增大,造成過大壓力。
解決方案1:熱點資料永不過期
解決方案2:寫快取時加鎖,同一時間只能有一個執行緒查詢資料庫更新快取,其他執行緒阻塞等待(Spring Cache支援)
》快取雪崩:由於原有快取失效(快取集中失效、Redis宕機等種種原因),所有原本應該訪問快取的請求都去查詢資料庫了,而對資料庫CPU和記憶體造成巨大壓力,嚴重的會造成資料庫宕機。從而形成一系列連鎖反應,造成整個系統崩潰。 特點:快取擊穿是針對某一條快取,快取雪崩是針對快取整體;快取雪崩主要體現在連鎖反映上
解決方案1:快取不集中失效(一般情況下天然打散)
解決方案2:快取預熱,針對可預先知道的查詢資料進行預熱,定時重新整理快取(例如秒殺商品)
解決辦法3:提前預案-快取降級,(1)兩臺Redis熱備;(2)降級為本地快取
五、分散式鎖
分散式鎖:保證在分散式系統中對於同一共享資源,同時只有一個執行緒在操作。
Redis實現分散式鎖的方式:
方案1: setnx + expire
獲取鎖:set ${lockKey} ${lockValue} setnx:
setnx指令,SET if Not eXists,如果不存在則設定成功;
expire:設定超時時間
釋放鎖:del ${lockKey}
缺點:
獲取鎖:setnx與expire不是原子的,setnx後程序宕機,那麼此鎖永遠得不到釋放。
釋放鎖:直接del操作,有可能釋放了不屬於當前執行緒的鎖,造成更多的問題。(執行緒1獲取鎖成功,但是執行超時,鎖已過期;執行緒2搶佔成功;執行緒1執行完成之後,本不該釋放鎖,直接del把執行緒2的鎖給釋放掉了)
方案2:與方案1類似,只是lockValue為過期的時間戳,解決了“setnx與expire“非原子操作的問題
獲取鎖:setnx之前先get鎖的值,發現時間已經過期,再setnx。
釋放鎖:del之前,先get值獲取鎖的值,是否和lockValue一致,一致則釋放。【缺點:先讀再del的方式不是原子的】
方案3: set + lua指令碼【Redis簡單分散式鎖實現的正確姿勢】
獲取鎖: SET ${lockKey} ${lockValue} ${過期時間} NX (Redis 2.6.12 +)
釋放鎖:使用lua指令碼保證原子性,執行過程中不會被打斷
六、string的底層資料結構
Redis的string底層資料結構-SDS(Simple Dynamic String,動態字串)
特點:
1.記錄了字串長度,獲取長度的時間複雜度為O(1)。
2. 具有動態增加空間的能力,擴容不需要使用者關心。
3.空間預分配,減少字串修改時記憶體分配次數。
擴容:當字串長度小於 1M 時,擴容都是加倍修改後的空間,如果超過 1M,擴容時一次只會多擴 1M 的空間。(字串最大長度為512M)。
縮容:不會釋放,修改len與free,供下次使用
對於Redis的key-value中value的結構體如下:
其中,type:型別,記錄了對外的型別(例如string、hash、set、zset等) (可以根據此判斷是否能夠執行某指令)
encoding:編碼,記錄了內部使用儲存“值”的資料結構(可以根據此判斷是否能夠執行某指令)
*ptr:指標,指向“值”
當我們的type=string時,有三種encoding,分別是:
1.int:整型(string的值為數字時,使用此編碼)
2.embstr:使用embstr編碼的SDS(string的值為非數字時,且長度小於等於44個位元組,使用此編碼)
3.raw:SDS(string的值為非數字時,且長度大於44個位元組,使用此編碼)
embstr與raw的區別:embstr是一種儲存短字串的優化手段,查詢與釋放更快
raw格式的RedisObject與SDS的記憶體分配是2次,是不連續的
embstr格式的RedisObject與SDS的記憶體分配是1次,是連續的
同一個key的embstr與raw轉換可逆
浮點型的string儲存也是使用raw或embstr的SDS,操作運算的時候轉換為浮點型,儲存的時候再轉換回SDS
小結-Redis底層資料結構概述
Redis基本資料結構[RedisObject的type] (string、list、hash、set、zset)
Redis底層資料結構[RedisObject的encoding]
int:整型
embstr:embstr格式的SDS,簡單動態字串
raw:SDS,簡單動態字串
quicklist:快速列表
hashtable :字典
zskiplist:跳躍表
intset:整數集合
ziplist:壓縮列表
底層對應關係:
七、list的底層資料結構
list的底層資料結構用到了快速列表quicklist和壓縮表ziplist(在Redis3.2版本之後一律採用quicklist資料結構)
如何快速?
quicklist表面是一個普通的連結串列,快速體現在data區域,quicklist的data指標指向的不是資料,而是ziplist,ziplist儲存了資料。
ziplist(壓縮列表)是一個Redis為節省記憶體而開發的連續記憶體組成的順序型資料結構。
quicklist是一個採用連結串列分段儲存,每段採用ziplist緊湊儲存的資料結構
下圖是ziplist的結構:
其中,entry的結構如下:
previous_entry_length:表示前一個entry的位元組長度,佔1個或者5個位元組
前一個元素長度小於254個位元組,則previous_entry_length=1個位元組
前一個元素長度大於等於254個位元組,則previous_entry_length=5個位元組
encoding:表示當前元素的編碼
content:儲存的資料
ziplist的反向遍歷:通過ziplist的zltail和entryN的previous_entry_length完成從後往前的遍歷
ziplist緊湊的代價-連鎖更新
案例:
1.在一個壓縮列表中, 有多個連續的、長度介於250位元組到253位元組之間的節點e1至eN。
因為e1至eN的所有節點的長度都小於254位元組, 所以記錄這些節點的長度只需要1位元組長的previous_entry_length屬性
2.將一個長度大於等於254位元組的新節點new設定為壓縮列表的表頭節點, 那麼new將成為e1的前置節點
e1的previous_entry_length屬性需要從原來的1位元組長擴充套件為5位元組長。
3.e2、e3直到eN都要更新
八、hash的底層資料結構
兩種底層實現的資料結構:ziplist(壓縮列表)與hashtable(字典)
使用ziplist時的儲存結構:K佔一個entry,V也佔一個entry,如下圖:
使用hashtable的儲存結構:C語言沒有字典,Redis自己實現了一個,類似於Java的HashMap
需要解決的問題與HashMap是相同的,原理也類似:hash碰撞、rehash、負載因子
區別:rehash過程是漸進式的,不是一次性完成的,若都在一個hset請求中進行rehash,可能會阻塞其他指令
兩種底層資料結構轉換的臨界點:
k-v的長度小於64個位元組 且 k數量小於512個:ziplist
其他:hashtable
九、set的底層資料結構
底層資料結構:
intset:整型集合,記憶體連續的陣列儲存
hashtable(類似與Java的HashSet的底層是HashMap,只不過Value為NULL)
兩種底層資料結構轉換的臨界點:
成員長度是整數值 且 成員數量小於512:intset
其他:hashtable
十、zset的底層資料結構
底層資料結構:
ziplist:壓縮列表
skiplist:跳躍表
兩種底層資料結構轉換的臨界點:
成員長度大小均小於64位元組 且 成員數量小於128:ziplist
其他:skiplist
skiplist:跳躍表,一種有序的資料結構,通過在每個節點中維護多個指向其他節點的指標,從而能快速訪問。
如何跳躍的邏輯如下圖所示:
1.一個普通的有序連結串列
2.每個節點增加指標,建立索引
3.再加一級索引,稱為L2
跳躍表:一個加了多級索引的連結串列
Redis的跳躍表有2個結構組成:
skiplist:用於儲存跳躍表節點的相關資訊,比如節點的數量,以及指向表頭節點和表尾節點的指標等等。
skiplistNode:用於表示跳躍表節點
其中BW是回退指標,插入/刪除/查詢:平均O(logN),最壞O(N)。
————面試題————
問法1:mysql為什麼不使用跳躍表做為索引的資料結構?(一般這麼問)
問法2:redis為什麼不使用B+樹做為zset的資料結構?
分析:redis的zset與mysql都面臨一個問題,根據條件查詢範圍內的資料
mysql:select * from table where a >= 1.0 and a <= 2.0
redis: zrangebyscore ${key} 1.0 2.0
資料結構的差異:
B+樹更適合於資料儲存在硬碟的場景:每次查詢基本代表了一次i/o,B+樹的查詢i/o次數穩定為O(logN),跳躍表最差為O(n)。
跳躍表更適合資料儲存在記憶體的場景:資料結構較為簡單,佔用的空間頁也較少。
十、scan
SCAN:一個基於遊標的迭代器。這意味著命令每次被呼叫都需要使用上一次這個呼叫返回的遊標作為該次呼叫的遊標引數,以此來延續之前的迭代過程。
SCAN 命令用於迭代當前資料庫中的資料庫鍵,替代keys
SSCAN 命令用於迭代集合鍵中的元素,替代smembers
HSCAN 命令用於迭代雜湊鍵中的鍵值對,替代hgetall
ZSCAN 命令用於迭代有序集合中的元素(包括元素成員和元素分值)
SCAN通用語法:[H/S/Z]SCAN [KEY] ${cursor} [MATCH pattern] [COUNT count]
使用方法: 第一次遍歷時,cursor 值為 0 將返回結果中第一個整數值作為下一次遍歷的 cursor 一直遍歷到返回的 cursor 值為 0 時結束,如下圖: