高併發存儲番外篇:Redis 套路,一網打盡
本文內容提要
Redis 爲什麼這麼快
1.1. 數據結構 SDS 的妙用
1.2. 性能優良的事件模型驅動
1.3. 基於內存的操作Redis 爲什麼這麼靠譜
2.1. AOF 持久化
2.2. RDB 持久化
2.3. Sentinel 高可用Redis6.x 多線程一覽
Redis 最佳實踐
Part1Redis 爲什麼這麼快
1.1 數據結構 SDS 的妙用
我們知道 redis 的底層是用 c 語言來編寫的,但是,數據結構確沒有直接套用 C 的結構,而是根據 redis 的定位自建了一套數據結構。
C 語言中的字符串結構:
SDS 定義下的字符串結構:
可以看到,相比於 C 語言來說,也就多了幾個字段,分別用來標識空閒空間和當前數據長度,但簡直是神來之筆:
-
可以 O(1) 複雜度獲取字符串長度;有 len 字段的存在,無需像 C 結構一樣遍歷計數。
-
杜絕緩存區溢出;C 字符串不記錄已佔用的長度,所以需要提前分配足夠空間,一旦空間不夠則會溢出。而有 free 字段的存在,讓 SDS 在執行前可以判斷並分配足夠空間給程序
-
減少字符串修改帶來的內存重分配次數;有 free 字段的存在,使 SDS 有了空間預分配和惰性釋放的能力。
-
對二進制是安全的;二進制可能會有字符和 C 字符串結尾符 '\0' 衝突,在遍歷和獲取數據時產生截斷異常,而 SDS 有了 len 字段,準確了標識了數據長度,不需擔心被中間的 '\0' 截斷。
上面的內容以字符串來說明 SDS 和 C 語言數據結構的差異和優勢。順便來看看鏈表、hash 表、跳錶分別被 Redis 設計成了什麼樣的數據結構:
可以看到,Redis 在設計數據結構的時候出發點是一致的。總結起來就是一句話:空間換時間。
用犧牲存儲空間和微小的計算代價,來換取數據的快速操作
1.2 性能優良的事件驅動模式
redis6.x 之前,一直在說單線程如何如之何的好。
那麼,具體單線程體現在哪裏,又是怎麼完成數據讀寫工作的呢?
$ 單線程
關於新版本的多線程模型在後面小節單獨說,這裏先說單線程。
所謂單線程是指對數據的所有操作都是由一個線程按順序挨個執行的,使用單線程可以:
-
避免了不必要的上下文切換和競爭條件,也不存在多進程或者多線程導致的切換而消耗 CPU;
-
不用去考慮各種鎖的問題,不存在加鎖釋放鎖操作,沒有因爲可能出現死鎖而導致的性能消耗。
然而,使用了單線程的處理方式,就意味着到達服務端的請求不可能被立即處理。
那麼怎麼來保證單線程的資源利用率和處理效率呢?
$ IO 多路複用和事件驅動
Redis 服務端,從整體上來看,其實是一個事件驅動的程序,所有的操作都以事件的方式來進行。
如圖所示,Redis 的事件驅動架構由套接字、I/O 多路複用、文件事件分派器、事件處理器四個部分組成:
套接字 (Socket),是對網絡中不同主機上的應用進程之間進行雙向通信的端點的抽象。
I/O 多路複用,通過監視多個描述符,當描述符就緒,則通知程序進行相應的操作,來幫助單個線程高效的處理多個連接請求。
Redis 爲每個 IO 多路複用函數都實現了相同的 API,因此,底層實現是可以互換的。
Reids 默認的 IO 多路複用機制是 epoll,和 select/poll 等其他多路複用機制相比,epoll 具有諸多優點:
事件驅動,Redis 設計的事件分爲兩種,文件事件和時間事件,文件事件是對套接字操作的抽象,而時間事件則是對一些定時操作的抽象。
文件事件:
-
客戶端連接請求(AE_READABLE 事件)
-
客戶端命令請求(AE_READABLE 事件)和事
-
服務端命令回覆(AE_WRITABLE 事件)
時間事件: 分爲定時事件和週期性時間;redis 的所有時間事件都存放在一個無序鏈表中,當時間事件執行器運行時,需要遍歷鏈表以確保已經到達時間的事件被全部處理。
可以看到,Redis 整個執行方案是通過高效的 I/O 多路複用件驅動方式加上單線程內存操作來達到優秀的處理效率和極高的吞吐量。
1.3 基於內存的操作
上面的小節也提到了,redis 之所以可以使用單線程來處理,其中的一個原因是,內存操作對資源損耗較小,保證了處理的高效性。
如此寶貴的內存資源,Redis 是怎麼維護和管理的呢?
$ 除了增刪改查還有哪些維護性操作 [1]
命中率統計,在讀取一個鍵之後,服務器會根據鍵是否存在來更新服務器的鍵空間命中次數或鍵空間不命中次數。
LRU 時間更新,在讀取一個鍵之後,服務器會更新鍵的 LRU 時間,這個值可以用於計算鍵的閒置時間。
惰性刪除,如果服務器在讀取一個鍵時發現該鍵已經過期,那麼服務器會先刪除這個過期鍵,然後才執行餘下的其他操作。
鍵的 dirty 標識,如果有客戶端使用 WATCH 命令監視了該鍵,服務器會將這個鍵標記爲 dirty,讓事務程序注意到這個鍵已經被修改過。每次修改都會對 dirty 加一,用於_觸發持久化和複製_。
數據庫通知,“如果服務器開啓了數據庫通知功能,那麼在對鍵進行修改之後,服務器將按配置發送相應的數據庫通知”
$ Redis 何如管理內存
過期鍵刪除,內存和 CPU 資源都是寶貴的,Redis 通過定期刪除設定合理的執行時長和執行頻率,配合惰性刪除兜底的方式,來達到 CPU 時間佔用和內存浪費之間的平衡。
數據淘汰,如果 key 生產的太快,定期刪除操作跟不上新生產的速率,而這些 key 又很少被訪問無法觸發惰性刪除,是否會把內存撐爆?回答是不會,因爲 redis 有數據淘汰策略:
-
noeviction:當內存不足以容納新寫入數據時,新寫入操作會報錯。
-
allkeys-lru:當內存不足以容納新寫入數據時,,移除最近最少使用的 Key。
-
allkeys-random:當內存不足以容納新寫入數據時,隨機移除某個 Key。
-
volatile-lru:當內存不足以容納新寫入數據時,在設置了過期時間的鍵空間中,移除最近最少使用的 Key。
-
volatile-random:當內存不足以容納新寫入數據時,在設置了過期時間的鍵空間中,隨機移除某個 Key。
-
volatile-ttl:當內存不足以容納新寫入數據時,在設置了過期時間的鍵空間中,有更早過期時間的 Key 優先移除。
值得一提的是,這裏的 lru 和平常我們所熟知的 lru 還不完全一樣,redis 使用的是採樣概率的思想,省略了雙向鏈表的內存消耗。
Redis 會在每一次處理命令的時候判斷是否達到了最大限制,如果達到則使用對應的算法去刪除涉及到的 Key,這時,我們前面所維護過鍵的 LRU 值就會派上用場了。
Part2Redis 爲什麼這麼靠譜
天有不測風雲,服務器也有趴窩的時候,Redis 這個基於內存的存儲遇到服務器宕機該怎麼應對呢?
2.1RDB 持久化
持久化是一種常見的解決方案,那麼,我們首先能想到的最簡單的持久化方案,就是每隔一段時間把內存裏的數據保存一次,來避免絕大部分數據的丟失。這也是 Redis 的 RDB 持久化得思路。
RDB 有兩種方式,save 和 bgsave
save,會阻塞服務器的其他操作,直到 save 執行完成,所以,這個期間的所有命令請求都會被拒絕。對客戶端影響較大。
BGSave,由子進程進行數據保存,期間 redis 仍然可以繼續處理客戶端請求。爲了防止競爭和衝突,bgsave 被設計成和 save/bgrewriteaof 操作互斥。
Redis 服務器默認每 100 毫秒執行一次,如果數據庫修改次數 (dirty 計數器) 大於設置的閾值,並且距離上次執行保存的時間 (lastsave 屬性) 大於設置的閾值,則執行保存操作。
因爲是統一批量的保存操作,rdb 文件有二進制存儲、結構緊湊、空間消耗少、恢復速度快等特點,在持久化方案上不可或缺。
2.2AOF 持久化
然而,因爲 bgsave 的週期間隔和保存觸發條件等原因,在服務器宕機時,不可避免的會丟失一部分最新的數據。這就需要一些輔助手段來做持久化補充。
RDB 保存的是鍵值對,而 AOF 則用來保存寫命令。
爲什麼 AOF 保存的是命令,而不是鍵值對呢?
Coder 的技術之路認爲,一是因爲 aof 刷盤,是在文件事件處理過程當中的,具體位置是在結束一個事件循環之前,調用追加函數進行,所以,使用請求命令來存儲更方便;二是如果遇到追加過程中命令被破壞,也可以通過 redis-check-aof 來恢復 (命令恢復起來比較方便)。
AOF 刷盤策略,由於 aof 追加動作是和客戶端請求處理串行執行的,所以每次都刷盤對性能影響較大,因此都是先追加到 aof_buf 緩存區裏,而是否同步到 AOF 文件中則依賴 always、everysec(默認)、no 的刷盤配置。想比 everysec ,always 對性能影響較大,而 no 則容易丟失數據。
AOF 文件重寫壓縮,AOF 因爲保存了請求命令,自然要比 RDB 更大,並且隨着程序的運行,會越來越大,然而,文件中有很多冗餘的命令數據是可以壓縮的,因爲對於某個鍵值對,某一時刻只會有一個狀態。
那麼,在重寫過程中新產生的操作該怎麼辦呢?
2.3Sentinel 高可用解決方案
上面兩個小節,主要是在闡述單機服務器的數據穩定性保障,那麼,如果是多機、多進程該怎麼來保障呢?
哨兵的作用:監視服務節點的健康
當主節點宕機時,由哨兵感知,並在從節點中重新選舉主節點:
同時,sentinel 還會監視宕機的 master 節點,恢復之後會將其設置爲從節點加入集羣。
除了主從切換的 sentinel 方案,還有 Cluster 集羣模式來保障 redis 的高可用,用來解決主從複製的存儲浪費問題。
Part3Redis6.x 的多線程
之前已經闡述過了單線程模型的整體流程,這裏不太贅述。
Redis 的多線程模型,不是傳統意義上的多線程併發,而是把 socket 解析回寫的這部分操作並行化,以解決 IO 上的時間消耗帶來的系統瓶頸。
對客戶端的任何請求,其實還是主線程在執行,避免了操作相同數據時線程間的競爭,把 io 部分並行化,降低了 io 對資源的損耗,從而提升了系統的吞吐量。仔細想來,感覺和 rpc 中的異步調用差不多意思,都是綁定來源,等待處理完成後給給各來源返回對應結果。
Part4Redis 最佳實踐
Redis 被當做分佈式緩存的應用場景非常普遍,有關緩存穿透、緩存擊穿、緩存雪崩、數據漂移、緩存踩踏、緩存污染、熱點 key 等常見問題,在上一篇文章 諸多策略,緩存爲王中已經有了詳細闡述,這裏不再重複。
這裏主要給出一些日常開發中的關注點:
-
Key 的設計。儘量控制 key 的長度,一是過長會佔用較多空間,二是我們知道鍵空間是字典類型,即時本身在查找過程中很快,過長的鍵也會對比較判斷時間有所增加。
-
批量命令的使用。因爲 redis 操作絕大部分都耗在網絡傳輸上,將多次傳輸改爲一次傳輸,大概率會提升效果。
-
value 的大小。儘量避免大 value,原因同上,value 太大會影響網絡傳輸效率。比如,之前的一次經歷,批量獲取了 200 個商品的信息 (信息比較多,可以認爲是大 value),發現很慢,後來把 200 拆成了 4 個 50,並行去調用,效果提升的比較明顯。這個問題也可以考慮用數據壓縮的方式進行優化
-
複雜命令的使用。比如排序、聚合等等操作,應該在離線階段就處理完畢,然後再存入緩存,而不是在線使用複雜命令去計算。
-
善用數據結構。redis 豐富的數據結構對支撐業務有天然的優勢,比如,之前曾用消息隊列配合 bitmap 數據結構存儲和維護商品的多個狀態 (庫存、上下架、秒殺、黑白名單等),getbit 來直接判斷該商品是否允許展示。
其實沒有什麼最佳實踐,業務各有各的不同,都需要在實踐中研究嘗試,如果大家有非常好的實際案例,也歡迎補充,歡迎留言交流~
**鑑於之前 5000 字及以上長文的閱讀完成率,很少有讀者朋友可以看到這裏。如果兄弟竟然真的看到了這段話,那說明。。。 **
要是覺得文章還湊活,其實可以點個贊鼓勵一下
參考資料
[1] Redis 設計與實現: 黃健宏. 著
本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源:https://mp.weixin.qq.com/s/cJwxQdudc99-YzGA3Fzaqg