在前篇文章《使用 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