19 May 2012

Cubie Message Server

Cubie 的後端是以 Java 開發,由三個子系統構成,一是 message server,二是資料庫,三是處理外部發簡訊的服務。整個後端的的設計目標就是要能 horizontal scale,而且是非集權式的,不會有任何的 Single Point of Failure,最終要佈署在 Amazon EC2。為了這個目標,資料庫我們就直接選擇了 Cassandra。Cassandra 是分散式的 NoSQL 資料庫,我們已經上線使用了一年,所以經驗上 ok。而 message 的部份,我們放棄了我們用了三年的 message server: ActiveMQ,它過去在我們公司拿來是處理遊戲的即時訊息。但它是集權式的,而且我對於它帶來的複雜度一直很感冒,所以我們很快就轉往其他解決方案。一開始我們有研究一下業界的標準 Jabber,不過看到 XMPP 複雜的規格我就逃了,而且 ejabberd 還是用 Erlang 寫的,這讓我到抽一口涼氣,我們沒有時間再熟悉另一個 server side 語言平台了。

我不禁開始想自己發明輪子,但畢竟 message server 是個大大的難題,一開始這實在是大煩惱... 不過我們也沒有煩惱多久就決定自己寫了,原因是 -- 這個 Cubie 最終不會只是個 messenger,未來有太多的地方需要客製。我選定了 Akka framework,以及 Netty ,就開工自己苦幹。前後大概花了一個月的時間草草的完成了 prototype。prototype 只達到一個目標:兩台 server 任一台重開,訊息不會掉,訊息順序是對的,也支援離線訊息。剩下的通通沒處理了,後來我就跑去開發 Cubie iPhone 版,這個 server prototype 就停在那邊,半年後就突然的拿出來上線了 (驚!)

prototype 終究還是 prototype,太嫩了,上線沒多久就被打掛,這二個月來問題層出不窮,苦哈哈的狂修,你們聽我慢慢道來:

  • 同步操作 (Synchronous Operation) 是我們面臨的大問題,最初佈署了三台 message server,才兩千人連線就會讓 server 永久凍結,凍結就是到處都有 method 被 block,後來整個 server 就沒有回應了。這第一個版本中,我們已經替繁重的遠端連線做非同步操作 (Asynchronous),因為就經驗來看網路很慢而且很容易有異常,所以我們早早就做了準備。但沒想到人數一多起來連本機的訊息傳送也會 block,當然這一方面也是我們對 Akka 不熟所至,Akka 的 Thread pool 大小預定是固定的,它仰賴增加 Actor 數來達到 scale,而不是靠增加 thread 數。我們寫了幾年的 Java 一般都根深蒂固認為 Thread Pool 都會設計成隨著用量自行增加,但是 Akka 不是這樣。等我們搞懂原來如此時,一個禮拜已經過去了,非常悲慘的一週,server 每天都在重開,服務品質大打折扣,每天都煩惱用戶因為一直不順就跑了,畢竟我們的對手 Whatsapp/Line 很穩啊....
  • 將訊息傳送的同步操作改為非同步後,我們 server 群就可以處理到 10 萬人了。但到了這個量級,server 又開始凍結了,同樣又是同步操作殺了我們,這一次 block 是在存取 Cassandra 時發生的,資料庫果然是效能殺手。我們沒有足夠的時間最佳化 Cassandra 的存取,我們用空間換取時間,馬上加開更多的 message server 和 Cassandra,希望能先度過眼前的難關,之後再慢慢修正。還好我們的系統一開始就規畫可以 horizontal scale,所以加開 server 這招奏效,我們得到一週的時間可以喘息。這一次我們將所有的資料存取全部改成非同步處理,這樣就可以容忍資料庫偶而變慢造成 server 凍結的問題。
  • 當人數越來越多後,我們發現 JVM heap 用量異常的多,Garbage Collect (GC) 也很頻繁。原本以為是 server 哪裡有 memory leak 了。實際分析 heap dump 才發現原來 Java SSLEngine 吃掉巨大的記憶體 (32k per connection),而且 SSLEngine 加密的運算也把 CPU 拖垮了。是的,為了保護用戶的隱私也為了保護我們自己,Cubie 的連線全程都是 SSL 加密,但這也讓我們的 message server 單台無法處理更多的連線。我們很早就觀察到用戶連線斷線很頻繁,一開始我們以為頻繁的斷線是常態,因為畢竟是 3G 網路,斷斷續續很正常。但一番研究後發現 Android Client 為了持續保持連線狀態,平均每台每十分鐘就會重新連線一次 (每次 Screen off 就有機會會斷線)。因為頻繁的斷線 server 需要重算 SSL 加密,也需要配置新的記憶體,這對 server 傷害很大,因些我們決定先從頻繁的連線/斷線下手。Koji.lin 當時負責 Cubie Android 的開發,他最後找到一些技巧讓 Android Client 減少重連次數,大約變成每台每一小時才重連一次,又不至於讓用戶覺得訊息太慢。當然這個改進又花了另一個禮拜,這段混亂期間還是老招,用空間換取時間,加開更多的 message server 來處理大量的重連。直到 Android 版用戶漸漸升級到新版後,我們的 message server 總算才可以撐到單台 5 萬人 (EC2 large instance)。
  • 我們解決了頻繁斷線的問題,只是讓 CPU 有比較多的時間可以喘息,實際上 Java SSLEngine 每個連線吃掉的記憶體還是在那邊,五萬個連線就是 配置 2~3 G 的 heap。這時候另一個 JVM 最可怕的問題來了: Full GC Stop The World。這聽起來像漫畫 JoJo 的絕招,啊,的確是個絕招啊,我們被殺的好慘... Anyway,當你的 heap 到達 6G 時,每次的 Full GC 就要花 30 秒,其間整個 server 凍結,對一個即時訊息 server 來說,這 30 秒跟永久沒兩樣了。怎麼解決?打開 JVM GC log 開始觀察,研究與調整 JVM 參數,直到完全不會發生 Full GC 為止。我們目前使用 JDK 6,為了避免凍結,改用 Concurrent Mark Sweep GC (CMS),白話講就是應用程式邊執行時邊在背景做 GC,好處是不會再凍結了,壞處是隨時都有 thread 在背景處理,而這會吃掉一部份的 cpu 資源。下面是我們目前在 EC2 large instance 使用的參數:
        -server 
        -Xms5500m
        -Xmx5500m
        -Xmn512m 
        -XX:+DisableExplicitGC 
        -XX:MaxPermSize=256m 
        -XX:+HeapDumpOnOutOfMemoryError 
        -XX:+UseParNewGC 
        -XX:+UseConcMarkSweepGC 
        -XX:+CMSParallelRemarkEnabled 
        -XX:SurvivorRatio=8 
        -XX:CMSInitiatingOccupancyFraction=75 
        -XX:+UseCMSInitiatingOccupancyOnly 
        -XX:+PrintGCDetails 
        -XX:+PrintGCTimeStamps 
    
    如果你有類似的需求和環境,可以參考上述的參數,以它為起點開始調整。要持續觀察 GC 的 log,如果你看到 "concurrent mode failure",那表示背景的 CMS GC 來不及做完你的 heap 又不夠用了,可能是你的程式產生 garbage 太快了,你得改寫程式或是換更大的 server。如果你看到 "ParNew (promotion failed)" 表示你的年輕世代的 heap (-Xmn) 開的太大,而且老世代的 heap 破碎的太嚴重,導至年輕世代的區塊放不進去。 這兩個 fail 都會啟動 Full GC,造成應用程式停滯。
  • Amazon EC2 EBS的惡夢 -- EC2 雲端的自由度太棒了,我們的服務遇到了問題,馬上先開新 EC2 instance 擋著,掙取到時間再好好的修改程式。但是!但是!EC2 的 instance 的硬碟現在都改成 EBS (Elastic Block Storage) 了,EBS 簡單說就是網路硬碟,頻寬是 1Gbit ,而且還跟同一台實體機器的人共享,它的 throughput 只有實體硬碟的 1/2 到 1/3。但這不打緊,最殺人的是它時好時壞,壞的時候連光碟機都比它快。這對資料庫太傷了,Cassandra 本身對緩慢的硬碟已經特別的最佳化了,但是還是沒辦法處理變成烏龜時的 EBS。目前我們加開 Cassandra server 先擋著,最終要全部切換到 EC2 的實體硬碟。EC2 的實體硬碟雖然快且穩定,但是每次 instance stop/crash 資料就會全部不見,因此我們佈署 Cassandra 到跨不同的 data center 做異地備援,以防資料消失,目前主要的 Cassandra cluster 是在日本 EC2,而即時備份則放在新加坡 EC2。實際佈署後發現 Cassandra 跨 data center 的能力真的不錯。
  • 我們的用戶成長到數十萬之後,我們的 server 群又開始凍結了,這一次的問題就更難找了 (scale 到越後面就越難找 bug)。最後我們發現是另一個 Akka 的問題 -- Akka 的 remote client 建立連線時會 block。是的,又是另一個 block 點。我們上了 Akka mailing list 請教,發現原來 Akka 2.0 已做了若干的修正,而我們提了這個問題之後,commiter 沒幾天就將連線 block 的點解決了。聽起來不錯是吧?很可惜我們是用 Akka 1.3 舊版,新版的 Akka 2.0 整個 framework 都有翻新,完全無法相容,除非我們打算重寫 Cubie server,不然就不能套用他們的解決方案 (Akka commiters 打概也不打算 backport 回 1.3 了)。因此我們還是苦哈哈的在應用程式面繞過這個 block 點。從這次經驗來看,Akka 的 commiter 反應迅速,我們只大概回報了一下有這個問題,他們就急著把它修好,這是非常好的一面。另一方面來說 Akka 還不是很成熟,沒有足夠的正式戰場歷練,而且 API 也沒穩定下來。也許再過個一兩年這問題就能改善吧。
  • Cubie server 有一個子系統是發簡訊認証,認証你輸入的電話是不是你的。我們的用戶現在偏佈東南亞,發國際簡訊快把我們搞瘋了,每個國家都有自己的電信商,適用不同的規則。對於像我們這樣的小公司,我們只能去找能夠提供簡訊的第三方服務 (web service based) 來處理發簡訊的難題,我們找了五、六家,而且全部都用,因為某一家可以發到 A 國,但可能發不到 B 國,我們寫了一大堆 ifelse 切換。直到現在,我們還是有很多國家的電信商無法收到我們發簡訊,目前還是一團混亂中。簡訊這部份的服務完全是由我們老闆 tempo 處理的,所以細節我不大清楚,我只知道發簡訊非常的非常的傷錢...

雖然我上面是逐條列下來,但實際上發生的時間是交錯的,同時間有兩三個問題等在那裡交互影響,跟這些問題奮戰真的是不知死了多少腦細胞。Cubie message server prototype 大多是我一人獨立完成的。等 server 正式上線後,同事 koji 才加入一起改進,很可惜的是沒有一開始就 pair 一起開發,不然應該會少走一些冤枉路,系統也會更有效率。Anyway,上面歷經的種種讓我領悟到兩件事:

  1. 如果你運行一個分散式系統,確定你的內部架構不會有 block 的地方,而任何與外部資源 (資料庫、其他節點、第三方服務提供者) 的溝通最好是非同步的。
  2. 穩定性比 throughput 來的重要,尤其是不可預期的變差。如果一個子系統時好時壞,當它壞時就會連累所有的服務 (典型的 slow producer or consumer 問題);如果它直接 crash 反到對整個系統的傷害較小,因為你很容易對失聯的服務做處理。如果子系統一開始就是很慢的話,你不是馬上改用別的方法解決 (例如常用的 cache),不然就是重新設計這個服務,它還沒能拖垮整個系統前你就改善好了。

ok, 經過了這兩個月,回到我最初的問題:我到底該不該重新發明輪子,自己寫一個 message server 呢?我的答案是 50%,我有一半的驕傲,一半的後悔。不過時間也不允許我煩惱這些了,現在一台 message server 因為 SSLEngine 的關係還是只能撐 5 萬人,我必須要及早找到解決方案....


回響

可以用 Tag <I>、<B>,程式碼請用 <PRE>