Redis所有的資料都存在記憶體中,目前記憶體雖然越來越便宜,但跟廉價的硬碟相比成本還是比較昂貴,是以如何高效利用Redis記憶體變得非常重要。高效利用Redis記憶體首先需要了解Redis記憶體消耗在哪裡,如何管理記憶體,最後才能考慮如何優化記憶體。
1.記憶體消耗
首先需要掌握Redis記憶體消耗在哪些方面。有些記憶體消耗是必不可少的,而有些可以通過參數調整和合理使用來規避記憶體浪費。記憶體消耗可以分為程序自身消耗和子程序消耗。
1.1 記憶體使用統計
首先需要了解 Redis 自身使用記憶體的統計資料,可通過執行 info memory指令擷取記憶體相關名額。讀懂每個名額有助于分析Redis記憶體使用情況,表8-1列舉出記憶體統計名額和對應解釋。
info memory詳細解釋 | |
屬性名 | 屬性說明 |
used_memory | Redis配置設定器配置設定的記憶體總量,也就是内部存儲的所有資料記憶體占用量 |
used_memory_human | 以可讀的格式傳回used_memory |
used_memory_rss | 從作業系統的角度顯示Redis程序占用的實體記憶體總量 |
used_memory_peak | 記憶體使用的最大值,表示used_memory的峰值 |
used_memory_peak_human | 以可讀的格式傳回used_memory peak |
used_memory_lua | Lua引擎所消耗的記憶體大小 |
mem_fragmentation_ratio | used_memory_rss/used_memory比值,表示記憶體碎片率 |
mem_allocator | Redis所使用的記憶體配置設定器。預設jemalloc |
需要重點關注的名額有:used_memory_rss和used_memory以及它們的比值mem_fragm entation_ratio。
當mem_fragmentation_ratio > 1 時,說明 used_memory_rss-used_memory多出的部分記憶體并沒有用于資料存儲,而是被記憶體碎片所消耗,如果兩者相差很大,說明碎片率嚴重。
當mem_fragmentation_ratio < 1 時,這種情況一般出現在作業系統把Redis記憶體交換(Swap) 到硬碟導緻,出現這種情況時要格外關注,由于硬碟速度遠遠慢于記憶體,Redis性能會變得很差,甚至僵死。
1.2 記憶體消耗劃分
Redis程序内消耗主要包括: 自身記憶體+對象記憶體+緩沖記憶體+記憶體碎片,其中Redis空程序自身記憶體消耗非常少,通常used_memory_rss在3MB左右,used_memory在800KB左右 ,一個空的Redis程序消耗記憶體可以忽略不計。Redis主要記憶體消耗如圖8-1所示。下面介紹另外三種記憶體消耗。
1.對象記憶體
對象記憶體是Redis記憶體占用最大的一塊,存儲着使用者所有的資料。Redis所有的資料都采用 key-value資料類型,每次建立鍵值對時,至少建立兩個類型對象:key對象和value對象。對象記憶體消耗可以簡單了解為sizeof(keys)+ sizeof (values)。鍵對象都是字元串,在使用Redis時很容易忽略鍵對記憶體消耗的影響,應當避免使用過長的鍵。value對象更複雜些,主要包含5 種基本資料類型:字元串、清單、哈希、集合、有序集合。其他資料類型都是建立在這5 種資料結構之上實作的,如:Bitmaps和 HyperLogLog使用字元串實作,GEO使用有序集合實作等。每種value對象類型根據使用規模不同,占用記憶體不同。在使用時一定要合理預估并監控value對象占用情況,避免記憶體溢出。
2.緩沖記憶體
緩沖記憶體主要包括:用戶端緩沖、複制積壓緩沖區、AOF緩沖區。
用戶端緩沖指的是所有接入到Redis伺服器TCP連接配接的輸人輸出緩沖。輸入緩沖無法控制,最大空間為1G , 如果超過将斷開連接配接。輸出緩沖通過參數client-output-buffer-limit 控制,如下所示:
□ 普通用戶端: 除了複制和訂閱的用戶端之外的所有連接配接,Redis的預設配置是: clie nt-output-buffer-limit normal 0 0 0, Redis并沒有對普通用戶端的輸出緩沖區做限制,一般普通用戶端的記憶體消耗可以忽略不計,但是當有大量慢連接配接用戶端接入時這部分記憶體消耗就不能忽略了,可以設定maxclients做限制。特别是當使用大量資料輸出的指令且資料無法及時推送給用戶端時,如monitor指令,容易造成Redis伺服器記憶體突然飙升。
□ 從用戶端: 主節點會為每個從節點單獨建立一條連接配接用于指令複制,預設配置是:client-output-buffer-limit slave 256mb 64mb 60。當主從節點之間網絡延遲較高或主節點挂載大量從節點時這部分記憶體消耗将占用很大一部分,建議主節點挂載的從節點不要多于2 個,主從節點不要部署在較差的網絡環境下,如異地跨機房環境,防止複制用戶端連接配接緩慢造成溢出。
□ 訂閱用戶端:當使用釋出訂閱功能時,連接配接用戶端使用單獨的輸出緩沖區,預設配置為: client-output-buffer-limit pubsub 32mb 8mb 60,當訂閱服務的消息生産快于消費速度時,輸出緩沖區會産生積壓造成輸出緩沖區空間溢出。輸入輸出緩沖區在大流量的場景中容易失控,造成Redis記憶體的不穩定,需要重點監控。
複制積壓緩沖區: Redis在2.8版本之後提供了一個可重用的固定大小緩沖區用于實作部分複制功能,根據repl-backlog-size參數控制,預設1MB。對于複制積壓緩沖區整個主節點隻有一個,所有的從節點共享此緩沖區,是以可以設定較大的緩沖區空間,100MB,這部分記憶體投入是有價值的,可以有效避免全量複制。更多細節見Redis複制章節。
3.記憶體碎片
Redis預設的記憶體配置設定器采用jemalloc,可選的配置設定器還有:glibc、tcmalloc。記憶體配置器為了更好地管理和重複利用記憶體,配置設定記憶體政策一般采用固定範圍的記憶體塊進行配置設定。例如jemalloc在64位系統中将記憶體空間劃分為: 小、大、巨大三個範圍。每個範圍内又劃分為多個小的記憶體塊機關,如下所示:
□ 小:[8byte], [16byte, 32byte, 48byte, 128byte], [192byte,256byte, 512byte], [768byte, 1024byte, ...,3840byte]
□ 大: [4KB, 8KB, 12KB, ...,4072KB]
□ 巨大: [4MB, 8MB, 12MB , ...]
比如當儲存5KB對象時jemalloc可能會采用8KB的塊存儲,而剩下的3KB空間變為了記憶體碎片不能再配置設定給其他對象存儲。記憶體碎片問題雖然是所有記憶體服務的通病,但是jemalloc針對碎片化問題專門做了優化,一般不會存在過度碎片化的問題,正常的碎片率(mem_fragmentation_ratio) 在1.03左右。但是當存儲的資料長短差異較大時,以下場景容易出現高記憶體碎片問題:
口 頻繁做更新操作,例如頻繁對已存在的鍵執行append、setrange等更新操作。
□ 大量過期鍵删除,鍵對象過期删除後,釋放的空間無法得到充分利用,導緻碎片率上升。
出現高記憶體碎片問題時常見的解決方式如下:
□ 資料對齊: 在條件允許的情況下盡量做資料對齊,比如資料盡量采用數字類型或者固定長度字元串等,但是這要視具體的業務而定,有些場景無法做到。
□ 安全重新開機: 重新開機節點可以做到記憶體碎片重新整理,是以可以利用高可用架構,如Sentinel或 Cluster, 将碎片率過高的主節點轉換為從節點,進行安全重新開機。
4.子程序記憶體消耗
子程序記憶體消耗主要指執行AOF/RDB重寫時Redis建立的子程序記憶體消耗。Redis執行fork 操作産生的子程序記憶體占用量對外表現為與父程序相同,理論上需要一倍的實體記憶體來完成重寫操作。但 Linux具有寫時複制技術(copy-on-write),父子程序會共享相同的實體記憶體頁,當父程序處理寫請求時會對需要修改的頁複制出一份副本完成寫操作,而子程序依然讀取fork 時整個父程序的記憶體快照。
Linux Kernel在2.6.38核心增加了Transparent Huge Pages(THP)機制,而有些 Linux發行版即使核心達不到2.6.38也會預設加入并開啟這個功能,如 Redhat Enterprise Linux在6.0以上版本預設會引人THP。雖然開啟THP可以降低fork子程序的速度,但之後copy-on-write期間複制記憶體頁的機關從4KB變為2MB,如果父程序有大量寫指令,會加重記憶體拷貝量,進而造成過度記憶體消耗。例如,以下兩個執行AOF重寫時的記憶體消耗日志:
〃開啟 THP:
C * AOF rewrite: 1039 MB of memory used by copy-on-write
//關閉 THP:
C * AOF rewrite: 9MB of memory used by copy-on-write
這兩個日志出自同一Redis程序,used_memory總量為1.5GB,子程序執行期間每秒寫指令量都在200左右。當分别開啟和關閉 THP 時,子程序記憶體消耗有天壤之别。如果在高并發寫的場景下開啟THP,子程序記憶體消耗可能是父程序的數倍,極易造成機器實體記憶體溢出,進而觸發SWAP或 OOM killer。
子程序記憶體消耗總結如下:
□ Redis産生的子程序并不需要消耗1倍的父程序記憶體,實際消耗根據期間寫入令量決定,但是依然要預留出一些記憶體防止溢出。
口 要設定sysctl vm.overcommit_memory=l 允許核心可以配置設定所有的實體記憶體,防止Redis程序執行fork時因系統剩餘記憶體不足而失敗。
□ 查目前系統是否支援并開啟THP, 如果開啟建議關閉,防止copy-on-write期間記憶體過度消耗。
2.記憶體管理
Redis主要通過控制記憶體上限和回收政策實作記憶體管理,本節将圍繞這兩個方面來介紹Redis如何管理記憶體。
2.1 設定記憶體上限
Redis使用maxmemory參數限制最大可用記憶體。限制記憶體的目的主要有:
□ 用于緩存場景,當超出記憶體上限maxmemory時使用LRU等删除政策釋放空間。
□ 防止所用記憶體超過伺服器實體記憶體。
需要注意,maxmemory限制的是Redis實際使用的記憶體量,也就是used_memory統計項對應的記憶體。由于記憶體碎片率的存在,實際消耗的記憶體可能會比maxmemory設定的更大,實際使用時要小心這部分記憶體溢出。通過設定記憶體上限可以非常友善地實作一台伺服器部署多個Redis程序的記憶體控制。比如一台24GB記憶體的伺服器,為系統預留4GB記憶體,預留4GB空閑記憶體給其他程序或Redis fork 程序,留給Redis 16GB記憶體,這樣可以部署4個 maxmemory=4GB的Redis程序。得益于Redis單線程架構和記憶體限制機制,即使沒有采用虛拟化,不同的Redis程序之間也可以很好地實作CPU和記憶體的隔離性,如圖8-2所示。
2.2 動态調整記憶體上限
Redis的記憶體上限可以通過config set maxmemory進行動态修改,即修改最大可用記憶體。例如之前的示例,當發現Redis-2沒有做好記憶體預估,實際隻用了不到2GB記憶體,而Redis-1執行個體需要擴容到6GB記憶體才夠用,這時可以分别執行如下指令進行調整:
Redis-1>config set maxmemory 6GB
Redis-2>config set maxmemory 2GB
通過動态修改maxmemory, 可以實作在目前伺服器下動态伸縮Redis記憶體的目的,如圖8-3所示。這個例子過于理想化,如果此時Redis-3和 Redis-4執行個體也需要分别擴容到6GB, 這時超出系統實體記憶體限制就不能簡單的通過調整maxmemory來達到擴容的目的,需要采用線上遷移資料或者通過複制切換伺服器來達到擴容的目的。可以參考哨兵和叢集。
Redis預設無限使用伺服器記憶體,為防止極端情況下導.緻系統記憶體耗盡,建議所有的Redis程序都要配置maxmemory。
在保證實體記憶體可甩的情況下,系統中所有Redis執行個體可以調整maxmemory參數來達到自由伸縮記憶體的目的。
2.3 記憶體回收政策
Redis的記憶體回收機制主要展現在以下兩個方面:
□ 删除到達過期時間的鍵對象。
□ 記憶體使用達到maxmemory上限時觸發記憶體溢出控制政策。
1.删除過期鍵對象
Redis所有的鍵都可以設定過期屬性,内部儲存在過期字典中。由于程序内儲存大量的鍵,維護每個鍵精準的過期删除機制會導緻消耗大量的CPU, 對于單線程的Redis來說成本過高,是以Redis采用惰性删除和定時任務删除機制實作過期鍵的記憶體回收
□ 惰性删除:惰性删除用于當用戶端讀取帶有逾時屬性的鍵時,如果已經超過鍵設定的過期時間,會執行删除操作并傳回空,這種政策是出于節省CPU成本考慮,不需要單獨維護TTL連結清單來處理過期鍵的删除。但是單獨用這種方式存在記憶體洩露的問題,當過期鍵一直沒有通路将無法得到及時删除,進而導緻記憶體不能及時釋放。正因為如此,Redis還提供另一種定時任務删除機制作為惰性删除的補充。
□ 定時任務删除:Redis内部維護一個定時任務,預設每秒運作10次 (通過配置hz控制)。定時任務中删除過期鍵邏輯采用了自适應算法,根據鍵的過期比例、使用快慢兩種速率模式回收鍵,流程如圖8-4所示。
流程說明:
1) 定時任務在每個資料庫空間随機檢查20個鍵,當發現過期時删除對應的鍵。
2) 如果超過檢查數25%的鍵過期,循環執行回收邏輯直到不足25% 或運作逾時為止,慢模式下逾時時間為25毫秒。
3) 如果之前回收鍵邏輯逾時,則在 Redis 觸發内部事件之前再次以快模式運作回收過期鍵任務,快模式下逾時時間為1毫秒且2 秒内隻能運作1次。
4) 快慢兩種模式内部删除邏輯相同,隻是執行的逾時時間不同。
2.記憶體溢出控制政策
當Redis所用記憶體達到maxmemory上限時會觸發相應的溢出控制政策。具體政策受maxmemory-policy參數控制,Redis支援6種政策,如下所示:
1) noeviction: 預設政策,不會删除任何資料,拒絕所有寫人操作并傳回用戶端錯誤資訊(error) OOM command not allowed when used memory, 此時 Redis 隻響應讀操作。
2) volatile-lru: 根據LRU算法删除設定了逾時屬性(expire) 的鍵,直到騰出足夠空間為止。如果沒有可删除的鍵對象,回退到noeviction 政策。
3) allkeys-lru: 根據LRU算法删除鍵,不管資料有沒有設定逾時屬性,直到騰出足夠空間為止。
4) allkeys-random: 随機删除所有鍵,直到騰出足夠空間為止。
5) volatile-random: 随機删除過期鍵,直到騰出足夠空間為止。
6) vlatile-ttl : 根據鍵值對象的ttl屬性,删除最近将要過期資料。如果沒有,回退到noeviction政策。
記憶體溢出控制政策可以采用config set maxmemory-policy {policy}動态配置。Redis支援豐富的記憶體溢出應對政策,可以根據實際需求靈活定制,比如當設定volatile-lru政策時,保證具有過期屬性的鍵可以根據LRU剔除,而未設定逾時的鍵可以永久保留。還可以采用allkeys-lru政策把Redis變為純緩存伺服器使用。當 Redis因為記憶體溢出删除鍵時,可以通過執行info stats指令檢視evicted _keys名額找出目前Redis伺服器已剔除的鍵數量。
每次Redis執行指令時如果設定了maxmemory參數,都會嘗試執行回收記憶體操作。當Redis—直工作在記憶體溢出(used_memory>maxmemory) 的狀态下且設定非noeviction政策時,會頻繁地觸發回收記憶體的操作,影響Redis伺服器的性能。
頻繁執行回收記憶體成本很高,主要包括查找可回收鍵和删除鍵的開銷,如果目前Redis有從節點,回收記憶體操作對應的删除指令會同步到從節點,導緻寫放大的問題,如圖8-5所示。
建議線上Redis記憶體工作在 maxmemory>used_memory 狀态下,避免頻繁記憶體回收開銷。
對于需要收縮 Redis 記憶體的場景,可以通過調小maxmemory來實作快速回收。比如對一個實際占用6GB記憶體的程序設定maxmemory=4GB, 之後第一次執行指令時,如果使用非n oeviction政策,它會一次性回收到maxmemory指定的記憶體量,進而達到快速回收記憶體的目的。注意,此操作會導緻資料丢失和短暫的阻塞問題,一般在緩存場景下使用。
3.記憶體優化
Redis所有的資料都在記憶體中,而記憶體又是非常寶貴的資源。如何優化記憶體的使用一直是Redis使用者非常關注的問題。
3.1 redisObject對象
Redis存儲的所有值對象在内部定義為redis Object結構體,内部結構如圖8-6所示。
Redis 存儲的資料都使用redis Object來封裝,包 括string、hash、list、set、z set在内的所有資料類型。了解redis Object對記憶體優化非常有幫助,下面針對每個字段做詳細說明:
□ type字段:表示目前對象使用的資料類型,Red is主要支援5 種資料類型:strin g、hash、list、set、zset。可以使用type {key}指令査看對象所屬類型,type指令傳回的是值對象類型,鍵都是string類型。
□ encoding字段:表示Redis内部編碼類型,encoding在 Redis 内部使用,代表目前對象内部采用哪種資料結構實作。了解 Redis 内部編碼方式對于優化記憶體非常重要,同一個對象采用不同的編碼實作記憶體占用存在明顯差異。
□ lru 字段:記錄對象最後一次被通路的時間,當配置了 maxmemory 和 maxmemory-policy=volatile-lru 或者allkeys-lru 時,用于輔助LRU算法删除鍵資料。可以使用 object idletime {key} 指令在不更新 lru 字段情況下檢視目前鍵的空閑時間。
開發提示: 可以使用 scan + object idletime 指令批量查詢哪些鍵長時間未被通路,找出長時間不通路的鍵進行清理,可降低記憶體占用。
□ refcount 字段: 記錄目前對象被引用的次數,用于通過引用次數回收記憶體,當Refcount=0 時,可以安全回收目前對象空間。使用 object refcount {key}擷取目前對象引用。當對象為整數且範圍在[0-9999]時,Redis可以使用共享對象的方式來節省記憶體。具體細節見之後節 “共享對象池”部分。
□ *Ptr 字段: 與對象的資料内容相關,如果是整數,直接存儲資料;否則表示指向資料的指針。 Redis在3.0之後對值對象是字元串且長度 <=39 位元組的資料,内部編碼為 embstr 類型,字元串 sds 和 redisObject —起配置設定,進而隻要一次記憶體操作即可。
開發提示: 高并發寫入場景中,在條件允許的情況下,建議字元串長度控制在39位元組以 内 ,減少建立redisObject 記憶體配置設定次數,進而提高性能。
3.2 縮減鍵值對象
降低Redis記憶體使用最直接的方式就是縮減鍵(key)和值(value) 的長度。
□ key長度:如在設計鍵時,在完整描述業務情況下,鍵值越短越好。如 user:{uid}: friends:notify:{fid}可以簡化為 u:{uid}:fs:nt:{fid}。
口 value長度:值對象縮減比較複雜,常見需求是把業務對象序列化成二進制數組放入Redis。首先應該在業務上精簡業務對象,去掉不必要的屬性避免存儲無效資料。其次在序列化工具選擇上,應該選擇更高效的序列化工具來降低位元組數組大小。以 Java為例,内置的序列化方式無論從速度還是壓縮比都不盡如人意,這時可以選擇更高效的序列化工具,如:Protostuff、kryo等,圖 8-7 是 Java 常見序列化工具空間壓縮對比。
其中java-built-in-serializer表示Java内 置 序 列 化 方 式 ,更多資料見jvm-serializers 項目:https://github.com/eishay/jvm-serializers/wiki,其他語言也有各自對應的高效序列化工具。
值對象除了存儲二進制資料之外,通常還會使用通用格式存儲資料比如:json、xml等作為字元串存儲在Redis中。這種方式優點是友善調試和跨語言,但是同樣的資料相比位元組數組所需的空間更大,在記憶體緊張的情況下,可以使用通用壓縮算法壓縮json、xml後再存入 Redis,進而降低記憶體占用,例如使用 GZIP 壓縮後的json可降低約 60% 的空間。
3.3 共享對象池
共享對象池是指Redis内部維護[0-9999] 的整數對象池。建立大量的整數類型redisObject存在記憶體開銷,每個redisObject内部結構至少占 16 位元組,甚至超過了整數自身空間消耗。是以 Redis 記憶體維護一個[0-9999] 的整數對象池,用于節約記憶體。除了整數值對象,其他類型如list、hashset、zset内部元素也可以使用整數對象池。是以開發中在滿足需求的前提下,盡量使用整數對象以節省記憶體。
整數對象池在 Redis 中通過變量 REDIS_SHARED_INTEGERS 定義,不能通過配置修改。可以通過object refcount指令檢視對象引用數驗證是否啟用整數對象池技術,如下:
redis> set foo 100
OK
redis> object refcount foo
(integer) 2
redis> set bar 100
OK
redis> object refcount bar
(integer) 3
設定鍵foo等于100時,直接使用共享池内整數對象,是以引用數是2,再設定鍵。bar等于100時,引用數又變為3,如圖 8-8所示。
使用整數對象池究竟能降低多少記憶體?讓我們通過測試來對比對象池的記憶體優化效果。如表8-2所示。
表8-2 是否使用整數對象池記憶體對比 | |||||
操作說明 | 是否對象共享 | Key大小 | Value大小 | used_mem | |
插入200萬 | 否 | 20位元組 | [0-9999]整數 | 199.91MB | 205.28MB |
是 | 138.87MB | 143.28MB |
本章所有 測試環境都保持一緻,資訊如下 :
伺服器資訊: cpu=Intel-Xeon E56O602.13GHz memory=32GB
Redis 版本 :Redis server v=3.0.7 sha=00000000:0 malloc=jemalloc-3.6.0 bits=64
使用共享對象池後,相同的資料記憶體使用降低30%以上。可見當資料大量使用[0-9999]的整數時,共享對象池可以節約大量記憶體。需要注意的是對象池并不是隻要存儲[0-9999]的 整數就可以工作。當設定maxmemory并啟用LRU相關淘汰政策如:volatile-lru,allkeys-lru 時,Redis 禁止使用共享對象池,測試指令如下:
redis> set key:1 99
OK //設定 key: 1=99
redis> object refcount key:1
(integer) 2 //使用了對象共享,引用數為2
redis> config set maxmemory-policy volatile-lru
OK //開啟LRU淘汰政策
redis> set key:2 99
OK //設定 key: 2 = 99
(integer) 3 //使用了對象共享,引用數變為3
redis> config set maxmemory 1GB
OK //設定最大可用記憶體
redis> set key:3 99
OK //設定 key:3=99
redis> object refcount key:3
(integer) 1 //未使用對象共享,引用數為1
redis> config set maxmemory-policy volatile-ttl
OK //設定非L R U 淘汰政策
redis> set key:4 99
OK //設定 key: 4 99
redis> object refcount key:4
(integer) 4 //又可以使用對象共享,引用數變為4
為什麼開啟maxmemory和LRU淘汰政策後對象池無效?
LRU 算法需要擷取對象最後被通路時間,以便淘汰最長未通路資料,每個對象最後通路時間存儲在redisObject對象的lru字段。對象共享意味着多個引用共享同一個redisO bject,這時lru字段也會被共享,導緻無法擷取每個對象的最後通路時間。如果沒有設定maxmemory, 直到記憶體被用盡 Redis 也不會觸發記憶體回收,是以共享對象池可以正常工作。
綜上所述,共享對象池與maxmemory+LRU政策沖突,使用時需要注意。對于ziplist 編碼的值對象,即使内部資料為整數也無法使用共享對象池,因為ziplist使用壓縮且記憶體連續的結構,對象共享判斷成本過高,ziplist編碼細節後面内容詳細說明。
為什麼隻有整數對象池?
首先整數對象池複用的幾率最大,其次對象共享的一個關鍵操作就是判斷相等性, Redis之是以隻有整數對象池,是因為整數比較算法時間複雜度為0(1),隻保留一萬個整數為了防止對象池浪費。如果是字元串判斷相等性,時間複雜度變為特别是長字元串更消耗性能(浮點數在 Redis 内部使用字元串存儲)。對于更複雜的資料結構如hash、list等,相等性判斷需要〇(n2)。對于單線程的Redis來說,這樣的開銷顯然不合理,是以Redis隻保留整數共享對象池。
3.4 字元串優化
字元串對象是 Redis 内部最常用的資料類型。所有的鍵都是字元串類型,值對象資料除了整數之外都使用字元串存儲。比如執行指令:Ipush cache:type "redis”"memcache” "tair" "levelDB", Redis 首先建立"cache:type” 鍵字元串,然後建立連結清單對象,連結清單對象内再包含四個字元串對象,排除Redis内部用到的字元串對象之外至少建立5個字元串對象。可見字元串對象在 Redis 内部使用非常廣泛,是以深刻了解 Redis 字元串對于記憶體優化非常有幫助。
1.字元串結構
Redis沒有采用原生C語言的字元串類型而是自己實作了字元串結構,内部簡單動态字元串(simple dynamic string,SDS)。結構如圖 8-9所示。
Redis自身實作的字元串結構有如下特點:
□ 0(1)時間複雜度擷取:字元串長度、已用長度、未用長度。
□ 可用于儲存位元組數組,支援安全的二進制資料存儲。
□ 内部實作空間預配置設定機制,降低記憶體再配置設定次數。
□ 惰性删除機制,字元串縮減後的空間不釋放,作為預配置設定空間保留。
2.預配置設定機制
因為字元串(SDS) 存在預配置設定機制,日常開發中要小心預配置設定帶來的記憶體浪費,例如表 8-3的測試用例。
表 8-3 字元串記憶體預配置設定測試 | ||||||||
階段 | 資料量 | 指令 | mem_fragmentation_ration | |||||
階段1 | 200W | 新插入200W資料 | set | 60位元組 | 321.98MB | 331.44MB | 1.02 | |
階段2 | 在階段1上每個對象追加60位元組資料 | append | 657.67MB | 752.80MB | 1.14 | |||
階段3 | 重新插入200W資料 | 120位元組 | 474.56MB | 482.45MB |
從測試資料可以看出,同樣的資料追加後記憶體消耗非常嚴重,下面我們結合圖來分析這一現象。階段1每個字元串對象空間占用如圖8-10所示。
階段1插入新的字元串後,free字段保留白間為0 , 總占用空間=實際占用空間+1位元組,最後1位元組儲存标示結尾,這裡忽略int類型len和 free字段消耗的8 位元組。在階段1原有字元串上追加60位元組資料空間占用如圖8-11所示。
追加操作後字元串對象預配置設定了一倍容量作為預留白間,而且大量追加操作需要記憶體重新配置設定,造成記憶體碎片率(mem_fragmentation_ratio)上升。直接插入與階段2 相同資料的空間占用,如圖8-12所示。
階段3直接插人同等資料後,相比階段2節省了每個字元串對象預配置設定的空間,同時降低了碎片率。
字元串之是以采用預配置設定的方式是防止修改操作需要不斷重配置設定記憶體和位元組資料拷貝。但同樣也會造成記憶體的浪費。字元串預配置設定每次并不都是翻倍擴容,空間預配置設定規則。
如下:
1) 第一次建立len屬性等于資料實際大小,free等于0,不做預配置設定。
2) 修改後如果已有free空間不夠且資料小于1M, 每次預配置設定一倍容量。如原有len= 60byte, free=0, 再追加60byte, 預配置設定120byte, 總占用空間:60byte+60byte+120byte+1byte。
3) 修改後如果已有free空間不夠且資料大于1MB,每次預配置設定1MB資料。如原有 len=30MB, free=0, 當再追加1OObyte,預配置設定1MB,總占用空間:1MB+100byte+lMB+lbyte。
盡量減少字元串頻繁修改操作如append、setrange,改為直接使用set修改字元串降低預配置設定帶來的記憶體浪費和記憶體碎片化。
3.字元串重構
字元串重構:指不一定把每份資料作為字元串整體存儲,像json這樣的資料可以使用hash結構,使用二級結構存儲也能幫我們節省記憶體。同時可以使用hmget、hmset指令支援字段的部分讀取修改,而不用每次整體存取。例如下面的json資料:
{
"vid": "413368768",
"title" : 搜狐屌絲男士 ••,
"videoAlbumPic" : "http://photocdn.sohu.com/60160518/vrsa_ver8400079_ae433_pic26. jpg" ,
"pid": "6494271",
"type": "1024",
"playlist": "6494271",
"playTime": "468"
}
分别使用字元串和hash結構測試記憶體表現,如表8-4所示。
表8-4 測試記憶體表現 | |||||
Key | 存儲類型 | Value | 配置 | ||
string | Json字元串 | 預設 | 612.62M | ||
hash | key-value | 1.88GB | |||
535.6M |
根據測試結構,第一次預設配置下使用hash類型,記憶體消耗不但沒有降低反而比字元串存儲多出2倍,而調整 hash-max-ziplist-value=66 之後記憶體降低為535.60M。因為 json 的 videoAlbumPic 屬性長度是 65,而hash-max-ziplist-value 預設值是64, Redis 采用 hashtable 編碼方式,反而消耗了大量記憶體。調整配置後hash類型内部編碼方式變為ziplist, 相比字元串更省記憶體且支援屬性的部分操作。下一節将具體介紹ziplist 編碼優化細節。
3.5 編碼優化
1.了解編碼
Redis對外提供了 string、list、hash、set、zet等類型,但是Redis内部針對不同類型存在編碼的概念,所謂編碼就是具體使用哪種底層資料結構來實作。編碼不同将直接影響資料的記憶體占用和讀寫效率。使用object encoding {key}指令擷取編碼類型。如下所示:
redis> set str:1 hello
OK
redis> object encoding str:1
"embstr" //embstr 編碼字元串
redis> lpush list:1 1 2 3
(integer) 3
redis> object encoding list: 1
"ziplist" //ziplist 編碼清單
Redis 針對每種資料類型(type)可以采用至少兩種編碼方式來實作,表8-5表示type和 encoding 的對應關系。
表 8-5 type和encoding 對應關系表 | ||
類型 | 編碼方式 | 資料結構 |
Raw | 動态字元串編碼 | |
embstr | 優化記憶體配置設定的字元串編碼 | |
int | 整數編碼 | |
hashtable | 散清單編碼 | |
ziplist | 壓縮清單編碼 | |
list | linkedlist | 雙向連結清單編碼 |
quicklist | 3.2版本新的清單編碼 | |
intset | 整數集合編碼 | |
zset | skiplist | 跳躍表編碼 |
了解編碼和類型對應關系之後,我們不禁疑惑Redis為什麼對一種資料結構實作多種編碼方式?
主要原因是Redis作者想通過不同編碼實作效率和空間的平衡。比如當我們的存儲隻有10個元素的清單,當使用雙向連結清單資料結構時,必然需要維護大量的内部字段如每個元素需要:前置指針,後置指針,資料指針等,造成空間浪費,如果采用連續記憶體結構的壓縮清單(ziplist),将會節省大量記憶體,而由于資料長度較小,存取操作時間複雜度即使為0(n2)性能也可滿足需求。
2.控制編碼類型
編碼類型轉換在Redis寫入資料時自動完成,這個轉換過程是不可逆的,轉換規則隻能從小記憶體編碼向大記憶體編碼轉換。例如:
redis> lpush list:1 a b e d
(integer) 4 // 存儲 4 個元素
redis> object encoding list:1
"ziplist" // 采用 ziplist 壓縮清單編碼
redis> config set list-max-ziplist-entries 4
OK // 設定清單類型 ziplist 編碼最大允許 4 個元素
redis> lpush list:1 e
(integer) 5 // 寫入第 5 個元素 e
redis> object encoding list:1
"linkedlist" //編碼類型轉換為連結清單
redis> rpop list:1
"a" // 彈出元素 a
redis> llen list:1
(integer) 4 // 清單此時有 4 個元素
redis> object encoding list:1
"linkedlist" // 編碼類型依然為連結清單,未做編碼回退
以上指令展現了list類型編碼的轉換過程,其中Redis之是以不支援編碼回退,主要 是資料增删頻繁時,資料向壓縮編碼轉換非常消耗CPU,得不償失。以上示例用到了list-max-ziplist-entries參數,這個參數用來決定清單長度在多少範圍内使用ziplist編碼。當然還有其他參數控制各種資料類型的編碼,如表8-6所示。
表8-6 hash、list、set、zset内部編碼配置 | ||
編碼 | 決定條件 | |
滿足所有條件: value最大空間(位元組)<=hash-max_ziplist-value field個數 <=hash-max-ziplist-entries | ||
滿足任意條件: value最大空間(位元組)>hash-max-ziplist-value field個數>hash-max-ziplist-entries | ||
滿足所有條件: value最大空間(位元組)<=list-max-ziplist-value 連結清單長度 <=list-max-ziplist-entries | ||
Linkedlist | 滿足任意條件 value最大空間(位元組)>list-max-ziplist-value 連結清單長度>list-max-ziplist-entries | |
3.2 版本新編碼: 廢棄list-max-ziplist-entrie和list-max-ziplist-entries 配置 使用新配置: list-max-ziplist-size: 表示最大壓縮空間或長度 最大空間使用 [-5-1] 範圍配置,預設 -2 表示 8KB 正整數表示最大壓縮長度 list-compress-depth: 表示最大壓縮深度,預設 = 0 不壓縮 | ||
intest | 元素必須為整數 集合長度<=set-max-intset-entries | |
元素非整數類型 集合長度 shash-max-ziplist-entries | ||
value最大空間(位元組)<=zset-max-ziplist-value 有序集合長度<=zset-max-ziplist-entries | ||
value最大空間(位元組>zset-max-ziplist-value 有序集合長度>zset-max-ziplist-entries |
掌握編碼轉換機制,對我們通過編碼來 優化記憶體使用非常有幫助。下面以hash類型為例,介紹編碼轉換的運作流程,如圖8-13所示。
了解編碼轉換流程和相關配置之後,可以使用config set 指令設定編碼相關參數來滿足使用壓縮編碼的條件。對于已經采用非壓縮編碼類型的資料如 hashtable、 linkedlist等,設定參數後即使資料滿足壓縮編碼條件,Redis也不會做轉換,需要重新開機Redis重新加載資料才能完成轉換。
3.ziplist編碼
ziplist編碼主要目的是為了節約記憶體,是以所有資料都是采用線性連續的記憶體結構。 ziplist編碼是應用範圍最廣的一種,可以分别作為hash、list、zset類型的底層資料結 構實作。首先從ziplist編碼結構開始分析,它的内部結構類似這樣:<zlbytes><zltail><zllen><entry-1><entry-2><....><entry-n><zlend>。一個 ziplist可以包含多個entry(元素),每個entry儲存具體的資料(整數或者位元組數組 ),内部結構如圖8-14所示。
ziplist 結構字段含義:
1) zlbytes: 記錄整個壓縮清單所占位元組長度,友善重新調整ziplist空間。類型是 int-32,長度為4位元組。
2) zltail: 記錄距離尾節點的偏移量,友善尾節點彈出操作。類型是int-32,長度為 4位元組。
3) zllen: 記錄壓縮連結清單節點數量,當長度超過216-2時需要周遊整個清單擷取長,一 般很少見。類型是int-16, 長度為2位元組。
4) entry: 記錄具體的節點,長度根據實際存儲的資料而定。
a) prev_entry_bytes_length : 記錄前一個節點所占空間,用于快速定位上一個節 點,可實作清單反向疊代。
b) encoding: 标示目前節點編碼和長度,前兩位表示編碼類型:字元串/整數,其餘位表示資料長度。
c) contents: 儲存節點的值,針對實際資料長度做記憶體占用優化。
5) zlend: 記錄清單結尾,占用一個位元組。
根據以上對ziplist字段說明,可以分析出該資料結構特點如下:
□ 内部表現為資料緊湊排列的一塊連續記憶體數組。
□ 可以模拟雙向 連結清單結構,以O(1) 時間複雜度人隊和出隊。
□ 新增删除操作涉及記憶體重新配置設定或釋放,加大了操作的複雜性。
□ 讀寫操作涉及複雜的指針移動,最壞時間複雜度為〇(n2)。
□ 适合存儲小對象和長度有限的資料。
下面通過測試展示ziplist編碼在不同類型中記憶體和速度的表現,如表8-7所示。
表8-7 ziplist在hash,list,zset記憶體和速度測試 | ||||||||
Key總量 | 長度 | 普通編碼記憶體量/平均耗時 | 壓縮編碼記憶體量/平均耗時 | 記憶體降低比例 | 耗時增長倍數 | |||
100萬 | 1千 | 36位元組 | 103.37M/0.84 微秒 | 43.83M/13.24 微秒 | 57.5% | 15倍 | ||
92.46M/2.04 微秒 | 39.92M/5.45 微秒 | 56.8% | 2.5倍 | |||||
151.84M/1.85 微秒 | 43.83M/77.88 微秒 | 71% | 42倍 |
測試資料采用100W個36位元組資料,劃分為1000個鍵,每個類型長度統一為1000。從測試結果可以看出:
1) 使用ziplist可以分另别作為hash、list、zset 資料類型實作。
2) 使用ziplist 編碼類型可以大幅降低記憶體占用。
3) ziplist 實作的資料類型相比原生結構,指令操作更加耗時,不同類型耗時排序:l ist < hash < zset。
ziplist壓縮編碼的性能表現跟值長度和元素個數密切相關,正因為如此Redis提供了{type}-max-ziplist-value和{type}-max-ziplist-entries相關參數來做控制ziplist編碼轉換。最後再次強調使用ziplist壓縮編碼的原則:追求空間和時間的平衡。
針對性能要求較高的場景使用ziplist,建議長度不要超過1000,每個元素大小& 控制在512位元組以内。
指令平均耗時使用info Commandstats stats指令擷取,包含每個指令調用次數、總耗時、平均粍時,機關為微秒。
4.intset編碼
intset編碼是集合(set) 類型編碼的一種,内部表現為存儲有序、不重複的整數集。當集合隻包含整數且長度不超過set-max-intset-entries配置時被啟用。執行以下指令檢視intset表現:
redis> sadd set:test 3 4 2 6 8 9 2
(integer) 6 // 亂序寫入 6 個整數
Redis> object encoding set:test
"intset" //使用 intset編碼
Redis> smembers set:test
"2" "3" "4" "6" "8" "9" //排序輸出整數結合
redis> config set set-max-intset-entries 6
OK //設定 intset最大允許整數長度
redis> sadd set:test 5
(integer) 1 // 寫入第7個整數5
redis> object encoding set:test
"hashtable" //編碼變為hashtable
redis> smembers set:test
"8" "3" "5" "9" "4" "2" "6" //亂序輸出
以上指令可以看出intset對寫入整數進行排序,通過0(log(n))時間複雜度實作查找和去重操作,intset編碼結構如圖8-15所示。
intset的字段結構含義:
1) encoding: 整數表示類型,根據集合内最長整數值确定類型,整數類型劃分為三種:int-16、int-32、int-64。
2) length: 表示集合元素個數。
3) contents: 整數數組,按從小到大順序儲存。
intset儲存的整數類型根據長度劃分,當儲存的整數超出目前類型時,将會觸發自動更新操作且更新後不再做回退。更新操作将會導緻重新申請記憶體空間,把原有資料按轉換類型後拷貝到新數組。
提示:使用intset編碼的集合時,盡量保持整數範圍一緻,如都在int-16範圍内。防止個别大整數觸發集合更新操作,産生記憶體浪費。
下面通過測試檢視ziplist編碼的集合記憶體和速度表現,如表8-8所示。
表 8-8 ziplist編碼在set下記憶體和速度表現 | |||||||
value大小 | 集合長度 | 記憶體量 | 平均耗時 | ||||
100w | 7位元組 | 61.97MB | --- | 0.78毫秒 | |||
4.77MB | 92.6% | 0.51毫秒 | |||||
8.67MB | 86.2% | 13.12毫秒 |
根據以上測試結果發現intset表現非常好,同樣的資料記憶體占用隻有不到hashtable編碼的十分之一。intset資料結構插人指令複雜度為0(h),査詢指令為0(log(n)),由于整數占用空間非常小,是以在集合長度可控的基礎上,寫人指令執行速度也會非常快,是以當使用整數集合時盡量使用intset編碼。表8-8測試第三行把ziplist-hash類型也放入其中,主要因為intset編碼必須存儲整數,當集合内儲存非整數資料時,無法使用intset實作記憶體優化。這時可以使用ziplist-hash類型對象模拟集合類型,hash的field當作集合中的元素,value設定為1位元組占位符即可。使用ziplist編碼的hash類型依然比使用hashtable編碼的集合節省大量記憶體。
3.6 控制鍵的數量
當使用Redis存儲大量資料時,通常會存在大量鍵,過多的鍵同樣會消耗大量記憶體。Redis本質是一個資料結構伺服器,它為我們提供多種資料結構,如 hash、list、set、zset等。使用Redis時不要進入一個誤區,大量使用get/set這樣的API,把Redis當成Memcached 使用。對于存儲相同的資料内容利用Redis的資料結構降低外層鍵的數量,也可以節省大量記憶體。如圖8-16所示,通過在用戶端預估鍵規模,把大量鍵分組映射到多個hash結構中降低鍵的數量。
hash結構降低鍵數量分析:
□ 根據鍵規模在用戶端通過分組映射到一組hash對象中,如存在100萬個鍵,可以映射到1000個hash中,每個hash儲存1000個元素。
□ hash的field可用于記錄原始key字元串,友善哈希查找。
□ hash的value儲存原始值對象,確定不要超過hash-max-ziplist-value限制。
下面測試這種優化技巧的記憶體表現,如表8-9所示。
表8-9 hash分組控制鍵規模測試 | |||||||
String類型占用記憶體 | hash-ziplist類型占用記憶體 | string:set平均耗時 | hash:hset平均耗時 | ||||
200w | 512位元組 | 1392.64MB | 1000.97MB | 28.1% | 2.13 微秒 | 21.28 微秒 | |
200位元組 | 596.62MB | 399.38MB | 33.1% | 1.49 微秒 | 16.08 微秒 | ||
100位元組 | 382.99MB | 211.88MB | 44.6% | 1.30 微秒 | 14.92 微秒 | ||
50位元組 | 291.46MB | 110.32MB | 62.1% | 1.28 微秒 | 13.48 微秒 | ||
246.40MB | 55.63MB | 77.4% | 1.10 微秒 | 13.21 微秒 | |||
5位元組 | 199.93MB | 24.42MB | 87.7% | 13.06 微秒 |
通過這個測試資料,可以說明:
□ 同樣的資料使用ziplist編碼的hash類型存儲比string類型節約記憶體。
□ 節省記憶體量随着value空間的減少越來越明顯。
□ hash-ziplist類型比string類型寫人耗時,但随着value空間的減少,耗時逐漸降低。
使用hash重構後節省記憶體量效果非常明顯,特别對于存儲小對象的場景,記憶體隻有不到原來的1/5。下面分析這種記憶體優化技巧的關鍵點:
1) hash類型節省記憶體的原理是使用ziplist編碼,如果使用hashtable編碼方式反而會增加記憶體消耗。
2) ziplist 長度需要控制在1000以内,否則由于存取操作時間複雜度在O(n) 到0(n2)之間,長清單會導緻CPU消耗嚴重,得不償失。
3) ziplist适合存儲小對象,對于大對象不但記憶體優化效果不明顯還會增加指令操作耗時。
4) 需要預估鍵的規模,進而确定每個hash結構需要存儲的元素數量。
5) 根據hash長度和元素大小,調整 hash-max-ziplist-entries和hash-max-ziplist-value 參數,確定 hash 類型使用 ziplist 編碼。
關于hash鍵和field鍵的設計:
1) 當鍵離散度較高時,可以按字元串位截取,把後三位作為哈希的field, 之前部分作為哈希的鍵。如: key=1948480哈希key=group:hash:1948, 哈希 field=480。
2) 當鍵離散度較低時,可以使用雜湊演算法打散鍵,如:使用crc32(key)&10000函數把所有的鍵映射到“0-9999”整數範圍内,哈希field存儲鍵的原始值。
3) 盡量減少hash鍵和field的長度,如使用部分鍵内容。
使用hash結構控制鍵的規模雖然可以大幅降低記憶體,但同樣會帶來問題,需要提前做好規避處理。如下所示:
□ 用戶端需要預估鍵的規模并設計hash分組規則,加重用戶端開發成本。
□ hash重構後所有的鍵無法再使用逾時(expire)和LRU淘汰機制自動删除,需要手動維護删除。
□ 對于大對象,如1KB以上的對象,使用hash-ziplist結構控制鍵數量反而得不償失。
不過瑕不掩瑜,對于大量小對象的存儲場景,非常适合使用ziplist編碼的hash類型控制鍵的規模來降低記憶體。
開發提示:使用 ziplist+hash優化keys後,如果想使用逾時删除功能,開發人員可以存儲每個對象寫入的時間,再通過定時任務使用 hscan 指令掃描資料,找出 hash内逾時的資料項删除即可。
本節主要講解Redis記憶體優化技巧,Redis的資料特性是“ all in memory”,優化記憶體将變得非常重要。對于記憶體優化建議讀者先要掌握Redis記憶體存儲的特性比如字元串、壓縮編碼、整數集合等,再根據資料規模和所用指令需求去調整,進而達到空間和效率的最佳平衡。建議使用Redis存儲大量資料時,把記憶體優化環節加入到前期設計階段,否則資料大幅增長後,開發人員需要面對重新優化記憶體所帶來開發和資料遷移的雙重成本。當 Redis記憶體不足時,首先考慮的問題不是加機器做水準擴充,應該先嘗試做記憶體優化,當遇到瓶頸時,再去考慮水準擴充。即使對于叢集化方案,垂直層面優化也同樣重要,避免不必要的資源浪費和叢集化後的管理成本。
4.總結
1. Redis實際記憶體消耗主要包括: 鍵值對象、緩沖區記憶體、記憶體碎片。
2. 通過調整maxmemory控制 Redis最大可用記憶體。當記憶體使用超出時,根據maxmemory-policy控制記憶體回收政策。
3. 記憶體是相對寶貴的資源,通過合理的優化可以有效地降低記憶體的使用量,記憶體優化的思路包括:
□ 精簡鍵值對大小,鍵值字面量精簡,使用高效二進制序列化工具。
□ 使用對象共享池優化小整數對象。
□ 資料優先使用整數,比字元串類型更節省空間。
□ 優化字元串使用,避免預配置設定造成的記憶體浪費。
□ 使用ziplist壓縮編碼優化hash、list等結構,注重效率和空間的平衡。
□ 使用intset編碼優化整數集合。
口 使用ziplist編碼的hash結構降低小對象鍊規模。
作者:小家電維修
出處:https://www.cnblogs.com/lizexiong/
轉世燕還故榻,為你銜來二月的花。