項目背景
最近,做一個按優先級和時間先后排隊的需求。用 Redis 的 sorted set 做排隊隊列。
主要使用的 Redis 命令有, zadd, zcount, zscore, zrange 等。
測試完畢后,發到線上,發現有大量接口請求返回超時熔斷(超時時間為3s)。
Error日志打印的異常堆棧為:
redis.clients.jedis.exceptions.JedisConnectionException: Could not get a resource from the pool
Caused by: redis.clients.jedis.exceptions.JedisConnectionException: java.net.ConnectException: Connection timed out (Connection timed out)
Caused by: java.net.ConnectException: Connection timed out (Connection timed out)
且有一個怪異的現象,只有寫庫的邏輯報錯,即 zadd 操作。像 zcount, zscore 這些操作全部能正常執行。
還有就是報錯和正常執行交錯持續。即假設每分鍾有1000個 Redis 操作,其中900個正常,100個報錯。而不是報錯后,Redis 就不能正常使用了。
問題排查
1.連接池泄露?
從上面的現象基本可以排除連接池泄露的可能,如果連接未被釋放,那么一旦開始報錯,后面的 Redis 請求基本上都會失敗。而不是有90%都可正常執行。
但 Jedis 客戶端據說有高並發下連接池泄露的問題,所以為了排除一切可能,還是升級了 Jedis 版本,發布上線,發現沒什么用。
2.硬件原因?
排查 Redis 客戶端服務器性能指標,CPU利用率10%,內存利用率75%,磁盤利用率10%,網絡I/O上行 1.12M/s,下行 2.07M/s。接口單實例QPS均值300左右,峰值600左右。
Redis 服務端連接總數徘徊在2000+,CPU利用率5.8%,內存使用率49%,QPS1500-2500。
硬件指標似乎也沒什么問題。
3.Redis參數配置問題?
1 JedisPoolConfig config = new JedisPoolConfig(); 2 config.setMaxTotal (200); // 最大連接數 3 config.setMinIdle (5); // 最小空閑連接數 4 config.setMaxIdle (50); // 最大空閑連接數 5 config.setMaxWaitMillis (1000 * 1); // 最長等待時間 6 config.setTestOnReturn (false); 7 config.setTestOnBorrow (false); 8 config.setTestWhileIdle (true); 9 config.setTimeBetweenEvictionRunsMillis (30 * 1000); 10 config.setNumTestsPerEvictionRun (50);
基本上大部分公司的配置包括網上博客提供的配置其實都和上面差不多,看不出有什么問題。
這里我嘗試把最大連接數調整到500,發布到線上,並沒什么卵用,報錯數反而變多了。
4.連接數統計
在 Redis Master 庫上執行命令:client list。打印出當前所有連接到服務器的客戶端IP,並過濾出當前服務的IP地址的連接。
發現均未達到最大連接數,確實排除了連接泄露的可能。
5.最大連接數調優和壓測
既然連接遠未打滿,說明不需要設置那么大的連接數。而 Redis 服務端又是單線程讀寫。客戶端創建過多連接,只會耗費資源,反而拖累性能。
使用以上代碼,在本機使用 JMeter 壓測300個線程,連續請求30秒。
首先把最大連接數設為500,成功率:99.61%
請求成功:82004次,TP90耗時目測在50-80ms左右。
請求失敗322次,全部為請求服務器超時:socket read timeout,耗時2s后,由 Jedis 自行熔斷。
(這種情況造成數據不一致,實際上服務端已執行了命令,只是客戶端讀取返回結果超時)。
再把最大連接數設為20,成功率:98.62%(有一定幾率100%成功)
請求成功:85788次,TP90耗時在10ms左右。
請求失敗:1200次,全部為等待客戶端連接超時:Caused by: java.util.NoSuchElementException: Timeout waiting for idle object,熔斷時間為1秒。
再將最大連接數調整為50,成功率:100%
請求成功:85788次, TP90耗時10ms。
請求失敗:0次。
綜上,Redis 服務端單線程讀寫,連接數太多並沒卵用,反而會消耗更多資源。最大連接數配置太小,不能滿足並發需求,線程會因為拿不到空閑連接而超時退出。
在滿足並發的前提下,maxTotal連接數越小越好。在300線程並發下,最大連接數設為50,可以穩定運行。
基於以上結論,嘗試調整 Redis 參數配置並發布上線,但以上實驗只執行了 zadd 命令,仍未解決一個問題:為什么只有寫庫報錯?
果然,發布上線后,接口超時次數有所減少,響應時間有所提升,但仍有報錯,沒能解決此問題。
6.插曲 - Redis鎖
在優化此服務的同時,把同事使用的另一個 Redis 客戶端一起優化了,結果同事的接口過了一天開始大面積報錯,接口響應時間達到8個小時。
排查發現,同事的接口僅使用 Redis 作為分布式鎖。而這個 RedisLock 類是從其他服務拿過來直接用的,自旋時間設置過長,這個接口又是超高並發。
最大連接數設為50后,鎖資源競爭激烈,直接導致大部分線程自旋把連接池耗盡了。於是又緊急把最大連接池恢復到200,問題得以解決。
由此可見,在分布式鎖的場景下,配置不能完全參考讀寫 Redis 操作的配置。
7.排查服務端持久化
在把客戶端研究了好幾遍之后,發現並沒有什么可以優化的了,於是開始懷疑是服務端的問題。
持久化是一直沒研究過的問題。在查閱了網上的一些博客,發現持久化確實有可能阻塞讀寫IO的。
“1) 對於沒有持久化的方式,讀寫都在數據量達到800萬的時候,性能下降幾倍,此時正好是達到內存10G,Redis開始換出到磁盤的時候。並且從那以后再也沒辦法重新振作起來,性能比Mongodb還要差很多。
2) 對於AOF持久化的方式,總體性能並不會比不帶持久化方式差太多,都是在到了千萬數據量,內存占滿之后讀的性能只有幾百。
3) 對於Dump持久化方式,讀寫性能波動都比較大,可能在那段時候正在Dump也有關系,並且在達到了1400萬數據量之后,讀寫性能貼底了。在Dump的時候,不會進行換出,而且所有修改的數據還是創建的新頁,內存占用比平時高不少,超過了15GB。而且Dump還會壓縮,占用了大量的CPU。也就是說,在那個時候內存、磁盤和CPU的壓力都接近極限,性能不差才怪。” ---- 引用自lovecindywang 的博客園博客
“內存越大,觸發持久化的操作阻塞主線程的時間越長
Redis是單線程的內存數據庫,在redis需要執行耗時的操作時,會fork一個新進程來做,比如bgsave,bgrewriteaof。 Fork新進程時,雖然可共享的數據內容不需要復制,但會復制之前進程空間的內存頁表,這個復制是主線程來做的,會阻塞所有的讀寫操作,並且隨着內存使用量越大耗時越長。例如:內存20G的redis,bgsave復制內存頁表耗時約為750ms,redis主線程也會因為它阻塞750ms。” ---- 引用自CSDN博客
而我們的Redis實例內存配額20G,已使用了50%,keys數量達4000w。
主從集群,從庫不做持久化,主庫使用RDB持久化。rdb的save參數是默認值。(這也恰好能解釋通為什么寫庫報錯,讀庫正常)
且此 Redis 已使用了幾年,里面可能存在大量的key已經不使用了,但未設置過期時間。
然而,像 Redis、MySQL 這種都是由數據中台負責,我們並無權查看服務端日志,這個事情也不好推動,中台會說客戶端使用的有問題,建議調整參數。
所以最佳解決方案可能是,重新申請 Redis 實例,逐步把項目中使用的 Redis 遷移到新實例,並注意設置過期時間。遷移完成后,把老的 Redis 實例廢棄回收。
小結
1)如果簡單的在網上搜索,Could not get a resource from the pool , 基本都是些連接未釋放的問題。
然而很多原因可能導致 Jedis 報這個錯,這條信息並不是異常堆棧的最頂層。
2)Redis其實只適合作為緩存,而不是數據庫或是存儲。它的持久化方式適用於救救急啥的,不太適合當作一個普通功能來用。
3)還是建議任何數據都設置過期時間,哪怕設1年呢。不然老的項目可能已經都廢棄了,殘留在 Redis 里的 key,其他人也不敢刪。
4)不要存放垃圾數據到 Redis 中,及時清理無用數據。業務下線了,就把相關數據清理掉。