Firebase Realtime Database實作實況聊天室會遇到的效能問題與解決方案

Wei-Hong Ho
Ho, Japan
Published in
11 min readJun 7, 2020

Realtime Database是Firebase所提供服務之一,不同於以往傳統的http request,而是使用數據同步的機制,當資料庫中數據發生變化時,與資料庫連接的裝置都能夠同步進行更新,於是經常被用來實做需要頻繁更新畫面內容的網站或app,像是社群軟體、實況聊天室等等。

於是最近用Firebase Realtime Database+ React實作了類似實況聊天室的功能,分享幾個會遇到的效能問題與解決方案,這邊的解決方案是不依靠任何server端的,僅採用Firebase現有的JavaScript SDK所提供的API去做優化。

React + Firebase

這次的開發過程,主要會有兩個問題,將個別去做探討:

  • 當同一時間request過多時,導致畫面渲染過度頻繁,造成卡頻的問題
  • 如何計算當前留言總數

課題一:畫面渲染過度頻繁,造成卡頻的問題

這個問題首先會牽扯到聊天室的設計原理,有常常在看遊戲實況的人可能會發現,在目前比較熱門的平台上,像是twitch、youtube等等,當人數超過一定數目時,也會有聊天室或實況畫面變卡的情況發生,而為了盡量不要去影響使用者體驗,常見的做法有以下幾種:

  • 剛進入頻道的觀眾,不會看到過去的留言,而是只更新當前時間點之後的留言
  • 當留言超過一定數目時,隱藏或移除過去的留言,並保持更新留言
  • 載入留言時,不會一有request就立即更新,而是要限制載入的頻率
  • 限制每一位觀眾發言秒數

接下來介紹一下,如何應用Firebase JavaScript的SDK,去針對以上的解決方案做優化。再說明之前,先假定我們設計的聊天室有以下的資料庫樹狀結構,我將一個實況頻道底下的節點分成room-info以及comment-list,其中room-info包含該實況的一些資訊,而commnet-list則是關於一連串的留言資訊,包含留言內容、留言的觀眾暱稱、發布時間等等,結構如下圖:

room {
room-info {
roomId: "abc",
commentCount: 100,
...
},
comment-list {
comment-1 { ... },
comment-2 { ... },
comment-3 { ... },
...
}
}

Firebase RealTime Database的數據更新方法

最基本的方法是直接設置 on 監聽器,去監聽對應節點的數據變化,搭配上react的作法,就是一但更新了數據,就重新setState去觸發重新渲染畫面。

這邊想提供另一個方法,那就是使用Observable,因為可以藉著RxJS的幫忙,去簡單處理資料流的內容,於是官方自己推出一套叫做Rxfire的Library,標榜著以RxJS + Firebase的方式去處理非同步事件,於是上面的 on 監聽器寫法可以改寫為以下的方式,

接著就以這樣的基底,去針對各個課題去做優化吧。

1. 只更新當前時間點之後的留言

有使用過twitch的觀眾可能會發現,當你進到一個聊天室的時候,一開始是全部空白的,新的留言都是從當前的時間點之後開始載入,這麼做的目的是為了加快初回載入的速度,如果在過去的時間點,有成千上萬條的留言,在剛進入時一併載入的話,肯定是會比較耗時間的,於是我們可以在推送留言的時候,也順便加入當前時間點的timestamp去紀錄每則留言的時間點。

由於有記錄了每則留言的時間點,我們就可以只去讀取當前時間點之後的留言,這裡要運用兩個query函式去做查詢, orderByChildstartAt ,先根據子節點的timestamp由小排到大,然後 startAt(當前時間點) ,只截取在這個時間點或在這之後的留言,

這樣一來初次載入留言時,就不會包含過去的留言,效能上也會優化許多。但只要隨著規模的擴大,我們也可以在更進一步的優化,在Firebase資料結構上,可以自行去定義資料庫的規則,包含讀寫權限,如果我們經常需要使用timestamp去進行查詢,可以通過新增 .indexOn 到資料庫規則裡,去告訴Firebase保持著使用timestamp進行索引,例如可以新增以下規則到 database.rules.json 裡:

{
"rules": {
"comment-list": {
".indexOn": ["timestamp"]
}
}
}

2. 隨時間過去,隱藏過去的留言

隨時間過去,留言不斷載入越來越多時,Firebase所要監聽的節點量就會越來越龐大,當到達成千上萬條留言時,有可能一次拿取的節點就是好幾mb,這樣當然對使用者體驗不是這麼的好,在twitch或youtube的平台聊天室裡,只要留言數超過一定量,就會開始隱藏過去的留言,就可以讓畫面維持在100~200則留言,保持資料量不要過於龐大。

這一點Firebase提供了一個方便的query函式 limitToLast 。這個函式的原理是這樣的,假設將數量上限設置為 100,則只能接收最多 100 個 child_added 事件。如果資料庫中只存儲了不到 100 條留言,那麼每條留言都可以觸發一個 child_added事件。但是,如果有超過 100 條消息,那麼就是排在最後100條的留言。隨著留言發生變化,對於最新的留言,會收到 child_added事件;對於最舊的留言,您會收到 child_removed事件,這樣總數始終保持為 100。於是上述的取得留言的寫法可以改寫為這樣:

3. 限制載入的頻率

即便我們嘗試了以上兩種方法,但進行負荷測試的時候,發送留言request過多的時候,還是會造成卡頓,原因是什麼呢?這個問題就要牽扯到rps,

rps是每秒轉數的縮寫(revolutions per second),並且是指示週期性現象(例如旋轉)在一秒內重複的次數的單位。也可以寫為轉/秒。

而在聊天室的世界裡,rps指的就是每秒約幾則留言被發送進來,這次的負荷測試發現,當rps到達30左右,網頁就幾乎會停頓,呈現一個當機的狀態。

原因是,即便Firebase的database能夠承受高rps的負荷量,但我們實際要去接收資料的衝擊點還是在前端這一塊,React的原理是,只要state或props有改變,就會觸發畫面的re-render,於是我們在頁面上設置的監聽器,假設每秒接受了30則留言的更新,在一秒鐘內,React就會以 setState 的方式去觸發畫面渲染30次,對一般的瀏覽器而言,是承受不住這樣的更新次數的。

至於如何去做rps負荷性測試呢?這邊採用的是Locust負荷測試框架,可以直接透過Firebase提供的open API去做對接,就不詳細說明了。

做了負荷性測試,果不其然會造成網頁當機的問題,而JavaScript中,負荷對策最傳統的方法有兩種,這邊直接簡單說明一下,

* debounce: 一定時間停止觸發後,才會執行。* throttle: 一定時間內不管觸發幾次,只會執行最後一次。ps: lodash這個library提供了基本的實作,有興趣的人可以參考看看

當然,我們使用的RxJS裡,也提供了這兩個函數的operator,所以可以直接拿來採用,這次我們選擇的解決方案是 throttle ,因為如果是 debounce 的話,每秒都有留言在產生的話,畫面就會保持不變,直到一定時間內不再產生留言後,才觸發更新,這就不是一個適合應用在聊天室的作法,因為聊天室是需要持續保持畫面更新的應用,所以採用 throttle。改寫後的observable函數就會長下面這個樣子:

如此一來,每一秒鐘就只會觸發最後一次的資料更新,在這之後的負荷測試,即使到rps高達300也不會造成瀏覽器畫面當機。

4. 限制觀眾發言秒數

常見的熱門聊天室中,為了防止觀眾洗頻,會去限制觀眾發言的秒數,例如每10秒或20秒只能發言一次之類的。這部分的實作我目前只在前端設置一個簡單的timeout,沒什麼特別的,

但實際上應該是要記錄每一位觀眾上一次發言的時間在Firebase上,若在允許的時間範圍內,才打開發言權,但因為這次使用者資料的記錄比較麻煩,先暫時採用只在前端設timeout的作法。

課題二:計算當前留言總數

這個課題是計算子節點數量的方法,這也是令很多Firebase使用者都相當頭痛的問題,因為Firebase推出了好幾年,直到目前為止還沒有提供一個api能夠取得一個節點所有的child數量,儘管官方也提供了解決方法,但也並不是一個能夠對應大量負荷的實作。

這邊提供一些常用的解決方案。

1. parent.numChildren計算節點數

使用Firebase SDK提供的 numChildren 取得節點下的所有子節點,由於這種方法會直接拿取節點下的所有資料,當資料量過於龐大時,下載會很花時間。

2. 當留言數增加時,觸發Firebase Cloud Function獲得更新後的留言數

cloud function

Firebase Cloud Function可以在Firebase的雲端上設置運算函數,等於就是用別人的伺服器幫你處理一些複雜的運算流程,而不是在自己的網頁或者app上面處理,Cloud Function可以監聽資料庫中某個節點所觸發的事件,像是下列的 onWrite 可以監聽節點發生新增、刪除以及更新時觸發,當函數觸發時,由於先前已經在雲端上運算過一次了,只需要針對更新事件做更新,因此執行速度比方法一快上許多。

方法一 + Client Side

若不採用Cloud Function,直接放在Client Side去計算numChildren,資料量龐大時(大於1MB時),會直接造成畫面卡頓。

方法一 + Cloud Function

最一開始,我嘗試當有留言新增時,就用Cloud Function去執行方法一,然而當留言總數超過幾萬則時,下載的速度真的太慢了。因為Cloud Function是沒有辦法直接設定觸發週期的,再加上若是留言的rps達到100以上時,觸發的次數會過於頻繁,會導致Firebase Console直接超過CPU的上限。

方法二

方法二看似可行,但根據Realtime Database的規定,只要一個節點的大小超過1MB時,任何Cloud Function的事件都不會被觸發。

就算用API解除嚴格模式的規定,只能讓大於1MB的資料大小能夠進行讀寫,Cloud Function仍然無法被觸發。到這邊,我開始認為Firebase根本不適合做計算子節點數目這件事。

直到目前2020/06/07,Firebase版本為 8.4.2 ,仍然沒有提供一個類似SQL SELECT COUNT( * ) FROM COMMENTS那樣的方法去計算總數。

Transaction數據保存法

transaction 可以應用在一些容易損毀資料轉換的過程中,例如最經典的計數器加減一,在Firebase官方的Cloud Function Sample裡面,有著這樣的一個範例,Child-Count,這是目前唯一能夠對應節點量龐大的計算方法,一樣透過新增留言時去觸發,有新的留言產生時,可以用 transaction拿取當前的留言總數,去做對應的刪減。像是下列這個樣子:

儘管可以應對大量資料的節點,負荷量又是另一個問題了,於是我測試當留言在同一時間大量發送的結果又會是如何呢?一樣將rps調整到300~500左右,在Firebase Console就會發現這樣的error log:

我想原因也是發送 transaction 過於頻繁,造成同一時間點的數值出現偏差,一但出現數值偏差,Firebase就會重新執行一次 transaction ,不斷重複這樣的循環,最後跳出了error log: maxretry

儘管如此,這個error並不會導致使用者體驗出任何問題,可能只是導致背後的某個時間點,留言數目少算了一次之類的Bug吧。之後我會繼續調查有沒有更好的解決方法,如果有,也歡迎告訴我。

結論

不要用Firebase去計算聊天室留言數量zzz

--

--

Wei-Hong Ho
Ho, Japan

Hello, I’m from Taiwan. I’m a Front-End Developer working In Tokyo. I would like to share my work experience in Japan and technique in programming.