天天看點

斷網故障時Mtop觸發tomcat高并發場景下的BUG排查和修複(已被apache采納)

現象

nio模式背景介紹

排查過程

結合業務場景解釋問題産生的原因

進一步的發現

解決辦法

向apache社群的回報

總結

mtop的機器,環境為ali-tomcat 7.0.54.2,連接配接器采用的是nio模式,在高流量(約1000 qps)的情況下,在tomcat的啟動後一段時間内,抛出concurrentmodificationexception,然後再過一段時間後,tomcat無法再接受新的請求。

異常堆棧如下:

tomcat共有三種連接配接器模式,bio/nio/apr,其中nio是異步io模式的簡稱。在預設的配置下,nio的模式的實作模型如下:

acceptor線程:全局唯一,負責接受請求,并将請求放入poller線程的事件隊列。accetpr線程在分發事件的時候,采用的round robin的方式來分發的

poller線程:官方的建議是每個處理器配一個,但不要超過兩個,由于現在幾乎都是多核處理器,是以一般來說都是兩個。每個poller線程各自維護一個事件隊列(無上限),它的職責是從事件隊列裡面拿出socket,往自己的selector上注冊,然後等待selector選擇讀寫事件,并交給socketprocessor線程去實際處理請求。

socketprocessor線程:ali-tomcat的預設配置是250(參見server.xml裡面的maxthreads),它是實際的工作線程,用于處理請求。

斷網故障時Mtop觸發tomcat高并發場景下的BUG排查和修複(已被apache采納)

如圖所示,是一個典型的請求處理過程。其中綠色代表線程,藍色代表資料。

acceptor線程接受請求,從socketcache裡面拿出socket對象(沒有的話會建立,緩存的目的是避免對象建立的開銷),

acceptor線程标記好poller對象,組裝成pollerevent,放入該poller對象的pollerevent隊列

poller線程從事件隊列裡面拿出pollerevent,将其中的socket注冊到自身的selector上,

poller線程等到有讀寫事件發生時,分發給socketprocessor線程去實際處理請求

socketprocessor線程處理完請求,socket對象被回收,放入socketcache

熟悉并發程式設計的同學應該都知道,這個異常出現的原因是a線程在周遊某個集合的過程中,另外一個b線程修改了這個集合的内容,在a線程中就會抛出concurrentmodificationexception。

根據異常堆棧,我們可以知道,poller1線程在處理timeout的時候,會周遊selector上的keys,然後在這個周遊的過程中,另外一個線程修改了keys的資料,導緻抛出異常。大緻代碼如下:

代碼分析看不出端倪,所有對selector的操作都是在clientpoller的線程上做的修改,而每個clientpoller在初始化的過程中各自持有自己的selector,設計上決定了不應該有多線程的問題。那麼到底是誰在修改底層的keys呢?

由于在tomcat源碼中看不出問題所在,我們決定在jdk層面打patch,在jdk層面,所有可能修改keys的地方隻有兩處,sun.nio.ch.epollselectorimpl的implregister方法以及impldereg方法(注意,不同作業系統下的實作類可能不一樣,在linux下是sun.nio.ch.epollselectorimpl)。

我們下載下傳了openjdk源碼,在sun.nio.ch.epollselectorimpl的implregister方法以及impldereg方法中列印相關資訊,并通過以下的方式覆寫jdk的原有實作:

不幸的是,加上patch之後無法重制問題,看來輸出debug資訊本身拖慢了線程的執行,導緻本來就很小的時間視窗稍縱即逝。不斷的縮減輸出内容也沒有任何重制的迹象,在我們快要放棄的時候,下面這個已經減無可減的輸出(放棄輸出時戳、并且減少+号)帶來了一線曙光。

當把輸出從

換成

終于重制出了問題。通過分析得到的日志,發現了不尋常的現象,同樣的一個clientpoller線程在進入impleregister方法的時候操作的是兩個不同的selector執行個體,并且這兩個不同的selector執行個體隻與這兩個clientpoller關聯,看起來的确像兩個線程之間的并發問題,但是代碼分析并不支援這個觀點。

出現問題的jdk是taobao-jdk 1.6,會不會是jdk的bug呢?為了驗證這個想法,我們嘗試了ali-jdk1.7,以及sun的jdk,但是問題依然存在,這樣排除了是jdk的bug的猜想。看來問題又回到了tomcat端。

那如果隻有一個poller線程,會不會也有這個問題呢?我們驗證的結果是,沒有問題。這也就進一步确認了是**兩個poller線程才會出現的并發問題**。

那麼我們看看到底是從什麼地方調到implregister這個方法來導緻出問題的。修改sun.nio.ch.epollselectorimpl增加thread.dumpstack()

在得到的結果裡面,與clientpoller相關的堆棧,隻有一處是可能造成這個問題的:

對應的java代碼在nioendpoint#pollerevent#run方法裡面:

對于這個現象唯一可能的解釋是,當clientpoller調用這行代碼的時候,隻有當socket.getpoller().getselector()獲得的selector執行個體和目前clientpoller所持有的執行個體不一緻,才有可能造成我們前面看到的clientpoller會操作不同selector執行個體的現象。

也就是說,**socket.getpoller()拿到的poller的執行個體,不是自己!!!**

這怎麼可能!因為socket所代表的資料結構pollerevent是放在clientpoller的自身的隊列裡的,并且放進來的時候pollerevent上的poller執行個體會被置成目前的clientpoller。

那為什麼當clientpoller線程再從socket.getpoller()得到的poller執行個體不是自己,這太不科學了。為了驗證這一個看似不可能的猜想,我們加入了以下的調試資訊來觀察目前線程clientpoller和socket所持有的poller的關系

果然有所收獲!這說明了**socket所持有的poller在某些條件下不一定和目前的clientpoller線程一緻**。

進一步的調試發現,當poller線程從自身的事件隊列中拿出socket對象的時候,socket.getpoller拿到的執行個體就已經錯亂了,但是acceptor線程放入事件隊列之前,socket.getpoller拿到的執行個體是沒有錯亂的。那麼可以肯定的是,socket.getpoller拿到的對象在進入事件隊列之後,被修改了。

為什麼會被修改呢?出現這個情況隻有兩種可能:

socket對象本身被修改了.

socket裡面的poller對象被修改了。

socket對象封裝在pollerevent對象中,如果是socket對象本身被修改,那麼隻有一個入口,就是pollerevent的reset方法,我們在reset方法裡面加入調試資訊,确沒有任何發現。這個方法在socket對象進入隊列後根本沒有調用過。

如果socket對象沒被改變,那唯一的可能性就是socket.getpoller的對象會發生變化。也就是說**同一個socket有可能會被修改兩次,而且還是來自兩個不同的poller線程**。

通過對clientpoller上遊的分析,發現tomcat是這麼工作的,當請求進來的時候,有一個acceptor線程負責建立連接配接,然後遞交請求給後面的兩個clientpoller線程來處理

為了避免垃圾回收的壓力,channel并不是每次都建立,而會從一個<code>niochannels</code>的緩存隊列中擷取,并在使用完畢後放回該緩存隊列。在排除掉<code>niochannels</code>的資料結構(concurrentlinkedqueue)本身的并發問題之後,唯一的可能性就指向了:在某種情況下這個緩存隊列中,同一個channel對象在隊列中存在多份。

按照這個思路,在niochannels.offer以及niochannels.poll之前判斷隊列中是否已經存在要還回的channel執行個體,或者有要還回的channel執行個體,如果有,就列印出以下資訊。輸出的結果證明了猜想是正确的,這是一個**重大的進展**

這時,其他同僚也發現tomcat的bugzilla上有一個類似的bug,并且跟我們的場景非常類似,并且官方在7.0.58裡面已經fix了。另一位同僚提出是否可以更新到新版tomcat試一下,最近我們正好release了ali-tomcat 7.0.59,于是馬上更新到7.0.59,結果果真沒有出這個問題。這進一步驗證了我們的判斷。

或許到這裡,很多同學會以為問題到此就結束了,這是tomcat7.0.54上的bug,在新版本的tomcat裡面這個問題已經被修複了。

但是這根本擋不住我們刨根問題的好奇心,我們還想知道的是,socket對象到底在什麼地方被重複放了兩次,以及,有哪些線程會通過niochannels.offer還回socket對象。由于niochannel.offer是發生在socketprocessor的run方法裡,并且run方法的總入口是:

通過改造代碼打标(<code>where</code>)并輸出目前執行線程,最終得到以下的日志:

其中标記<code>1</code>是poller#processkey,标記<code>2</code>是poller#timeout,标記<code>3</code>是http11nioprocessor#actioninternal(**注意,這裡的線程是hsf-callback線程,不是http-nio-7001-clientpoller線程**),其中唯一有可能并發的地方就是hsf-callback線程與http-nio-7001-clientpoller線程在交還niochannel的時候并發,并導緻錯誤。通過閱讀代碼,發現hsf-callback線程這個時候在做async complete的操作。熟悉async servlet程式設計的同學應該已經明白了出問題的場景。

首先介紹一下mtop的場景

在mtop的場景中,mtop采用了async servlet中調用hsf callback的模式,以提升mtop的性能。

mtop針對async servlet 逾時的設定為4s,因為一些業務特殊需要,最大可按api設定到15s。為了與async servlet的逾時時間錯開,hsf callback的預設逾時時間是3s。但後端hsf服務可以自定義逾時時間,比如阿裡雲的幾個拖慢的api,com.taobao.taotv.alitv.service.alitvservice:1.0.0其hsf逾時時間是15s。

在高并發的高qps(當時的qps約為1000)的場景下,如果hsf callback的逾時時間設定的大于了async servlet的逾時時間,那麼hsf callback就會有很大機率在asyncservlet逾時的瞬間傳回,導緻對于同一個請求,hsf callback與async servlet逾時(poller線程調用poller#timeout方法)并發的調用nioendpoint#processsocket方法。同時,如果nginx端此時主動斷連,也可能會加劇nioendpoint#processsocket的并發通路。

poller線程和hsf callback線程同時嘗試回收socket對象到緩存隊列niochannels中,由于socketprocessor#dorun方法中,對于并發通路的控制缺陷,導緻同一個socket被兩個線程分别offer了一次,緩存隊列出現重複對象,并且,有極高的機率是被連續offer了兩次(兩次offer之間沒有别的對象被offer進入隊列)。

acceptor線程從緩存隊列中取出對象向poller派發的過程中,由于是round robin方式派發,如果socket對象重複出現的位置是連續的,那麼一定是被分派到了兩個不同的poller所在的事件隊列當中。

其中一個poller線程(poller1)進入到timeout處理,會擷取selector裡面所有連接配接的set進行周遊處理。selector底層用一個hashset維護所有連接配接的selectionkey,這是一個非線程安全的集合。

不幸同時另外的一個poller線程(poller0),正在把socket對象注冊到selector上,由于調用socket.getpoller.getselector拿到的是poller1的selector上,導緻poller1的keys集合裡面的資料被修改。poller1正在周遊這個集合,因為keys被修改而抛了concurrentmodificationexception異常,因線程未捕獲這個異常,poller1線程直接挂掉退出.

acceptor線程并不知道poller1線程已經挂掉了,而是繼續在配置設定連接配接到這兩個poller線程上,那個存活的poller線程可以正常處理業務,而配置設定到挂掉poller線程的連接配接請求都無法處理。

tomcat有一個控制最大可建立請求數的參數,maxconnections,nio下預設是10000,因為挂掉的poller線程上的連接配接請求一直不處理,是以随着時間累計,配置設定到這個線程上的連接配接越多(正常的poller處理完連接配接後,每次都有50%連接配接被配置設定到挂掉的poller線程那,挂掉的poller線程成為黑洞,慢慢吸收連接配接過去),直到挂掉的poller線程占滿10000個連接配接,accept線程發現連接配接已滿,拒絕接受新的請求,線程進入locksupport.park狀态。

是以這個階段vipserver在健康檢查的時候,會有50%的機率成功,在nginx上看到的現象,就是可用的mtop機器數量,按機率上上下下波動。

在tomcat7.0.58版本以後,官方針對一個類似的對象池污染的問題(bug57340)做過一個修複。大緻的邏輯是通過原子操作來保證多個線程并發的時候最終一個socket對象被offer進入緩存隊列。事例代碼如下

雖然說更新到7.0.59後并沒有出問題,但是分析發現,在<code>else if (handshake == -1)</code>分支中,也有可能出現重複放入的情況:

出現這個情況也是可以了解的。handshake預設值是-1,其中的一種可能性就是入參key為空,很顯然,代碼沒有考慮到處理這種情況。當key為空的時候,從run方法的第一行代碼可以知道,這個時候socket已經被其他線程處理過了。

也就是說,**官方在7.0.58上提供的fix并不能完整的修複這個問題!**

為了避免多線程不往同一個隊列裡重複放同一個對象,有以下幾個辦法:

在隊列放入隊列之前檢查是否有重複,如果有重複,就不放入,優點是實作簡單明了,但是這麼做的缺點是必須要加鎖,這會極大降低并發度。

在多線程放入隊列之前,保證隻有一處地方放進去了。這麼做的好處是,緩存隊列還是無鎖設計,不會降低并發度。缺點是開發難度比較高,必須要保證所有可能涉及到的地方都要修改。

放棄緩存,不再重用對象。這樣能根本解決這個問題,但是缺點也是先而易見的,會帶來極大的gc開銷。

官方在7.0.58上的做法,實際上是方法二,但是正是因為采用這種方法,導緻考慮并不周全,造成遺漏。

對于遺漏的地方,我們采取的辦法是方法二和方法三中間的折中方案,即在key不等于null的時候,在放回緩存隊列,對于傳回值為null的情況下,不再放入緩存,而是直接丢棄。這是一種在重用和垃圾回收開銷之間的權衡方案.

然而到這一步真的就完了嗎?并沒有!

代碼裡面不僅僅有niochannels這個緩存隊列,還有以下其他幾個緩存隊列

keycahce

processorcache

eventcache

那麼這幾個隊列裡面有沒有可能同樣有對象重複放置的問題呢?

答案是有的。keycache就有這個可能。原因很簡單,它的放回邏輯跟niochannels完全一緻。是以keycache的放回邏輯也應該控制起來,避免重複offer。

最終的修複方案如下:

我們将分析報告和送出的patch遞交給了apache tomcat,官方很快給予我們回複,并且根據我們的patch,很快作出了fix,基本思路跟我們是一樣的,詳細可以參考這裡。

ali-tomcat也将這個patch back-port回來,釋出了taobao-tomcat-7.0.59-255.noarch.rpm這個版本,目前位于yum倉庫的test分支。

感謝内部其他團隊大量的分析和讨論,以及提供了官方類似bug的資訊,以及pe同學在排查過程中幫助協調線上流量,以重制這個問題。

排查的過程中有很多讓人覺得不可思議的地方,這時候要保持理智和冷靜,不要讓大腦中潛在的假設幹擾了思維,通過缜密的分析,才能找到真正的問題所在。由于在高并發下處理,問題不好重制,是以排查比較耗時間,如果日志打的比較多就會影響并發度,異常就不會複現,這時候不要着急,勝利的曙光其實就在前頭。

在這個過程中,為了向線上機器傳送patch,學會了使用oss這個工具。在這裡強烈推薦使用oss的python用戶端,相比之下java用戶端就有點那個啥了。

這種錯誤也就隻能在我們大淘寶的高并發場景下才能看到。官方接到過好幾次别人的回報,但是經過仔細代碼檢查,也沒有發現任何的問題,最終隻是簡單的catch住了這個異常,這隻能保證線程不會退出,但是不能保證資料不會産生錯亂。而這次的修複應該說從根本上解決了這個異常出現的可能性。