随着 Web 页面的功能不断堆砌(其实我是极简主义者,但PM或者说是国内的风气,总是喜欢不断加入各类功能),页面需要的数据越来越多,串行连接后端的耗时自然是不断增大。常见的优化手段就是后端并行化,而对于前端来说,后端并行化只是降低了 response time,但用户最终看到页面的时间并没有减少。
Facebook 提出了 BigPipe 的方案,将页面功能分块,分成若干个 pagelet。Pagelet 的加载使用了 http 的 chunked 特性,采用类似 Pipeline 的方式进行前后端数据传输。浏览器端会首先获得一个框架层的 HTML/css,以及基础 JavaScript 代码。同时,后端也可以进行并行化,每个 pagelet 完成后,通过 flush 输出到浏览器。浏览器端的 JavaScript 基于事件机制,收到数据后进行渲染。
这样,前后端就可以都做到并行化,用户可以先看到部分页面内容,从而获得了更好的用户体验。目前,国外主要是 Facebook 应用了这项技术,而国内微博也通过 BigPipe 获得了不错的效果。
图:Facebook 加载时的 timing,可以看到 waiting 时间(也就是后端响应时间 response time)明显低于 content download 耗时。
echo 'hello';
flush();
ob_flush();
sleep(1);
echo 'world';
这是一个最简单的 BigPipe demo,然而由于 fastcgi_buffer
的存在,并不能看到分段输出的效果。那么,我们把程序进行一下改动,用 str_pad
填充一些字符以达到 buffer。ps:吐槽一下 str_pad
这个函数名,明明 str 系列函数都是不带下划线的,如 strlen
, strcpy
等,但这个函数却有下划线。str 系列函数表示:我们之间出现了叛徒!
echo str_pad('hello', 10000, ' ');
flush();
ob_flush();
sleep(1);
echo str_pad('world', 10000, ' ');
进行字符填充后,BigPipe 效果显现了出来,hello 之后过1秒后会才会出现 world。由此可见,buffer 这块是个问题,后面会单独具体介绍科学优雅的解决方法。
BigPipe 的整体方案是需要具体实现环节分为如下几部分:
由于贴吧现有框架本身并不支持纯异步调用,只支持阻塞并行的远程调用(ral_multi),其响应时间为:
框架本身需要升级,在升级完成前需要基于现有架构模拟纯异步,并且要在框架支持纯异步后,平滑对 DataProvider 透明地迁移成纯异步模式。
因此,BigPipe 并行框架采用异步-回调模式,通过状态机模拟异步过程。状态机会以深度优先遍历 DataProvider 以及其依赖的 DataProvider,并初始化成 INITIAL
状态。没有依赖的 DataProvider 会直接执行,进入 EXECUTING
状态。当一个有依赖的 DataProvider 的依赖已经全部处于 READY
状态时,则会同一般的 DataProvider 一样 execute
执行。execute
函数中会有数据交互和业务逻辑处理,当处理完毕后需要主动调用 ready
函数将 DataProvider 自身置为 READY
。
Pagelet 依赖的 DataProvider 都 READY
后,就会渲染页面。
由于“各路” buffer 的存在,如果包比较小的话 BigPipe 的 chunked 输出很可能会被 buffer 住。针对这种情况,一般来说有两种方式。
str_pad
这类函数进行填充,如:填充空格。永远将一次 flush 的数据填充到 buffer_size
。buffer_size
。对于 Nginx 来说,会有 proxy_buffer
和 fastcgi_buffer
。第一种方式,不用调整 buffer,但这种方式很不优雅,而且增加了带宽,并不是很合理。至于调小 buffer,这看起来是一个很好的思路,然而对于 gzip 过的数据来说,最小的 buffer 可能也比较大。因此,我们选择了关闭 proxy_buffer
和 fastcgi_buffer
。
然而,这样带来了一个问题。线上运行中的 Nginx 1.4.4版本过低,关闭 proxy_buffer
的指令 proxy_buffering off
原生就支持。而关闭 fastcgi_buffer
的 fastcgi_buffering
需要1.5.6版本。所以首先,我们把 Nginx 版本升级到了1.7.8,重编译后上线。
Syntax: fastcgi_buffering on | off;
Default: fastcgi_buffering on;
Context: http, server, location
This directive appeared in version 1.5.6.
详见 http://nginx.org/en/docs/http/ngx_http_fastcgi_module.html#fastcgi_buffering
# 旧版本的Nginx并不支持fastcgi_buffering
nginx: [emerg] unknown directive "fastcgi_buffering"
还有一个问题,就是我们并不想对所有请求都关闭 buffer。为了将影响面做得最小,我们只想关闭特定模块的 buffer。改 nginx.conf
固然可以实现,不过对于不支持嵌套 if
的 nginx.conf
来说这是个很不舒服的用法。
幸好,在升级的过程中,发现了一个刚好可以用 http header,用于关闭 buffer。
Buffering can also be enabled or disabled by passing “yes” or “no” in the “X-Accel-Buffering” response header field. This capability can be disabled using the fastcgi_ignore_headers directive.
因此,配置上完全不用关闭 buffer,只需要在 php 代码中加 header 就好,顺利把 buffer 优雅关闭。
header('X-Accel-Buffering: no');