單線程為什么能支持10w+的QPS?
我們經常聽到Redis是一個單線程程序。准確的說Redis是一個多線程程序,只不過請求處理的部分是用一個線程來實現的。
阿里雲對Redis QPS的測試結果如下所示

「Redis是如何用單線程來實現每秒10w+的QPS的呢?」
- 使用IO多路復用
- 非CPU密集型任務
- 純內存操作
- 高效的數據結構
「只用一個線程怎么來處理多個客戶端的連接呢?」
這就不得不提IO多路復用技術,即Java中的NIO。
當我們使用阻塞IO(Java中的BIO),調用read函數,傳入參數n,表示讀取n個字節后線程才會返回,不然就一直阻塞。write方法一般不會阻塞,除非寫緩沖區被寫滿,write才會被阻塞,直到緩沖區中有空間被釋放出來。
當我們使用IO多路復用技術時,當沒有數據可讀或者可寫,客戶端線程會直接返回,並不會阻塞。這樣Redis就可以用一個線程來監聽多個Socket,當一個Socket可讀或可寫的時候,Redis去讀取請求,操作內存中數據,然后返回。
「當采用單線程時,就無法使用多核CPU,但Redis中大部分命令都不是CPU密集型任務,所以CPU並不是Redis的瓶頸」。
高並發和大數據量的請寬下Redis的瓶頸主要體現在內存和網絡帶寬,所以你看Redis為了節省內存,在底層數據結構上占用的內存能少就少,並且一種類型的數據在不同的場景下會采用不同的數據結構。

「所以Redis采用單線程就已經能處理海量的請求,因此就沒必要使用多線程」。除此之外,「使用單線程還有如下好處」
- 沒有了線程切換的性能開銷
- 各種操作不用加鎖(如果采用多線程,則對共享資源的訪問需要加鎖,增加開銷)
- 方便調試,可維護性高
「最后Redis是一個內存數據庫,各種命令的讀寫操作都是基於內存完成的」。大家都知道操作內存和操作磁盤效率相差好幾個數量級。雖然Redis的效率很高,但還是有一些慢操作需要大家避免
Redis有哪些慢操作?

Redis的各種命令是在一個線程中依次執行的,如果一個命令在Redis中執行的時間過長,就會影響整體的性能,因為后面的請求要等到前面的請求被處理完才能被處理,這些耗時的操作有如下幾個部分
Redis可以通過日志記錄那些耗時長的命令,使用如下配置即可
# 命令執行耗時超過 5 毫秒,記錄慢日志
CONFIG SET slowlog-log-slower-than 5000
# 只保留最近 500 條慢日志
CONFIG SET slowlog-max-len 500
執行如下命令,就可以查詢到最近記錄的慢日志
127.0.0.1:6379> SLOWLOG get 5
1) 1) (integer) 32693 # 慢日志ID
2) (integer) 1593763337 # 執行時間戳
3) (integer) 5299 # 執行耗時(微秒)
4) 1) "LRANGE" # 具體執行的命令和參數
2) "user_list:2000"
3) "0"
4) "-1"
2) 1) (integer) 32692
2) (integer) 1593763337
3) (integer) 5044
4) 1) "GET"
2) "user_info:1000"
...
使用復雜度過高的命令
之前的文章我們已經介紹了Redis的底層數據結構,它們的時間復雜度如下表所示
名稱時間復雜度dict(字典)O(1)ziplist (壓縮列表)O(n)zskiplist (跳表)O(logN)quicklist(快速列表)O(n)intset(整數集合)O(n)
「單元素操作」:對集合中的元素進行增刪改查操作和底層數據結構相關,如對字典進行增刪改查時間復雜度為O(1),對跳表進行增刪查時間復雜為O(logN)
「范圍操作」:對集合進行遍歷操作,比如Hash類型的HGETALL,Set類型的SMEMBERS,List類型的LRANGE,ZSet類型的ZRANGE,時間復雜度為O(n),避免使用,用SCAN系列命令代替。(hash用hscan,set用sscan,zset用zscan)
「聚合操作」:這類操作的時間復雜度通常大於O(n),比如SORT、SUNION、ZUNIONSTORE
「統計操作」:當想獲取集合中的元素個數時,如LLEN或者SCARD,時間復雜度為O(1),因為它們的底層數據結構如quicklist,dict,intset保存了元素的個數
「邊界操作」:list底層是用quicklist實現的,quicklist保存了鏈表的頭尾節點,因此對鏈表的頭尾節點進行操作,時間復雜度為O(1),如LPOP、RPOP、LPUSH、RPUSH
*「當想獲取Redis中的key時,避免使用keys 」 ,Redis中保存的鍵值對是保存在一個字典中的(和Java中的HashMap類似,也是通過數組+鏈表的方式實現的),key的類型都是string,value的類型可以是string,set,list等
例如當我們執行如下命令后,redis的字典結構如下
set bookName redis;
rpush fruits banana apple;

我們可以用keys命令來查詢Redis中特定的key,如下所示
# 查詢所有的key
keys *
# 查詢以book為前綴的key
keys book*
keys命令的復雜度是O(n),它會遍歷這個dict中的所有key,如果Redis中存的key非常多,所有讀寫Redis的指令都會被延遲等待,所以千萬不用在生產環境用這個命令(如果你已經准備離職的話,祝你玩的開心)。
「既然不讓你用keys,肯定有替代品,那就是scan」
scan和keys相比,有如下特點
- 復雜雖然也是O(n),但是是通過游標分布執行的,不會阻塞線程
- 同keys一樣,提供模式匹配功能
- 從完整遍歷開始到完整遍歷結束,一直存在於數據集內的所有元素都會被完整遍歷返回,但是同一個元素可能會被返回多次
- 如果一個元素是在迭代過程中被添加到數據集的,或者在迭代過程中從數據集中被刪除的,那么這個元素可能會被返回,也可能不會被返回
- 返回結果為空並不意味着遍歷結束,而要看返回的游標值是否為0
有興趣的小伙伴可以分析一下scan源碼的實現就能明白這些特性了
「用用zscan遍歷zset,hscan遍歷hash,sscan遍歷set的原理和scan命令類似,因為hash,set,zset的底層實現的數據結構中都有dict。」
操作bigkey
「如果一個key對應的value非常大,那么這個key就被稱為bigkey。寫入bigkey在分配內存時需要消耗更長的時間。同樣,刪除bigkey釋放內存也需要消耗更長的時間」
如果在慢日志中發現了SET/DEL這種復雜度不高的命令,此時你就應該排查一下是否是由於寫入bigkey導致的。
「如何定位bigkey?」
Redis提供了掃描bigkey的命令
$ redis-cli -h 127.0.0.1 -p 6379 --bigkeys -i 0.01 ... -------- summary ------- Sampled 829675 keys in the keyspace! Total key length in bytes is 10059825 (avg len 12.13) Biggest string found 'key:291880' has 10 bytes Biggest list found 'mylist:004' has 40 items Biggest set found 'myset:2386' has 38 members Biggest hash found 'myhash:3574' has 37 fields Biggest zset found 'myzset:2704' has 42 members 36313 strings with 363130 bytes (04.38% of keys, avg size 10.00) 787393 lists with 896540 items (94.90% of keys, avg size 1.14) 1994 sets with 40052 members (00.24% of keys, avg size 20.09) 1990 hashs with 39632 fields (00.24% of keys, avg size 19.92) 1985 zsets with 39750 members (00.24% of keys, avg size 20.03)
可以看到命令的輸入有如下3個部分
- 內存中key的數量,已經占用的總內存,每個key占用的平均內存
- 每種類型占用的最大內存,已經key的名字
- 每種數據類型的占比,以及平均大小
這個命令的原理就是redis在內部執行了scan命令,遍歷實例中所有的key,然后正對key的類型,分別執行strlen,llen,hlen,scard,zcard命令,來獲取string類型的長度,容器類型(list,hash,set,zset)的元素個數
使用這個命令需要注意如下兩個問題
- 對線上實例進行bigkey掃描時,為避免ops(operation per second 每秒操作次數)突增,可以通過-i增加一個休眠參數,上面的含義為,每隔100條scan指令就會休眠0.01s
- 對於容器類型(list,hash,set,zset),掃描出的是元素最多的key,但一個key的元素數量多,不一定代表占用的內存多
「如何解決bigkey帶來的性能問題?」
- 盡量避免寫入bigkey
- 如果使用的是redis4.0以上版本,可以用unlink命令代替del,此命令可以把釋放key內存的操作,放到后台線程中去執行
- 如果使用的是redis6.0以上版本,可以開啟lazy-free機制(lazyfree-lazy-user-del yes),執行del命令的時候,也會放到后台線程中去執行
大量key集中過期
我們可以給Redis中的key設置過期時間,那么當key過期了,它在什么時候會被刪除呢?
「如果讓我們寫Redis過期策略,我們會想到如下三種方案」
- 定時刪除,在設置鍵的過期時間的同時,創建一個定時器。當鍵的過期時間來臨時,立即執行對鍵的刪除操作
- 惰性刪除,每次獲取鍵的時候,判斷鍵是否過期,如果過期的話,就刪除該鍵,如果沒有過期,則返回該鍵
- 定期刪除,每隔一段時間,對鍵進行一次檢查,刪除里面的過期鍵 定時刪除策略對CPU不友好,當過期鍵比較多的時候,Redis線程用來刪除過期鍵,會影響正常請求的響應
定時刪除策略對CPU不友好,當過期鍵比較多的時候,Redis線程用來刪除過期鍵,會影響正常請求的響應
惰性刪除讀CPU是比較有好的,但是會浪費大量的內存。如果一個key設置過期時間放到內存中,但是沒有被訪問到,那么它會一直存在內存中
定期刪除策略則對CPU和內存都比較友好
redis過期key的刪除策略選擇了如下兩種
- 惰性刪除
- 定期刪除
「惰性刪除」客戶端在訪問key的時候,對key的過期時間進行校驗,如果過期了就立即刪除
「定期刪除」Redis會將設置了過期時間的key放在一個獨立的字典中,定時遍歷這個字典來刪除過期的key,遍歷策略如下
- 每秒進行10次過期掃描,每次從過期字典中隨機選出20個key
- 刪除20個key中已經過期的key
- 如果過期key的比例超過1/4,則進行步驟一
- 每次掃描時間的上限默認不超過25ms,避免線程卡死
「因為Redis中過期的key是由主線程刪除的,為了不阻塞用戶的請求,所以刪除過期key的時候是少量多次」。源碼可以參考expire.c中的activeExpireCycle方法
為了避免主線程一直在刪除key,我們可以采用如下兩種方案
- 給同時過期的key增加一個隨機數,打散過期時間,降低清除key的壓力
- 如果你使用的是redis4.0版本以上的redis,可以開啟lazy-free機制(lazyfree-lazy-expire yes),當刪除過期key時,把釋放內存的操作放到后台線程中執行
內存達到上限,觸發淘汰策略

Redis是一個內存數據庫,當Redis使用的內存超過物理內存的限制后,內存數據會和磁盤產生頻繁的交換,交換會導致Redis性能急劇下降。所以在生產環境中我們通過配置參數maxmemoey來限制使用的內存大小。
當實際使用的內存超過maxmemoey后,Redis提供了如下幾種可選策略。
noeviction:寫請求返回錯誤
volatile-lru:使用lru算法刪除設置了過期時間的鍵值對 volatile-lfu:使用lfu算法刪除設置了過期時間的鍵值對 volatile-random:在設置了過期時間的鍵值對中隨機進行刪除 volatile-ttl:根據過期時間的先后進行刪除,越早過期的越先被刪除
allkeys-lru:在所有鍵值對中,使用lru算法進行刪除 allkeys-lfu:在所有鍵值對中,使用lfu算法進行刪除 allkeys-random:所有鍵值對中隨機刪除
「Redis的淘汰策略也是在主線程中執行的。****但****內存超過Redis上限后,每次寫入都需要淘汰一些key,導致請求時間變長」
可以通過如下幾個方式進行改善
- 增加內存或者將數據放到多個實例中
- 淘汰策略改為隨機淘汰,一般來說隨機淘汰比lru快很多
- 避免存儲bigkey,降低釋放內存的耗時
寫AOF日志的方式為always

Redis的持久化機制有RDB快照和AOF日志,每次寫命令之后后,Redis提供了如下三種刷盤機制
always:同步寫回,寫命令執行完就同步到磁盤 everysec:每秒寫回,每個寫命令執行完,只是先把日志寫到aof文件的內存緩沖區,每隔1秒將緩沖區的內容寫入磁盤 no:操作系統控制寫回,每個寫命令執行完,只是先把日志寫到aof文件的內存緩沖區,由操作系統決定何時將緩沖區內容寫回到磁盤
當aof的刷盤機制為always,redis每處理一次寫命令,都會把寫命令刷到磁盤中才返回,整個過程是在Redis主線程中進行的,勢必會拖慢redis的性能
當aof的刷盤機制為everysec,redis寫完內存后就返回,刷盤操作是放到后台線程中去執行的,后台線程每隔1秒把內存中的數據刷到磁盤中
當aof的刷盤機制為no,宕機后可能會造成部分數據丟失,一般不采用。
「一般情況下,aof刷盤機制配置為everysec即可」
fork耗時過長
在持久化一節中,我們已經提到「Redis生成rdb文件和aof日志重寫,都是通過主線程fork子進程的方式,讓子進程來執行的,主線程的內存越大,阻塞時間越長。」
可以通過如下方式優化
- 控制Redis實例的內存大小,盡量控制到10g以內,因為內存越大,阻塞時間越長
- 配置合理的持久化策略,如在slave節點生成rdb快照
作者:傻姑不傻
鏈接:https://www.jianshu.com/p/1d9cf5cf6e10
來源:簡書
著作權歸作者所有。商業轉載請聯系作者獲得授權,非商業轉載請注明出處。