硬核乾貨! Redis 性能優化, 建議收藏!

在一些網絡服務的系統中,Redis 的性能,多是比 MySQL 等硬盤數據庫的性能更重要的課題。好比微博,把熱點微博[1],最新的用戶關係,都存儲在 Redis 中,大量的查詢擊中 Redis,而不走 MySQL。node

那麼,針對 Redis 服務,咱們能作哪些性能優化呢?或者說,應該避免哪些性能浪費呢?linux

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

首先,Redis 使用操做系統提供的虛擬內存來存儲數據。並且,這個操做系統通常就是指 Unix。Windows 上也能運行 Redis,可是須要特殊處理。若是你的操做系統使用交換空間,那麼 Redis 的數據可能會被實際保存在硬盤上。關注公衆號:程序員白楠楠,獲取2020最新面試題算法

其次,Redis 支持持久化,能夠把數據保存在硬盤上。不少時候,咱們也確實有必要進行持久化來實現備份,數據恢復等需求。但持久化不會憑空發生,它也會佔用一部分資源。mongodb

第三,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 的多個請求就構成下面的關係:

image.png

(備註:若是不是你要發送的 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 來調起腳本,這樣通信量更小。

image.png

這樣確實更能減小網絡傳輸時間,不是麼?但如此以來,就必需要求這個 transaction / script 中涉及的 key 在同一個 node 上,因此要酌情考慮。

若是上面的方法咱們都考慮過了,仍是沒有辦法合併多個請求,咱們還能夠考慮合併多個 responses。好比把 2 個回覆信息合併:

image.png

這樣,理論上能夠省去 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 比那些原子性的方法更有可能。

小結一下:

  1. 使用 unix 進程間通訊,若是單機部署

  2. 使用 multi-key 指令合併多個指令,減小請求數,若是有可能的話

  3. 使用 transaction、script 合併 requests 以及 responses

  4. 使用 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 訪問。檢查的方式是:

  1. 從中隨機取出 20 個 keys

  2. 把過時的刪掉。

  3. 若是剛剛 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)進行增刪改查的效率是由其底層的存儲結構決定的。

咱們在使用一種數據類型時,能夠適當關注一下它底層的存儲結構及其算法,避免使用複雜度過高的方法。舉兩個例子:

  1. ZADD 的時間複雜度是 O(log(N)),這比其餘數據類型增長一個新元素的操做更復雜,因此要當心使用。

  2. 若 Hash 類型的值的 fields 數量有限,它頗有可能採用 ziplist 這種結構作存儲,而 ziplist 的查詢效率可能沒有同等字段數量的 hashtable 效率高,在必要時,能夠調整 Redis 的存儲結構。

除了時間性能上的考慮,有時候咱們還須要節省存儲空間。好比上面提到的 ziplist 結構,就比 hashtable 結構節省存儲空間(Redis Essentials 的做者分別在 hashtable 和 ziplist 結構的 Hash 中插入 500 個 fields,每一個 field 和 value 都是一個 15 位左右的字符串,結果是 hashtable 結構使用的空間是 ziplist 的 4 倍。)。但節省空間的數據結構,其算法的複雜度可能很高。因此,這裏就須要在具體問題面前作出權衡。歡迎關注公衆號:朱小廝的博客,回覆:1024,能夠領取redis專屬資料。

如何作出更好的權衡?我以爲得深挖 Redis 的存儲結構才能讓本身安心。這方面的內容咱們下次再說。

以上這三點都是編程層面的考慮,寫程序時應該注意啊。下面這幾點,也會影響 Redis 的性能,但解決起來,就不僅是靠代碼層面的調整了,還須要架構和運維上的考慮。

考慮操做系統和硬件是否影響性能

Redis 運行的外部環境,也就是操做系統和硬件顯然也會影響 Redis 的性能。在官方文檔中,就給出了一些例子:

  1. CPU:Intel 多種 CPU 都比 AMD 皓龍系列好

  2. 虛擬化:實體機比虛擬機好,主要是由於部分虛擬機上,硬盤不是本地硬盤,監控軟件致使 fork 指令的速度慢(持久化時會用到 fork),尤爲是用 Xen 來作虛擬化時。

  3. 內存管理:在 linux 操做系統中,爲了讓 translation lookaside buffer,即 TLB,可以管理更多內存空間(TLB 只能緩存有限個 page),操做系統把一些 memory page 變得更大,好比 2MB 或者 1GB,而不是一般的 4096 字節,這些大的內存頁叫作 huge pages。同時,爲了方便程序員使用這些大的內存 page,操做系統中實現了一個 transparent huge pages(THP)機制,使得大內存頁對他們來講是透明的,能夠像使用正常的內存 page 同樣使用他們。但這種機制並非數據庫所須要的,多是由於 THP 會把內存空間變得緊湊而連續吧,就像mongodb 的文檔[11]中明確說的,數據庫須要的是稀疏的內存空間,因此請禁掉 THP 功能。Redis 也不例外,但 Redis 官方博客上給出的理由是:使用大內存 page 會使 bgsave 時,fork 的速度變慢;若是 fork 以後,這些內存 page 在原進程中被修改了,他們就須要被複制(即 copy on write),這樣的複製會消耗大量的內存(畢竟,人家是 huge pages,複製一份消耗成本很大)。因此,請禁止掉操做系統中的 transparent huge pages 功能。

  4. 交換空間:當一些內存 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),異步完成。

這兩部均可能是延時問題的緣由:

  1. write 可能會由於輸出的 buffer 滿了,或者 kernal 正在把 buffer 中的數據同步到硬盤,就被阻塞了。

  2. fsync 的做用是確保 write 寫入到 aof 文件的數據落到了硬盤上,在一個 7200 轉/分的硬盤上可能要延時 20 毫秒左右,消耗仍是挺大的。更重要的是,在 fsync 進行的時候,write 可能會被阻塞。

其中,write 的阻塞貌似只能接受,由於沒有更好的方法把數據寫到一個文件中了。但對於 fsync,Redis 容許三種配置,選用哪一種取決於你對備份及時性和性能的平衡:

  1. always:當把 appendfsync 設置爲 always,fsync 會和客戶端的指令同步執行,所以最可能形成延時問題,但備份及時性最好。

  2. everysec:每秒鐘異步執行一次 fsync,此時 redis 的性能表現會更好,可是 fsync 依然可能阻塞 write,算是一個折中選擇。

  3. no:redis 不會主動出發 fsync (並非永遠不 fsync,那是不太可能的),而由 kernel 決定什麼時候 fsync

使用分佈式架構 —— 讀寫分離、數據分片

以上,咱們都是基於單臺,或者單個 Redis 服務進行優化。下面,咱們考慮當網站的規模變大時,利用分佈式架構來保障 Redis 性能的問題。

首先說,哪些狀況下不得不(或者最好)使用分佈式架構:

  1. 數據量很大,單臺服務器內存不可能裝得下,好比 1 個 T 這種量級

  2. 須要服務高可用

  3. 單臺的請求壓力過大

解決這些問題能夠採用數據分片或者主從分離,或者二者都用(即,在分片用的 cluster 節點上,也設置主從結構)。

這樣的架構,能夠爲性能提高加入新的切入點:

  1. 把慢速的指令發到某些從庫中執行

  2. 把持久化功能放在一個不多使用的從庫上

  3. 把某些大 list 分片

其中前兩條都是根據 Redis 單線程的特性,用其餘進程(甚至機器)作性能補充的方法。

固然,使用分佈式架構,也可能對性能有影響,好比請求須要被轉發,數據須要被不斷複製分發。(待查)

總結

其實還有不少東西也影響 Redis 的性能,好比 active rehashing(keys 主表的再哈希,每秒 10 次,關掉它能夠提高一點點性能),可是這篇博客已經寫的很長了。並且,更重要不是收集已經被別人提出的問題,而後記憶解決方案;而是掌握 Redis 的基本原理,以不變應萬變的方式決絕新出現的問題。

小編總結了2020面試題,這份面試題的包含的模塊分爲19個模塊,分別是: Java 基礎、容器、多線程、反射、對象拷貝、Java Web 、異常、網絡、設計模式、Spring/Spring MVC、Spring Boot/Spring Cloud、Hibernate、MyBatis、RabbitMQ、Kafka、Zookeeper、MySQL、Redis、JVM 。

關注公衆號:程序員白楠楠,獲取上述資料。

相關文章
相關標籤/搜索