Last active

Embed URL

HTTPS clone URL

SSH clone URL

You can clone with HTTPS or SSH.

Download Gist

EtherCalc.tw

View posa.mkdn

從 SocialCalc 到 EtherCalc

先前在《開源應用程式架構》 一書中,我介紹了 SocialCalc 這個在瀏覽器中運行的試算表編輯器,以取代伺服器為中心的 WikiCalc 架構。SocialCalc 在瀏覽器中執行所有的運算,只有在載入和儲存試算表時才會使用伺服器。

追求效能是 Socialtext 團隊在 2006 年時設計 SocialCalc 的主要目的。重點在於:在 JavaScript 環境下執行客戶端運算,儘管在當年的速度僅有伺服器端 Perl 運算的十分之一,但仍然勝過 AJAX 來回傳輸資料造成的網路延遲:


WikiCalc 與 SocialCalc 架構比較


《開源應用程式架構》的最後一段裡,我們介紹了如何透過一種簡單、類似聊天室的架構,來進行試算表同步協作:


多人連線版 SocialCalc


然而,當我們開始進行上線測試時,卻發現它的效能與延展性不符實際需求,這也激發我們重寫整個系統,以便達到可接受的效能水準。

本章將會討論 EtherCalc 系統的演進過程。它是 SocialCalc 的後續專案,為試算表提供多人同步的編輯功能。我們會詳述系統架構的沿革,介紹相關的效能分析工具,以及我們創造出哪些新的工具來克服效能上的問題。

設計限制

Socialtext 平台同時具有「防火牆內」及「雲端部署」兩種選項,這對 EtherCalc 的資源及效能需求增加了獨特的限制。本書寫作時,Socialtext 在 vSphere 為基礎的內部網路主機服務內的最低需求,是雙核心處理器和 4GB 的記憶體容量。典型的 EC2 虛擬主機服務則提供大約兩倍的效能,相當於四核心和 7.5GB 的記憶體容量。

內部網路的部署需求,代表我們不能像多租戶的主機模式系統那樣,靠擴充硬體來解決問題(例如 DocVerse,後來成為 Google Docs 的一部分);系統必須能在一個普通的伺服器上運行。

相較於內部網路部署,雲端主機可以藉由隨選擴充,來提供較高的效能,但是瀏覽器的網路連線通常比較慢,而且斷線與重新連線的狀況相當頻繁。

綜上所述,這些形塑 EtherCalc 架構方向的資源所受的限制有:

  • 記憶體:以事件為基礎的伺服器,能讓我們用較少的記憶體處理數千個同時發生的網路連線。

  • 處理器:基於 SocialCalc 的原始設計,我們把大部分運算及所有內容繪製移到客戶端 JavaScript 運行,以減少伺服器的負載。

  • 網路:傳送試算表操作指令而非內容,可降低所需的頻寬,並從不穩定的網路連線上恢復。

初步原型

我們首先用 Perl 5 語言撰寫了一套 WebSocket 伺服器,透過 Socialtext 開發的 Feersum 這個以 libev 為基礎的事件引擎提供支援。Feersum 的速度相當快,在一般狀況下每秒可處理上萬筆請求。

除了 Feersum 以外,我們還使用中介軟體 PocketIO,接上廣受好評的 Socket.io JavaScript 客戶端,以相容於尚未支援 WebSocket 的舊版瀏覽器。

這個初步原型跟聊天室伺服器十分相似,每個協作時段就相當於一個聊天室;客戶端可以將本地的執行指令及游標動作傳送到伺服器,然後透過伺服器轉送給同一個聊天室裡的所有客戶。

典型的操作流程就像這樣:


紀錄重播式的原型伺服器


伺服器在紀錄每個收到的指令時,都會附上時間戳記。如果客戶斷線後又重新連線,它可以擷取這段時間的積存紀錄,然後重新執行那些指令,以達到跟其他人相同的狀態。

如同我們在《開源應用程式架構》提到的,這個簡單的設計大幅減少了伺服器端的處理器與記憶體需求,並且可以在網路連線失敗的狀況下,展現出合理的復原能力。

第一個瓶頸

然而,我們在 2011 年 6 月實地測試雛型時,卻發現隨著協作編輯的執行時段愈長,就會出現愈嚴重的效能問題。

由於試算表是長久存在的文件,因此經過數週的編輯,協作時段可能會累積數千筆的修改紀錄。

在前述的積存紀錄模型下,在新客戶端加入協作時段時,勢必遇上明顯的啟動延遲:它得先重新執行數千個指令,才能進行任何修改。

為了減輕這個問題,我們採用了快照機制。每當 100 個指令傳送到協作時段後,伺服器就會調查線上每個客戶的狀態,然後將最新收到的快照儲存在積存紀錄中。新加入的客戶端僅需接收這個快照,以及快照儲存之後新輸入的指令即可。這樣一來,它最多只需要重新執行 99 個指令。


加入快照機制的原型伺服器


這個權宜之計解決了新加入客戶端的處理器延遲問題,但卻帶來了網路效能不佳的問題,因為它會每隔一陣子,就耗用每個客戶端的上載頻寬。若是連線速度緩慢,客戶端後續指令的發送時間就會受到延遲。

除此以外,伺服器沒有辦法確認客戶端上傳的快照是否正確。錯誤的快照會弄亂所有新加入者的狀態,導致它們和其他其他共同編輯者失去一致性。

細心的讀者也許會發現,這兩個問題的癥結,都是因為伺服器缺乏執行試算表指令的能力。如果伺服器在接收到每個指令時,可以自行更新內部的試算表狀態,它其實根本不需要維護指令的積存紀錄。

瀏覽器內的 SocialCalc 試算表引擎,是用 JavaScript 語言寫成。我們曾考慮過把它的邏輯轉譯成 Perl,以在伺服器端執行,但是維護兩套程式碼需要付出極大的成本。我們也嘗試在伺服器端嵌入 JavaScript 引擎(V8SpiderMonkey 等),但它們在 Feersum 事件迴圈裡運作時,會產生許多效能上的問題。

到了 2011 年 8 月,我們終於決定打掉重練,用 Node.js 重寫伺服器。

移植到 Node.js

由於 Feersum 和 Node.js 都以 libev 事件模型為基礎,而且 Pocket.io 的程式介面跟 Socket.io 幾乎相同,所以最初的改寫十分順利。

感謝 ZappaJS 框架提供的簡潔介面,我們只花了一個下午,用了 80 行程式,就寫出了功能相當的伺服器。

簡單的效能測試顯示,Node.js 的處理效率比 Feersum 少了一半左右:在 2011 年的 Core i5 處理器上,Feersum+Tatsumaki 每秒可處理 5000 次請求,而 Node.js+Express 的每秒上限約為 2800 次請求。

由於這還在我們可接受的範圍內,不致於影響日常使用,因此我們接受這項缺陷,並且期望它在一段時間後會有所改善。

在初步移植完畢之後,我們便著手將每個編輯階段的試算表狀態存放在伺服器端,以減少客戶端的處理器使用,並大幅降低所需的頻寬:


將試算表狀態存放於 Node.js 伺服器上


伺服器端 SocialCalc

jsdom 是提升作業效能的關鍵技術,它完整實作了 W3C 文件物件模型,讓 Node.js 能在模擬的瀏覽器環境內,載入寫給客戶端的 JavaScript 程式庫。

利用 jsdom,我們可以在伺服器端任意創建 SocialCalc 試算表,它們會在各自的沙盒裡進行運算:

require! <[ vm jsdom ]>
create-spreadsheet = ->
  document = jsdom.jsdom \<html><body/></html>
  sandbox  = vm.createContext window: document.createWindow! <<< {
    setTimeout, clearTimeout, alert: console.log
  }
  vm.runInContext """
    #packed-SocialCalc-js-code
    window.ss = new SocialCalc.SpreadsheetControl
  """ sandbox

每個協作時段都對應到一個沙盒內的 SocialCalc 控制器,即時執行客戶端傳來的指令。當新客戶端加入時,伺服器僅需傳送試算表控制器內的最新狀態,從而徹底解決積存紀錄帶來的效能問題。

對測試結果感到滿意之後,我們編寫了一個以 Redis 為基礎的儲存引擎,並在 EtherCalc.org 公開測試。在接下來的六個月裡,它展現了極佳的延展性,順利執行了數百萬筆試算表運作,沒有發生任何狀況。

2012 年 4 月,我在 OSDC.tw 大會上以 EtherCalc 為主題發表演講,之後趨勢科技公司邀我參加他們的黑客松,將 EtherCalc 改作成可編程式的視覺化引擎,用來即時監視網路流量資料。

為了這個使用案例,我們製作 REST 介面,以便用 GET、PUT 存取試算表中的個別儲存格,並使用 POST 將指令直接發送到試算表內。在這場黑客松裡,嶄新的 REST 處理器每秒接收數百筆呼叫,在瀏覽器中即時更新圖像及公式格內容,完全沒有發生速度減緩或記憶體洩漏的狀況。

然而在最後展示會上,當我們將流量資料輸送到 EtherCalc,開始把公式鍵入瀏覽器中的試算表時,伺服器突然當掉,凍結了所有執行中的連線。我們重新執行 Node.js 作業,卻只見它耗用 100% 的處理器資源,隨即又鎖住不動。

吃驚之餘,我們換回較早的資料重新執行。它的運作沒有問題,也讓我們的展示得以完成。但我不禁在想:一開始導致程式當掉的原因究竟是什麼?

Node.js 效能分析

要找出 CPU 卡在哪裡,就得使用效能分析器。

Perl 初步原型的效能監測方式相當簡單明瞭,這大半要歸功於優秀的 NYTProf 工具,它能利用詳盡的 HTML 報告以及互動式的函式呼叫視覺界面,詳細列出每個函式、每個區塊、每列、每個操作碼的時間資訊。除此以外,我們也利用 Perl 內建的 DTrace 支援,針對長時運行的程序,取得函式出入的即時數據。

相形之下,Node.js 的效能分析工具還有很大的進步空間。截至此時,DTrace 仍只能在 illumos 系作業系統的 32 位元模式下運行,因此我們大多得靠 Node Webkit Agent 提供的分析介面,即使它只提供函式層級的數據資料。

典型的運行方式如下:

# "lsc" 是 LiveScript 編譯器
# 先載入 WebKit agent 模組,然後執行 app.js:
lsc -r webkit-devtools-agent -er ./app.js
# 另開一個終端機頁籤,啟動分析器:
killall -USR2 node
# 在 WebKit 瀏覽器裡開啟下列網址,開始效能分析:
open http://tinyurl.com/node0-8-agent

為了重現沉重的背景負載,我們運用 ab 執行高度並行的 REST API 呼叫程式。為了模擬移動游標、更新公式等瀏覽器端的運作狀況,我們採用了同樣以 jsdom 和 Node.js 編寫的無顯示介面瀏覽器 Zombie.js

有趣的是,我們發現瓶頸正是出在 jsdom 本身:

效能分析器螢幕截圖(包括 jsdom)

從上面的報告中可以看出,RenderSheet 佔用 CPU 的時間最多:每當收到指令時,服務器都會用幾微秒的時間重新繪製單元格的 innerHTML 屬性,以反映指令的執行效果。

因為所有 jsdom 代碼都在同一個線程中運行,所以後續的 REST API 呼叫將會卡住,直到上一命令的繪製過程結束為止。在高度並行的情況下,過長的佇列觸發了潛藏的瑕疵,最終使伺服器當掉。

我們在仔細檢查了物件使用情況之後,發現繪製結果幾乎毫無用處,因為伺服器端根本毋需即時顯示 HTML 內容。唯一用到繪製結果的是「匯出 HTML」這個 API,但其實我們可以等到實際有人呼叫它時,再利用記憶體內的試算表結構,繪製出每個單元格的 innerHTML 屬性。

所以,我們移除了 RenderSheet 函式,用 20 行 LiveScript 代碼 重新實作了匯出 HTML 所需的極少數 DOM 介面,然後再運行了一次效能分析器:

經過更新的效能分析器螢幕截圖(去除 jsdom)

現在好多了!我們將流量提高了 4 倍,將 HTML 匯出速度加快了 20 倍,也順利解決了當機問題。

多核心延展

這一輪改進完成後,我們終於覺得沒有顧慮,可以將 EtherCalc 整合到 Socialtext 平台裡,為共筆頁面和試算表提供同時編輯的功能。

為了確保實際上線時的回應效率,我們部署了一個反向代理 nginx 伺服器,利用它的 limit_req 指令對 API 呼叫的速率設置上限。對於「防火牆內」和「專屬遠端伺服器」這兩種情況,執行結果確實都令人滿意。

但是,對於中小型企業客戶,Socialtext 還有第三種部署方式:「多戶共用遠端伺服器」。在一台大型伺服器裡,我們同時為超過 35000 家公司提供服務,每家公司平均約有 100 位用戶。

在這種多戶共用情形裡,所有執行 REST API 調用的客戶的請求,都會計入每秒的最大請求次數,從而使每位客戶的實際限制都嚴格得多,平均限制約為每秒請求 5 次。上一節中已經指出,這種限制的成因,是由於 Node.js 僅能使用一個 CPU 來執行所有運算操作:


事件伺服器(單處理器)


是否有辦法利用大型伺服器裡那些閒置的 CPU 呢?

對於運行在多核心機器上的其他 Node.js 服務,我們採用了預先分支的 cluster-server 模組,同時運行與 CPU 數量相同的行程:


事件叢集伺服器(多處理器)


儘管 EtherCalc 確實能同時運行在多個伺服器上(透過 Redis 作統籌),但在單一伺服器的情形下,Socket.io 叢集RedisStore 的相互作用會使程式邏輯變得非常複雜,難以偵錯。

此外,如果叢集裡的每個行程都在忙著處理 CPU 運算,新來的連線仍然會被卡住。

因此,我們決定不採用固定數量的預先分支行程,而是設法為伺服器內的每份試算表各創建一個執行緒,從而讓每顆 CPU 平均分攤所有的指令執行工作:


事件執行緒伺服器(多處理器)


W3C 定義的 Web Worker 界面,剛好符合這項需求。它原先是為了瀏覽器環境下,獨立運行的背景執行緒而設計。如此一來,長時間運行的背景任務,便不會影響主執行緒的回應速度。

因此,我寫出了 webworker-threads 這套 Node.js 模組,提供相容於 W3C 標準的跨平台介面。

利用 webworker-threads,可以輕易創建新的 SocialCalc 執行緒(每份試算表約需 30kb 記憶體),並與其進行通信:

{ Worker } = require \webworker-threads
w = new Worker \packed-SocialCalc.js
w.onmessage = (event) -> ...
w.postMessage command

這套解決方案堪稱兩全其美:在多核環境下,我們可按照實際需求,分配多顆 CPU 供 EtherCalc 使用。在單核環境下,創建線程也僅需耗用極少的資源,即可將運算移到背景執行。

開發經驗談

不像 SocialCalc 專案有精準的規格定義及團隊開發流程,EtherCalc 在 2011 年中到 2012 年底的這段時間裡,僅是筆者個人的實驗計劃,用來評估 Node.js 是否足堪正式上線使用。

這樣不受限制的自由度,讓筆者得以嘗試各式各樣的語言、函式庫、演算法及架構。在這裡,我希望能向各位分享這 18 個月來的一些開發經驗。

限制帶來解放

Fred Brooks 在《設計的設計》一書中提到「限制」的重要性:它讓設計者可以縮小搜尋空間、幫助專注並加速設計流程。這也包括了自行加諸的限制:

在一個設計任務上加諸人為的限制有個好處,就是設計者日後可以自行放寬這些限制。在理想情況下,這可以引人踏進設計空間中未曾探索過的角落,藉以激發創意。

在 EtherCalc 多次更迭的開發過程裡,自行加諸的限制,讓專案得以維持核心概念的完整。

舉例來說,乍看之下,為三種不同的運行架構(內部網路、網際網路、及多用戶託管)各自客製化一套伺服器,似乎是不錯的主意。但是,這種「過早的最佳化」,卻會嚴重干擾核心概念的一致性。

與此相反,我持續專注在如何讓 EtherCalc 在處理器、記憶體及網路同時受限時仍能運作順暢,毋需顧此失彼。事實上,由於對於記憶體的需求小於 100MB,就算是像 Raspberry Pi 這樣的嵌入式平台,都能輕鬆地運行 EtherCalc。

這樣自我要求的設計,讓將 EtherCalc 得以部署在三項資源都受限,而非只限制一項的「平台即服務」環境(例如 DotCloud、Nodejitsu 及 Heroku)下。這讓人們可以輕易地架設試算表服務,進一步促使獨立的整合開發者作出更多貢獻。

劣即是夯

在 2006 年於芝加哥舉辦的 YAPC::NA 大會上,筆者受邀對開源社群的未來發表預測,以下是我當時的發言

雖然我無法證明,但我認為明年 JavaScript 2.0 將會達成自舉、編譯回 Javascript 1,並且取代 Ruby,成為各個環境中的明日之星。

我認為 CPAN 與 JSAN 將會整併;JavaScript 會成為所有動態語言的普遍基礎。Perl 將可以編譯成 JavaScript,在瀏覽器、伺服器及資料庫中運行,並共用一套開發工具。

正因為「劣即是夯」的緣故,所以最差勁的語言,注定會成為最棒的。

我當時的看法,隨著能以機器碼速度執行的新一代 JavaScript 運算引擎出現,在 2009 年成為現實。到了 2012 年時,JavaScript 已成為「編寫一次,隨處運行」的虛擬機器;其他各式主要語言,包括 Perl,也都能被編譯成 JavaScript。

除了客戶端的瀏覽器與伺服器端的 Node.js 之外,我們也讓 JavaScript 能在 Postgres 資料庫內運行,並在這三種運行環境下共用模組

是什麼促成了社群這樣快速的成長?回到我開發 EtherCalc 的初期,參加剛具雛形的 NPM 社群的經驗,我推估這是因為 JavaScript 並不強加特定的世界觀到程式上,而是將自身融入許多不同的用途裡。因此,創新者得以專注於創造字彙與用法(例如 jQuery 與 Node.js),從同一個自由的核心出發,淬煉出自己心目中的「優良部份」。

對新加入的開發者來說,只學到語言的一小部份就可以上手開發;資深的開發者則可以挑戰既有傳統,將它修改演進成為更好的版本。相對於仰賴一群核心團隊將語言設計成適合所有預期的用途,JavaScript 的草根開發演進歷程呼應了 Richard P. Gabriel 著名的「劣即是夯」概念。

舊語新枝

相對於 Coro::AnyEvent 直接了當的 Perl 語法,Node.js 以回喚為基礎的程式介面,迫使我們寫出層層相疊、難以重複利用的內嵌函式。

在嘗試過許多輔助流程控制的程式庫之後,我們最後決定改用 LiveScript 這套嶄新的程式語言。它的語法深受 Perl 及 Haskell 影響,並且可以直接轉譯成 JavaScript。

事實上,EtherCalc 歷經四種一脈相承的語言:JavaScript、CoffeScript、Coco 與 LiveScript,每次移植都帶來更好的表達力。js2coffeejs2ls 這些自動轉譯工具,也讓程式碼得以保有向前及向後的相容性。

由於 LiveScript 直接編譯成為 JavaScript,用它寫出來的程式可以用原生速度運作,同時也完整支援以函式為範圍的效能分析器。

LiveScript 使用新穎的建構方式,像是 backcallcascade,來減少巢狀回喚。它也讓我們得以使用強大的語意工具,來自由組合函數式及物件導向的程式佈局。

我對 LiveScript 的第一印象是「像是蘊含在 Perl 6 裡的輕量語言,掙扎著想要誕生…」 — 透過專注於語法的親和力,並採用與 JavaScript 相同的語意,這個新語言想達成的目標,確實比 Perl 6 要容易多了。

自由之零

自由軟體基金會持續倡導四大類的軟體自由。其中最基本的一種,稱為「自由之零」,就是「無論為任何目的,都能執行程式的自由」。

在二十世紀,開源軟體及私有軟體都賦予使用者這種自由。我們太習慣這種自由,以致認為它理所當然,直到「雲端運算」出現為止。

將資料託管在共享的伺服器上,並不是什麼新的概念。遠端儲存服務的歷史,幾乎跟網際網路一樣悠久,而在持續進步的傳輸與加密技術防範資料遺失及竄改下,它們通常也都能順利運作。

但是到了這個世紀,遠端儲存逐漸與遠端運算及通訊掛勾。一旦我們將運算交給遠端的伺服器,便再也不可能「為任何目的執行程式」了。取而代之的情況,是服務運營者獨占了運算的內容,並擁有不受監管而能檢視、審查使用者資料的權力。

因此,在「日常倚賴的程式都應該能取得源碼」這個眾所周知的理念之外,「只將資料交給我們能信任的伺服器進行運算」也是同等重要。為了達成這個目的,我將 EtherCalc 設計成可以輕易安裝,因此它永遠都能在您自己的電腦上運作。

Socialtext 為了 SocialCalc 試算表的引擎,特別制定了通用公共授權,讓使用者可以向服務運營者要求完整的 JavaScript 源碼,來鼓勵服務運營商將他們所做的修改貢獻出來。

至於 EtherCalc 這套多人協作伺服器,筆者已將它捐入公共領域,讓它可以整合進各式內容管理系統裡。如此一來,任何人都能輕易為自己的團隊架設一套試算表協作系統。很多人已經這樣做了,也非常歡迎您的加入!

寫得很好,很有啟發,謝謝樓主。

cool ~

nice

Thanks for sharing this!

谢谢! 这个程序 会成为开源版的 EditGrid 吗?

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Something went wrong with that request. Please try again.