一、業(yè)務(wù)描述
???????為了實(shí)時(shí)監(jiān)控業(yè)務(wù)系統(tǒng)的健康狀態(tài),我們需要采集各業(yè)務(wù)系統(tǒng)的指標(biāo)數(shù)據(jù).所以定義了一系列的上報(bào)協(xié)議,其格式是以"|"分隔的字符串。例如:
30#module_name|time|dst_ip|dst_server|dst_service|src_ip|src_server|src_service|total_num|success_num|err_num|總等待隊(duì)列|最大等待隊(duì)列|總耗時(shí)|最大耗時(shí)|時(shí)耗
???????但是為了在監(jiān)控曲線的查詢框中實(shí)時(shí)拉取到最新上報(bào)的數(shù)據(jù)(比如:IDC信息、模塊下的接口名以及所屬server),我們需要將上報(bào)消息中的各個(gè)維度信息去重后存入DB,這樣就可以在前端進(jìn)行展示。
出于這樣一個(gè)需求,我們上線了一個(gè)緯度值匯總的服務(wù),其大概流程可以簡(jiǎn)化為如下幾步:
- 分鐘級(jí)匯總服務(wù)處理完消息后發(fā)送到kafka
- 緯度值匯總服務(wù)(本服務(wù))訂閱該topic,然后從kafka循環(huán)取出消息(格式是以|分隔的字符串),然后傳給下面的bolt。
- bolt主要是完成消息的解析、過(guò)濾、去重操作,然后按規(guī)則入庫(kù)。
二、技術(shù)架構(gòu)
???????線上由3個(gè)節(jié)點(diǎn)組成的storm集群來(lái)做緯度值的匯總,storm中配置了5個(gè)bolt(不了解storm的童鞋可以理解成Task,即一個(gè)bolt處理完后自己預(yù)訂的邏輯后傳給下一個(gè)bolt,直到全部bolt處理完),使用了redis做數(shù)據(jù)去重。由于數(shù)據(jù)量非常大,一天的數(shù)據(jù)量在20億左右,redis也是采用的集群模式(分片)。每個(gè)storm節(jié)點(diǎn)啟動(dòng)了4個(gè)worker(單獨(dú)的進(jìn)程),堆配置了4G。storm由一個(gè)supervisor節(jié)點(diǎn)、nimbus節(jié)點(diǎn)和worker組成,其中的supervisor和nimbus不負(fù)責(zé)具體的邏輯,只負(fù)責(zé)任務(wù)的協(xié)調(diào)和分配,真正做事的是worker節(jié)點(diǎn),向storm集群提交完topology(拓?fù)?后,storm會(huì)給我們fork出一系列worker(可配置),然后觸發(fā)任務(wù),storm集群之間是通過(guò)zk來(lái)做協(xié)調(diào)。
三、現(xiàn)象描述
???????storm的worker進(jìn)程平均每5分鐘宕機(jī)一次,然后被supervisor節(jié)點(diǎn)拉起,然后5分鐘又宕機(jī)。而且沒(méi)有oom日志,通過(guò)查看dmsg,也沒(méi)發(fā)現(xiàn)killer日志。在log文件中也沒(méi)有任何oom或者jvm crash的蛛絲馬跡。瞬間有點(diǎn)慌了,心里默念:"storm是什么鬼?"。
四、問(wèn)題定位思路
???????由于對(duì)storm不太熟悉,所以剛開(kāi)始一直懷疑是supervisor節(jié)點(diǎn)接受不到worker節(jié)點(diǎn)的心跳導(dǎo)致的。查看nimbus和supervisor的日志,都沒(méi)有太多有用信息,只是打印出worker宕機(jī),又被重新拉起的日志,然后再zk上注冊(cè)worker的信息。查看zk的log,也沒(méi)有發(fā)現(xiàn)可疑日志??磥?lái)問(wèn)題不是太順利,沒(méi)有那么容易解決。所以嘗試將worker的堆調(diào)到8G,worker從原來(lái)的5分鐘勉強(qiáng)可以撐到10分鐘。通過(guò)jstat查看發(fā)現(xiàn)old區(qū)只增不減,很快就達(dá)到100%。
通過(guò)jmap查看,發(fā)現(xiàn)其中最占內(nèi)存的都是char[]:
???????由于我們處理的消息都是以|分隔的字符串,而且每個(gè)bolt都做了分隔操作,所以懷疑每個(gè)bolt同時(shí)做截取操作,但是會(huì)占用多份內(nèi)存。查看jvm手冊(cè),配置了
-XX:StringTableSize=65536
這樣一個(gè)jvm參數(shù),希望能針對(duì)同一個(gè)字符串做到只耗費(fèi)一份內(nèi)存,但是依舊無(wú)效。通過(guò)jmap發(fā)現(xiàn)TupleImpl
也占用內(nèi)存非常大,也懷疑從storm取出的消息沒(méi)有手動(dòng)ack,導(dǎo)致無(wú)限重復(fù)消費(fèi)",將ack機(jī)制去掉后發(fā)現(xiàn)問(wèn)題依舊存在。???????看來(lái)問(wèn)題真心沒(méi)有我們想象中的那么簡(jiǎn)單,不是top -> top -Hp就可以搞定的(呵呵)。后面還是沉下心來(lái)分析gc日志,平均幾秒鐘一次fullgc,ygc也是不斷發(fā)生。而且fullgc的時(shí)間的有的持續(xù)在10幾秒鐘,每次fullgc后,old區(qū)基本回收不了,只能眼睜睜的看著宕機(jī),可見(jiàn)數(shù)據(jù)量還是非常大的。
???????由于storm oom了也沒(méi)有產(chǎn)生hprof日志,所以只能借助于人工
jmap -dump:format=b,file=xx.hprof
來(lái)手動(dòng)dump。可能是由于worker內(nèi)存不夠的緣故,每次dump要么直接把worker弄宕機(jī),要么就拋出一個(gè)attach EOF
的錯(cuò)誤,瞬間感覺(jué)知識(shí)不夠用。所以只能在worker被拉起的時(shí)候立馬dump出來(lái),雖然不能代表最終問(wèn)題,但是應(yīng)該不會(huì)相差太遠(yuǎn)(畢竟數(shù)據(jù)量很大,業(yè)務(wù)比較單一)。通過(guò)MAT查看最大對(duì)象,如下圖:然后通過(guò)list objects->list outgoing refrences查看它引用的內(nèi)容:
果不其然,都是從kafka取出的消息,但是又不能作為解決問(wèn)題的依據(jù)~
接下來(lái)通過(guò)Dominator視圖,發(fā)現(xiàn)占最大內(nèi)存的是Storm的DisruptorQueue,然后一層一層的展開(kāi)內(nèi)部引用,發(fā)現(xiàn)其中引用到了一個(gè)ThreadLocalBatcher,里面竟然引用到了一個(gè)無(wú)界隊(duì)列ConcurrentLinkedQueue:
查看一下ThreadLocalBatcher
的源碼:
private class ThreadLocalBatcher implements ThreadLocalInserter {
private final ReentrantLock _flushLock;
private final ConcurrentLinkedQueue<ArrayList<Object>> _overflow;
private ArrayList<Object> _currentBatch;
public ThreadLocalBatcher() {
_flushLock = new ReentrantLock();
_overflow = new ConcurrentLinkedQueue<ArrayList<Object>>();
_currentBatch = new ArrayList<Object>(_inputBatchSize);
}
//called by the main thread and should not block for an undefined period of time
public synchronized void add(Object obj) {
_currentBatch.add(obj);
_overflowCount.incrementAndGet();
if (_enableBackpressure && _cb != null && (_metrics.population() + _overflowCount.get()) >= _highWaterMark) {
try {
if (!_throttleOn) {
_throttleOn = true;
_cb.highWaterMark();
}
} catch (Exception e) {
throw new RuntimeException("Exception during calling highWaterMark callback!", e);
}
}
if (_currentBatch.size() >= _inputBatchSize) {
boolean flushed = false;
if (_overflow.isEmpty()) {
try {
publishDirect(_currentBatch, false);
_overflowCount.addAndGet(0 - _currentBatch.size());
_currentBatch.clear();
flushed = true;
} catch (InsufficientCapacityException e) {
//Ignored we will flush later
}
}
if (!flushed) {
_overflow.add(_currentBatch);
_currentBatch = new ArrayList<Object>(_inputBatchSize);
}
}
}
//May be called by a background thread
public synchronized void forceBatch() {
if (!_currentBatch.isEmpty()) {
_overflow.add(_currentBatch);
_currentBatch = new ArrayList<Object>(_inputBatchSize);
}
}
//May be called by a background thread
public void flush(boolean block) {
if (block) {
_flushLock.lock();
} else if (!_flushLock.tryLock()) {
//Someone else if flushing so don't do anything
return;
}
try {
while (!_overflow.isEmpty()) {
publishDirect(_overflow.peek(), block);
_overflowCount.addAndGet(0 - _overflow.poll().size());
}
} catch (InsufficientCapacityException e) {
//Ignored we should not block
} finally {
_flushLock.unlock();
}
}
}
大概心里有點(diǎn)眉目了,一般這種使用無(wú)界隊(duì)列的場(chǎng)景只要沒(méi)控制好生產(chǎn)者和消費(fèi)者的處理速度,就非常容易導(dǎo)致內(nèi)存溢出。所以問(wèn)題基本鎖定在storm客戶端上,這里省略一些細(xì)節(jié),直接看從ConcurrentLinkedQueue中消費(fèi)數(shù)據(jù)的 flush
方法:
_flushLock.lock();
try {
while (!_overflow.isEmpty()) {
publishDirect(_overflow.peek(), block);
_overflowCount.addAndGet(0 - _overflow.poll().size());
}
} catch (InsufficientCapacityException e) {
//Ignored we should not block
} finally {
_flushLock.unlock();
}
其邏輯是: 如果隊(duì)列不空,就直接從隊(duì)列取出一個(gè)元素,但是不從隊(duì)列中移除(peek),然后發(fā)布到disrutor中;發(fā)布完成后,移除掉剛剛發(fā)布的元素(poll)。看似沒(méi)問(wèn)題,但是在數(shù)據(jù)量非常大的情況下,如果發(fā)布disruptor一直失敗,會(huì)導(dǎo)致永遠(yuǎn)沒(méi)法從ConcurrentLinkedQueue中移除數(shù)據(jù),oom是必然的。所以重新編譯了該類,在catch InsufficientCapacityException
出打出了錯(cuò)誤日志,錯(cuò)誤日志確實(shí)在刷屏,正好驗(yàn)證猜想。
???????那怎么解決呢?擺在我面前的有如下幾種方案:
- storm限流
明顯不合適,如果做了限流會(huì)導(dǎo)致kafka積壓非常嚴(yán)重,業(yè)務(wù)方目前還有提速的要求。 - storm有反壓機(jī)制,能自動(dòng)根據(jù)下游的積壓情況來(lái)反向控制入口流量。
- 升級(jí)storm client版本。我們目前使用的是storm 1.2.1,而storm 1.2.2確實(shí)將flush方法標(biāo)記成了錯(cuò)誤,可能是storm 1.2.1的一個(gè)bug(待驗(yàn)證)。
- 重新編譯DisruptorQueue
我嘗試了第二種和第三種方案,反壓機(jī)制一直沒(méi)生效,不知道是不是客戶端版本問(wèn)題.而升級(jí)storm client又導(dǎo)致服務(wù)啟動(dòng)失敗,不得已采用了第四種方案->重新編譯DisrutorQueue??刂艭oncurrentLinkedQueue的容量,以及當(dāng)publish
失敗的時(shí)候,控制丟棄消息的頻率(業(yè)務(wù)上能容忍),如果超過(guò)我設(shè)置的一個(gè)最大容忍閾值,我就完全丟棄。而且觸發(fā)flush
是通過(guò)一個(gè)TimerTask
來(lái)實(shí)現(xiàn)的:
TimerTask t = new TimerTask() {
@Override
public void run() {
invokeAll(flushInterval);
}
};
_pendingFlush.put(flushInterval, pending);
_timer.schedule(t, flushInterval, flushInterval);
_tt.put(flushInterval, t);
if (_isFlushing.compareAndSet(false, true)) {
for (ThreadLocalInserter batcher: _batchers.values()) {
batcher.forceBatch();
batcher.flush(true);
}
_isFlushing.set(false);
}
在run方法里會(huì)獲取到所有的Flusher,然后調(diào)用最終的flush方法。客戶端沒(méi)有考慮掉flush出現(xiàn)異常的場(chǎng)景,必須將invokeAll catch住,然后改成ExecutorService的方式,減少定時(shí)調(diào)度間隔,問(wèn)題得以解決。
五、最后
???????解決這個(gè)問(wèn)題是漫長(zhǎng)的,當(dāng)然解決方式可能也不是最佳的,但至少業(yè)務(wù)上是能接受的。分析的過(guò)程中當(dāng)然也涉及到一些jvm調(diào)優(yōu)的內(nèi)容,最終的jvm參數(shù)如下:
-Xmx13312m -Xms13312m -XX:MaxMetaspaceSize=512m
-XX:MetaspaceSize=512m
-XX:StringTableSize=65536
-XX:+UseG1GC -XX:MaxGCPauseMillis=1000
-XX:+ParallelRefProcEnabled -XX:ErrorFile=crash/hs_err_pid%p.log
-XX:HeapDumpPath=crash
-XX:+HeapDumpOnOutOfMemoryError -XX:+PrintGCDetails -Xloggc:artifacts/gc.log
-XX:+PrintGCDateStamps -XX:+PrintGCTimeStamps -XX:+PrintClassHistogramBeforeFullGC
-XX:+PrintCommandLineFlags
-XX:+PrintGCApplicationConcurrentTime -XX:+PrintTenuringDistribution -XX:+PrintHeapAtGC
-Xloggc:artifacts/gc.log -XX:+OmitStackTraceInFastThrow -XX:+UnlockExperimentalVMOptions
-XX:G1NewSizePercent=20 -XX:G1MaxNewSizePercent=20
-XX:SurvivorRatio=6
考慮到我們的業(yè)務(wù)都是流水作業(yè)式的,所以把新生代調(diào)大、survivor的比例也調(diào)大了。
???????在一般情況下,線上的問(wèn)題都比較好解決,難免會(huì)遇到些奇葩,比如:
- 沒(méi)有日志(oom/crash)
- jvm相關(guān)的異常(attach 失敗、EOF之類的)
- 第三方中間件客戶端bug
要是遇到這種問(wèn)題,一定要沉著冷靜。一般靠google是搞不定的,還不如自己靜下心來(lái)想辦法。解決這個(gè)問(wèn)題,得益于如下工具:
- jmap
- jstat
- MAT
- 阿里Arthas
- jstack
今天就到這吧~很晚了。畢竟知識(shí)有限,如果里面有講錯(cuò)的地方,還請(qǐng)大家原諒,如果能指點(diǎn)一二,那就更好了!感謝大家閱讀!