記錄一次線上頻繁宕機(jī)的案例

一、業(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)。


storm架構(gòu)
三、現(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%。

image.png

通過(guò)jmap查看,發(fā)現(xiàn)其中最占內(nèi)存的都是char[]:
image.png

???????由于我們處理的消息都是以|分隔的字符串,而且每個(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ì)象,如下圖:
image.png

然后通過(guò)list objects->list outgoing refrences查看它引用的內(nèi)容:
image.png

果不其然,都是從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:
image.png

查看一下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)證猜想。
???????那怎么解決呢?擺在我面前的有如下幾種方案:

  1. storm限流
    明顯不合適,如果做了限流會(huì)導(dǎo)致kafka積壓非常嚴(yán)重,業(yè)務(wù)方目前還有提速的要求。
  2. storm有反壓機(jī)制,能自動(dòng)根據(jù)下游的積壓情況來(lái)反向控制入口流量。
  3. 升級(jí)storm client版本。我們目前使用的是storm 1.2.1,而storm 1.2.2確實(shí)將flush方法標(biāo)記成了錯(cuò)誤,可能是storm 1.2.1的一個(gè)bug(待驗(yàn)證)。
  4. 重新編譯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ì)遇到些奇葩,比如:

  1. 沒(méi)有日志(oom/crash)
  2. jvm相關(guān)的異常(attach 失敗、EOF之類的)
  3. 第三方中間件客戶端bug

要是遇到這種問(wèn)題,一定要沉著冷靜。一般靠google是搞不定的,還不如自己靜下心來(lái)想辦法。解決這個(gè)問(wèn)題,得益于如下工具:

  1. jmap
  2. jstat
  3. MAT
  4. 阿里Arthas
  5. jstack

今天就到這吧~很晚了。畢竟知識(shí)有限,如果里面有講錯(cuò)的地方,還請(qǐng)大家原諒,如果能指點(diǎn)一二,那就更好了!感謝大家閱讀!

最后編輯于
?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請(qǐng)聯(lián)系作者
平臺(tái)聲明:文章內(nèi)容(如有圖片或視頻亦包括在內(nèi))由作者上傳并發(fā)布,文章內(nèi)容僅代表作者本人觀點(diǎn),簡(jiǎn)書(shū)系信息發(fā)布平臺(tái),僅提供信息存儲(chǔ)服務(wù)。

推薦閱讀更多精彩內(nèi)容