Redis上踩過的一些坑-美團 https://blog.csdn.net/chenleixing/article/details/50530419java
tpn(taobao push notification)在使用redis計算消息未讀數的過程當中,遇到了一系列的問題,下面把這個過程整理了一下,也讓你們瞭解這個糾結的過程,供你們之後使用redis或者作相似的功能時進行參考
redis在tpn裏面主要是用於計算移動千牛(Android、IOS)上的消息未讀數。tpn的未讀消息數是基於bizId維度的,即同一個bizId(每條消息的業務id,若是商品id、訂單id等),即便有多條消息,未讀數也只能算1。所以在接收消息,計算移動千牛未讀數的過程當中,就須要對bizId去重,這個去重的功能就是經過redis來實現的。隨着消息量的不斷上漲,這個基於redis的去重方案也不斷變化。
1、基於redis Set結構的未讀數計算
前面說到的tpn未讀數計算的最大特色就是基於bizId去重,在java裏面,咱們很容易想到利用HashMap或者HashSet來判重,所以最初tpn就是利用redis的Set結構來進行判重。主要利用了redis set結構的這兩個命令:SADD和SCARD
SADD key member [member....]:將一個或多個 member 元素加入到集合 key 當中,已經存在於集合的 member 元素將被忽略。假如 key 不存在,則建立一個只包含 member 元素做成員的集合。 若是member元素不在集合裏面,則返回1;若是member元素已經存在於集合當中,則返回0。
SCARD key:返回集合 key 中元素的數量。
有了這兩個命令,計算未讀數的步驟就是這樣的:git
tpn會爲用戶保留7天內的消息,也就是說保存到redis set結構中的bizId失效時間是7天,同時用戶在查看消息後,就會把其對應的redis set清空(即若是一個用戶連續幾天都不查看千牛的消息,那麼其對應的redis set集合裏面就會保存大量的bizid)。tpn總共有6臺redis機器,每臺機器上部署5個redis實例,每一個實例的maxmemory設爲1G,總共30G的內存用於存放消息bizId。在tpn的早期,因爲用戶量很少,消息量也不大,redis的內存徹底能夠存放7天內的全部消息bizId,所以這個方案work的很好。但隨着全網大多數活躍賣家開始使用千牛,tpn的消息量也隨之暴漲,愈來愈多的消息bizId給redis帶來了極大的壓力,在消息高峯期,tpn的日誌裏會有大量的redis timeout異常(tpn使用jedis,配置的timeout是300ms),通過分析,主要是由下面緣由形成的:github
由於bizId太多,而redis內存不夠,因此形成redis請求大量超時,最簡單地辦法就是加機器,部署更多的redis實例來存儲愈來愈多的消息bizId。初步估計了一下,要徹底把7天內的全部消息bizId都保存到內存中,須要高達上百G的內存:交易消息和商品消息是tpn最主要的兩類消息,由於目前全網大多數活躍賣家都使用了千牛,爲了去重,tpn須要把全網7天內全部新增的交易id和商品id都保存到redis內存中,換句話來講,也就是要用內存來保存7天內tc和ic新增的全部id。tpn基本不可能申請到這麼多的redis機器,就算有這麼多的redis機器,部署維護成本也是巨大的。就算不用redis,使用tair的rdb,這個陳本仍然是不能接受的。
在移動千牛客戶端,推送沒有正常到達的狀況下(好比長鏈接斷開的時候),是依賴客戶端在發現長鏈接斷開之後調用messagecount.get接口來獲取到消息未讀數,而後促使用戶手動獲取最新的消息。當redis的內存使用量接近極限時,調用redis的sadd、scard命令很容易就timeout了,所以不能正確地計算出消息未讀數,就會形成用戶不能及時獲取到最新的消息。
總的來講,redis的內存容量不足以容納愈來愈多的業務消息bizId,形成大量redis請求超時,不能正確地計算消息未讀數。所以須要對上述方案進行優化。
2、redis用於消息去重判斷,tair存放未讀數消息數的方案
根據上面的分析,當redis內存使用量達到了上限時,很容易發送timeout,同時redis內存使用量會之因此會很快地達到上限,主要是由於不活躍用戶的set結構裏面保存了大量的bizId。在不能快速增長redis機器的前提下,最簡單地方法就是在夜間重啓redis。重啓redis會帶來一下影響:
全部用戶保存在set裏面的消息bizId所有被清空了,就會形成誤判:即對同一個bizId的消息重複提醒用戶有新消息。但這個並不會對用戶形成太大的影響:由於活躍用戶會及時地來查看消息,因此活躍的set結構基本都是空的;而非活躍用戶的redis set結構雖然有不少消息bizId,可是由於其是不活躍的,就算被清空,很快又會有新的bizId存放進去,但認爲是不活躍用戶,對這種狀況基本無感知。
由於set結構被清空,因此全部用戶的消息未讀數也被清空(經過scard命令來計算未讀數)。根據前面的分析,在消息推送不能正常達到的狀況下,正確的未讀數會促使用戶主動地來獲取最新消息,因此基本不能接受重啓redis的時候,清空用戶的消息未讀數
由於不能接受隨意清空用戶的消息未讀數,因此咱們不能按期重啓redis來釋放內存。可是若是咱們把消息去重和計算未讀數分開,即redis的set結構只用於判斷一條消息是不是新消息,是否須要增長未讀數,而把未讀數保存在其餘的地方,若是tair之類的,那咱們是否是就能夠按期重啓redis了呢?所以咱們獲得了下面的方案:
繼續是用redis的set結構來判斷一條消息是否是新消息,是否是須要增長消息未讀數
再也不使用redis的scard命令計算消息未讀數,而是採用基於tair的計數器來計算消息未讀數,即若是經過redis的set結構判斷出是新消息,則對保存在tair裏面的未讀數計數器執行incr unReadCountKey 1。redis
這樣一來,redis就只用於對消息bizId去重,而再也不用於計算消息未讀數,消息未讀數單獨保存在基於tair的計數器當中。所以咱們就大膽地按期在夜間重啓redis了。這個方案成功work了一段時間,但過了一段時間後,應用在請求redis的時候又開始是否是拋出大量的timeout exception。分析了一下,問題仍是處在redis內存上:
雖然能夠經過按期重啓redis來釋放內存,可是redis內存的增長的速度是不可預期的,咱們並不能每次都能在內存使用達到極限前重啓redis
有時候雖然redis的總體內存使用量尚未達到極限,可是若是一個用戶的set結構裏面的bizId太多了,scard命令仍然會timeout
因此這個方案還不是一個最佳的方案,仍然須要經過更好的辦法來下降redis的內存使用量
3、基於redis的bloomfilter的消息去重方案
從方案一到方案二,咱們一直想解決的就是如何用最小的內存來判斷一個消息bizId是否是新的bizId,即一個消息bizId是否是已經存在了。以最小的內存來實現判斷操做,很容易就聯想到bloomfilter。可是在這個場景,咱們不能簡單地使用bloomfilter,先來計算一下「最直接」地使用bloomfilter須要多大的內存:bloomfilter的所佔用的內存由bitSize決定,而根據公式:
bitSize = (int) Math.ceil(maxKey * (Math.log(errorRate) / Math.log(0.6185)));
咱們爲每一個用戶的每一個消息類型建立一個bloomfilter,以500萬用戶,每一個用戶訂閱了10個消息類型,那麼這個用於去重的bloomfilter所佔用的內存總量是:
totalMemory(G) = 5000000*10*Math.ceil(maxKey * (Math.log(errorRate) / Math.log(0.6185)))
這個totalMemory的大小就取決於maxKey和errorRate,保證errorRate不變的前提下,bloomfilter 的maxKey越大,bloomfilter所須要的內存也就越大。那咱們估算一下使用bloomfilter,須要多少內存。
以商品消息和交易小爲例,不一樣的賣家,7天內的消息數從幾個到幾萬個不等。最小的是7天只有幾條消息,最多的7天內有7萬多條。就算取個1000的評價值,這5000w個bloomfilter的內存消耗也在上百G,這明顯行不通。
可是,tpn的消息未讀數還有一個業務特色就是,當一個用戶的某個消息類型的未讀數已經超99了,就再也不顯示具體的數字,而是顯示成99+,同時一個用戶的消息未讀數超過了99,那麼其實他本身對消息未讀數的敏感性也不高了,即就算有一條消息不是新消息,可是仍然給未讀數+1了,用戶也察覺不出來。
所以,在上面的公式裏,咱們能夠把每一個bloomfilter的maxKey設爲100,那這樣一來,所佔用的內存就是一個十分可以接受的數字了:設errorRate=0.0001,maxKey=100,那麼上面的5000w個bloomfilter只須要11G的內存,很明顯,這不是一個徹底能夠接受的內存消耗。
這樣一來,咱們就得出下面這個基於redis bloomfilter去重方案:緩存
這樣一來,終於咱們能夠經過能接受的內存來實現未讀數的計算,再也不要天天擔憂redis是否是內存不夠用了,應用又頻繁拋timeout exception了
4、詭異的connection broken pipe
在方案三上線之後,我認爲這些redis應該會消停了,redis運行一段時間後,的確再也沒用timeout exception了,可是在運行一段時間後,tpn在向redis執行請求時,往redis寫入命令時會報這個異常:
java.net.SocketException: Broken pipe。咱們知道,若是一個socket鏈接已經被遠端給close掉了,可是客戶端沒有察覺,仍然經過這個鏈接讀寫數據,那麼就會產生Broken pipe異常。由於tpn使用jedis,經過common pool來實現jedis的connection pool,我第一反應就是tpn沒用正確使用jedis的connection pool,沒有銷燬掉broken的redis connection,而是已經從新把歸還給了connection pool,或者是jedis的connection pool有bug,形成了connection泄露,致使ton在往一條已經往一條已經被close的鏈接寫入數據。可是仔細檢查了一遍tpn的代碼和jedis connection pool的代碼,發現沒用什麼問題,那就說明有些redis是真的被redis服務端給關閉了,可是jedis 的connection pool沒有發現。
由於客戶端的jedis pool沒有問題,那麼基本上能夠肯定的確是redis server端關閉了一些鏈接。首先懷疑的就是tpn的redis 配置出錯了,錯誤地配置了redis.conf裏的timeout 配置項:
首先懷疑的是否是tpn的redis配置很少,形成所以就去查看redis的相關代碼。redis的配置文件redis.config裏面有timeou這個配置項:
# Close the connection after a client is idle for N seconds (0 to disable) timeout 0
檢查了下tpn 6臺redis上的全部配置文件,發現都沒有配置這個選擇,可是tpn部署了兩個版本的redis,redis-2.6.14和redis-2.4,結果在redis-2.4裏面,若是沒有配置這個值,redis就會使用默認的值,5*60(s),而redis-2.6.14的默認值是0,即disable timeout,同時又去查看了下jedis common pool的設置,發現minEvictableIdleTimeMillis=1000L * 60L * 60L * 5L(ms),即一個redis鏈接的空閒時間超過5個小時纔會被connection pool給回收。很明顯,就是由於客戶端和服務端的connection idle time設置不同,形成了connection被一端關閉了,可是另外一端沒有感知,全部形成了broken pipe。解決辦法就是把redid-2.4升級到redid-2.6.14。
5、總結
從方案一到方案三,我最大的感觸就是,在解決問題,優化方案的時候,不能僅僅固執於技術自己,而是要聯繫業務思考。這個redis的bloomfilter的想法我很早就有了,可是我以前一直沒有想到tpn未讀數消息數只顯示99+這個業務邏輯,而是一直想如何經過下降消息bizId的長度來儘量地去節省內存,結果越想越複雜,而後就沒有而後了。。。。socket