EtherCalc 是一款针对同时编辑进行了优化的在线电子表格系统,使用SocialCalc作为其浏览器内电子表格引擎。SocialCalc由电子表格的发明者Dan Bricklin设计,是Socialtext平台的一部分,Socialtext平台是为企业用户提供的一套社交协作工具。
对于Socialtext团队来说,性能是2006年SocialCalc开发背后的首要目标。关键的观察结果是:虽然JavaScript中的客户端计算比Perl中的服务器端计算慢一个数量级,但仍然比在AJAX往返过程中产生的网络延迟快得多。
图2.1 - WikiCalc和SocialCalc的性能模型。自2009年以来,JavaScript运行时的进步已将50毫秒减少到不到10毫秒。
SocialCalc在其浏览器中执行所有计算;它仅使用服务器加载和保存电子表格。在关于SocialCalc的开源应用程序架构章节的结尾,我们介绍了使用简单的聊天室式架构对电子表格进行同时协作。
图2.2 - 多人SocialCalc
但是,当我们开始对其进行生产部署测试时,我们发现其性能和可扩展性特性存在一些不足,这促使我们进行了一系列系统范围内的重写,以达到可接受的性能。在本章中,我们将了解我们是如何得到该架构的,如何使用性能分析工具,以及如何开发新的工具来克服性能问题。
Socialtext平台既有防火墙后部署选项,也有云端部署选项,这对EtherCalc的资源和性能要求施加了独特的约束。
在撰写本文时,Socialtext需要2个CPU核心和4 GB RAM才能进行基于VMWare vSphere的内联网部署。对于基于云的托管,典型的Amazon EC2实例提供的容量大约是其两倍,拥有4个核心和7.5 GB的RAM。
防火墙后部署意味着我们不能像多租户、仅托管系统那样简单地向问题抛硬件(例如,后来成为Google Docs一部分的DocVerse);我们只能假设服务器容量有限。
与内联网部署相比,云托管实例提供了更好的容量和按需扩展,但来自浏览器的网络连接通常较慢,并且经常断开和重新连接。
因此,以下资源约束塑造了EtherCalc的架构方向
基于事件的服务器使我们能够使用少量RAM扩展到数千个并发连接。
基于SocialCalc的原始设计,我们将大部分计算和所有内容呈现卸载到客户端JavaScript。
通过发送电子表格操作而不是电子表格内容,我们减少了带宽使用,并允许在不可靠的网络连接上恢复。
我们从一个用Perl 5实现的WebSocket服务器开始,该服务器由Feersum支持,Feersum是Socialtext开发的基于libev的非阻塞Web服务器。Feersum非常快,能够在一个CPU上处理每秒超过10,000个请求。在Feersum之上,我们使用PocketIO中间件来利用流行的Socket.io JavaScript客户端,该客户端为不支持WebSocket的旧版浏览器提供了向后兼容性。
初始原型非常类似于聊天服务器。每个协作会话都是一个聊天室;客户端将其本地执行的命令和光标移动发送到服务器,服务器将它们转发到同一房间中的所有其他客户端。
下图显示了典型的操作流程。
图2.3 - 带有快照机制的原型服务器
此解决方法解决了新客户端的CPU问题,但自身也产生了一个网络性能问题,因为它会占用每个客户端的上行带宽。在缓慢的连接上,这会延迟接收客户端后续的命令。
此外,服务器无法验证客户端提交的快照的一致性。因此,错误的或恶意的快照可能会破坏所有新加入用户的状态,使他们与现有用户不同步。
细心的读者可能会注意到,这两个问题都是由服务器无法执行电子表格命令引起的。如果服务器可以在收到每个命令时更新自己的状态,则根本不需要维护命令积压。
浏览器内的SocialCalc引擎是用JavaScript编写的。我们考虑将该逻辑转换为Perl,但这将带来维护两个代码库的高昂成本。我们还尝试了嵌入式JS引擎(V8、SpiderMonkey),但它们在Feersum的事件循环中运行时会产生自己的性能损失。
最后,到2011年8月,我们决定用Node.js重写服务器。
最初的重写进行得很顺利,因为Feersum和Node.js都基于相同的libev事件模型,并且Pocket.io的API与Socket.io非常匹配。由于ZappaJS提供的简洁API,我们只花了一个下午的时间就用短短80行代码编写了一个功能等效的服务器。
初步的微基准测试表明,移植到Node.js使我们损失了大约一半的最大吞吐量。在2011年的典型英特尔酷睿i5 CPU上,原始的Feersum堆栈处理每秒5000个请求,而Node.js上的Express最大处理每秒2800个请求。
对于我们的第一个JavaScript移植,这种性能下降被认为是可以接受的,因为它不会显着增加用户的延迟,并且我们预计它会随着时间的推移而改善。
随后,我们继续努力减少客户端CPU的使用并最大限度地减少带宽使用,方法是使用服务器端SocialCalc电子表格跟踪每个会话的正在进行的状态。
图2.4 - 使用Node.js服务器维护电子表格状态
我们工作的关键支持技术是jsdom,它是W3C文档对象模型的完整实现,它使Node.js能够在模拟的浏览器环境中加载客户端JavaScript库。
使用jsdom,创建任意数量的服务器端SocialCalc电子表格非常简单,每个电子表格大约占用30 KB的RAM,并在其自己的沙箱中运行
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 API,用于使用GET/PUT访问单个单元格,以及将命令直接发布到电子表格。在黑客马拉松期间,全新的REST处理程序每秒接收数百个调用,更新浏览器上的图形和公式单元格内容,没有任何减速或内存泄漏的迹象。
但是,在演示结束时,当我们将流量数据导入EtherCalc并开始在浏览器内电子表格中输入公式时,服务器突然锁定,冻结了所有活动连接。我们重新启动了Node.js进程,却发现它消耗了100%的CPU,并在不久后再次锁定。
我们感到困惑,回滚到较小的数据集,该数据集工作正常,并允许我们完成演示。但我想知道:是什么导致了最初的锁定?
要找出这些CPU周期去了哪里,我们需要一个分析器。
分析最初的Perl原型非常简单,这在很大程度上要归功于著名的NYTProf分析器,该分析器提供每个函数、每行、每个操作码和每个块的计时信息,并提供详细的调用图可视化和HTML报告。除了NYTProf之外,我们还使用Perl内置的DTrace支持跟踪长时间运行的进程,获取函数进入和退出时的实时统计信息。
相比之下,Node.js的性能分析工具还有很多不足之处。在撰写本文时,DTrace支持仍然仅限于32位模式下的基于illumos的系统,因此我们主要依赖于Node Webkit Agent,它提供了一个可访问的性能分析界面,尽管只提供了函数级别的统计信息。
一个典型的性能分析会话如下所示
# "lsc" is the LiveScript compiler
# Load WebKit agent, then run app.js:
lsc -r webkit-devtools-agent -er ./app.js
# In another terminal tab, launch the profiler:
killall -USR2 node
# Open this URL in a WebKit browser to start profiling:
open http://tinyurl.com/node0-8-agent
为了重现繁重的后台负载,我们使用Apache基准测试工具ab
执行高并发REST API调用。为了模拟浏览器端操作(例如移动光标和更新公式),我们使用了Zombie.js,这是一个也使用jsdom和Node.js构建的无头浏览器。
具有讽刺意味的是,瓶颈出现在jsdom本身。
图2.5 - 性能分析器截图(使用jsdom)
从图2.5中的报告中,我们可以看到RenderSheet
占用了大部分CPU使用量。每次服务器收到命令时,它都会花费几微秒来重新绘制单元格的innerHTML
以反映每个命令的效果。
因为所有jsdom代码都在单个线程中运行,所以后续的REST API调用会被阻塞,直到上一个命令的渲染完成。在高并发下,此队列最终触发了一个潜在的错误,最终导致服务器锁定。
当我们仔细检查堆使用情况时,我们发现渲染结果几乎没有被引用,因为我们实际上不需要在服务器端进行实时HTML显示。对它的唯一引用是在HTML导出API中,为此,我们始终可以从电子表格的内存中结构重建每个单元格的innerHTML
渲染。
因此,我们从RenderSheet
函数中删除了jsdom,用20行LiveScript重新实现了一个最小的DOM用于HTML导出,然后再次运行了性能分析器(参见图2.6)。
图2.6 - 更新后的性能分析器截图(不使用jsdom)
好多了!我们使吞吐量提高了4倍,HTML导出速度提高了20倍,并且锁定问题消失了。
经过这轮改进后,我们终于可以放心地将EtherCalc集成到Socialtext平台中,为Wiki页面和电子表格提供同时编辑功能。
为了确保生产环境中的公平响应时间,我们部署了一个反向代理 nginx 服务器,并使用其 limit_req
指令来限制 API 调用的速率。此技术在防火墙后和专用实例托管场景中均被证明令人满意。
但是,对于中小型企业客户,Socialtext 提供了第三种部署选项:多租户托管。一台大型服务器托管超过 35,000 家公司,每家公司平均约 100 名用户。
在这种多租户场景中,速率限制由所有进行 REST API 调用的客户共享。这使得每个客户端的有效限制更加严格——大约每秒 5 个请求。如上一节所述,此限制是由于 Node.js 仅使用一个 CPU 来执行所有计算。
图 2.7 - 事件服务器(单核)
有没有办法利用多租户服务器中所有这些空闲的 CPU 呢?
对于在多核主机上运行的其他 Node.js 服务,我们使用了预分叉的 集群服务器,该服务器为每个 CPU 创建一个进程。
图 2.8 - 事件集群服务器(多核)
但是,虽然 EtherCalc 支持使用 Redis 进行多服务器扩展,但 Socket.io 集群 与 RedisStore 在同一服务器上的交互会极大地增加逻辑复杂性,从而使调试变得更加困难。
此外,如果集群中的所有进程都绑定在 CPU 密集型处理上,后续连接仍将被阻塞。
我们没有预分叉固定数量的进程,而是寻求一种方法,为每个服务器端电子表格创建一个后台线程,从而将命令执行的工作分散到所有 CPU 内核中。
图 2.9 - 事件线程服务器(多核)
对于我们的目的,W3C 的 Web Worker API 是一个完美的匹配。它最初是为浏览器设计的,定义了一种在后台独立运行脚本的方法。这允许长时间运行的任务持续执行,同时保持主线程的响应能力。
因此,我们创建了 webworker-threads,这是 Node.js 的 Web Worker API 的跨平台实现。
使用 webworker-threads,创建新的 SocialCalc 线程并与其通信非常简单。
{ Worker } = require \webworker-threads
w = new Worker \packed-SocialCalc.js
w.onmessage = (event) -> ...
w.postMessage command
此解决方案兼具两者的优势:它使我们能够根据需要为 EtherCalc 分配更多 CPU,并且在单 CPU 环境中,后台线程创建的开销仍然可以忽略不计。
弗雷德·布鲁克斯在其著作《设计的设计》中论证,通过缩小设计者的搜索空间,约束可以帮助集中和加快设计过程。这包括自我强加的约束。
设计任务的人为约束具有一个很好的特性,即可以自由地放松它们。理想情况下,它们会将人推向设计空间的未探索角落,从而激发创造力。
在 EtherCalc 的开发过程中,此类约束对于在各种迭代中保持其概念完整性至关重要。
例如,采用三种不同的并发架构,每种架构都针对我们的一种服务器类型(防火墙后、云端和多租户托管)量身定制,这似乎很有吸引力。但是,这种过早优化将严重损害概念完整性。
相反,我一直专注于让 EtherCalc 运行良好,而不会牺牲一种资源需求来换取另一种资源需求,从而同时最大限度地减少其 CPU、RAM 和网络使用。实际上,由于 RAM 需求低于 100 MB,即使是树莓派等嵌入式平台也可以轻松托管它。
这种自我强加的约束使得能够在 PaaS 环境(例如 DotCloud、Nodejitsu 和 Heroku)中部署 EtherCalc,在这些环境中,所有三种资源都受到限制,而不仅仅是一种。这使得人们很容易设置个人电子表格服务,从而促使独立集成商做出更多贡献。
在芝加哥举行的 YAPC::NA 2006 大会上,我被邀请预测开源领域的未来,这是我的 预测
我认为,但我无法证明,明年 JavaScript 2.0 将会自我引导,完成自我托管,编译回 JavaScript 1,并在所有环境中取代 Ruby 成为下一个热门事物。
我认为 CPAN 和 JSAN 将会合并;JavaScript 将成为所有动态语言的通用后端,因此您可以编写 Perl 以在浏览器、服务器和数据库内部运行,所有这些都使用相同的开发工具集。
因为,正如我们所知,越差越好,因此最差的脚本语言注定会成为最好的。
随着以原生机器指令速度运行的新 JavaScript 引擎的出现,这一愿景在 2009 年左右变成了现实。在撰写本文时,JavaScript 已成为一个“一次编写,随处运行”的虚拟机——其他主要语言可以编译成它,几乎没有性能损失。
除了客户端的浏览器和服务器端的 Node.js 之外,JavaScript 还进入了 Postgres 数据库,并拥有大量这些运行时环境共享的免费可重用模块。
是什么促使社区如此迅速地发展?在 EtherCalc 开发过程中,通过参与初期的 NPM 社区,我意识到正是因为 JavaScript 规定很少并且适应各种用途,因此创新者可以专注于词汇和习惯用法(例如 jQuery 和 Node.js),每个团队都从一个共同的、自由的核心抽象出自己的优秀部分。
新用户首先会获得一个非常简单的子集;经验丰富的开发人员面临着从现有约定中发展出更好约定的挑战。与其依赖核心设计团队来为所有预期用途正确地构建完整的语言,不如说 JavaScript 的基层开发呼应了 Richard P. Gabriel 的著名格言越差越好。
与 Coro::AnyEvent 的简单 Perl 语法形成对比,Node.js 基于回调的 API 需要深度嵌套的函数,这些函数难以重用。
在尝试了各种流程控制库之后,我最终通过选择 LiveScript 解决了这个问题,这是一种编译成 JavaScript 的新语言,其语法深受 Haskell 和 Perl 的启发。
事实上,EtherCalc 通过四种语言的传承进行了移植:JavaScript、CoffeeScript、Coco 和 LiveScript。每一次迭代都带来了更多的表达能力,同时保持了完全的前后兼容性,这要归功于 js2coffee 和 js2ls 等工作。
由于 LiveScript 编译成 JavaScript 而不是解释自己的字节码,因此它与函数范围内的分析器完全兼容。它生成的代码性能与手工调整的 JavaScript 一样好,充分利用了现代原生运行时。
在语法方面,LiveScript 使用新颖的结构(如 回呼 和 级联)消除了嵌套回调。它为我们提供了强大的语法工具,用于函数式和面向对象的组合。
当我第一次遇到 LiveScript 时,我评论说它就像“Perl 6 中一个较小的语言,正在努力脱颖而出”——通过采用与 JavaScript 本身相同的语义并严格关注语法人体工程学,这一目标变得容易得多。
与 SocialCalc 项目明确定义的规范和团队开发流程不同,EtherCalc 是从 2011 年年中到 2012 年年底的个人实验,并作为评估 Node.js 是否已准备好用于生产环境的试验场。
这种不受约束的自由提供了一个激动人心的机会,可以探索各种替代语言、库、算法和架构。我非常感谢所有贡献者、合作者和集成商,尤其感谢 Dan Bricklin 和我的 Socialtext 同事鼓励我尝试这些技术。谢谢大家!