在前篇文章《使用 Docker 和 Node 搭建公式渲染服务(前篇)》中,我们已经使用 Nginx 和开源软件 Math-API 搭建了一个基础的公式渲染服务。虽然在测试中可以正常工作,但是存在高并发的情况下服务压力过大,会导致预期之外的事情发生。
本篇文章,我们就接着上篇文章内容,在尽可能“不编码”的情况下,继续进行性能调优工作。
写在前面
在公式服务实际的使用场景中,存在“首次生成公式图片后,内容被多次请求”,简而言之,满足“读多写少”的使用模式。
在对服务进行优化之前,我们先使用“前篇”文章的配置来启动服务,进行一些运行数据收集,作为服务优化前的参考基准。
server {
listen 80;
client_max_body_size 1k;
access_log off;
location / {
if ( $arg_source = '') {
return 404;
}
set $args source=$arg_source&input=latex&inline=0&output=svg&width=256;
proxy_pass http://math-api:3000;
}
location = /get-health {
access_log off;
default_type text/html;
return 200 'alive';
}
}
接着,请求下面的公式链接,让服务能够绘制一个简单复杂度的公式,并打开浏览器控制台,观察并记录页面响应时间。
http://localhost:3000/render?source=a=b\\E=mc^2%20+%20\int_a^a%20x\,%20dx
可以看到,首次绘制生成的请求响应接近 80ms ,随后应用创建内存缓存后,服务响应时间缩短到了 20ms ,虽然看起来数值尚可,但是在高并发的测试下,响应不是很理想。
前面提到,考虑到“绘制公式”是一次性的,而嵌入在页面中的图片则会被大量频繁访问,故先不针对绘制进行优化,而是选择对预期总请求量占比最高的“20ms”响应的缓存开刀。
在避免使用极端数值的前提下,随便抽取一次 Node 服务有缓存时的响应作为参考,稍后可以与我们优化后的结果进行对比。
使用文件缓存提升服务性能
由于语言机制、工具使用场景的差异,相比 Nginx 而言,Node 执行效率会弱一些,尤其是处理偏静态的资源,而我们动态绘制出的公式,正是静态资源范畴。
在不借助三方模块、和外部应用的前提下,仅使用 Nginx 自带的“文件缓存”功能,已经能够完成一个读多写少、支持强缓存业务的性能优化。那么,我们来调整 Nginx 配置,让 Nginx 能够缓存来自 Node 的计算结果。
proxy_cache_path /data/nginx/cache levels=1:2 keys_zone=formula:10m max_size=2g inactive=24h use_temp_path=off;
proxy_cache_key "$request_uri";
proxy_cache_valid any 24h;
server {
listen 80;
client_max_body_size 1k;
access_log on;
location / {
if ( $arg_source = '') {
return 404;
}
set $args source=$arg_source&input=latex&inline=0&output=svg&width=256;
proxy_cache formula;
proxy_buffering on;
proxy_ignore_headers Expires;
proxy_hide_header Expires;
proxy_ignore_headers X-Accel-Expires;
proxy_hide_header X-Accel-Expires;
proxy_ignore_headers Cache-Control;
proxy_hide_header Cache-Control;
proxy_ignore_headers Set-Cookie;
proxy_hide_header Pragma;
add_header X-Proxy-Cache $upstream_cache_status;
proxy_pass http://math-api:3000;
}
location = /get-health {
access_log off;
default_type text/html;
return 200 'alive';
}
}
Nginx 官方教程 对于Proxy Cache 的介绍十分简短,在我们的场景下,需要额外添加一些指令,来确保我们的请求结果一定能够被缓存,主要包含以下三个场景:
- 对于正确的结果,我们要进行缓存,避免重复进行计算。
- 当用户浏览器要求不使用 Nginx 缓存的时候,我们依旧能够使用缓存内容进行响应。
- 遭遇有意或无意构造错误请求,服务被攻击的时候,我们能够进行计算结果缓存,避免服务重复针对错误数据进行计算,浪费资源。
为了低成本持久化计算结果,可以将容器缓存写入位置挂载在本地或者其他合适的位置,在编排文件中进行类似下面的声明:
...
nginx:
image: nginx:1.19.8-alpine
restart: always
ports:
- 3000:80
volumes:
- ./default.conf:/etc/nginx/conf.d/default.conf:ro
- ./cache:/data/nginx/cache:rw
...
在完成 Nginx 缓存功能配置后,再次请求相同的公式地址若干次,并对请求结果进行观察。
可以看到缓存内容的响应时间从 20ms 缩短到了平均 5~6ms ,不严谨的说,原本处理一个请求的时间,我们可以完成 3~4 倍的服务支撑,并且因为 Nginx 在处理大并发的情况下,服务性能衰减远比 Node 低,实际性能提升非常可观(感兴趣可以深入的测试来进行验证)。
还是随便展开一个请求的详情,可以看到 TTFB 从 20ms 缩短到了 2ms。这里主要得益于语言红利以及 Nginx 针对缓存的特殊优化,感兴趣的同学可以围观 Nginx 关于 Cache 处理的源代码,探索 Nginx 在文件缓存上做了哪些工作:ngx_http_file_cache.c
。
限制不合理的高频调用
前文使用文件缓存方式,针对高频访问的计算结果进行访问优化,初步解决了计算结果的缓存性能问题。我们来继续看看如何针对计算过程进行优化。
如果有心人构造足够多的未被请求、未能调用 Nginx 缓存的公式内容,构造“缓存击穿”场景,我们的服务可能会存在因为服务器总资源有限,“结果计算不过来”而导致拒绝服务,从而影响对正常用户的内容展示。
在不优化计算相关代码(Node)之前,我们能够解决这个问题的最简单方案便是针对请求进行频率限制。
Nginx 的频率限制,主要采取漏斗算法,官方曾推出过一篇博文,对这个功能进行详细的介绍,感兴趣的同学可以自行了解。我们在这里有一个共识即可:Nginx 会在遭遇峰值压力的时候,预设一个流量桶,针对桶内的请求使用先进先出的策略提供服务,对桶外的请求进行放弃操作。(每年的购物节似曾相识的场景,笑)
为了能够让 Nginx 进行请求限速,我们基于之前的配置进行一些调整:
proxy_cache_path /data/nginx/cache levels=1:2 keys_zone=formula:10m max_size=2g inactive=24h use_temp_path=off;
proxy_cache_key "$request_uri";
proxy_cache_valid any 24h;
limit_req_zone $binary_remote_addr zone=limitbyip:10m rate=5r/s;
server {
listen 80;
client_max_body_size 1k;
access_log on;
location / {
limit_req zone=limitbyip burst=12 delay=8;
if ( $arg_source = '') {
return 404;
}
set $args source=$arg_source&input=latex&inline=0&output=svg&width=256;
proxy_cache formula;
proxy_buffering on;
proxy_ignore_headers Expires;
proxy_hide_header Expires;
proxy_ignore_headers X-Accel-Expires;
proxy_hide_header X-Accel-Expires;
proxy_ignore_headers Cache-Control;
proxy_hide_header Cache-Control;
proxy_ignore_headers Set-Cookie;
proxy_hide_header Pragma;
add_header X-Proxy-Cache $upstream_cache_status;
proxy_pass http://math-api:3000;
}
location = /get-health {
access_log off;
default_type text/html;
return 200 'alive';
}
}
在上面的配置中,我们根据访客IP进行请求速率限制,假设一个页面最多会出现 12 张公式图,我们允许用户一次性并发下载其中的 8张图,随后 4 张在下载完毕第一批图片之后再进行下载。如果这个时间里,这个用户还在尝试请求更多的图片,那么我们将降低对这个用户的服务响应能力,允许他每秒获取 5 张图片,超出这个速率的请求将被当作溢出水桶的水,而被丢弃,毕竟这个场景不是“正常人”的行为。
更新完 Nginx 配置后,重新启动服务。使用 wrk
进行 16 线程 100 并发的高频率请求,模拟有人恶意访问服务(这里是不严谨模拟,访问的是相同的地址,相对严谨的模拟,需要编写脚本,进行动态改写参数,对服务进行独立部署,先偷个懒)。
wrk -t16 -c 100 -d 10s http://localhost:3000/render\?source\=a\=b\\E\=mc\^2%20+%20\int_a\^a%20x\,%20dx
Running 10s test @ http://localhost:3000/render?source=a=b\E=mc^2%20+%20int_a^a%20x,%20dx
16 threads and 100 connections
Thread Stats Avg Stdev Max +/- Stdev
Latency 22.88ms 87.45ms 881.04ms 96.83%
Req/Sec 733.13 157.63 1.42k 66.44%
117000 requests in 10.03s, 41.72MB read
Non-2xx or 3xx responses: 117000
Requests/sec: 11662.60
Transfer/sec: 4.16MB
根据测试请求日志,可以看到 10 秒钟内,我们的服务接收到了 1 万 1 千多次请求。
在请求的过程中,同样使用浏览器对服务状态进行相对直观的访问记录。
可以看到测试过程中,“正常合理”请求之外的请求都被返回了 “503 Service Temporarily Unavailable”,而非公式图片内容。这样做从根本上减少了服务绘制计算的并发压力,而请求结束后,再次进行访问,可以看到服务又很快的会恢复到正常的响应水平。
到此为止,一个基本能用的服务就完成了。
其他
另外再提两个细节,实际生产使用,还需要配合 waf 或者 fail2ban,针对恶意的 IP 进行长时间的封禁,避免无意义的服务响应。
以及如果你是在容器内使用,需要将默认的 binary_remote_addr
IP 字段替换为 SLB、CDN 或者其他可信来源传递的 IP 标识请求头,真正做到“无视非合理请求”,将资源留给正常用户。
最后
关于公式渲染的前两篇内容,就先写到这里。
在这两篇内容中,我们尽可能使用配置来完成功能,但是仅仅是配置不足以完成极致的性能调整,下一篇内容中,我们将稍微调整应用代码、以及软件架构,来对服务性能进行进一步提升。
–EOF