Redis 性能優化實戰
Redis 作為內存數據庫,其性能表現非常出色,單機 OPS 很容易達到 10萬以上,這主要得益于其高效的內存數據結構、單線程無鎖設計、IO 多路復用等技術實現。但是在線上生產環境的使用中,我們仍然會發現在使用 Redis 的時候其性能和預期是不符的,例如出現了明顯的延遲等,如果我們能從 Redis 的實現結合操作系統的一些原理來進行分析,就可以比較全面的定位問題,并且能夠想出比較高效的解決辦法。
本文的相關示例都是基于 Redis 6.x 版本運行的。
首先,對于 Redis 操作變慢的原因可能有兩個方面:
- 應用到 Redis 服務之間的網絡出現問題,導致延遲變大,比如帶寬被過多的占用等情況。
- Redis 操作本身存在問題,比如有些復雜度比較高的指令導致了慢查詢等。
如果是第一種情況,我們應該重點排查網絡的問題,當然也有可能是 Redis 發送了大量的數據導致網絡資源被占滿。第二種情況更加常見,我們可以將當前的性能和基準性能進行比較,定位是否出現了操作變慢的情況。
通常我們可以在當前環境正常的前提下,做一次基準性能測試,并將結果保存下來,方便后續排查問題,可以運行下面的命令進行 60s 的測試:
# 如果有密碼的話要添加密碼參數
redis-cli -h 127.0.0.1 -p 6379 --intrinsic-latency 60
結果示例如下:
Max latency so far: 1 microseconds.
Max latency so far: 15 microseconds.
Max latency so far: 18 microseconds.
Max latency so far: 37 microseconds.
Max latency so far: 49 microseconds.
Max latency so far: 66 microseconds.
Max latency so far: 81 microseconds.
Max latency so far: 145 microseconds.
Max latency so far: 169 microseconds.
Max latency so far: 301 microseconds.
Max latency so far: 769 microseconds.
Max latency so far: 959 microseconds.
Max latency so far: 1343 microseconds.
Max latency so far: 2183 microseconds.
Max latency so far: 2457 microseconds.
Max latency so far: 5108 microseconds.
881760112 total runs (avg latency: 0.0680 microseconds / 68.05 nanoseconds per run).
Worst run took 75067x longer than the average latency.
那么可以看到,這一分鐘內最大的延遲是 5108 微秒也就是 5.1 毫秒,平均每次查詢是 68.05 納秒。可以多次執行得到平均的統計。
我們還可以采樣一段時間內 Redis 的最小、最大以及平均訪問延遲:
# 每秒輸出一次統計的結果
redis-cli -h 127.0.0.1 -p 6379 --latency-history -i 1
結果示例如下:
min: 0, max: 1, avg: 0.33 (97 samples) -- 1.01 seconds range
min: 0, max: 3, avg: 0.26 (96 samples) -- 1.01 seconds range
min: 0, max: 4, avg: 0.36 (95 samples) -- 1.00 seconds range
min: 0, max: 1, avg: 0.26 (96 samples) -- 1.00 seconds range
min: 0, max: 1, avg: 0.30 (96 samples) -- 1.01 seconds range
min: 0, max: 1, avg: 0.31 (96 samples) -- 1.00 seconds range
min: 0, max: 15, avg: 0.51 (95 samples) -- 1.01 seconds range
這個是每秒輸出一行,時間的單位是毫秒,比如第一行表示范圍是 0 ~ 1ms,平均是 0.33ms,可以通過多次輸出計算整體的范圍。
當我們將測試的結果保存下來之后,當遇到性能瓶頸的時候,我們可以和最初測試的基準性能區間進行比較,如果延遲比基準性能高 2 倍以上,說明當前 Redis 實例確實變慢了,然后我們就可以考慮找到變慢的因素并進行優化,下面我們將通過不同的角度來進行分析對 Redis 的性能進行分析及調優。
1. 分析命令的復雜度是否過高
我們可以查詢 Redis 的慢日志,查看哪些命令的耗時比較久,首先關于慢日志的配置有下面兩個:
# 默認配置
slowlog-log-slower-than 10000
slowlog-max-len 128
其中 slowlog-log-slower-than 表示時間的閾值,也就是命令執行的時間超過閾值,則會記錄到慢日志中,這個單位是微秒,當前配置也就是說當命令執行超過 10ms 時會被記錄。slowlog-max-len 表示慢日志保存的條數,默認只保存最近的 128 條。
那么我們下面可以查詢當前的慢日志,首先進入到 Redis 客戶端中,然后執行:
slowlog get 10
返回的結果依次包括:慢日志編號、執行時間戳、執行耗時(微秒)、命令和參數、客戶端 IP 和端口以及客戶端名稱。
當找到慢查詢的命令后,我們就可以分析當前的命令是否是復雜度過高,比如 HGETALL、SORT、SUNION、ZUNION、ZUNIONSTORE 等復雜度在 O(N) 或者以上的命令,而且看一下當前 N 的值是不是比較大。
這個時候有兩種原因會造成延遲變大:
- N 比較大,操作數據結構會占用比較多的 CPU 資源。
- N 比較大,并且需要返回大量數據到客戶端,占用過多的網絡資源而導致延遲變大。
對于前者,我們只需要查看 Redis 的 CPU 利用率,如果利用率過高,那么就要考慮操作這個數據結構復雜度過高導致 CPU 飆升。如果 CPU 利用率不高,但是執行命令時 Redis 的網絡帶寬會升高,那么要考慮命令返回的數據量過大導致網絡帶寬占用過高。
另外,由于 Redis 是單線程執行命令,如果前面的命令沒有執行完畢,后面的命令也會一直排隊等待,客戶端的延遲也會升高,基于這種情況我們對應的優化思路如下:
- 對于復雜度過高的命令要慎重使用,如果確實會導致 CPU 飆升,我們應該考慮將數據在客戶端做一些處理來減輕 Redis 的負擔,如果數據量過大,我們應該采用漸進式的方式獲取數據到客戶端,或者采用其他更好的邏輯來實現。
- 如果執行 O(N) 大小的命令,那么要確保 N 盡量要小 (推薦在 100 以內),這樣可以及時的返回,其實這本身也是一個 bigkey 的問題。
2. 分析是否操作了 bigkey
如果慢日志中的命令復雜度大部分都不太高,而是有很多 GET、SET、DEL 這樣的命令,那么我們要小心是不是存在了 bigkey,我們可以查看慢日志中參數的長度來定位可能的 bigkey,但不一定是參數比較長,也有可能 value 比較長。
那么先簡要說一下什么是 bigkey,當 Redis 實例寫入數據時需要為新的數據分配內存空間,相應的,從 Redis 中刪除數據時,也會釋放對應的空間,在傳輸數據時,也會通過網絡發送至少同等容量的字節流。
那么如果我們寫入的 key 或者 value 非常大,在分配空間、釋放空間以及傳輸數據時都會比較耗時,這種 key 或者 value 我們統稱為 bigkey。
所以當出現上面的情況時,我們需要排查我們的業務代碼,看是否存在寫入 bigkey 的情況,如果存在的話,盡可能想辦法進行優化。
如果 Redis 實例有很多客戶端都在用,或者說可能已經有 bigkey 寫入到 Redis 中了,Redis 本身也提供了掃描 bigkey 的命令,我們可以掃描一下 bigkey 的分布,例如執行下面的命令:
redis-cli -h 127.0.0.1 -p 6379 --bigkeys
# 為了節省資源,每掃描 100 個 key 休眠 0.1s
redis-cli -h 127.0.0.1 -p 6379 --bigkeys -i 0.1
# 指定不同的庫編號
redis-cli -h 127.0.0.1 -p 6379 -n 2 --bigkeys
執行完之后結果是以類型展示的,即每個類型會輸出一個最大的 key 以及具體的大小,不過執行的時候也會輸出對應的進度,可以看到更多的一些可能的 bigkey。
掃描 bigkey 的實現原理就是在 Redis 中運行 SCAN 命令,遍歷整個實例中所有的 key,針對不同 key 的類型分別執行 STRLEN、LLEN、HLEN、SCARD、ZCARD 等命令來獲取對應的長度。
不過在執行這個命令分析之前要注意一些問題:
- 執行掃描的時候,Redis 的 CPU 利用率會升高,所以最高放在非業務高峰期運行。或者添加 -i 參數控制一下每次掃描一批后休眠的時間,但是總的分析時間會增大。
- 掃描結果中對于容器類型的 key,比如 List、Hash 以及 Set 等,只能掃描元素個數最多的 key,但實際的空間占用并不一定是最大的,這點也需要注意。
綜上,當確定了 bigkey 之后,我們就可以更好地分析業務應用在哪些地方使用了這些 bigkey,從而對程序進行優化。
另外,我們還需要在事后刪除 Redis 中的 bigkey,對于早期的 Redis 3.0 以前的版本來說,刪除容器需要漸進式逐個刪除子元素,最后再刪除最外層的 key,但是在 Redis 4.0 版本之后提供了異步刪除 key 的命令 UNLINK,這個命令是在后臺異步刪除 key,不會阻塞當前的主線程,可以降低對業務的影響。不過我們當前使用的是 Redis 6.0 版本,只需要修改下面的配置:
lazyfree-lazy-user-del yes
這樣就可以放心地執行 DEL 命令刪除,Redis 會自動將刪除操作轉為后臺去運行。
3. 分析是否存在大量集中過期的 key
如果出現一種比較奇怪的現象:在平時操作 Redis 時并沒有很大的延遲出現,但是在某些時間點會突然延遲變高,過后又恢復正常。這種延遲的時間點比較有規律,總是間隔固定的時間或者整點就會出現延遲。
這種情況,我們就要重點排查業務中是否存在大量的 key 集中過期的情況,如果確實存在這種情況,那么這個過期的時間段內 CPU 負載會比較高,這個時候客戶端再有其他操作時延遲就會明顯變大。
Redis 中 key 的過期主要有下面兩種形式:
- 當訪問這個 key 時,才會判斷這個 key 是否過期,如果發現已經過期將不會返回給客戶端并且從實例中刪除 key。
- Redis 內部存在一個定義任務,默認每 100ms 運行一次,在源碼的
serverCron->databasesCron中執行activeExpireCycle這里面定義了刪除的邏輯,每次從全局哈希表中取 20 個 key,然后判斷并刪除其中過期的 key,當過期的數量占總數小于 10% 或者整體運行時間超過 25ms 時,則停止循環。這部分的主要邏輯如下:
for (j = 0; j < dbs_per_call && timelimit_exit == 0; j++) {
/* Expired and checked in a single loop. */
unsigned long expired, sampled;
redisDb *db = server.db+(current_db % server.dbnum);
/* Increment the DB now so we are sure if we run out of time
* in the current DB we'll restart from the next. This allows to
* distribute the time evenly across DBs. */
current_db++;
/* Continue to expire if at the end of the cycle there are still
* a big percentage of keys to expire, compared to the number of keys
* we scanned. The percentage, stored in config_cycle_acceptable_stale
* is not fixed, but depends on the Redis configured "expire effort". */
do {
unsigned long num, slots;
long long now, ttl_sum;
int ttl_samples;
iteration++;
/* If there is nothing to expire try next DB ASAP. */
if ((num = dictSize(db->expires)) == 0) {
db->avg_ttl = 0;
break;
}
slots = dictSlots(db->expires);
now = mstime();
/* When there are less than 1% filled slots, sampling the key
* space is expensive, so stop here waiting for better times...
* The dictionary will be resized asap. */
if (slots > DICT_HT_INITIAL_SIZE &&
(num*100/slots < 1)) break;
/* The main collection cycle. Sample random keys among keys
* with an expire set, checking for expired ones. */
expired = 0;
sampled = 0;
ttl_sum = 0;
ttl_samples = 0;
// config_keys_per_loop = 20 + 20/4*effort(default=0) = 20
if (num > config_keys_per_loop)
num = config_keys_per_loop;
/* Here we access the low level representation of the hash table
* for speed concerns: this makes this code coupled with dict.c,
* but it hardly changed in ten years.
*
* Note that certain places of the hash table may be empty,
* so we want also a stop condition about the number of
* buckets that we scanned. However scanning for free buckets
* is very fast: we are in the cache line scanning a sequential
* array of NULL pointers, so we can scan a lot more buckets
* than keys in the same time. */
// max_buckets = 20 * 20 = 400
long max_buckets = num*20;
long checked_buckets = 0;
// 條件1:當采樣的 key 大于或等于 20 時將退出循環
while (sampled < num && checked_buckets < max_buckets) {
for (int table = 0; table < 2; table++) {
if (table == 1 && !dictIsRehashing(db->expires)) break;
unsigned long idx = db->expires_cursor;
idx &= db->expires->ht[table].sizemask;
dictEntry *de = db->expires->ht[table].table[idx];
long long ttl;
/* Scan the current bucket of the current table. */
checked_buckets++;
while(de) {
/* Get the next entry now since this entry may get
* deleted. */
dictEntry *e = de;
de = de->next;
ttl = dictGetSignedIntegerVal(e)-now;
if (activeExpireCycleTryExpire(db,e,now)) expired++;
if (ttl > 0) {
/* We want the average TTL of keys yet
* not expired. */
ttl_sum += ttl;
ttl_samples++;
}
sampled++;
}
}
db->expires_cursor++;
}
total_expired += expired;
total_sampled += sampled;
/* Update the average TTL stats for this database. */
if (ttl_samples) {
long long avg_ttl = ttl_sum/ttl_samples;
/* Do a simple running average with a few samples.
* We just use the current estimate with a weight of 2%
* and the previous estimate with a weight of 98%. */
if (db->avg_ttl == 0) db->avg_ttl = avg_ttl;
db->avg_ttl = (db->avg_ttl/50)*49 + (avg_ttl/50);
}
/* We can't block forever here even if there are many keys to
* expire. So after a given amount of milliseconds return to the
* caller waiting for the other active expire cycle. */
if ((iteration & 0xf) == 0) { /* check once every 16 iterations. */
elapsed = ustime()-start;
// 運行時間大于 timelimit=25ms 也退出循環
// timelimit = config_cycle_slow_time_perc*1000000/server.hz/100 = 25*1000us = 25ms
if (elapsed > timelimit) {
timelimit_exit = 1;
server.stat_expired_time_cap_reached_count++;
break;
}
}
/* We don't repeat the cycle for the current database if there are
* an acceptable amount of stale keys (logically expired but yet
* not reclaimed). */
} while (sampled == 0 ||
(expired*100/sampled) > config_cycle_acceptable_stale);
}
詳細的代碼參考:https://github.com/redis/redis/blob/6.2/src/expire.c
基于上面的第二個主動刪除策略,如果同時出現大量過期的 key,那么頻繁刪除會占用比較大的 CPU 資源,此時如果業務應用再訪問就有可能出現延遲的情況。還需要注意的時,過期刪除是在后臺執行,哪怕刪除的 key 屬于 bigkey 本身耗時較長,但是也不會記錄到慢日志中,因為慢日志只記錄主動執行的命令,我們只有通過業務側的感知來排查具體的原因,這種情況值得我們注意。
另外在 Redis 中執行 INFO 命令可以獲得實例當前的統計信息,其中 expired_keys 表示實例從啟動到目前為止累計刪除過期 key 的數量,如果我們配置了監控,就可以看到在很短的時間內這個指標突然增大,如果有規律出現,基本上就能夠很快定位到批量過期的問題。
如果我們確實排查到這類問題,那么可以采用下面的可選方案來應對:
- 為了避免集中過期,我們可以在程序中分散設置過期時間,這樣就不會導致大量的 key 同時過期,對 Redis 的影響也就比較小。
- 可以對過期的 key 開啟 lazyfree 機制,這樣會在后臺異步刪除過期的 key,不會阻塞主線程的運行。
開啟 lazyfree 過期刪除可以修改如下的配置:
lazyfree-lazy-expire yes
4. 分析是否存在內存淘汰
默認情況下 Redis 實例的 maxmemory 配置為 0,也就是不限制內存的大小,但是假如我們限制了 maxmemory 的大小,并且設置了數據淘汰的策略,那么當實例的內存占用達到限制時,Redis 會按照既定的淘汰策略淘汰數據,使得整個實例的內存占用維持在 maxmemory 設置之下,這個時候如果數據操作比較頻繁,那么淘汰數據也需要消耗 CPU 資源,這個時候客戶端執行指令就會感覺到延遲變大的情況了。
常見的數據淘汰策略有下面這些:
- allkeys-lru:淘汰最近最少使用的 key,無論 key 本身是否設置過期時間。
- volatile-lru:只淘汰最近最少訪問并且設置了過期時間的 key。
- allkeys-random:隨機淘汰 key,無論 key 本身是否過期。
- volatile-random:隨機淘汰設置了過期時間的 key。
- volatile-ttl:在所有設置了過期時間的 key 中,淘汰距當前過期時間最短的 key
- allkeys-lfu:淘汰訪問頻率最低的 key,無論 key 是否設置過期時間。
- volatile-lfu:只淘汰訪問頻率最低并且設置了過期時間的 key。
- noeviction:不進行任何淘汰,但是當實例內存達到上限時,拒絕客戶端寫入數據。
關于淘汰策略的詳細說明可以參考文檔:https://redis.io/docs/reference/eviction/
一般我們比較常用的就是 LRU 和 LFU 相關的策略,但是這個策略也并不是精確地找到最近最少使用以及訪問頻率最低的 key,而是基于采樣的近似值。對于 LRU 策略,Redis 每次從所有 key 中采樣一批數據,這個批量的大小可以通過 maxmemory-samples 參數進行配置,每次從這個采樣的結果中計算出一個最近最少使用的 key,然后刪除掉,再不斷的進行采樣-刪除的操作,直到內存使用小于 maxmemory 配置的值則結束這個過程。之所以不使用精確的 LRU 是因為這個過程會耗費很大的內存和計算處理,而近似的算法同樣可以達到類似的效果,但是占用的資源將顯著降低。對于 LFU 也是類似,不同的是 LRU 采用了概率計數器來實現淘汰,思路仍然是近似的算法。
所以,Redis 淘汰 key 的過程就是刪除數據的過程,因此也會增大應用操作的延遲,而且淘汰的越頻繁或者客戶端的 OPS 越高,延遲也會越明顯。特別是如果被淘汰的數據中存在 bigkey,這個延遲會更明顯,bigkey 的危害到處都是,所以在業務中一定要避免使用 bigkey。
針對于數據淘汰,我們可以采用下面的一些策略來避免:
- 避免存儲 bigkey。
- 在允許的情況下修改淘汰策略,比如使用隨機的淘汰策略,這個策略執行的更快。
- 開啟 lazyfree 配置,將淘汰 key 的操作放到后臺線程中執行。
最后,還可以配置 Redis Cluster,降低單個實例的壓力。
5. 分析是否存在持久化的問題
持久化的問題可以分為兩個方面來探討,一方面是我們都比較熟悉的子進程持久化大量數據占用過多資源的問題,另一方面可能是我們更容易忽略的,也就是 fork 系統調用本身的耗時過大問題。
5.1 fork 子進程時耗時過長
我們知道 Redis 的持久化是通過子進程的方式來異步執行,從而減少對主進程的影響,子進程的創建是通過 fork 調用進行的,通常來說 fork 操作比較快,而且子進程不需要拷貝主進程的內存,只需要共享主進程的內存空間,利用操作系統的寫時復制(Copy on Write)技術實現高效的讀寫訪問。
但是假如 Redis 本身實例占用的內存空間非常大,fork 過程中需要將主進程的頁表復制給子進程,從而實現內存的共享,如果內存空間非常大,那么頁表也會占用一定的空間,所以在完成 fork 的時候可能會因為頁表拷貝引起 CPU 的突增,從而出現短暫的的阻塞,這段時間內的操作延遲就會增大,如果恰巧此時 Redis 實例比較繁忙,已經占用很大一部分 CPU 時間,那么這個影響會更明顯。
那么我們改如何確認這部分是否存在異常呢?
和上面一樣,我們可以執行 INFO 命令,查看其中的 latest_fork_usec 結果,這個單位是微秒,通常來說這個值也就是幾百到幾千微秒,如果看起來大的明顯,比如達到了幾百毫秒甚至秒級,那么就要考慮當前的內存是不是太大了,是不是需要進行優化了。另外如果是虛擬機的話,正常 fork 的時間也會比物理機長。
同時我們也應該根據業務情況,減少持久化子進程創建的數量,也就是調整持久化策略,盡可能拉長每次持久化之間的時間間隔,在數據安全性和性能之間進行平衡。
如果是在配置了主從復制的場景,可以在一部分 Slave 節點開啟持久化,其他節點都關閉持久化,這樣業務系統只訪問主節點以及未開啟持久化的從節點,也可以保證不受 fork 阻塞的影響。
在主從復制過程中,Master 進程除了向 Slave 進程發送數據之外,進程中還會存在一個環形緩沖區,當斷開的 Slave 節點重新和 Master 建立連接之后,Slave 會從斷開前的偏移開始獲取數據,從而實現增量的數據更新。但是假如這個緩沖區滿了,Redis Master 會覆寫掉之前的數據,如果子進程斷開的時間過長,重新恢復后請求的偏移在 Master 實例的緩沖區中沒有找到,這個時候就會執行全量的復制,這個時候主節點無論如何都會創建子進程來生成 RDB,然后再進行一次全量的數據同步,此時也會占用大量的帶寬,而 Master 實例環形緩沖區的大小也是可以調整的,所以我們考慮在內存大小允許的情況下,盡可能調大這個緩沖區的大小,盡量在大部分情況下都執行增量的數據同步,避免全量的數據同步。這個參數由 repl-backlog-size 來設置,這個大小默認是 1MB,我們可以根據實際情況調大,但注意不要影響當前實例的正常運行。
所以,我們總結出下面一些常見的解決思路:
- 控制 Redis 實例的內存盡量在 10GB 以內,也就是頁表整體大小小于 20MB,降低持久化子進程的
fork時間。 - 如果是 Redis 實例只做緩存用,沒有重要的業務數據,那么可以調小持久化的頻率或者關閉持久化。
- 如果配置了主從機制,那么可以在其中的某些 Slave 節點執行持久化,保證其他節點的性能。
- 適當調大
repl-backlog-size的值,降低主從節點全量同步數據的概率。
5.2 持久化配置是否合理
其實無論配置了哪種持久化方式,持久化的完全重寫都是通過 fork 子進程的方式執行,子進程一定會占用單獨的 CPU 資源,或多或少都會對主進程造成一定的影響。對于 RDB 持久化,主進程和子進程之間沒有通信,只是主進程新操作的數據會導致地址空間變大,而 AOF 持久化,主進程本來就會有持續不斷地系統調用,在 AOF 重寫期間,主進程和子進程之間會有增量的數據同步,所以 AOF 持久化對 Redis 性能的影響可能會更明顯。
所以我們重點來關注 AOF 持久化的情況下所引起的問題,當開啟 AOF 持久化之后,Redis 執行寫命令后,會通過系統調用 write 將命令寫入文件緩沖區,然后根據配置的 AOF 刷盤策略,將文件系統內存緩沖區的數據通過系統調用 fsync 真正的寫到磁盤上。
Redis 有 3 種 AOF 刷盤策略:
- always:主線程每次執行寫操作后立即刷盤,這種方案會有非常繁忙的系統 I/O,但是數據的安全性最高。
- no:主線程只寫內存緩沖區,至于刷盤的實際則交由操作系統執行,這種方案性能是最好的,但是丟失數據的可能性最大。
- everysec:主線程每次寫操作只寫內存緩沖區,由后臺線程每隔 1s 執行一次刷盤操作,這種方案對性能影響較小,同時最多丟失 1s 的數據,是前兩種方案的折衷。
首先是對于 always 策略,對于每個操作 Redis 主線程都會將這個命令寫入到磁盤中才返回,由于磁盤操作相較于內存操作慢兩個數量級,那么這種配置會嚴重拖慢速度,導致延遲增大,所以任何情況下都不要配置 always 策略。
然后再看 no 策略,這個策略每次寫入命令只寫入內存緩沖區,開銷僅僅是一個系統調用級別,所以對 Redis 的影響非常小,不會導致延遲增加,但是當 Redis 異常宕機后會丟失相當一部分數據,而且大小是不確定的,所以除非我們對數據丟失不敏感,例如只用作緩存,這種情況下可以配置 no 策略,其余情況下不建議配置。
最后是 everysec 策略, 這個策略主進程寫完就立即返回,刷盤的操作是放到后臺線程中執行,所以大部分情況下這種方法是推薦的。但是,總有一些特殊的情況,假如主進程的寫入非常頻繁,那么后臺的刷盤操作就會被阻塞住,但是主線程一直在接收請求,不斷地執行 write 調用,當刷盤的性能跟不上寫入的速度時,fsync會阻塞很長時間,在這個阻塞過程中 write 也會阻塞住,直到 fsync 成功刷新后才可以繼續恢復執行,這個時候其他 Redis 操作的延遲都會增大。
但是絕大部分情況下不會有這么頻繁的操作,不過存在特別需要注意的情況就是在 AOF rewrite 的同一時間段內存在大量的寫入,因為 AOF rewrite 本來就占用了很大的磁盤 I/O,這個時候再執行 fsync 就很容易出現阻塞的情況,主進程的 write 也會跟著阻塞導致延遲變大。對于這種情況,可以修改配置讓 Redis 在 AOF rewrite 期間不觸發 fsync 操作:
no-appendfsync-on-rewrite yes
但是如果在 AOF rewrite 期間實例宕機,那么會丟失很多的數據,所以這個參數要權衡利弊后設置。
另外就是注意檢查是否有 Redis 實例之外的其他程序頻繁操作硬盤,如果有的話也可能引起 Redis 的延遲,最好讓 Redis 在獨立的環境中運行。
最后,在硬件存儲的層面,建議 Redis 持久化配置到 SSD,和機械硬盤相比會有出色的性能,特別是可以減少持久化過程中帶來的延遲增加問題。
6. 操作系統級別優化
6.1 關閉內存大頁
在 RDB 和 AOF rewrite 期間,還有一個方面很容易導致性能問題,這就是 Linux 的內存大頁機制。我們知道現代操作系統管理內存是按照分頁方式來實現的,每個常規內存頁大小是 4KB,內存申請和釋放的最小單元就是頁,同時通過頁表在實現虛擬頁面和物理頁幀的一一對應。
從 Linux 內核 2.6.38 版本開始支持內存的大頁機制,允許大于 4K 的內存頁面,在 Linux 上如果開啟之后將允許程序申請以 2MB 為單位的頁面,也就是說原來 4KB 的最小單元變成了 2MB。
當然內存大頁有非常多的優點,首先就是頁表項的節省,大頁的頁表空間比原來節省了 512 倍,同時根據局部性原理,TLB 緩存命中率也會比較高,因此內存的訪問效率會更高效,所以大頁適合的場景就是大量連續內存的訪問。
有利就有弊,大頁的申請和釋放速度會比普通頁面慢很多,其實大頁主要應對的還是讀多寫少的情況或者是連續讀寫的情況,而 Redis 中更常見的是隨機讀寫,特別是隨機寫的情況下大頁帶來的損耗會非常嚴重。
我們來分析下如果開啟了大頁,在持久化期間會有什么樣的問題。
當 Redis 通過 fork 子進程的方式執行 RDB 或者 AOF rewrite 時,子進程共享主進程的地址空間,不過在這個時候主進程是可以繼續執行寫請求的,而寫進來的請求,操作系統將采用 Copy on Write 的方式操作內存。也就是說當對原有數據進行修改時,由于子進程也會共享這份數據,所以 Redis 修改這塊數據的內容時,操作系統會先將這塊內存的數據拷貝出來,再修改這塊內存的數據,然后當訪問修改的數據時也會訪問這塊內存的數據,而不是原來的數據,這就是寫時復制的實現原理。
但是,我們想因為 Redis 大部分的修改都是根據 key 來修改,所以操作大概率都是隨機訪問,并不是連續訪問,所以假如客戶端每次只修改 10 個字節的數據,但是由于開啟了內存大頁,Redis 也要申請 2MB 的空間,將原有內容拷貝過去,然后再修改這 10 個字節的內容。當存在大量這樣的操作時,每次一小部分數據的修改都要至少涉及到 2MB 的頁面創建、復制和修改,這會導致操作時的延遲增加,同時內存占用也會快速增長。

所以,總結一下是當開啟內存大頁后,Redis 持久化期間的操作延遲和寫放大會非常明顯,因此對于 Redis 來說并不適合內存大頁的這種應用場景,仍然是常規頁面比較合適。
對于這個問題的解決方法,我們只需要關閉內存大頁就可以了,我們可以查看下面的內核參數:
cat /sys/kernel/mm/transparent_hugepage/enabled
如果返回的結果是 [always] madvise never 則表示開啟了內存大頁,我們可以關閉它:
echo never >/sys/kernel/mm/transparent_hugepage/enabled
查看內存大頁的大小設置可以執行:
cat /sys/kernel/mm/transparent_hugepage/hpage_pmd_size
返回單位是字節,這個就是開啟大頁之后每次申請頁面的大小,通常都是 2MB,關于 Linux 內存大頁可以參考:https://www.kernel.org/doc/html/next/admin-guide/mm/transhuge.html
6.2 關閉交換分區
如果我們發現 Redis 突然變得非常慢,每次操作都是幾百毫秒甚至幾秒,而且慢查詢都是復雜度比較低的命令,如果恰好系統開啟了交換分區,那么這個時候我們要考慮檢查 Redis 是否被交換到了 Swap 中,如果確實使用了 Swap 那么性能也就很難得到保證。
操作系統采用多道程序設計來管理眾多的進程,每個進程本身看自己擁有完整且連續的地址空間,操作系統本身相當于對 CPU 和內存做了虛擬化,當多個進程同時運行時而物理內存的空間不夠時,操作系統會通過頁面置換策略將一部分程序內存空間交換到 Swap 中,當這部分內存需要訪問時再從 Swap 中換出,從而保證程序的正常運行。
頁面的置換雖然可以保證程序本身能夠運行,但是對于 Redis 這樣的對性能要求極高的服務,磁盤的延時比內存慢好幾個數量級,這個延遲在 Redis 操作中就會非常明顯,所以當我們發現 Redis 有這種跡象時,首先需要確認,先找到 Redis 的進程 ID,例如:
ps aux | grep redis-server
然后查看每個內存區域交換分區的占用情況:
grep '^Swap:' /proc/$PID/smaps
這個看到的結果會有很多行值,因為每一個 VMA(虛擬內存區域)都有自己對應的統計,每個 VMA 其實就是一段匿名內存,如果我們看到交換分區某些 VMA 中值比較大,就需要注意了。我們可以采用命令對所有的 Swap 求和:
grep '^Swap:' /proc/$PID/smaps | awk '{sum+=$2}END{print sum}'
這樣就可以看到當前 Redis 實例總體的交換空間占用,如果這個結果比較大,比如達到了幾百兆甚至 GB 級別的大小,必然會導致 Redis 性能的急劇下降。
我們這個時候首先要排查是否有其他進程占用了比較多的內存,導致 Redis 的內存被交換出去,如果是這樣要將其他的程序遷移出去,讓 Redis 獨立運行。再或者是 Redis 本身占用的內存確實比較大,物理內存確實不夠用,這種情況下可以考慮擴展物理內存或者部署 Redis Cluster 來解決。不過一般的情況下,如果交換分區存在,即使內存還有可用的空間,那么操作系統也傾向于將一部分不常用的程序內存換出到交換分區中,因此我們首先要做的是減少交換分區使用傾向或者關閉交換分區:
# 減小交換分區使用傾向
sysctl -w vm.swappiness=0 >> /etc/sysctl.conf
也可以關閉交換分區:
swapoff -a
不過這樣是臨時關閉,如果永久關閉可以取消 /etc/fstab 中的自動掛載,以后重啟交換分區將不會被重新加載。
關閉交換分區后,我們可以排查 Redis 本身的內存的問題:
- 如果存在其他進程占用比較多的內存,可以將其他的程序遷移到另外的服務器,防止對 Redis 造成影響。
- 優化業務設計,減小 Redis 實例的內存占用。
- 對 Redis 進行內存碎片整理,優化內存使用。
- 增大 Redis 所在機器的物理內存。
- 部署 Redis Cluster,將數據分散到集群中存儲。
其中方案 3 是進行碎片整理,我們可以通過執行 INFO 命令查看 Redis 內存的使用情況:
# Memory
used_memory:361929012
used_memory_human:345.16M
used_memory_rss:658710801
used_memory_rss_human:628.20M
...
mem_fragmentation_ratio:1.82
其中 used_memory 是 Redis 實際存儲占用的內存大小,used_memory_rss 是 Redis 向操作系統實際申請的內存大小,由于 Redis 中存在著頻繁的內存操作,所以導致兩者偏離逐漸增大,所以引入了一個內存碎片率的參數 mem_fragmentation_ratio ,這個計算方法就是 used_memory_rss/used_memory ,也就是兩者的比值。如果這個值比較大,比如大于 1.5 ~ 2,說明內存碎片率比較大,實際上 Redis 不應該占用這么多的內存,這個時候就可以考慮進行碎片整理從而釋放一部分空間。
Redis 支持自動碎片整理,主要有下面幾個參數:
# 開啟自動碎片整理,默認關閉
activedefrag yes
# 內存占用小于 500M,不進行碎片整理
active-defrag-ignore-bytes 500mb
# 內存碎片率超過 50% 開始整理
active-defrag-threshold-lower 50
# 內存碎片率超過 100% 將盡最大努力整理
active-defrag-threshold-upper 100
# 限制內存碎片整理占用 CPU 使用率的最小值
active-defrag-cycle-min 1
# 限制內存碎片整理占用 CPU 使用率的最大值
active-defrag-cycle-max 25
# 操作 set/hash/zset/list 每次 scan 數量的最大值
active-defrag-max-scan-fields 1000
配置開啟之后,Redis 就可以正常進行碎片整理了,不過碎片整理也是在主線程中執行,整理時也會消耗不少的 CPU 資源,我們雖然做了 CPU 最高使用率的限制,但是仍然可能會一定程度上增大請求的延遲,這同樣是一個需要權衡的問題。
6.3 配置內存過載申請處理
Redis 運行時有時候客戶端操作會出現下面的報錯:
MISCONF Redis is configured to save RDB snapshots, but it is currently not able to persist on disk. Commands that may modify the data set are disabled, because this instance is configured to report errors during writes if RDB snapshotting fails (stop-writes-on-bgsave-error option). Please check the Redis logs for details about the RDB error.
這個錯誤其實是表示無法保存快照,并且出現錯誤時,客戶端無法操作,會導致程序阻塞,這種報錯如果排除硬盤問題,很大可能是因為內存不足導致的,具體可以查看的 Redis 的日志,如果日志報錯大致為:Can’t save in background: fork: Cannot allocate memory. 那么基本就可以確定是內存分配失敗的錯誤了,有可能是內存確實不夠了,導致分配失敗,這種情況可以參考前面提到的處理方法解決。但是如果內存還有很多空閑,卻仍然報錯,原因可能就處在持久化階段,同樣還是 fork 子進程的時候,操作系統可能認為當前的空間不足從而阻止 fork 操作的執行,正常程序的虛擬空間都比較大,但是實際用的卻遠遠沒有那么多,在沒有寫時復制之前對于 fork 來說理論上內存會加倍,但是上面我們提到過現代操作系統都會共享主進程的地址空間,因此 fork 并不會真正占用太大的實際內存,而操作系統默認設置下會嚴格按照理論上進行限制,最終導致 fork 失敗,這就要提到內核參數 vm.overcommit_memory 了。
內核參數 vm.overcommit_memory 的取值可以為 0,1,2,默認情況下為 0,表示啟發式過度使用處理,簡單理解是系統認為內存夠用則放行,反之則拒絕,因此在 fork 時會很容易失敗。如果為 1 則表示直接分配,永遠不做檢查,這種情況特別適合頁表地址空間很大,但是實際使用內存很少的情況,或者說頁表是稀疏的情況下適合使用。如果為 2 表示不要過度使用,這種情況下總的可分配空間計算為:(total_RAM - total_huge_TLB) * overcommit_ratio / 100 + total_swap,其中 total_RAM 表示總的物理內存,total_huge_TLB 表示為大頁面預留的內存,overcommit_ratio 為另一個內核參數,表示內存百分比過載值,默認為 50,也就是 50%,total_swap 表示交換空間,也就是說這種情況下限制為物理內存減去預留內存的一半再加上交換空間,如果申請的虛擬內存大于這個值,那么就會拒絕了,這個值的嚴格程度是介于 0 和 1 之間的。關于內核參數的具體文檔可以參考:http://man7.org/linux/man-pages/man5/proc.5.html
如果我們仔細觀察,在 Redis 服務啟動時,日志中會給出警告建議 vm.overcommit_memory 值設置為 1,如果確實出現了上面的報錯,那么我們就很有必要調整這個內核參數了:
sysctl -w vm.overcommit_memory=1 > /etc/sysctl.conf
調整這個參數之后,Redis 再 fork 子進程時通常都不會再出現錯誤了。
6.4 CPU 核心綁定
我們通常不用為 Redis 綁定 CPU 核心,而是由操作系統自己調度即可,性能基本上沒有什么影響。如果手動做了綁核,有可能性能反而下降,因為 Redis 不僅有主線程,還會創建子進程、子線程執行持久化、異步寫盤、lazyfree 刪除過期數據、釋放連接等各類耗時操作,如果我們將 Redis 進程綁定到 1 個 CPU 核心,那么其中的線程和子進程都會集成主進程的 CPU 親和性,本來 Redis 采用多線程、子進程的目的就是為了利用多核的特性,減少主進程的開銷,綁核之后反而所有的進程、線程都堆在一個核上,只能不斷搶占 CPU 資源,導致性能降低和延遲增大。
所以,即使要綁定 CPU,也是綁定多個 CPU,讓所有子進程、線程都可以各自使用獨立的核心而不會相互干擾,同時也要減小進程和線程上下文切換的開銷,因此手動設置還是非常麻煩的。但是 Redis 從 6.0 開始,本身就支持對 CPU 核心的配置,不需要再用傳統的方式綁核了,我們來看下主要的配置:
# 設置 Redis Server 和 IO 線程使用的 CPU 核心:0,2,4,6, 下面 2 是步長:
# server-cpulist 0-7:2
#
# 設置后臺 bio 線程綁定到 CPU 核心:1,3
# bio-cpulist 1,3
#
# 設置 AOF rewrite 子進程綁定到 CPU 核心:8,9,10,11
# aof-rewrite-cpulist 8-11
#
# 設置 RDB 子進程綁定到 CPU 核心:1,10,11
# bgsave-cpulist 1,10-11
通過上面的配置就可以使得 Redis 啟動時自動綁定 CPU 核心,避免 CPU 的搶占和上下文切換的開銷。我們還需要格外注意當前系統的 NUMA 架構情況,盡量讓同一個 Redis 實例用到的所有 CPU 核心都在同一個 NUMA 節點上,從而降低跨 NUMA node 通信的開銷。
不過在絕大多數情況下,我們重點應該優化 Redis 的慢查詢、bigkey 等和業務結合上的存在的瓶頸,這部分是最容易出現明顯問題的,當優化好這部分之后,通常 Redis 的性能也就不存在瓶頸了,綁定核心能帶來的提升遠不如從業務和 Redis 使用上優化帶來的提升。所以,除非要求非常嚴苛的條件,并且對計算機體系結構有必要的了解,否則我們不建議做綁核的配置。
6.5 避免大量的短連接
通常情況下我們使用各類編程語言中的 Redis 客戶端庫,都是采用連接池或者至少是單個長連接的情況,這些庫目前已經足夠成熟了。但是如果研發人員使用不當,比如每次操作都創建新的連接或者每個線程中都新創建連接,這種情況下其實 TCP 的握手連接和揮手關閉過程也會帶來一定的開銷,當訪問頻率特別高的時候,整體的延遲就比較明顯了。
可以查看 Redis 服務當前的連接數量,來確認是否有大量的短連接存在:
netstat -an | grep :6379
一方面是看連接數量是不是比較大,另外還要看客戶端的端口是不是經常變化,如果變化非常頻繁,則對應的客戶端代碼需要考慮優化,這種情況下修改業務代碼和使用邏輯,改為長連接執行命令那么這部分性能就可以提高。
對于客戶端代碼,除了優化大量的短連接,還可以考慮使用 Pipeline、Lua 等封裝多個頻繁的操作,降低多次執行命令的通信開銷并增大吞吐,也可以比較好地提升性能。
以上就是 Redis 在生產環境中常見的實戰優化經驗,雖然解決問題的方法有時候比較簡單,但是其中涉及到的知識點是豐富的,會涵蓋:CPU、內存、存儲、網絡、數據結構和算法、操作系統等眾多的知識點,如果能夠比較好地吸收并利用,不僅對于 Redis,對于其他系統的優化都會帶來幫助或啟發。
當前文章的內容參考了微信公眾號 [水滴與銀彈] 的總結,在此對作者表示感謝!查看原文請點擊打開鏈接。

浙公網安備 33010602011771號