揭祕 Reddit 愚人節專案的技術實現過程

一杯雜湊不加鹽發表於2017-06-13

每年的愚人節,我們喜歡建立專案來探索人類大規模的交流互動,而不是做一些惡作劇。今年我們提出了 r/Place,這是一個協作的畫板,每個使用者每 5 分鐘只能修改一個小塊。這一限制弱化了個體的重要性,強化了大量使用者協作完成複雜創作的必要性。每個小塊的變化實時傳遞給觀察者。

許多開發團隊(前端、後端、移動端)協作開發這個專案,專案大部分基於 Reddit 已有的技術。這篇文章從技術角度詳細描述我們如何完成 r/Place。

且慢。如果你想檢視我們的程式碼,在這裡。如果你對構建 r/Place 這一類專案感興趣,我們歡迎你

需求

定義愚人節專案的需求十分重要,因為它一旦釋出即面向所有 Reddit 使用者,沒有增長過程。如果它一開始並不能完美運作,似乎就不能吸引足夠的使用者來創作並獲得有趣的體驗。

  • 畫板必須有 1000×1000 個小塊,所以它會非常大。
  • 所有客戶端必須和當前畫板狀態同步,並顯示一致,否則使用者基於不同版本的畫板難以協作。
  • 我們必須支援至少 100000 的併發同步使用者。
  • 使用者每 5 分鐘可以修改一個小塊,所以我們必須支援平均每 5 分鐘 100000 個小塊的更新(每秒 333 個更新)。
  • 專案的設計必須遵循這一點,即使 r/Place 流量巨大,也不能影響站點其他功能。
  • 配置必須有足夠彈性,應對意外的瓶頸或故障。這意味著畫板的大小和小塊的使用間隔可以在執行時調節,以防資料量過大或更新過於頻繁。
  • API 必須開放和透明,reddit 社群如果對此有興趣,可以在此之上構建專案(機器人、擴充套件、資料收集、外部視覺化等等)。

後端

實施決策

後端最大的挑戰就是保持所有客戶端與畫板狀態同步。我們的解決方案是初始化客戶端狀態時立刻實時監聽小塊的變化,然後請求整個畫板狀態。只要我們在生成畫板的時候有實時的小塊更改,那麼響應返回的整個畫板狀態就會有幾秒的延遲。當客戶端接收到整個畫板狀態,把在等待請求時的小塊變化在畫板上重演。之後接收到所有小塊變化實時繪製在畫板上。

為了讓這個策略正常實施,我們需要儘可能快的請求到畫板的整體狀態。我們的初步方案是用單行 Cassandra 儲存整個畫板,每個針對整個畫板的請求可以讀取整行。行中的每列格式如下所示:

(x, y): {‘timestamp’: epochms, ‘author’: user_name, ‘color’: color}

因為畫板包含一百萬個小塊,這意味著我們不得不讀取有一百萬列的行。在我們的生產叢集上這種讀取花費 30 秒,慢到無法接受,所以我們不能過度依賴 Cassandra。

我們下一個方案使用 redis 儲存整個畫板。我們使用 bitfield 處理一百萬個 4 位的整型。每個 4 位的整型可以編碼 4 位的顏色,橫縱(x,y)座標可以在 bitfield 裡用偏移量表示(offset = x + 1000y)。我們可以通過讀取整個 bitfield 來獲取整個畫板的狀態。我們可以通過在 bitfield 中更新指定偏移量上的值,來更新單獨的小塊(不再需要加鎖或讀/改/寫)。我們仍然需要在 Cassandra 中儲存所有的細節,讓使用者可以檢查單獨的小塊,看一看何時何人更改了它。我們也計劃用 Cassandra 備份整個畫板,以防 redis 失效。從 redis 中讀取整個畫板不超過 100ms,這已經足夠快了。

插圖展示了我們如何用 redis 儲存 2×2 畫板的顏色:

我們非常關心 redis 讀取最大頻寬。如果很多客戶端同時連結或重新整理,它們會同時請求整個畫板的狀態,全部都觸發 redis 的讀取操作。因為畫板是全域性共享狀態,顯而易見的解決方案是使用快取。我們決定在 CDN 層(Fastly)使用快取,因為實現簡單,並且快取離客戶端更近可以提高響應速度。對整個畫板的請求被 Fastly 快取下來並設定 1 秒的超時時間。我們也新增了 stale-while-revalidate 這個控制快取的頭資訊,來應對畫板快取過期導致超過預期的大量請求。Fastly 維護著大約 33 處獨立快取 POPs(接入點),所以我們預期每秒最多有 33 個針對整個畫板的請求。

我們使用我們的 websocket 服務 向所有客戶端推送更新。我們已經成功地在 reddit live 生產環境中應用過它,來處理超過 100000 的併發使用者,比如 live PM notifications 功能或其他特性。wesocket 服務也曾是我們過去愚人節專案的基礎,比如 The ButtonRobin 兩個專案。對於 r/Place 專案,客戶端維護一個 websocket 連結來接收實時的小塊變化更新。

API

檢索整個畫板

請求首先到達 Fastly。如果那裡有一份未過期的畫板副本,它會立刻返回從而不需要訪問 reddit 應用伺服器。否則如果快取未命中或副本過時,reddit 應用會從 redis 中讀取整個畫板然後返回到 Fastly 中並快取,並返回給客戶端。

reddit 應用測量的請求速率和響應時間:

揭祕 Reddit 愚人節專案的技術實現過程

注意,請求速率從沒超過 33 個/秒,說明 Fastly(CDN) 快取非常給力,阻止了大量直接訪問 reddit 應用的請求。

揭祕 Reddit 愚人節專案的技術實現過程

當請求訪問 reddit 應用時,redis 的讀取操作非常迅速。

繪製一個小塊

繪製一個小塊的步驟如下:

  1. 從 Cassandra 讀取使用者上一次更改小塊的時間戳。如果和當前時間間隔比冷卻時間(5 分鐘)短,拒絕繪製請求,返回給使用者一個錯誤。
  2. 向 redis 和 Cassandra 寫入小塊詳情。
  3. 向 Cassandra 寫入使用者上一次修改小塊的時間戳。
  4. 讓 websocket 服務向所有連結的客戶端傳送新的小塊。

Cassandra 的所有讀寫操作的一致性設定為 QUORUM 級別,來確保強一致性。

我們當然也有競態條件允許使用者一次更改多個小塊。在步驟 1-3 中並沒有鎖,因此批量小塊修改的操作通過步驟 1 的檢查之後將在步驟 2 中進行修改。看起來一些使用者發現了這個漏洞或一些機器指令碼不遵守速率限制,所以大概有 15000 個小塊被利用這個漏洞進行更改(佔全部更改小塊的 0.09%)

reddit 應用測量的請求速率和響應時間:

揭祕 Reddit 愚人節專案的技術實現過程

我們經歷了更改小塊最大速率大概 200/s。這比我們估算的最大速率 333/s 要低(平均每 5 分鐘 100000 個使用者更改小塊)。

揭祕 Reddit 愚人節專案的技術實現過程

獲取單個小塊詳情

直接從 Cassandra 請求單個小塊。

reddit 應用測量的請求速率和響應時間:

揭祕 Reddit 愚人節專案的技術實現過程

這個服務端點用的很多。除了客戶端頻繁的請求之外,有人編寫抓取工具每次檢索整個畫板的一個小塊。由於這個服務端點沒有在 CDN 快取,所有請求被 reddit 應用程式處理。

揭祕 Reddit 愚人節專案的技術實現過程

在整個專案中,這些請求的響應時間非常迅速穩定。

Websockets

我們並沒有在 websocket 服務中為 r/Place 做單獨指標,但是我們可以估計並減去專案開始前後的基本使用量。

websocket 服務總連線數:

揭祕 Reddit 愚人節專案的技術實現過程

r/Place 開始前的基本使用量大概有 20000 個連線,而峰值 100000 個連結,所以高峰期我們大概有 80000 個使用者連線到 r/Place。

Websocket 服務頻寬:

揭祕 Reddit 愚人節專案的技術實現過程

高峰期 r/Place 的 websocket 服務吞吐量超過 4gbps(24個例項,每個 150 Mbps)

前端:Web和移動端

構建 r/Place 的前端工程涉及到了跨平臺開發的眾多挑戰。我們期望 r/Place 在我們所有主流平臺上擁有無縫體驗,包括桌面web、移動web、iOS 和 Android。

r/Place 的 UI 需要做三件很重要的事:

  1. 實時展示畫板狀態。
  2. 讓使用者和畫板互動方便容易
  3. 在我們所有平臺上正常執行,包括移動端 app。

UI 的主要焦點集中在了 canvas,並且 Canvas API 完全能勝任要求。我們使用一個 1000 x 1000 的 <canvas> 元素,把每個小塊當做一個畫素進行繪製。

繪製 canvas

canvas 需要實時展示整個畫板的狀態。我們需要在頁面載入的時候繪製整個畫板的狀態,然後更新通過 websocket 傳輸過來的畫板狀態。通過 CanvasRenderingContext2D 介面,有三種方式更新 canvas 元素。

  1. drawImage() 將一個存在的影象繪製進 canvas。
  2. 通過眾多圖形繪製的方法來繪製各種形狀,比如用 fillRect() 繪製一個有顏色的矩形。
  3. 構造一個 ImageData 物件,然後用 putImageData() 方法將它繪製進 canvas。

第一種選項並不適合我們,因為我們並沒有畫板的影象格式,還剩下 2、3 選項。用fillRect()方法更新單獨的小塊非常簡潔:當 websocket 通知更新時,只需要在(x,y)位置處繪製一個 1 x 1 的矩形。一般來說這很棒,但並不適合繪製畫板的初始狀態。putImageData()方法顯然更合適,我們可以在 ImageData 物件中定義每個畫素的顏色,然後一次性繪製整個 canvas。

繪製畫板的初始狀態

我們使用putImageData()方法,前提需要將畫板狀態定義成 Uint8ClampedArray 形式,每個值用 8 位無符號整型表示 0-255 之間的數字。每一個值表示單個顏色通道(紅、綠、藍、alpha),每個畫素需要 4 個值組成的陣列。一個 2 x 2 的 canvas 需要一個 16 位元組的陣列,前 4 位元組表示 canvas 左上角的畫素,最後 4 位元組表示右下角畫素。

插圖展示了 canvas 畫素和 Uint8ClampedArray 對映關係:

對於 r/Place 的 canvas,陣列大小是四百萬位元組,也就是 4MB。

在後端,畫板狀態儲存格式是 4 位的 bitfield。每個顏色用 0 到 15 之間的數字表示,這允許我們將 2 畫素的顏色資訊打包進 1 個位元組(1位元組=8位)。為了在客戶端配合使用,我們需要做 3 件事:

  1. 將二進位制資料從我們的 API 拉取到客戶端。
  2. “解壓”資料
  3. 將 4 位顏色對映成可用的 32 位顏色。

為了拉取二進位制資料,我們在支援 Fetch API 的瀏覽器中使用此 API。在不支援的瀏覽器中,我們使用 XMLHttpRequest,並把 responseType 設定為 “arraybuffer”

我們從 API 接收到的二進位制資料中,每個位元組有 2 畫素的顏色資料。TypedArray 的建構函式允許操作的最小單位是 1 位元組。這在客戶端上並不方便使用,所以我們做的第一件事就是“解壓”,讓資料更容易處理。方式很簡潔,我們遍歷打包的資料並按照高位低位分割位元位,將它們複製到另一個陣列的不同位元組中。最後,4 位的顏色值對映成可用的 32 位顏色。

揭祕 Reddit 愚人節專案的技術實現過程

ImageData這種資料結構需要使用putImageData方法,最終結果要求是可讀的Uint8ClampedArray格式並且顏色通道位元組要按照 RGBA 這種順序。這意味著我們要做另一遍“解壓”,將每個顏色拆分成顏色通道位元組並按順序排列。每個畫素要做 4 次操作,這不是很方便,但幸運的是有其他方式。

TypeArray物件們本質上是ArrayBuffer的陣列檢視,實際上表示二進位制資料。它們共同的一點就是多個TypeArray例項可以基於一個ArrayBuffer例項進行讀寫。我們不必將 4 個值寫入 8 位的陣列,我們可以直接把單個值寫入一個 32 位的陣列。使用Uint32Array寫入值,我們可以通過更新陣列單個索引來輕鬆更新單個小塊顏色。我們唯一需要做的就是把我們的顏色位元組逆序儲存(ABGR),這樣一來使用Uint8ClampedArray讀取資料時可以自動把位元組填入正確位置。

揭祕 Reddit 愚人節專案的技術實現過程

處理 websocket 更新

響應每個畫素更新時,用drawRect()方法繪製它們很方便,但這有個缺點:當大量更新在同一時間來到,會影響瀏覽器效能。我們知道畫板狀態更新十分頻繁,所以我們需要處理這個問題。

我們希望在一個時間點前後的 websocket 更新能夠批量繪製一次,而不是每次 websocket 更新來到就立刻重新繪製 canvas。我們做了以下兩點改變:

  1. 因為我們發現了使用putImageData()一次更新多個畫素這條明路,所以我們不再使用drawRect()
  2. 我們把繪製 canvas 操作放到requestAnimationFrame迴圈中。

把繪製移到動作迴圈中,我們可以及時將 websocket 更新寫入ArrayBuffer,然後延遲繪製。每一幀(大概 16ms)間的 websocket 更新會再一次繪製中批量執行。因為我們使用requestAnimationFrame,這意味著每次繪製時間不能太長(不超過 16ms),只有 canvas 的重新整理速率受影響(而不是拖慢整個瀏覽器)。

Canvas 的互動

還有非常重要的一點,canvas 需要方便使用者的互動。使用者與 canvas 核心互動方式是更改上面的小塊。在 100% 縮放下,精確地選擇繪製單個畫素很不方便,而且容易出錯。所以我們需要放大顯示(放大很多)。我們也需要方便的平移 canvas,因為在多數瀏覽器上它太大了(尤其是放大後)。

視角縮放

使用者只能每五分鐘繪製一次小塊,所以選錯小塊非常令人不爽。我們需要把 canvas 放大到每個小塊都成為一個相當大的目標。這在觸控裝置上尤其重要。我們使用 40x 的放大比例,給每個小塊 40 x 40 的目標區域。為了應用縮放,我們把<canvas>元素包裹進一個<div>,並給 div 設定 CSS 屬性transform: scale(40, 40)。這樣一來,小塊的佈置變得非常方便,但整個畫板的顯示並不理想(尤其是在小螢幕上),所以我們混合使用兩種縮放級別:40x 用於繪製,4x 用於顯示。

使用 CSS 來放大 canvas 使得繪製畫板的程式碼和縮放程式碼相分離,但不巧這種方式也帶來一些問題。當放大一個圖片(或 canvas),瀏覽器預設使用“平滑”演算法處理圖片。這適用於一些場景,但也徹底毀滅了畫素藝術並把它變得混亂模糊。好訊息是有另一個 CSS image-rendering 允許我們命令瀏覽器不這麼做。壞訊息並不是所有瀏覽器完全支援這個屬性。

壞訊息,變得模糊:

我們需要在那些瀏覽器上用其他方式放大 canvas。我之前提到過繪製 canvas 有三種方式。其中第一個是drawImage()方法,它可以把一個存在的影象或另一個 canvas 繪製進一個 canvas。它也支援在繪製的時候放大或縮小影象,雖然放大的時候會和在 CSS 中放大一樣出現模糊問題,但是可以通過關閉 CanvasRenderingContext2D.imageSmoothingEnabled 標識,這種跨瀏覽器相容性的方式來解決。

所以修復模糊 canvas 問題的答案就是在渲染過程中增加額外一步。我們引入了另一個<canvas>元素,它大小位置適應於容器元素(比如畫板的可見區域)。每次重新繪製 canvas 後,我們使用drawImage()把它的一部分繪製到新的、有合適縮放比例的 canvas。因為額外的步驟給渲染過程帶來微小的開銷,所以我們只在不支援image-renderingCSS 屬性的瀏覽器上這樣做。

視角平移

canvas 是一個相當大的影象,尤其是放大之後,所以我們需要提供一些方式操作它。為了調整 canvas 在螢幕上的位置,我們採取和解決縮放問題一樣的方式:我們將<canvas>包裹進另一個<div>,並在它上面應用 CSS 屬性transform: translate(x, y)。使用單獨的 div 使得應用在 canvas 上的變換操作更容易控制,這對於防止視角在縮放時產生移動非常重要。

我們最後支援多種方式調整視角位置,包括:

  • 點選拖拽
  • 點選移動
  • 鍵盤導航

每種操作需要一點不同的實現方式。

點選拖拽

最基本的導航方式就是點選拖拽(或觸控拖拽)。我們儲存了mousedown事件的 x、y 座標。對於每次mousemove事件,我們計算滑鼠相對於起點的偏移量,然後把偏移量加到已存在的 canvas 偏移量中。視角位置立刻改變,讓人感覺這種到導航方式很靈敏。

點選移動

我們也支援點選一個小塊,使得小塊定位到螢幕中心。為了實現這個功能,我們需要跟蹤mousedownmouseup事件,為了區別“點選”和“拖動”。如果滑鼠移動距離達不到“拖動”的標準,我們會根據滑鼠位置和螢幕中心的距離來調整視角位置。和點選拖動不同,視角位置的更新使用了緩動函式(easing function)。我們沒有立刻設定新的位置,而是把它儲存成“目標”位置。在動畫迴圈中(每次繪製 canvas 的迴圈),我們使用緩動函式移動當前視角逐漸接近目標。這避免了視角移動太突然。

鍵盤導航

我們也支援鍵盤導航,既可以使用 WASD 鍵也可以使用方向鍵。四個鍵控制內建 移動向量。沒有按鍵按下時,向量預設是 (0, 0),每個按鍵按下時會增加或減少向量的 x 或 y 軸 1 個單位。舉個例子,按下“右”和“上”鍵會把移動向量設定成 (1,-1)。這個移動向量隨後應用在動畫迴圈中,來移動視角。

在動畫迴圈中,移動速度是基於當前縮放級別而計算出來的,公式如下:

movementSpeed = maxZoom / currentZoom * speedMultiplier

在縮小狀態下,鍵盤導航移動速度更快,這樣顯得更自然。

移動向量單位化並乘以移動速度,然後應用到當前視角位置。我們用單位向量來確保對角線移動和正交移動擁有相同速度,這也顯得更自然。最後我們對移動向量自身的變化也使用了緩動函式。這使得移動方向和速度變化的更平滑,視角變得流暢生動。

移動應用支援

在 iOS 和 Android 的移動應用嵌入 canvas 過程中,我們遇到一些挑戰。首先,我們需要認證使用者,然後使用者才能更改小塊。和基於 session 的 web 認證不同,移動應用中我們使用 OAuth。這意味著應用需要為 webview 提供當前登入使用者的訪問令牌。最安全的方式就是用 JavaScript 在應用呼叫 webview 時注入 oauth 認證頭資訊(這也允許我們設定其他需要的頭資訊)。問題就簡化為在每個 api 呼叫中傳遞認證頭資訊了。

r.place.injectHeaders({‘Authorization’: ‘Bearer <access token>’});

在 iOS 端,當你可以更改 canvas 中的下一個小塊時,我們實現了訊息提醒功能。因為小塊的變更完全在 webview 中,所以我們需要實現向原生應用的回撥。辛運的是在 iOS 8 及以上版本中只需要一個簡單的 JavaScript 呼叫:

webkit.messageHandlers.tilePlacedHandler.postMessage(this.cooldown / 1000);

應用中的委派方法根據傳入的冷卻計時器,會隨後排程傳送一條通知。

我們學到了什麼

你總會疏漏一些事

我們完美計劃好了任何事情,我們知道上線時,沒有什麼可能出錯的地方。我們對前端和後端分別進行了負載測試,我們不可能再遇到其他錯誤。

真的嗎?

上線過程很順利。經歷了一個黎明,r/Place 人氣迅速上升,我們 websocket 例項的連結數量和通訊量也隨之增加:

揭祕 Reddit 愚人節專案的技術實現過程

並沒有什麼驚喜,所有和我們預期的一樣。奇怪的是,我們懷疑限制了這些伺服器例項的網路頻寬,因為我們預計會有更大的流量。檢視了一下 CPU 的例項情況,卻顯示出一幅不同的圖片:

揭祕 Reddit 愚人節專案的技術實現過程

伺服器例項是 8 核的,所以很明顯它們快到上限了。為什麼它們突然表現的如此不同?我們將原因歸結於 r/Place 的工作負載型別不同於以往專案。畢竟這裡有很多微小的訊息,我們一般傳送大型訊息,比如直播帖子的更新和通知。我們也沒有處理過大量使用者接收相同訊息的情況,所以有很多地方都不同。

這沒什麼大不了,我們預計只需要呼叫和測量它一天而已。待命的運維數量是伺服器例項的兩倍,而且他們兩耳不聞窗外事,一心只顧伺服器。

然後,發生了這個:

揭祕 Reddit 愚人節專案的技術實現過程

這幅圖看上去可能並沒什麼,但事實上這是我們生產環境的 Rabbit MQ 例項,不僅處理 websocket 訊息,也處理 reddit.com 所有底層的依賴項。這不容樂觀,一點都不。

經過了各種調查、束手無策和升級例項,我們把問題鎖定在管理介面。它總是有點慢,隨後我們意識到,我們為了獲取專案狀態用 rabbit diamond collector 會頻繁查詢介面。我們認為建立新的 websocket 例項時建立了額外的 exchange (RabbitMq 中概念),再加上這些 exchange 的訊息吞吐量,導致了管理介面在查詢和記錄時,rabbit 卡住了。我們把它關掉,情況好多了。

但我們不想在一片黑暗之中(關掉了狀態顯示程式),所以我們迅速做了一個手工藝術品——監控指令碼,幫助我們觀察整個專案:

如果你好奇為什麼我們不斷調整修改畫素(小塊)的超時時間(冷卻時間),那麼現在你就知道了。我們試著減輕伺服器壓力以便整個專案順利執行。這也是為什麼在某個時間段,一些畫素的呈現花費較長時間。

所以不幸的是,你當時堅信如下的資訊:

10K upvotes to reduce the cooldown even further! ADMIN APPROVED

儘管調整完看 r/Place/new 版塊很有意思,但調整完全出於技術原因:

或許這也是調整的部分動機。

機器人終歸是機器人

我們在專案的末期經歷了一點小波折。一般來說,我們經常遇到的問題之一便是客戶端重試行為。眾多客戶端在遇到問題時便不停地重試。這意味著站點一旦有點小問題,對於那些沒有對故障進行回退程式設計的一些客戶端,很容易引發重試風暴。

當我們關閉 r/Place 時,很多機器人端點請求時返回非 200 的響應碼。像這樣的程式碼不是十分友好。值得慶幸的是,在 Fastly 層很容易攔截它們。

創造點其他東西

如果沒有龐大團隊的協作,專案不會這麼成功。我們很感謝 u/gooeyblob、u/egonkasper、u/eggplanticarus、u/spladug、u/thephilthe、u/d3fect 等人對 r/Place 團隊的貢獻,讓愚人節的嘗試變成現實。

正如我們之前提到的,如果你對為百萬使用者創造特殊體驗感興趣,看一看我們的招聘頁

打賞支援我翻譯更多好文章,謝謝!

打賞譯者

打賞支援我翻譯更多好文章,謝謝!

任選一種支付方式

揭祕 Reddit 愚人節專案的技術實現過程 揭祕 Reddit 愚人節專案的技術實現過程

相關文章