[性能] IP 定位緩存該如何做?
背景
我們有一個(gè)站點(diǎn)服務(wù),暴露 HTTP 接口,對(duì)接外部流量,類似網(wǎng)關(guān)。上線后發(fā)現(xiàn) Full GC 頻率比較高,老年代內(nèi)存使用情況如下圖。從圖上可以看出平均 3個(gè)小時(shí)左右會(huì)進(jìn)行一次 Full GC;內(nèi)存逐步上升,說(shuō)明每次 YGC 都有一些對(duì)象熬過(guò)了多次 YGC 并且晉升老年代;另外還可以注意到一點(diǎn),夜里的時(shí)候增長(zhǎng)速度比白天慢,說(shuō)明和流量相關(guān),請(qǐng)求越多,增長(zhǎng)越快。那到底是什么對(duì)象可以不斷的晉升老年代,而同時(shí)又可以被 Full GC 掉。
排查
先通過(guò) jstat -gcutil 觀察一下 GC 情況:
S0 S1 E O M CCS YGC YGCT FGC FGCT GCT4.00 0.00 94.34 21.54 93.44 87.29 28738 1107.341 53 18.695 1126.0360.00 4.70 4.01 21.60 93.44 87.29 28739 1107.378 53 18.695 1126.073
可以看出 YGC 時(shí)會(huì)有一些對(duì)象晉升老年代。并且注意到 YGC 耗時(shí) 37ms,也比較長(zhǎng)。
使用命令 jmap -histo 可以看出堆上類的統(tǒng)計(jì)信息,包含實(shí)例數(shù)量和占用內(nèi)存。jmap -histo 有一個(gè)選項(xiàng)是live,如果添加了該參數(shù),只會(huì)統(tǒng)計(jì)存活的對(duì)象,代價(jià)是進(jìn)行一次 Full GC。如果沒(méi)有添加該選項(xiàng),就會(huì)統(tǒng)計(jì)當(dāng)前全部的對(duì)象。
想看出哪些對(duì)象被回收了,可以在內(nèi)存占用比較高的時(shí)候,分別執(zhí)行兩次 jmap -histo,第一次統(tǒng)計(jì)全部的對(duì)象,第二次觸發(fā) Full GC 只統(tǒng)計(jì)存活的對(duì)象,然后對(duì)比兩份數(shù)據(jù)可以看出是哪些對(duì)象被回收了。
在線上進(jìn)行上述操作,發(fā)現(xiàn) LocalCache 相關(guān)的類從 600 多萬(wàn)變成了 100萬(wàn)整。存活對(duì)象的統(tǒng)計(jì)信息如下:
num #instances #bytes class name----------------------------------------------1: 40514 89987016 [B2: 1574061 78521184 [C3: 1000000 48000000 com.google.common.cache.LocalCache$StrongAccessEntry4: 1568771 37650504 java.lang.String5: 1000000 16000000 com.google.common.cache.LocalCache$StrongValueReference
通過(guò)這個(gè)信息確定與使用 Guava Cache 相關(guān),看下代碼發(fā)現(xiàn)是 IP 定位緩存,服務(wù)會(huì)使調(diào)用方傳遞的 IP 調(diào)用公司內(nèi)部服務(wù)獲取地域編碼,并且使用 Guava Cache 進(jìn)行緩存,Cache創(chuàng)建代碼如下,可以看到最大容量是 100萬(wàn)。
CacheBuilder.newBuilder().expireAfterAccess(1, TimeUnit.HOURS).maximumSize(1000000).recordStats().build();
由于設(shè)置了 1 個(gè)小時(shí)的過(guò)期時(shí)間,所以不斷的會(huì)有緩存過(guò)期,產(chǎn)生可回收的對(duì)象。到這里已經(jīng)確認(rèn)了問(wèn)題出現(xiàn)在 IP 到地域編碼的緩存上,那 IP到地域編碼的緩存還有優(yōu)化空間嗎?
IP 定位屬于基礎(chǔ)服務(wù),公司內(nèi)部有很多調(diào)用方,所以緩存也是一個(gè)通用的問(wèn)題。先找定位服務(wù)負(fù)責(zé)人請(qǐng)教一下緩存的經(jīng)驗(yàn),溝通未獲取到緩存方面更好的實(shí)踐,不過(guò)獲取到兩個(gè)很重要的信息,IP定位只用IP 的前三段,就是說(shuō)1.12.36.0~1.26.36.255 都會(huì)定位到同一個(gè)地域編碼;另外一個(gè)信息是國(guó)內(nèi)記錄數(shù)不到百萬(wàn),這說(shuō)明全量緩存是可行的。
解決方案
這個(gè)時(shí)候修改 Guava Cache 使用 IP 前三段作為 Key,可以實(shí)現(xiàn)全量緩存,可以想到結(jié)果就是命中率大幅提升,性能得到改進(jìn),但是還是會(huì)有垃圾產(chǎn)生。能不能優(yōu)化的更徹底?
只使用三段,每段取值 256 ,那全量就是 256 * 256 * 256 = 16,777,216。可以創(chuàng)建一個(gè)三維 int 型數(shù)組,來(lái)存儲(chǔ) IP到地域編碼的映射,占用內(nèi)存為 64M,還是可以的。
這時(shí)還有一個(gè)問(wèn)題就是過(guò)期時(shí)間怎么做,IP 到地域編碼每天會(huì)有少量的調(diào)整,所以要實(shí)現(xiàn)一個(gè)過(guò)期機(jī)制。考慮到該過(guò)期時(shí)間為了保證緩存可以更新,所以可以直接使用寫入時(shí)間,另外考慮到每天的調(diào)整很少,可以把過(guò)期時(shí)間設(shè)置的稍微長(zhǎng)一點(diǎn),這里給了四個(gè)小時(shí)。將三維 int 型數(shù)組調(diào)整為三維 long 型數(shù)組,高 32 位存儲(chǔ)寫入時(shí)間,低 32位存儲(chǔ)地域編碼。占用 128 M 內(nèi)存,由于大數(shù)組是 Free GC 的所以 Full GC 的問(wèn)題可以解決,另外就是數(shù)組的查找和讀寫性能都很好,所以對(duì)性能也有好處。這是一個(gè)空間換時(shí)間的實(shí)現(xiàn)。
最終緩存的實(shí)現(xiàn)如下:
public class FastIpLocalCache {/*** 過(guò)期時(shí)間,單位秒*/private int expireTime;private long[][][] store = newlong[256][256][256];private int baseTime = (int)(System.currentTimeMillis() / 1000);/*** 過(guò)期時(shí)間,單位秒** @param expireTime*/public FastIpLocalCache(int expireTime) {if (expireTime <= 0 || expireTime >24 * 60 * 60) {throw newIllegalArgumentException("expireTime 不合法");}this.expireTime = expireTime;}/*** 讀取** @param ip* @return*/public Integer get(String ip) {if (ip == null) {return null;}// IP 轉(zhuǎn)數(shù)字short[] s3 = toS3(ip);if (s3 == null) {return null;}// 讀取long value = store[s3[0]][s3[1]][s3[2]];if (value <= 0) {return null;}// 獲取當(dāng)前時(shí)間int currentTimeSecond = (int)(System.currentTimeMillis() / 1000);// 獲取寫入時(shí)間int writeTime = (int) (value >> 32);// 判斷超時(shí)if (currentTimeSecond - baseTime -writeTime > expireTime) {return null;}// 獲取地域int local = (int) (value & 0x7fffffff);return local;}/*** 保存** @param ip* @param local*/public void put(String ip, Integer local) {// IP 轉(zhuǎn)數(shù)字short[] s3 = toS3(ip);if (s3 == null) {return;}// 獲取當(dāng)前時(shí)間long current = System.currentTimeMillis() /1000 - baseTime;// 拼接時(shí)間和地域long value = current << 32 | local;// 保存store[s3[0]][s3[1]][s3[2]] = value;}/*** IP 轉(zhuǎn)數(shù)字,只轉(zhuǎn)前 3 段** @param ip* @return*/public short[] toS3(String ip) {if (ip == null) {return null;}short[] result = new short[3];int index = 0;int size = ip.length();short temp = 0;for (int i = 0; i < size; i++) {char c = ip.charAt(i);if (c >= '0' && c <= '9') {temp = (short) (temp * 10 + (c - '0'));} else if (c == '.' || (i + 1) == size) {if (temp > 255 || temp < 0) {return null;}result[index] = temp;index++;if (index == 3) {break;}temp = 0;} else {return null;}}if (index != 3) {return null;}return result;}}
效果
上線后老年代使用空間如下圖,從圖上可以看出 Full GC 頻率已經(jīng)從 3個(gè)小時(shí)左右變成了超過(guò) 24 小時(shí)一次,效果很明顯。還會(huì)有晉升是因?yàn)槲覀冞€有一些埋點(diǎn)統(tǒng)計(jì)等。
再使用 jstat -gcutil 觀察一些 GC 情況:
S0 S1 E O M CCS YGC YGCT FGC FGCT GCT0.00 2.55 96.28 16.74 91.98 84.94 18063 309.983 4 0.797 310.7802.51 0.00 5.92 16.75 91.98 84.94 18064 309.997 4 0.797 310.794
可以看到 YGC 耗時(shí)變成了 14ms,相比最開(kāi)始的 37ms 也有了大幅的提升。
全量緩存以及更長(zhǎng)的緩存時(shí)間也大幅減少了對(duì) IP 定位服務(wù)的調(diào)用量,從 12000 QPS 下降到 2400 QPS。
更高效的緩存定位方案也會(huì)減少服務(wù)的平均耗時(shí),從平均 11 ms,下降到平均 10ms。雖然只有 1ms,但是比例接近 10%。
推薦閱讀
如何快速判斷一個(gè)用戶是否訪問(wèn)過(guò)我們的 APP?
一起刷 leetcode 之螺旋矩陣(頭條和美團(tuán)真題)
一起刷 leetcode 之螺旋矩陣(頭條和美團(tuán)真題)
原創(chuàng)|如果懂了HashMap這兩點(diǎn),面試就沒(méi)問(wèn)題了
cpu使用率過(guò)高和jvm old占用過(guò)高排查過(guò)程
老年代又占用100%了,順便發(fā)現(xiàn)了vertx-redis-client 的bug
Kafka服務(wù)端之網(wǎng)絡(luò)層源碼分析
Redis 的過(guò)期策略是如何實(shí)現(xiàn)的?
原創(chuàng)|面試官:Java對(duì)象一定分配在堆上嗎?
三面阿里被掛,幸獲內(nèi)推名額,歷經(jīng) 5 面終獲口碑 offer
原創(chuàng)|ES廣告倒排索引架構(gòu)演進(jìn)與優(yōu)化
原創(chuàng)|這道面試題,大部分人都答錯(cuò)了
