來源 | jianshu.com/p/67093716547b
在一些網絡服務的系統中,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)更快。
但很多公司的業務規模不是單機部署能支撐的,所以還是得用 TCP。
Redis 客戶端和服務器的通訊一般使用 TCP 長鏈接。如果客戶端發送請求後需要等待 Redis 返回結果再發送下一個指令,客戶端和 Redis 的多個請求就構成下面的關係:
(備註:如果不是你要發送的 key 特別長,一個 TCP 包完全能放下 Redis 指令,所以只畫了一個 push 包)
這樣這兩次請求中,客戶端都需要經歷一段網絡傳輸時間。
但如果有可能,完全可以使用 multi-key 類的指令來合併請求,比如兩個 GET key 可以用 MGET key1 key2 合併。這樣在實際通訊中,請求數也減少了,延時自然得到好轉。
如果不能用 multi-key 指令來合併,比如一個 SET,一個 GET 無法合併。怎麼辦?
Redis 中有至少這樣兩個方法能合併多個指令到一個 request 中,一個是 MULTI/EXEC,一個是>
這樣確實更能減少網絡傳輸時間,不是麼?但如此以來,就必須要求這個 transaction />
如果上面的方法我們都考慮過了,還是沒有辦法合併多個請求,我們還可以考慮合併多個 responses。比如把 2 個回覆信息合併:
這樣,理論上可以省去 1 次回覆所用的網絡傳輸時間。這就是 pipeline 做的事情。舉個 ruby 客戶端使用 pipeline 的例子:
<code>require 'redis'
@redis = Redis.new()
@redis.pipelined do
@redis.get 'key1'
@redis.set 'key2' 'some value'
end
# => [1, 2]/<code>
據說,有些語言的客戶端,甚至默認就使用 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 訪問。檢查的方式是:
這裡對於性能的影響是,如果真的有很多的 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 操作系統中,為了讓 translation lookaside buffer,即 TLB,能夠管理更多內存空間(TLB 只能緩存有限個 page),操作系統把一些 memory page 變得更大,比如 2MB 或者 1GB,而不是通常的 4096 字節,這些大的內存頁叫做 huge pages。同時,為了方便程序員使用這些大的內存 page,操作系統中實現了一個 transparent huge pages(THP)機制,使得大內存頁對他們來說是透明的,可以像使用正常的內存 page 一樣使用他們。但這種機制並不是數據庫所需要的,可能是因為 THP 會把內存空間變得緊湊而連續吧,就像考慮持久化帶來的開銷
Redis 的一項重要功能就是持久化,也就是把數據複製到硬盤上。基於持久化,才有了 Redis 的數據恢復等功能。
但維護這個持久化的功能,也是有性能開銷的。
首先說,RDB 全量持久化。
這種持久化方式把 Redis 中的全量數據打包成 rdb 文件放在硬盤上。但是執行 RDB 持久化過程的是原進程 fork 出來一個子進程,而 fork 這個系統調用是需要時間的,根據Redis Lab 6 年前做的
雖然現在的機器應該都會比那個時候好,但是 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 允許三種配置,選用哪種取決於你對備份及時性和性能的平衡:
使用分佈式架構 —— 讀寫分離、數據分片
以上,我們都是基於單臺,或者單個 Redis 服務進行優化。下面,我們考慮當網站的規模變大時,利用分佈式架構來保障 Redis 性能的問題。
首先說,哪些情況下不得不(或者最好)使用分佈式架構:
數據量很大,單臺服務器內存不可能裝得下,比如 1 個 T 這種量級需要服務高可用單臺的請求壓力過大解決這些問題可以採用數據分片或者主從分離,或者兩者都用(即,在分片用的 cluster 節點上,也設置主從結構)。
這樣的架構,可以為性能提升加入新的切入點:
把慢速的指令發到某些從庫中執行把持久化功能放在一個很少使用的從庫上把某些大 list 分片其中前兩條都是根據 Redis 單線程的特性,用其他進程(甚至機器)做性能補充的方法。
當然,使用分佈式架構,也可能對性能有影響,比如請求需要被轉發,數據需要被不斷複製分發。(待查)
後話
其實還有很多東西也影響 Redis 的性能,比如 active rehashing(keys 主表的再哈希,每秒 10 次,關掉它可以提升一點點性能),但是這篇博客已經寫的很長了。而且,更重要不是收集已經被別人提出的問題,然後記憶解決方案;而是掌握 Redis 的基本原理,以不變應萬變的方式決絕新出現的問題。
[1] 熱點微博: https://www.infoq.cn/article/weibo-relation-service-with-redis
[2] 單線程運行: https://redis.io/topics/latency#single-threaded-nature-of-redis
[3] 多路 I/O 複用機制: https://redis.io/topics/clients#how-client-connections-are-accepted
[4] 1 微秒: https://redis.io/topics/latency#redis-latency-problems-troubleshooting
[5] 網絡才是瓶頸: https://redis.io/topics/benchmarks#factors-impacting-redis-performance
[6] 網絡: https://redis.io/topics/latency#latency-induced-by-network-and-communication
[7] 官方文檔: https://redis.io/topics/benchmarks#factors-impacting-redis-performance
[8] 博客: https://redis.io/topics/latency#i39ve-little-time-give-me-the-checklist
[9] 註釋: https://github.com/antirez/redis/blob/5.0/redis.conf#L669
[10] 建議: https://redis.io/topics/latency#latency-generated-by-expires
[11] mongodb 的文檔: https://docs.mongodb.com/manual/tutorial/transparent-huge-pages/
[12] 實驗: 參見:https://redis.io/topics/latency#fork-time-in-different-systems