前言
在一些網絡服務的系統中,Redis 的性能,可能是比 MySQL 等硬碟資料庫的性能更重要的課題。
比如微網誌,把
熱點微網誌[1]
,最新的使用者關系,都存儲在 Redis 中,大量的查詢擊中 Redis,而不走 MySQL。
那麼,針對 Redis 服務,我們能做哪些性能優化呢?或者說,應該避免哪些性能浪費呢?
Redis 性能的基本面
在讨論優化之前,我們需要知道,Redis 服務本身就有一些特性,比如單線程運作。除非修改 Redis 的源代碼,不然這些特性,就是我們思考性能優化的基本面。
那麼,有哪些 Redis 基本特性需要我們考慮呢?Redis 的項目介紹中概括了它特性:
Redis is an in-memory database that persists on disk.
The data model is key-value, but many different kind of values are supported.
- 首先,Redis 使用作業系統提供的虛拟記憶體來存儲資料。而且,這個作業系統一般就是指 Unix。Windows 上也能運作 Redis,但是需要特殊處理。如果你的作業系統使用交換空間,那麼 Redis 的資料可能會被實際儲存在硬碟上。
- 其次,Redis 支援持久化,可以把資料儲存在硬碟上。很多時候,我們也确實有必要進行持久化來實作備份,資料恢複等需求。但持久化不會憑空發生,它也會占用一部分資源。
- 再次,Redis 是用 key-value 的方式來讀寫的,而 value 中又可以是很多不同種類的資料;更進一步,一個資料類型的底層還有被存儲為不同的結構。不同的存儲結構決定了資料增删改查的複雜度以及性能開銷。
- 最後,在上面的介紹中沒有提到的是,Redis 大多數時候是
的(單線程運作[2]
),即同一時間隻占用一個single-threaded
,隻能有一個指令在運作,并行讀寫是不存在的。很多操作帶來的延遲問題,都可以在這裡找到答案。CPU
關于最後這個特性,為什麼 Redis 是單線程的,卻能有很好的性能(根據
Amdahl’s Law
,優化耗時占比大的過程,才更有意義),兩句話概括是:Redis 利用了多路 I/O
複用機制[3]
,處理用戶端請求時,不會阻塞主線程;Redis 單純執行(大多數指令)一個指令不到 1
微秒[4]
,如此,單核 CPU 一秒就能處理 1 百萬個指令(大概對應着幾十萬個請求吧),用不着實作多線程(網絡才是
瓶頸[5]
)。
優化網絡延時
Redis 的官方部落格在幾個地方都說,性能瓶頸更可能是
網絡[6]
,那麼我們如何優化網絡上的延時呢?
首先,如果你們使用單機部署(應用服務和 Redis 在同一台機器上)的話,使用 Unix 程序間通訊來請求 Redis 服務,速度比
localhost
區域網路(學名
loopback
)更快。官方文檔[7]是這麼說的,想一想,理論上也應該是這樣的。
但很多公司的業務規模不是單機部署能支撐的,是以還是得用 TCP。
Redis 用戶端和伺服器的通訊一般使用 TCP 長連結。如果用戶端發送請求後需要等待 Redis 傳回結果再發送下一個指令,用戶端和 Redis 的多個請求就構成下面的關系:
如果不是你要發送的 key 特别長,一個 TCP 包完全能放下 Redis 指令,是以隻畫了一個 push 包
這樣這兩次請求中,用戶端都需要經曆一段網絡傳輸時間。
但如果有可能,完全可以使用 multi-key 類的指令來合并請求,比如兩個 GET key 可以用 MGET key1 key2 合并。這樣在實際通訊中,請求數也減少了,延時自然得到好轉。
如果不能用 multi-key 指令來合并,比如一個 SET,一個 GET 無法合并。怎麼辦?
Redis 中有至少這樣兩個方法能合并多個指令到一個 request 中,一個是
MULTI/EXEC
,一個是 script。前者本來是建構 Redis 事務的方法,但确實可以合并多個指令為一個 request,它到通訊過程如下。至于 script,最好利用緩存腳本的
sha1 hash key
來調起腳本,這樣通訊量更小。
這樣确實更能減少網絡傳輸時間,不是麼?但如此以來,就必須要求這個
transaction / script
中涉及的 key 在同一個 node 上,是以要酌情考慮。
如果上面的方法我們都考慮過了,還是沒有辦法合并多個請求,我們還可以考慮合并多個
responses
。比如把 2 個回複資訊合并:
這樣,理論上可以省去 1 次回複所用的網絡傳輸時間。這就是
pipeline
做的事情。舉個
ruby
用戶端使用
pipeline
的例子:
require 'redis'
@redis = Redis.new()
@redis.pipelined do
@redis.get 'key1'
@redis.set 'key2' 'some value'
end
# => [1, 2]
據說,有些語言的用戶端,甚至預設就使用
pipeline
來優化延時問題,比如
node_redis
。
另外,不是任意多個回複資訊都可以放進一個
TCP
包中,如果請求數太多,回複的資料很長(比如 get 一個長字元串),TCP 還是會分包傳輸,但使用
pipeline
,依然可以減少傳輸次數。
pipeline
和上面的其他方法都不一樣的是,它不具有原子性。是以在
cluster
狀态下的叢集上,實作
pipeline
比那些原子性的方法更有可能。
小總結
- 使用 unix 程序間通信,如果單機部署
- 使用 multi-key 指令合并多個指令,減少請求數,如果有可能的話
- 使用 transaction、script 合并 requests 以及 responses
- 使用 pipeline 合并 response
警惕執行時間長的操作
在大資料量的情況下,有些操作的執行時間會相對長,比如
KEYS *,LRANGE mylist 0 -1
,以及其他算法複雜度為
O(n)
的指令。因為 Redis 隻用一個線程來做資料查詢,如果這些指令耗時很長,就會阻塞 Redis,造成大量延時。
盡管官方文檔中說
KEYS *
的查詢挺快的,(在普通筆記本上)掃描
1 百萬個 key
,隻需 40 毫秒(參見:
https://redis.io/commands/keys
),但幾十 ms 對于一個性能要求很高的系統來說,已經不短了,更何況如果有幾億個 key(一台機器完全可能存幾億個 key,比如一個
key 100位元組
,1 億個
key 隻有 10GB
),時間更長。
是以,盡量不要在生産環境的代碼使用這些執行很慢的指令,這一點 Redis 的作者在部落格[8]中也提到了。另外,運維同學查詢 Redis 的時候也盡量不要用。甚至,
Redis Essential
這本書建議利用
rename-command KEYS ''
來禁止使用這個耗時的指令。
除了這些耗時的指令,Redis 中
transaction,script
,因為可以合并多個
commands
為一個具有原子性的執行過程,是以也可能占用 Redis 很長時間,需要注意。
如果你想找出生産環境使用的「
慢指令
」,那麼可以利用
SLOWLOG GET count
來檢視最近的
count
個執行時間很長的指令。至于多長算長,可以通過在
redis.conf
中設定
slowlog-log-slower-than
來定義。
除此之外,在很多地方都沒有提到的一個可能的慢指令是
DEL
,但
redis.conf
檔案的
注釋[9]
中倒是說了。長話短說就是 DEL 一個大的
object
時候,回收相應的記憶體可能會需要很長時間(甚至幾秒),是以,建議用 DEL 的異步版本:UNLINK。後者會啟動一個新的
thread
來删除目标 key,而不阻塞原來的線程。
更進一步,當一個 key 過期之後,
Redis
一般也需要同步的把它删除。其中一種删除
keys
的方式是,每秒 10 次的檢查一次有設定過期時間的
keys
,這些 keys 存儲在一個全局的
struct
中,可以用
server.db->expires
通路。檢查的方式是:
- 從中随機取出 20 個 keys
- 把過期的删掉。
- 如果剛剛 20 個 keys 中,有 25% 以上(也就是 5 個以上)都是過期的,Redis 認為,過期的 keys 還挺多的,繼續重複步驟 1,直到滿足退出條件:某次取出的 keys 中沒有那麼多過去的 keys。這裡對于性能的影響是,如果真的有很多的 keys 在同一時間過期,那麼 Redis 真的會一直循環執行删除,占用主線程。
對此,Redis 作者的
建議[10]
是警惕
EXPIREAT
這個指令,因為它更容易産生 keys 同時過期的現象。我還見到過一些建議是給 keys 的過期時間設定一個随機波動量。最後,
redis.conf
中也給出了一個方法,把 keys 的過期删除操作變為異步的,即,在
redis.conf
lazyfree-lazy-expire yes
優化資料結構、使用正确的算法
一種資料類型(比如
string
,
list
)進行增删改查的效率是由其底層的存儲結構決定的。
我們在使用一種資料類型時,可以适當關注一下它底層的存儲結構及其算法,避免使用複雜度太高的方法。舉兩個例子:
ZADD
的時間複雜度是
O(log(N))
,這比其他資料類型增加一個新元素的操作更複雜,是以要小心使用。若
Hash
類型的值的
fields
數量有限,它很有可能采用
ziplist
這種結構做存儲,而
ziplist
的查詢效率可能沒有同等字段數量的
hashtable
效率高,在必要時,可以調整 Redis 的存儲結構。
除了時間性能上的考慮,有時候我們還需要節省存儲空間。比如上面提到的
ziplist
結構,就比
hashtable
結構節省存儲空間(
Redis Essentials
的作者分别在
hashtable
和
ziplist
結構的 Hash 中插入 500 個 fields,每個 field 和 value 都是一個 15 位左右的字元串,結果是
hashtable
結構使用的空間是
ziplist
的 4 倍。)。但節省空間的資料結構,其算法的複雜度可能很高。是以,這裡就需要在具體問題面前做出權衡。
如何做出更好的權衡?我覺得得深挖 Redis 的存儲結構才能讓自己安心。這方面的内容我們下次再說。
以上這三點都是程式設計層面的考慮,寫程式時應該注意啊。下面這幾點,也會影響 Redis 的性能,但解決起來,就不隻是靠代碼層面的調整了,還需要架構和運維上的考慮。
考慮作業系統和硬體是否影響性能
Redis 運作的外部環境,也就是作業系統和硬體顯然也會影響 Redis 的性能。在官方文檔中,就給出了一些例子:
- CPU:Intel 多種 CPU 都比 AMD 皓龍系列好
- 虛拟化:實體機比虛拟機好,主要是因為部分虛拟機上,硬碟不是本地硬碟,監控軟體導緻 fork 指令的速度慢(持久化時會用到
),尤其是用fork
來做虛拟化時。Xen
- 記憶體管理:在 linux 作業系統中,為了讓
,即 TLB,能夠管理更多記憶體空間(TLB 隻能緩存有限個 page),作業系統把一些translation lookaside buffer
變得更大,比如 2MB 或者 1GB,而不是通常的 4096 位元組,這些大的記憶體頁叫做memory page
。同時,為了友善程式員使用這些大的記憶體 page,作業系統中實作了一個huge pages
機制,使得大記憶體頁對他們來說是透明的,可以像使用正常的記憶體transparent huge pages(THP)
一樣使用他們。但這種機制并不是資料庫所需要的,可能是因為 THP 會把記憶體空間變得緊湊而連續吧,就像page
的mongodb
中明确說的,資料庫需要的是稀疏的記憶體空間,是以請禁掉 THP 功能。Redis 也不例外,但 Redis 官方部落格上給出的理由是:使用大記憶體 page 會使文檔[11]
時,bgsave
的速度變慢;如果 fork 之後,這些記憶體 page 在原程序中被修改了,他們就需要被複制(即fork
),這樣的複制會消耗大量的記憶體(畢竟,人家是copy on write
,複制一份消耗成本很大)。是以,請禁止掉作業系統中的huge pages
功能。transparent huge pages
- 交換空間:當一些記憶體 page 被存儲在交換空間檔案上,而 Redis 又要請求那些資料,那麼作業系統會阻塞 Redis 程序,然後把想要的 page,從交換空間中拿出來,放進記憶體。這其中涉及整個程序的阻塞,是以可能會造成延時問題,一個解決方法是禁止使用交換空間(
中如是建議,如果記憶體空間不足,請用别的方法處理)。Redis Essentials
考慮持久化帶來的開銷
Redis 的一項重要功能就是持久化,也就是把資料複制到硬碟上。基于持久化,才有了 Redis 的資料恢複等功能。
但維護這個持久化的功能,也是有性能開銷的。
首先說,RDB 全量持久化。
這種持久化方式把 Redis 中的全量資料打包成 rdb 檔案放在硬碟上。但是執行 RDB 持久化過程的是原程序 fork 出來一個子程序,而 fork 這個系統調用是需要時間的,根據
Redis Lab 6
年前做的
實驗[12]
,在一台新型的
AWS EC2 m1.small^13
上,
fork
一個記憶體占用
1GB
的 Redis 程序,需要 700+ 毫秒,而這段時間,redis 是無法處理請求的。
雖然現在的機器應該都會比那個時候好,但是 fork 的開銷也應該考慮吧。為此,要使用合理的 RDB 持久化的時間間隔,不要太頻繁。
接下來,我們看另外一種持久化方式:AOF 增量持久化。
這種持久化方式會把你發到
redis server
的指令以文本的形式儲存下來(格式遵循
redis protocol
),這個過程中,會調用兩個系統調用,一個是 write(2),同步完成,一個是 fsync(2),異步完成。
這兩部都可能是延時問題的原因:
write 可能會因為輸出的 buffer 滿了,或者 kernal 正在把 buffer 中的資料同步到硬碟,就被阻塞了。fsync 的作用是確定 write 寫入到 aof 檔案的資料落到了硬碟上,在一個 7200 轉/分的硬碟上可能要延時 20 毫秒左右,消耗還是挺大的。更重要的是,在 fsync 進行的時候,write 可能會被阻塞。其中,write 的阻塞貌似隻能接受,因為沒有更好的方法把資料寫到一個檔案中了。但對于 fsync,Redis 允許三種配置,選用哪種取決于你對備份及時性和性能的平衡:
- always:當把 appendfsync 設定為 always,fsync 會和用戶端的指令同步執行,是以最可能造成延時問題,但備份及時性最好。
- everysec:每秒鐘異步執行一次 fsync,此時 redis 的性能表現會更好,但是 fsync 依然可能阻塞 write,算是一個折中選擇。
- no:redis 不會主動出發 fsync (并不是永遠不 fsync,那是不太可能的),而由 kernel 決定何時 fsync
使用分布式架構
讀寫分離、資料分片。
以上,我們都是基于單台,或者單個 Redis 服務進行優化。下面,我們考慮當網站的規模變大時,利用分布式架構來保障 Redis 性能的問題。
首先說,哪些情況下不得不(或者最好)使用分布式架構:
資料量很大,單台伺服器記憶體不可能裝得下,比如 1 個 T 這種量級 需要服務高可用 單台的請求壓力過大 解決這些問題可以采用資料分片或者主從分離,或者兩者都用(即,在分片用的 cluster 節點上,也設定主從結構)。
這樣的架構,可以為性能提升加入新的切入點:
把慢速的指令發到某些從庫中執行 把持久化功能放在一個很少使用的從庫上 把某些大 list 分片 其中前兩條都是根據 Redis 單線程的特性,用其他程序(甚至機器)做性能補充的方法。
當然,使用分布式架構,也可能對性能有影響,比如請求需要被轉發,資料需要被不斷複制分發。(待查)
後話
其實還有很多東西也影響 Redis 的性能,比如 active rehashing(keys 主表的再哈希,每秒 10 次,關掉它可以提升一點點性能),但是這篇部落格已經寫的很長了。而且,更重要不是收集已經被别人提出的問題,然後記憶解決方案;而是掌握 Redis 的基本原理,以不變應萬變的方式決絕新出現的問題。
推薦閱讀:
世界的真實格局分析,地球人類社會底層運作原理
企業IT技術架構規劃方案
論數字化轉型——轉什麼,如何轉?
企業10大管理流程圖,數字化轉型從業者必備!
【中台實踐】華為大資料中台架構分享.pdf
數字化轉型的本質(10個關鍵詞)