LinkedIn的即時消息:在一臺機器上支持幾十萬條長連接
最近我們介紹了 LinkedIn的即時通信 ,最后提到了分型指標和讀回復。為了實現這些功能,我們需要有辦法通過長連接來把數據從服務器端推送到手機或網頁客戶端,而不是許多當代應用所采取的標準的請求-響應模式。在這篇文章中會描述在我們收到了消息、分型指標和讀回復之后,如何立刻把它們發往客戶端。內容會包含我們是如何使用Play框架和Akka Actor Model來管理長連接、由服務器主動發送事件的。我們也會分享一些在生產環境中我們是如何在服務器上做負載測試,來管理數十萬條并發長連接的,還有一些心得。最后,我們會分享在整個過程中我們用到的各種優化方法。
服務器發送事件
服務器發送事件( Server-sent events ,SSE)是一種客戶端服務器之間的通信技術,具體是在客戶端向服務器建立起了一條普通的HTTP連接之后,服務器在有事件發生時就通過這條連接向客戶端推送持續的數據流,而不需要客戶端不斷地發出后續的請求。客戶端要用到 EventSource接口 來以文本或事件流的形式不斷地接收服務器發送的事件或數據塊,而不必關閉連接。所有的現代網頁瀏覽器都支持EventSource接口,iOS和安卓上也都有現成的庫支持。
在我們最早實現的版本中,我們選擇了基于Websockets的SSE技術,因為它可以基于傳統的HTTP工作,而且我們也希望我們采用的協議可以最大的兼容LinkedIn的廣大會員們,他們會從各式各樣的網絡來訪問我們的網站。基于這樣的理念, Websockets 是一種可以實現雙向的、全雙工通信的技術,可以把它作為協議的候選,我們也會在合適的時候升級成它。
Play框架和服務器發送的消息
我們LinkedIn的服務器端程序使用了 Play框架 。Play是一個開源的、輕量級的、完全異步的框架,可用于開發Java和Scala程序。它本身自帶了對EventSource和Websockets的支持。為了能以可擴展的方式維護數十萬條SSE長連接,我們把 Play和Akka結合 起來用了。Akka可以讓我們改進抽象模型,并用 Actor Model 來為每個服務器建立起來的連接分配一個Actor。
// Client A connects to the server and is assigned connectionIdA public Result listen() { return ok(EventSource.whenConnected(eventSource -> { String connectionId = UUID.randomUUID().toString(); // construct an Akka Actor with the new EventSource connection identified by a random connection identifier Akka.system().actorOf( ClientConnectionActor.props(connectionId, eventSource), connectionId); })); }
上面的這段代碼演示了如何使用 Play的EventSource API 來在程序控制器中接受并建立一條連接,再將它置于一個Akka Actor的管理之下。這樣Actor就開始負責管理這個連接的整個生命周期,在有事件發生時把數據發送給客戶端就被簡化成了把消息發送給Akka Actor。
// User B sends a message to User A // We identify the Actor which manages the connection on which User A is connected (connectionIdA) ActorSelection actorSelection = Akka.system().actorSelection("akka://application/user/" + connectionIdA); // Send B's message to A's Actor actorSelection.tell(new ClientMessage(data), ActorRef.noSender());
請注意唯一與這條連接交互的地方就是向管理著這條連接的Akka Actor發送一條消息。這很重要,因此才能使Akka具有異步、非阻塞、高性能和為分布式系統而設計的特性。相應地,Akka Actor處理它收到的消息的方式就是轉發給它管理的EventSource連接。
public class ClientConnectionActor extends UntypedActor { public static Props props(String connectionId, EventSource eventSource) { return Props.create(ClientConnectionActor.class, () -> new ClientConnectionActor(connectionId, eventSource)); } public void onReceive(Object msg) throws Exception { if (msg instanceof ClientMessage) { eventSource.send(event(Json.toJson(clientMessage))); } } }
就是這樣了。用Play框架和Akka Actor Model來管理并發的EventSource連接就是這么簡單。
但是在系統上規模之后這也能工作得很好嗎?讀讀下面的內容就知道答案了。
使用真實生產環境流量做壓力測試
所有的系統最終都是要用真實生產流量來考驗一下的,可真實生產流量又不是那么容易復制的,因為大家可以用來模擬做壓力測試的工具并不多。但我們在部署到真實生產環境之前,又是如何用真實的生產流量來做測試的呢?在這一點上我們用到了一種叫“暗地啟動”的技術,在我們下一篇文章中會詳細討論一下。
為了讓這篇文章只關注自己的主題,讓我們假設我們已經可以在我們的服務器集群中產生真實的生產壓力了。那么測試系統極限的一個有效方法就是把導向一個單一節點的壓力不斷加大,以此讓整個生產集群在承受極大壓力時所該暴露的問題極早暴露出來。
通過這樣的辦法以及其它的輔助手段,我們發現了系統的幾處限制。下面幾節就講講我們是如何通過幾處簡單的優化,讓單臺服務器最終可以支撐數十萬條連接的。
限制一:一個Socket上的處于待定狀態的連接的最大數量
在一些最早的壓力測試中我們就常碰到一個奇怪的問題,我們沒辦法同時建立很多個連接,大概128個就到上限了。請注意服務器是可以很輕松地處理幾千個并發連接的,但我們卻做不到向連接池中同時加入多于128條連接。在真實的生產環境中,這大概相當于有128個會員同時在向同一個服務器初始化連接。
做了一番研究之后,我們發現了下面這個內核參數:
net.core.somaxconn
這個內核參數的意思就是程序準備接受的處于等待建立連接狀態的最大TCP連接數量。如果在隊列滿的時候來了一條連接建立請求,請求會直接被拒絕掉。在許多的主流操作系統上這個值都默認是128。
在“/etc/sysctl.conf”文件中把這個值改大之后,就解決了在我們的Linux服務器上的“拒絕連接”問題了。
請注意Netty 4.x版本及以上在初始化Java ServerSocket 時,會自動從操作系統中取到這個值并直接使用。不過,如果你也想在應用程序的級別配置它,你可以在Play程序的 配置 參數中這樣設置:
play.server.netty.option.backlog=1024
限制二:JVM線程數量
在讓比較大的生產流量第一次壓向我們的服務器之后,沒過幾個小時我們就收到了告警,負載均衡器開始沒辦法連上一部分服務器了。做了進一步調查之后,我們在服務器日志中發現了下面這些內容:
java.lang.OutOfMemoryError: unable to create new native thread
下面關于我們服務器上JVM線程數量的圖也證實了我們當時出現了線程泄露,內存也快耗盡了。
我們把JVM進程的線程狀態打出來查看了一下,發現了許多處于如下狀態的睡眠線程:
"Hashed wheel timer #11327" #27780 prio=5 os_prio=0 tid=0x00007f73a8bba000 nid=0x27f4 sleeping[0x00007f7329d23000] java.lang.Thread.State: TIMED_WAITING (sleeping) at java.lang.Thread.sleep(Native Method) at org.jboss.netty.util.HashedWheelTimer$Worker.waitForNextTick(HashedWheelTimer.java:445) at org.jboss.netty.util.HashedWheelTimer$Worker.run(HashedWheelTimer.java:364) at org.jboss.netty.util.ThreadRenamingRunnable.run(ThreadRenamingRunnable.java:108) at java.lang.Thread.run(Thread.java:745)
經過進一步調查,我們發現原因是LinkedIn對Play框架的實現中對于Netty的空閑超時機制的支持有個BUG,而本來的Play框架代碼中對每條進來的連接都會相應地創建一個新的 HashedWheelTimer 實例。這個 補丁 非常清晰地說明了這個BUG的原因。
如果你也碰上了JVM線程限制的問題,那很有可能在你的代碼中也會有一些需要解決的線程泄露問題。但是,如果你發現其實你的所有線程都在干活,而且干的也是你期望的活,那有沒有辦法改改系統,允許你創建更多線程,接受更多連接呢?
一如既往,答案還是非常有趣的。要討論有限的內存與在JVM中可以創建的線程數之間的關系,這是個有趣的話題。一個線程的棧大小決定了可以用來做靜態內存分配的內存量。這樣,理論上的最大線程數量就是一個進程的用戶地址空間大小除以線程的棧大小。不過,實際上JVM也會把內存用于堆上的動態分配。在用一個小Java程序做了一些簡單實驗之后,我們證實了如果堆分配的內存多,那棧可以用的內存就少。這樣,線程數量的限制會隨著堆大小的增加而減少。
結論就是,如果你想增加線程數量限制,你可以減少每個線程使用的棧大小(-Xss),也可以減少分配給堆的內存(-Xms,-Xmx)。
限制三:臨時端口耗盡
事實上我們倒沒有真的達到這個限制,但我們還是想把它寫在這里,因為當大家想在一臺服務器上支持幾十萬條連接時通常都會達到這個限制。每當負載均衡器連上一個服務器節點時,它都會占用一個 臨時端口 。在這個連接的生命周期內,這個端口都會與它相關聯,因此叫它“臨時的”。當連接被終止之后,臨時端口就會被釋放,可以重復使用。可是長連接并不象普通的HTTP連接一樣會終止,所以在負載均衡器上的可用臨時端口池就會最終被耗盡。這時候的狀態就是沒有辦法再建立新連接了,因為所有操作系統可以用來建立新連接的端口號都已經用掉了。在較新的負載均衡器上解決臨時端口耗盡問題的方法有很多,但那些內容就不在本文范圍之內了。
很幸運我們每臺負載均衡器都可以支持高達25萬條連接。不過,但你達到這個限制的時候,要和管理你的負載均衡器的團隊一起合作,來提高負載均衡器與你的服務器節點之間的開放連接的數量限制。
限制四:文件描述符
當我們在數據中心中搭建起來了16臺服務器,并且可以處理很可觀的生產流量之后,我們決定測試一下每臺服務器所能承受的長連接數量的限制。具體的測試方法是一次關掉幾臺服務器,這樣負載均衡器就會把越來越多的流量導到剩下的服務器上了。這樣的測試產生了下面這張美妙的圖,表示了每臺服務器上我們的服務器進程所使用的文件描述符數量,我們內部給它起了個花名:“毛毛蟲圖”。
文件描述符在Unix一類操作系統中都是一種抽象的句柄,與其它不同的是它是用來訪問網絡Socket的。不出意外,每臺服務器上支撐的持久連接越多,那所需要分配的文件描述符也越多。你可以看到,當16臺服務器只剩2臺時,它們每一臺都用到了2萬個文件描述符。當我們把它們之中再關掉一臺時,我們在剩下的那臺上看到了下面的日志:
java.net.SocketException: Too many files open
在把所有的連接都導向唯一的一臺服務器時,我們就會達到單進程的文件描述符限制。要查看一個進程可用的文件描述符限制數,可以查看下面這個文件的“Max open files”的值。
$ cat /proc/<pid>/limits Max open files 30000
如下面的例子,這個可以加大到20萬,只需要在文件/etc/security/limits.conf中添加下面的行:
<process username> soft nofile 200000 <process username> hard nofile 200000
注意還有一個系統級的文件描述符限制,可以調節文件/etc/sysctl.conf中的內核參數:
fs.file-max
這樣我們就把所有服務器上面的單進程文件描述符限制都調大了,所以你看,我們現在每臺服務器才能輕松地處理3萬條以上的連接。
限制五:JVM堆
下一步,我們重復了上面的過程,只是把大約6萬條連接導向剩下的兩臺服務器中幸存的那臺時,情況又開始變糟了。已分配的文件描述符數,還有相應的活躍長連接的數量,都一下子大大降低,而延遲也上升到了不可接受的地步。
經過進一步的調查,我們發現原因是我們耗盡了4GB的JVM堆空間。這也造就了下面這張罕見的圖,顯示每次內存回收器所能回收的堆空間都越來越少,直到最后全都用光了。
我們在數據中心的即時消息服務里用了TLS處理所有的內部通信。實踐中,每條TLS連接都會消耗JVM的約20KB的內存,而且還會隨著活躍的長連接數量的增加而增漲,最終導致如上圖所示的內存耗盡狀態。
我們把JVM堆空間的大小調成了8GB(-Xms8g, -Xmx8g)并重跑了測試,不斷地向一臺服務器導過去越來越多的連接,最終在一臺服務器處理約9萬條連接時內存再次耗盡,連接數開始下降。
事實上,我們又把堆空間耗盡了,這一次是8G。
處理能力倒是從來都沒用達到過極限,因為CPU利用率一直低于80%。
我們接下來是怎么測的?因為我們每臺服務器都是非常奢侈地有著64GB內存的配置,我們直接把JVM堆大小調成了16GB。從那以后,我們就再也沒在性能測試中達到這個內存極限了,也在生產環境中成功地處理了10萬條以上的并發長連接。可是,在上面的內容中你已經看到,當壓力繼續增大時我們還會碰上某些限制的。你覺得會是什么呢?內存?CPU?
結論
在這篇文章中,我們簡單介紹了LinkedIn為了向即時通信客戶端推送服務器主動發送的消息而要保持長連接的情況。事實也證明,Akka的Actor Model在Play框架中管理這些連接是非常好用的。
不斷地挑戰我們的生產系統的極限,并嘗試提高它,這樣的事情是我們在LinkedIn最喜歡做的。我們分享了在我們在我們經過重重挑戰,最終讓我們的單臺即時通信服務器可以處理幾十萬條長連接的過程中,我們碰到的一些有趣的限制和解決方法。我們把這些細節分享出來,這樣你就可以理解每個限制每種技術背后的原因所在,以便可以壓榨出你的系統的最佳性能。希望你能從我們的文章中借鑒到一些東西,并且應用到你自己的系統上。
來自:http://www.infoq.com/cn/articles/linkedin-instant-message