NGINX 支持 HTTP/2 server push 了
2018 3 7 06:07 PM 1715次查看
与传统的 HTTP/1.x 不同的是,HTTP/2 的服务端可以在建立连接后主动向客户端推送数据,而无需等待客户端发起请求。因此当页面中有很多外部资源时,HTTP/1.x 需要分别发起多个请求,并且还有并发请求数的限制;而 HTTP/2 则可以用同一个 HTTP 连接直接推送这些资源,也就节省了 HTTP 连接数和半个 RTT 的响应时间。
要启用 NGINX 的 HTTP/2 server push,首先得去安装当前最新的 NGINX 主线版(稳定版还没更新),并且编译时带上
--with-http_v2_module
参数,以启用 ngx_http_v2_module 模块。然后在 NGINX 配置的
server
段的 listen
指令中启用 ssl
和 http2
,因为主流的浏览器都只支持 HTTP/2 over TLS (h2),而不支持 HTTP/2 over TCP (h2c),因此 HTTPS 是必须的,相关的配置之前我已介绍过。之后就是关键的 server push 配置了,实现的方法有两种:
- 配置 http2_push 指令。
这种适合通用的资源,例如:
这样,所有location ^~ /pages/ { http2_push /style.css; }
/pages/
路径下的页面都会被推送/style.css
。如果这些页面都需要这个资源,那就可以接受;但如果每个页面需要的资源不一样,就得写很多条指令,维护起来就很麻烦了。 - 配置 http2_push_preload 指令。
这种适合动态页面,由后端负责通知 NGINX 需要推送什么资源,例如:
后端服务器只要在响应中带上location / { http2_push_preload on; }
Link
头即可:
如果有多个资源的话,可以输出多个Link: </style.css>; as=style; rel=preload
Link
头,也可以用,
来分隔各个资源:Link: </style.css>; as=style; rel=preload, </script.js>; as=script; rel=preload, </image.jpg>; as=image; rel=preload
下面以我的博客 Doodle 为例,让所有动态网页都自动将包含的静态资源输出到
Link
头。先修改下 NGINX 配置,让后端知道自己处理的是 HTTP/2 的请求:
location / {
proxy_set_header X-Server-Protocol $server_protocol;
http2_push_preload on;
}
其中,$server_protocol
变量的值可能为 HTTP/1.0
、HTTP/1.1
或 HTTP/2.0
。另外,ngx_http_v2_module 模块还提供了
$http2
变量,值可能为 h2
或 h2c
。接下来处理动态请求。因为我所有的动态页面都用
BaseHandler
来处理,因此只要修改它的 render
方法即可:class BaseHandler(RequestHandler):
_CSS_PATTERN = re.compile(r'<link rel="stylesheet" href="(/[\w\-./]+\.css)">') # 只查找以 "/" 开头的资源,因为 Link 头里的资源不能使用相对路径
_JS_PATTERN = re.compile(r'<script src="(/[\w\-./]+\.js)"></script>')
def render(self, template_name, context=None, globals=None, layout=False):
# ...
output = engine.render(template_name, context, globals, layout)
self.push_resources(output)
self.finish(output)
def push_resources(self, output):
if self.request.headers.get('X-Server-Protocol') != 'HTTP/2.0': # 如果不是 HTTP/2 请求,不需要推送
return
css_list = self._CSS_PATTERN.findall(output)
js_list = self._JS_PATTERN.findall(output)
if css_list or js_list: # 如果没找到静态资源,不需要推送
self.set_header('Link', ', '.join(['<%s>; as=style; rel=preload' % css for css in css_list] + ['<%s>; as=script; rel=preload' % js for js in js_list]))
这里我做了些简化的处理,并没有解析 HTML 去找出所有的样式和脚本,而是简单地用正则表达式来查找。毕竟即使多推送了一些资源,也只是浪费带宽而已,并不会造成什么严重的后果。现在,推送功能已经能正常使用了,但是仔细一想就会发现问题——后续的访问会推送已经缓存过的资源,造成带宽的浪费。虽然客户端在收到推送的帧时,发现资源已经被缓存过了,可以发送 RST_STREAM 帧来拒绝掉这个资源的推送,但是第一帧和服务器收到 RST_STREAM 帧之前已发送的帧还是浪费掉了。
H2O 服务器提供了一个叫作 cache-aware server push 的解决方案,原理就是将所有缓存过的资源都记录在 cookie 里,这样服务器就知道哪些资源不需要被推送了。
不过,在 cookie 里记录所有的资源路径会占用很多的空间,因此还需要将路径压缩一下。
一种方法是使用 bloom filter 技术。这个 bloom filter 的用法有点像集合,可以把元素加到一个 bloom filter 中,并可判断某个元素是否在 bloom filter 内。它的实现需要用到一个位数组和几个 hash 函数:添加元素时,将元素的每个 hash 值都填入位数组中;判断元素是否在内时,则计算元素的每个 hash 值,看所对应的位是否都是 1。正因如此,它只能添加,而不能删除元素,因为将位设为 0 时,可能影响其他元素的 hash 值。
很显然,这种技术可能会导致误判,将不在内的元素判定在内,但这也仅会导致少推送一些资源而已,客户端仍然可以主动请求这些资源,因此影响并不大。而如果要降低误判率,增加位数组的长度和 hash 函数的数目都有所帮助,但是在位数组不够长时,过多的 hash 函数数目会导致大部分的位都被填 1,也会增加误判率。
关于误判率的计算,维基百科里也有介绍。如果想让误判率低于 1‰,且尽可能少占用空间,可以选择 5 倍于元素个数的位数组长度和 3 个 hash 函数。
另一种方法是使用 Golomb-compressed sets,它的用法和 bloom filter 差不多,速度稍慢,但是能减少 20% ~ 30% 的空间占用。它的实现是计算出所有元素的 hash 值(值的范围分布在 0 和元素总数 / 误判率之间),形成一个数组,排序之后用 Golomb encoding 算法来压缩它。
很显然,bloom filter 判定元素是否在内的时间复杂度是 O(1) 的(计算多个 hash 值),而 Golomb-compressed sets 则是 O(logn) 的(二分查找)。
H2O 采用了后者,而我则决定使用前者,因为 Python 有前者的第三方库,这里以 pybloof 为例:
class BaseHandler(RequestHandler):
def push_resources(self, output):
if self.request.headers.get('X-Server-Protocol') != 'HTTP/2.0': # 如果不是 HTTP/2 请求,不需要推送
return
css_list = self._CSS_PATTERN.findall(output)
js_list = self._JS_PATTERN.findall(output)
if css_list or js_list:
filter = None
filter_was_empty = True
resources_cookie = self.get_cookie('resources')
if resources_cookie:
try:
filter = pybloof.StringBloomFilter.from_base64(resources_cookie)
except Exception:
pass
else:
filter_was_empty = False
if not filter:
filter = pybloof.StringBloomFilter(size=120, hashes=3) # 全站主要就 20 多个资源,大概 5 倍即可
if filter_was_empty:
new_css_list = css_list
new_js_list = js_list
else:
new_css_list = [css for css in css_list if css not in filter]
new_js_list = [js for js in js_list if js not in filter]
if not (new_css_list or new_js_list):
return
for css in new_css_list:
filter.add(css)
for js in new_js_list:
filter.add(js)
self.set_cookie('resources', filter.to_base64())
self.set_header('Link', ', '.join(['<%s>; as=style; rel=preload' % css for css in new_css_list] + ['<%s>; as=script; rel=preload' % js for js in new_js_list]))
这里的 cookie 并没有设置过期时间,而是会话级别的。原因是我对不同的静态资源设置的过期时间不一样,没法设定一个合理的值。好在这种影响也不大,可以忽略。最后就可以打开浏览器来测试了。对于推送的资源,Chrome 会在 Network 标签的 Initiator 栏显示为 「Push / Other」,Waterfall 栏中也会显示 Receiving Push 和 Reading Push 的时间。
至于实际效果,在未启用 server push 时,我的博客页面加载时间大概 1.3 秒,启用之后降为约 0.25 秒,就是这么牛逼。
0条评论 你不来一发么↓