【51CTO.com原創(chuàng)稿件】今天在容器環(huán)境發(fā)布服務,我發(fā)誓我就加了一行日志,在點擊發(fā)布按鈕后,我悠閑地掏出泡著枸杞的保溫杯,準備來一口老年人大保健......
圖片來自 Pexels
正當我一邊喝,一邊沉思今晚吃點啥的問題時,還沒等我想明白,報警系統(tǒng)把我的黃粱美夢震碎成一地雞毛。
我急忙去 Sentry 上查看上報錯誤日志,發(fā)現全都是:
redis.clients.jedis.exceptions.JedisConnectionException: Could not get a resource from the pool
我沒動過 Redis 啊......
內心激動的我無以言表,但是外表還是得表現鎮(zhèn)定,此時我必須的做出選擇:回滾or重啟。
我也不知道是從哪里來的蜜汁自信,我堅信這跟我沒關系,我不管,我就要重啟。
時間每一秒對于等待重啟過程中的我來說變得無比的慢,就像小時候犯了錯,在老師辦公室等待父母到來那種感覺。
重啟的過程中我繼續(xù)去看報錯日志,猛地發(fā)現一條:
什么鬼,誰打日志打成這樣?當我點開準備看看是哪位大俠打的日志的時候,我驚奇的發(fā)現:
***************************
AppLICATION FAILED TO START
***************************
...... ......
原來是服務沒起來。此刻我的內心是凌亂的,無助的,彷徨不安的。服務沒起來,哪里來的 Redis 請求?
能解釋通的就是應該是來自于定時任務刷新數據對 Redis 的請求。這里也說明另一個問題:雖然端口占用,但是服務其實還是發(fā)布起來了,不然不可能運行定時任務。
但是還有另一個問題,Redis 為什么報錯,且報錯的原因還是:
JAVA.lang.IllegalStateException: Pool not open
Jedis 線程池未初始化。項目既然能去執(zhí)行定時任務,為什么不去初始化 Redis 相關配置呢?想想都頭疼。這里可以給大家留個坑盡管猜。
我們今天的重點不是項目為啥沒起來,而是 Redis 那些年都報過哪些錯,讓你夜不能寐。以下錯誤都基于 Jedis 客戶端。
忘記添加白名單
之所以把這個放在第一位,是因為上線不規(guī)范,親人不能睡。
上線之前檢查所有的配置項,只要是測試環(huán)境做過的操作,一定要拿個小本本記下。
在現如今使用個啥啥都要授權的時代你咋能就忘了白名單這種東西呢!
無法從連接池獲取到連接
如果連接池沒有可用 Jedis 連接,會等待 maxWaitMillis(毫秒),依然沒有獲取到可用 Jedis 連接,會拋出如下異常:
redis.clients.jedis.exceptions.JedisException: Could not get a resource from the pool
at redis.clients.util.Pool.getResource(Pool.java:51)
at redis.clients.jedis.JedisPool.getResource(JedisPool.java:226)
at com.yy.cs.base.redis.RedisClient.zrangeByScoreWithScores(RedisClient.java:2258)
...... java.util.NoSuchElementException: Timeout waiting for idle object
at org.Apache.commons.pool2.impl.GenericObjectPool.borrowObject(GenericObjectPool.java:448)
at org.apache.commons.pool2.impl.GenericObjectPool.borrowObject(GenericObjectPool.java:362)
at redis.clients.util.Pool.getResource(Pool.java:49)
at redis.clients.jedis.JedisPool.getResource(JedisPool.java:226)
......
其實出現這個問題,我們從兩個方面來探測一下原因:
- 連接池配置有問題
- 連接池沒問題,使用有問題
連接池配置
Jedis Pool 有如下參數可以配置:
①如何確定 maxTotal 呢?
最大連接數肯定不是越大越好,首先 Redis 服務端已經配置了允許所有客戶端連接的最大連接數,那么當前連接 Redis 的所有節(jié)點的連接池加起來總數不能超過服務端的配置。
其次對于單個節(jié)點來說,需要考慮單機的 Redis QPS,假設同機房 Redis 90% 的操作耗時都在 1ms,那么 QPS 大約是 1000。
而業(yè)務系統(tǒng)期望 QPS 能達到 10000,那么理論上需要的連接數=10000/1000=10。
考慮到網絡抖動不可能每次操作都這么準時,所以實際配置值應該比當前預估值大一些。
②maxIdle 和 minIdle 如何確定?
maxIdle 從默認值來看是等于 maxTotal。這么做的原因在于既然已經分配了 maxTotal 個連接,如果 maxIdle
如果你的系統(tǒng)只是在高峰期才會達到 maxTotal 的量,那么你可以通過 minIdle 來控制低峰期最低有多少個連接的存活。
所以連接池參數的配置直接決定了你能否獲取連接以及獲取連接效率問題。
使用有問題
說到使用,真的就是仁者見仁智者也會犯錯,誰都不能保證他寫的代碼一次性考慮周全。
比如有這么一段代碼:
是不是沒有問題。再好好想想,這里從線程池中獲取了 Jedis 連接,用完了是不是要歸還?不然這個連接一直被某個人占用著,線程池慢慢連接數就被消耗完。
所以正確的寫法:
多個線程使用同一個 Jedis 連接
這種錯誤一般發(fā)生在新手身上會多一些。
這段代碼乍看是不是感覺良好,不過你跑起來了之后就知道有多痛苦:
redis.clients.jedis.exceptions.JedisConnectionException: Unexpected end of stream.
at redis.clients.util.RedisInputStream.ensureFill(RedisInputStream.java:199)
at redis.clients.util.RedisInputStream.readByte(RedisInputStream.java:40)
at redis.clients.jedis.Protocol.process(Protocol.java:151)
......
這個報錯是不是讓你一頭霧水,不知所措。出現這種報錯是服務端無法分辨出一條完整的消息從哪里結束,正常情況下一個連接被一個線程使用,上面這種情況多個線程同時使用一個連接發(fā)送消息,那服務端可能就無法區(qū)分到底現在發(fā)送的消息是哪一條的。
類型轉換錯誤
這種錯誤雖然很低級,但是出現的幾率還不低。
java.lang.ClassCastException: com.test.User cannot be cast to com.test.User
at redis.clients.jedis.Connection.getBinaryMultiBulkReply(Connection.java:199)
at redis.clients.jedis.Jedis.hgetAll(Jedis.java:851)
at redis.clients.jedis.ShardedJedis.hgetAll(ShardedJedis.java:198)
上面這個錯乍一看是不是很吃驚,為啥同一個類無法反序列化。因為開發(fā)這個功能的同學用了一個序列化框架 Kryo 先將 User 對象序列化后存儲到 Redis。
后來 User 對象增加了一個字段,而反序列化的 User 與新的 User 對象對不上導致無法反序列化。
客戶端讀寫超時
出現客戶端讀超時的原因很多,這種情況就要綜合來判斷。
redis.clients.jedis.exceptions.JedisConnectionException:
java.net.SocketTimeoutException: Read timed out
......
出現這種情況的原因我們可以綜合分析:
- 首先檢查讀寫超時時間是否設置的過短,如果確定設置的很短,調大一點觀察一下效果。
- 其次檢查出現超時的命名是否本身執(zhí)行較大的存儲或者拉數據任務。如果數據量過大,那么就要考慮做業(yè)務拆分。
- 前面這兩項如果還不能確定,那么就要檢查一下網絡問題,確定當前業(yè)務主機和 Redis 服務器主機是否在同機房,機房質量怎么樣。
- 機房質量如果還是沒問題,那能做的就是檢查當前業(yè)務中 Redis 讀寫是否發(fā)生有可能發(fā)生阻塞,是否業(yè)務量大到這種程度,是否需要擴容。
大 Key 造成的 CPU 飆升
我們有個新項目中 Redis 主要存儲教師端的講義數據(濃縮講義非全部), QPS 達到了15k,但是通過監(jiān)控查看命中率特別低,僅 15% 左右。這說明有很多講義是沒有被看的,Cache 這樣使用是對內存的極大浪費。
項目在上線中期就頻繁出現 Redis 所在機器 CPU 使用率頻頻報警,單看這么低的命中率也很難想象到底是什么導致 CPU 超。后面觀察到報警時刻的 response 數據基本都在 15k-30 k 左右。
觀察了 Redis 的錯誤日志,有一些頁交換錯誤的日志。聯系起來看可以得出結論:Redis 獲取大對象時該對象首先被序列化到通信緩沖區(qū)中,然后寫入客戶端套接字,這個序列化是有成本的,涉及到隨機 I/O 讀寫。
另外 Redis 官方也不建議使用 Redis 存儲大數據,雖然官方建議值是一個 value 最大值不能超過 512M,試想真的存儲一個 512M 的數據到緩存和到關系型數據庫的區(qū)別應該不大,但是成本就完全不一樣。
Too Many Cluster Redirections
這個錯誤信息一般在 cluster 環(huán)境中出現,主要原因還是單機出現了命令堆積。
redis.clients.jedis.exceptions.JedisClusterMaxRedirectionsException: Too many Cluster redirections?
at redis.clients.jedis.JedisClusterCommand.runWithRetries(JedisClusterCommand.java:97)
at redis.clients.jedis.JedisClusterCommand.runWithRetries(JedisClusterCommand.java:152)
at redis.clients.jedis.JedisClusterCommand.runWithRetries(JedisClusterCommand.java:131)
at redis.clients.jedis.JedisClusterCommand.runWithRetries(JedisClusterCommand.java:152)
at redis.clients.jedis.JedisClusterCommand.runWithRetries(JedisClusterCommand.java:131)
at redis.clients.jedis.JedisClusterCommand.runWithRetries(JedisClusterCommand.java:152)
at redis.clients.jedis.JedisClusterCommand.runWithRetries(JedisClusterCommand.java:131)
at redis.clients.jedis.JedisClusterCommand.run(JedisClusterCommand.java:30)
at redis.clients.jedis.JedisCluster.get(JedisCluster.java:81)
Redis 是單線程處理請求的,如果一條命令執(zhí)行的特別慢(可能是網絡阻塞,可能是獲取數據量大),那么新到來的請求就會放在 TCP 隊列中等待執(zhí)行。但是等待執(zhí)行的命令數如果超過了設置的隊列大小,后面的請求就會被丟棄。
出現上面這個錯誤的原因是:
- 集群環(huán)境中 client 先通過key 計算 slot,然后查詢 slot 對應到哪個服務器,假設這個 slot 對應到 server1,那么就去請求 server1。
- 此時如果 server1 整由于執(zhí)行慢命令而被阻塞且 TCP 隊列也已滿,那么新來的請求就會直接被拒絕。
- client 以為是 server1不可用,隨即請求另一個服務器 server2。server2 檢查到該 slot 由 server1 負責且 server1 心跳檢查正常,所以告訴 client 你還是去找 server1 吧。
- client 又來請求 server1,但是 server1 此時還是阻塞中,又回到 3。當請求的次數超過拒絕服務次數之后,就會拋出異常。
再次說明,大命令要不得。對于這種錯誤,最首要的就是要優(yōu)化存儲結構或者獲取數據方式。其次,增加 TCP 隊列長度。再次,擴容也是可以解決的。
集群擴容之后找不到 Key
現在有如下集群,6 臺主節(jié)點,6 臺從節(jié)點:
- redis-master001~redis-master006
- redis-slave001~redis-slave006
之前 Redis 集群的 16384 個槽均勻分配在 6 臺主節(jié)點中,每個節(jié)點 2730 個槽。
現在線上主節(jié)點數已經出現到達容量閾值,需要增加 3 主 3 從。
為保證擴容后,槽依然均勻分布,需要將之前 6 臺的每臺機器上遷移出 910 個槽,方案如下:
分配完之后,每臺節(jié)點 1820 個 slot。遷移完數據之后,開始報如下異常:
Exception in thread "main" redis.clients.jedis.exceptions.JedisMovedDataException: MOVED 1539 34.55.8.12:6379
at redis.clients.jedis.Protocol.processError(Protocol.java:93)
at redis.clients.jedis.Protocol.process(Protocol.java:122)
at redis.clients.jedis.Protocol.read(Protocol.java:191)
at redis.clients.jedis.Connection.getOne(Connection.java:258)
at redis.clients.jedis.ShardedJedisPipeline.sync(ShardedJedisPipeline.java:44)
at org.hu.e63.MovieLens21MPipeline.push(MovieLens21MPipeline.java:47)
at org.hu.e63.MovieLens21MPipeline.main(MovieLens21MPipeline.java:53
報這種錯誤肯定就是 slot 遷移之后找不到了。
我們看一下代碼:
之所以這種方式會出問題還是在于我們沒有明白 Redis Cluster 的工作原理。
Key 通過 Hash 被均勻的分配到 16384 個槽中,不同的機器被分配了不同的槽,那么我們使用的 API 是不是也要支持去計算當前 Key 要被落地到哪個槽。
你可以去看看 Pipelined 的源碼它支持計算槽嗎。動腦子想想 Pipelined 這種批量操作也不太適合集群工作。
所以我們用錯了 API。如果在集群模式下要使用 JedisCluster API,示例代碼如下:
JedisPoolConfig config = new JedisPoolConfig();
//可用連接實例的最大數目,默認為8;
//如果賦值為-1,則表示不限制,如果pool已經分配了maxActive個jedis實例,則此時pool的狀態(tài)為exhausted(耗盡)
private Integer MAX_TOTAL = 1024;
//控制一個pool最多有多少個狀態(tài)為idle(空閑)的jedis實例,默認值是8
private Integer MAX_IDLE = 200;
//等待可用連接的最大時間,單位是毫秒,默認值為-1,表示永不超時。
//如果超過等待時間,則直接拋出JedisConnectionException
private Integer MAX_WAIT_MILLIS = 10000;
//在borrow(用)一個jedis實例時,是否提前進行validate(驗證)操作;
//如果為true,則得到的jedis實例均是可用的
private Boolean TEST_ON_BORROW = true;
//在空閑時檢查有效性, 默認false
private Boolean TEST_WHILE_IDLE = true;
//是否進行有效性檢查
private Boolean TEST_ON_RETURN = true;
config.setMaxTotal(MAX_TOTAL); config.setMaxIdle(MAX_IDLE); config.setMaxWaitMillis(MAX_WAIT_MILLIS); config.setTestOnBorrow(TEST_ON_BORROW); config.setTestWhileIdle(TEST_WHILE_IDLE); config.setTestOnReturn(TEST_ON_RETURN); Set<HostAndPort> jedisClusterNode = new HashSet<HostAndPort>();
jedisClusterNode.add(new HostAndPort("192.168.0.31", 6380));
jedisClusterNode.add(new HostAndPort("192.168.0.32", 6380));
jedisClusterNode.add(new HostAndPort("192.168.0.33", 6380));
jedisClusterNode.add(new HostAndPort("192.168.0.34", 6380));
jedisClusterNode.add(new HostAndPort("192.168.0.35", 6380));
JedisCluster jedis = new JedisCluster(jedisClusterNode, 1000, 1000, 5, config);
以上介紹了看似平常實則在日常開發(fā)中只要一不注意就會發(fā)生的錯誤。出錯了先別慌,保留日志現場,如果一眼能看出問題就修復,如果不能就趕緊回滾,不然再過一會就是一級事故你的年終獎估計就沒了。
Redis 正確使用小技巧
①正確設置過期時間
把這個放在第一位是因為這里實在是有太多坑。
如果你不設置過期時間,那么你的 Redis 就成了垃圾堆,假以時日你領導看到了告警,再看一下你的代碼,估計你可能就 “沒了”!
如果你設置了過期時間,但是又設置了特別長,比如兩個月,那么帶來的問題就是極有可能你的數據不一致問題會變得特別棘手。
我就遇到過這種,用戶信息緩存中包含了除基本信息外的各種附加屬性,這些屬性又是隨時會變的,在有變化的時候通知緩存進行更新,但是這些附加信息是在各個微服務中,服務之間調用總會有失敗的時候,只要發(fā)生那就是緩存與數據不一致之日。
但是緩存又是 2 個月過期一次,遇到這種情況你能怎么辦,只能手動刪除緩存,重新去拉數據。
所以過期時間設置是很有技巧性的。
②批量操作使用 Pipeline 或者 Lua 腳本
使用 Pipeline 或 Lua 腳本可以在一次請求中發(fā)送多條命令,通過分攤一次請求的網絡及系統(tǒng)延遲,從而可以極大的提高性能。
③大對象盡量使用序列化或者先壓縮再存儲
如果存儲的值是對象類型,可以選擇使用序列化工具比如 protobuf,Kyro。對于比較大的文本存儲,如果真的有這種需求,可以考慮先壓縮再存儲,比如使用 snappy 或者 lzf 算法。
④Redis 服務器部署盡量與業(yè)務機器同機房
如果你的業(yè)務對延遲比較敏感,那么盡量申請與當前業(yè)務機房同地區(qū)的 Redis 機器。同機房 Ping 值可能在 0.02ms,而跨機房能達到 20ms。當然如果業(yè)務量小或者對延遲的要求沒有那么高這個問題可以忽略。
Redis 服務器內存分配策略的選擇:
首先我們使用 info 命令來查看一下當前內存分配中都有哪些指標:
info
$2962
# Memory
used_memory:325288168
used_memory_human:310.22M #數據使用內存
used_memory_rss:337371136
used_memory_rss_human:321.74M #總占用內存
used_memory_peak:327635032
used_memory_peak_human:312.46M #峰值內存
used_memory_peak_perc:99.28%
used_memory_overhead:293842654
used_memory_startup:765712
used_memory_dataset:31445514
used_memory_dataset_perc:9.69%
total_system_memory:67551408128
total_system_memory_human:62.91G # 操作系統(tǒng)內存
used_memory_lua:43008
used_memory_lua_human:42.00K
maxmemory:2147483648
maxmemory_human:2.00G
maxmemory_policy:allkeys-lru # 內存超限時的釋放空間策略
mem_fragmentation_ratio:1.04 # 內存碎片率(used_memory_rss / used_memory)
mem_allocator:jemalloc-4.0.3 # 內存分配器
active_defrag_running:0
lazyfree_pending_objects:0
上面我截取了 Memory 信息。根據參數:mem_allocator 能看到當前使用的內存分配器是 jemalloc。
Redis 支持三種內存分配器:tcmalloc,jemalloc 和 libc(ptmalloc)。
在存儲小數據的場景下,使用 jemalloc 與 tcmalloc 可以顯著的降低內存的碎片率。
根據這里的評測:
https://matt.sh/redis-quicklist
保存 200 個列表,每個列表有 100 萬的數字,使用 jemalloc 的碎片率為 3%,共使用 12.1GB 內存,而使用 libc 時,碎片率為 33%,使用了 17.7GB 內存。
但是保存大對象時 libc 分配器要稍有優(yōu)勢,例如保存 3000 個列表,每個列表里保存 800 個大小為 2.5k 的條目,jemalloc 的碎片率為 3%,占用 8.4G,而 libc 為 1%,占用 8GB。
現在有一個問題:當我們從 Redis 中刪除數據的時候,這一部分被釋放的內存空間會立刻還給操作系統(tǒng)嗎?
比如有一個占用內存空間(used_memory_rss)10G 的 Redis 實例,我們有一個大 Key 現在不使用需要刪除數據,大約刪了 2G 的空間。那么理論上占用內存空間應該是 8G。
如果你使用 libc 內存分配器的話,這時候的占用空間還是 10G。這是因為 malloc() 方法的實現機制問題,因為刪除掉的數據可能與其他正常數據在同一個內存分頁中,因此這些分頁就無法被釋放掉。
當然這些內存并不會浪費掉,當有新數據寫入的時候,Redis 會重用這部分空閑空間。
如果此時觀察 Redis 的內存使用情況,就會發(fā)現 used_memory_rss 基本保持不變,但是 used_memory 會不斷增長。
小結
今天給大家分享 Redis 使用過程中可能會遇到的問題,也是我們稍不留神就會遇到的坑。
很多問題在測試環(huán)境我們就能遇到并解決,也有一些問題是上了生產之后才發(fā)生的,需要你臨時判斷該怎么做。
總之別慌,你遇到的這些問題都是前人曾經走過的路,只要仔細看日志都是有解決方案的。
作者:楊越
簡介:目前就職廣州歡聚時代,專注音視頻服務端技術,對音視頻編解碼技術有深入研究。日常主要研究怎么造輪子和維護已經造過的輪子,深耕直播類 APP 多年,對垂直直播玩法和應用有廣泛的應用經驗,學習技術不局限于技術,歡迎大家一起交流。
【51CTO原創(chuàng)稿件,合作站點轉載請注明原文作者和出處為51CTO.com】