最近咱們介紹了LinkedIn的即時通訊,最後提到了分型指標和讀回覆。爲了實現這些功能,咱們須要有辦法經過長鏈接來把數據從服務器端推送到手機或網頁客戶端,而不是許多當代應用所採起的標準的請求-響應模式。在這篇文章中會描述在咱們收到了消息、分型指標和讀回覆以後,如何馬上把它們發往客戶端。java
內容會包含咱們是如何使用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。app
// Client A connects to the server and is assigned connectionIdA負載均衡
public Result listen() {框架
return ok(EventSource.whenConnected(eventSource -> {dom
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?請經過個人Twitter帳號@agupta03告訴我你的想法。
結論
在這篇文章中,咱們簡單介紹了LinkedIn爲了向即時通訊客戶端推送服務器主動發送的消息而要保持長鏈接的狀況。事實也證實,Akka的Actor Model在Play框架中管理這些鏈接是很是好用的。
不斷地挑戰咱們的生產系統的極限,並嘗試提升它,這樣的事情是咱們在LinkedIn最喜歡作的。咱們分享了在咱們在咱們通過重重挑戰,最終讓咱們的單臺即時通訊服務器能夠處理幾十萬條長鏈接的過程當中,咱們碰到的一些有趣的限制和解決方法。咱們把這些細節分享出來,這樣你就能夠理解每一個限制每種技術背後的緣由所在,以即可以壓榨出你的系統的最佳性能。但願你能從咱們的文章中借鑑到一些東西,而且應用到你本身的系統上。