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