前面两篇内容,我们聊过了如何在尽可能不写代码的情况下,完成一个可用的公式渲染接口,本篇我们深入的聊一下如何调整设计,让服务更可靠,性能更好。

写在前面

前两篇文章,我们主要以调整架构,调整配置来完成性能调优,本篇为了从根本解决问题,我们会从代码依赖入手,结合动静态分析,找到程序浪费性能的地方,从而进行调优。

静态分析:梳理公式渲染背后的原理

为了让服务整体更可靠,我们首先需要梳理清晰整体业务逻辑,避免出现“黑盒”。

前两篇文章中,公式渲染服务整体处理流程为:

公式渲染服务整体处理流程

从图上可以清晰的看到,在穿越层层缓存后,我们终将调用 Node 进行公式计算,为了让服务可靠性更高,我们来对公式渲染计算进行刨析。

梳理 Math-API 公式渲染计算背后的调用链

我们将 Math-API 项目 下载至本地,通过对项目文件进行浏览,可以得出以下结论:

  • 项目使用 Express v4 提供 Web 接口服务
  • 项目依赖 mathjax-node v2 进行公示内容绘制,输出 SVG 格式图片
  • 项目使用 svg2png 项目将 SVG 转换为 PNG 图片
  • 项目运行在 Node v8.10.0 环境下,使用了 async/await 语法来处理异步任务
  • 项目核心逻辑在 src/render/index.js,主要针对请求参数进行封装,调用依赖进行公式处理,项目本身并未设计任何缓存,缓存功能可能在其依赖中实现。

目前为止,如果在不调整架构的前提下,我们可以得到第一个可能有帮助的优化点:对项目使用着的 Node 8 版本的 Runtime 进行升级替换,尝试提升整体代码运行效率,毕竟早在 2020 年 Node 8 的维护支持就已经结束了,而在早些时候官方博客的文章中,有提到新版本对于各种语法执行效率的提升。其他的改进方案,我们需要进一步分析依赖和进行调试才能得到。

进一步分析依赖

Express 与核心功能无关,作为 HTTP 模块封装,仅提供 Web 服务,可以先忽略。

svg2png 项目使用 PhantomJS 进行图片转换,之所以使用古老的 “PhantomJS”,是为了解决“指定尺寸渲染公式图片”的需求,作者在项目介绍中提到,使用 Webkit 环境渲染图片会比使用 GraphicsMagick 或者 Inkscape 渲染的结果更精致。针对SVG图片进行按比例放大的核心实现逻辑在 lib/converter.js文件中。

看到这里,我们可以再记录一个可能有效优化点:或许可以尝试使用 puppeteer / browserless 来针对渲染 PNG 图片的请求进行优化,以提升整体性能。

核心公式渲染逻辑,出自 mathjax-node 模块,这个模块决定了服务整体水平的下限,我们继续来分析这个模块。

分析 Mathjax-Node 和 Mathjax

Mathjax-Node 项目出自 MathJax 官方团队,立项于七年前,起初目的是为了创建一个支持从 Node 进行 API 调用的计算库,能够将公式输出为几种不同的结果:带有样式的HTML、MathML 代码、以及 SVG 图片。

这个项目对于依赖管理非常严格,自己实现了所有必须的依赖,并进行了模块封装,所以在我们依赖的 2.x 版本项目的 package.json 中,你是找不到任何三方依赖的存在的,即使在 3.0 版本,也仅存在一个开发依赖,也就是说,此刻,我们可以不必继续寻找项目的依赖调用了。

项目主要依赖 jsdom 项目,针对在 Node 环境进行 “DOM Patch” 来运行设计在浏览器端执行的 MathJax 2.x 版本,处理了各种不同参数的转换,并继续调用依赖进行计算处理。主要文件只有两个,做的事情类似前文提到的 Math-API 所做的事情在某种程度上来看差不多

那么寻着线索,继续浏览 MathJax 项目,看到官方早在两年前便推出了组件化,适合运行在 Node 环境中的 3.0 版本。截止我写在这篇文章之际,最新的版本是 2020年9月推出的 3.1.2

那么到此为止,我们可以再记录一条应该有效和一条可能有效的优化点:试着简化公式渲染服务的调用依赖,直接让 Mathjax-Node 甚至是 MathJax@3 提供服务,扔掉我们可能不再需要的 Math-API 项目;试着对整个项目的调用进行 “Tree Shake” 处理,抖掉完全无用或者说,是冗余的代码。因为这个操作存在破坏性,在没有测试保障的前提下,可能会有副作用,所以我们暂且定义为“可能有效”。

动态分析:针对运行中的程序进行 profiling

因为这个服务使用的是 Node.js,所以我们需要对其进行分析。Node 团队曾写过一篇简单的 profiling 教程,感兴趣的同学可以移步阅读。

这里除了使用官方提到的 --prof 系列参数进行调试分析外,我们还可以使用 --inspect 和 Chrome 浏览器 Web 开发工具进行程序运行过程中的多次采样。

在进行动态分析之前,我们还需要做一个准备工作,就是完成在《使用 Docker 和 Node 搭建公式渲染服务(中篇)》的“限制不合理的高频调用”小节中提到的“模拟严谨的测试”所需要的相关脚本。

使用 WRK 和 Lua 脚本模拟真实场景

在真实场景中,我们除了会遭遇“中篇”中提到的大量重复请求外,还会遇到来自不同IP的大量的“随机请求”。

使用 wrk 和 lua 可以轻松模拟各种随机请求,让我们的服务承受更真实的请求压力,为了行为简单,我们先使用一个比较老的 lua 脚本作为演示。

-- https://github.com/RosarioGrosso/wrk-lua-random-requests/blob/master/add_random_alpha.lua

function getAlphaChar()
    selection = math.random(1, 3)
    if selection == 1 then return string.char(math.random(65, 90)) end
    if selection == 2 then return string.char(math.random(97, 122)) end
    return string.char(math.random(48, 57))
end


function getRandomString(length)
           length = length or 1
                if length < 1 then return nil end
                local array = {}
                for i = 1, length do
                    array[i] = getAlphaChar()
                end
                return table.concat(array)
end

function removeTrailingSlash(s)
  return (s:gsub("(.-)/*$", "%1"))
end


-- add a random string to the original request path.
request = function()
    local path = wrk.path .. getRandomString(20)
    return wrk.format(wrk.method, path, wrk.headers, wrk.body)
end

将内容保存为 add_random_alpha.lua ,然后对我们之前的压力测试命令进行简单参数调整,就可以模拟“缓存击穿”后的真实压力啦:

# 原始命令
wrk -t16 -c 100 -d 10s http://localhost:3000/render\?output\=svg\&source\=E\=mc\^2

# 添加自定义脚本
wrk -t16 -c 100 -d 10s -s add_random_alpha.lua http://localhost:3000/render\?output\=svg\&source\=E\=mc\^2

此外,因为我们直接针对 Node 服务进行测试,所以还需要在请求上添加我们之前“固化”在 Nginx 中的参数,将 Node 服务需要的请求参数补全,以实现正确的请求,类似下面这样:

wrk -t16 -c 100 -d 10s -s add_random_alpha.lua http://localhost:3000/render\?input\=latex\&inline\=0\&output\=svg\&width\=256\&source\=E\=mc\^2

使用 Node inspect 参数进行动态分析

为了让整体测试更可靠,尤其是针对运行起来的程序有一个直观的资源使用情况了解,我们先不对 Node 版本进行替换,使用项目官方使用的 Node 8.10.0 版本进行第一轮分析。

这里为了方便,使用 nvm 搭配淘宝源来切换 Node 老版本。

nvm install 8.10

Downloading and installing node v8.10.0...
Downloading https://npm.taobao.org/mirrors/node/v8.10.0/node-v8.10.0-darwin-x64.tar.xz...
############################################################################################################################################################################################################# 100.0%
Computing checksum with shasum -a 256
Checksums matched!
Now using node v8.10.0 (npm v5.6.0)

安装完毕后,声明使用该版本,并全局安装 CNPM 来减少安装 PhantomJS 时的时间浪费。

# nvm use 8.10
# npm install -g cnpm --registry=https://registry.npm.taobao.org

+ cnpm@6.1.1
added 700 packages in 16.068s

# cnpm install

✔ Installed 7 packages
✔ Linked 284 latest versions
[1/1] scripts.install svg2png@4.1.1 › phantomjs-prebuilt@^2.1.14 run "node install.js", root: "/Users/soulteary/math-api/node_modules/_phantomjs-prebuilt@2.1.16@phantomjs-prebuilt"
PhantomJS not found on PATH
Download already available at /var/folders/2d/m7gwmhr54c779jggxf_98d100000gn/T/phantomjs/phantomjs-2.1.1-macosx.zip
Verified checksum of previously downloaded file
Extracting zip contents
Removing /Users/soulteary/math-api/node_modules/_phantomjs-prebuilt@2.1.16@phantomjs-prebuilt/lib/phantom
Copying extracted folder /var/folders/2d/m7gwmhr54c779jggxf_98d100000gn/T/phantomjs/phantomjs-2.1.1-macosx.zip-extract-1618467446058/phantomjs-2.1.1-macosx -> /Users/soulteary/math-api/node_modules/_phantomjs-prebuilt@2.1.16@phantomjs-prebuilt/lib/phantom
Writing location.js file
Done. Phantomjs binary available at /Users/soulteary/math-api/node_modules/_phantomjs-prebuilt@2.1.16@phantomjs-prebuilt/lib/phantom/bin/phantomjs
[1/1] scripts.install svg2png@4.1.1 › phantomjs-prebuilt@^2.1.14 finished in 812ms
✔ Run 1 scripts
deprecate mocha@6.2.3 › debug@3.2.6 Debug versions >=3.2.0 <3.2.7 || >=4 <4.3.1 have a low-severity ReDos regression when used in a Node.js environment. It is recommended you upgrade to 3.2.7 or 4.3.1. (https://github.com/visionmedia/debug/issues/797)
deprecate svg2png@4.1.1 › phantomjs-prebuilt@^2.1.14 this package is now deprecated
deprecate svg2png@4.1.1 › phantomjs-prebuilt@2.1.16 › request@^2.81.0 request has been deprecated, see https://github.com/request/request/issues/3142
deprecate mocha@6.2.3 › mkdirp@0.5.4 Legacy versions of mkdirp are no longer supported. Please update to mkdirp 1.x. (Note that the API surface has changed to use Promises in 1.x.)
deprecate svg2png@4.1.1 › phantomjs-prebuilt@2.1.16 › request@2.88.2 › har-validator@~5.1.3 this library is no longer supported
deprecate mathjax-node@2.1.1 › jsdom@11.12.0 › request-promise-native@^1.0.5 request-promise-native has been deprecated because it extends the now deprecated request package, see https://github.com/request/request/issues/3142
deprecate mathjax-node@2.1.1 › jsdom@11.12.0 › left-pad@^1.3.0 use String.prototype.padStart()
✔ All packages installed (325 packages installed from npm registry, used 4s(network 3s), speed 192.41kB/s, json 291(615.14kB), tarball 0B)

先忽略项目中一堆过期依赖的警告,继续携带调试参数启动项目。

# NODE_ENV=production node --inspect bin/server.js

Debugger listening on ws://127.0.0.1:9229/f1cc58ad-e5ec-4077-9f09-6c1a6ea524f2
For help see https://nodejs.org/en/docs/inspector
Server running at http://localhost:3000/

项目启动后,我们打开 Chrome 浏览器的 Web DevTools,会看到原本用于切换移动端和桌面端设备模拟的按钮旁边,多出了一个 “Node DevTools”。

通过 Chrome 调试工具打开 Node DevTools

点击按钮,呼出 Node DevTools 窗口,选择“Profiler”选项卡,继续点击 Start ,开启对应用的运行性能信息采集。

开启程序性能分析

使用 wrk 搭配上面小节中的 lua 脚本,针对服务进行 SVG 格式的公式图片生成压力测试。

# wrk -t16 -c 100 -d 10s -s add_random_alpha.lua http://localhost:3000/render\?input\=latex\&inline\=0\&output\=svg\&width\=256\&source\=E\=mc\^2

Running 10s test @ http://localhost:3000/render?input=latex&inline=0&output=svg&width=256&source=E=mc^2
  16 threads and 100 connections
  Thread Stats   Avg      Stdev     Max   +/- Stdev
    Latency   615.66ms  102.27ms   1.10s    91.45%
    Req/Sec    15.34     14.58    50.00     81.25%
  1520 requests in 10.08s, 21.69MB read
Requests/sec:    150.77
Transfer/sec:      2.15MB

可以看到在 10 秒中内进行了 1500 多次有效请求,中止 DevTool 的 Profile 采集,查看调用具体情况。

分析生成 SVG 图片过程中的调用

Profiler 工具默认会使用“单个调用最长时间”视角,来呈现结果。那么就先来看哪些函数计算过程比较慢吧,因为我们使用 Chrome 辅助调试,所以需要忽略掉(program),关于这个“program”如果你想了解更多,可以浏览 Webkit 的Bugzilla 中的讨论

从上图的“排行榜”可以看出,位列三甲的是“垃圾回收”、xml-name-validator 模块中针对 XML 的解析处理、以及 MathJax 对于 SVG 元素处理,随后是伴随 jsdom 里的一些计算操作,超过整体 2% 占比的只有 MathJax 对于公式的具体渲染计算,其他每一个调用占比都不算特别高,总体来说还比较合理,但是整体调用数量看起来真的不少。

生成 SVG 图片过程中总耗时视角

接着,点击“Total Time”,将结果切换到总耗时排序视角,观察占比最高的调用。可以看到前七项都是由 Express “承包”的,随后是 MathJax 针对绘制的相关计算,继续往下观察,可以看到 Math- API 的“包装”占用了至少 24s 的计算时间,紧随其后的列表中夹杂了大量 Express 其他的逻辑处理和 MathJax 的绘制计算。

我们先不进行任何程序调整,对 wrk 测试链接中的请求参数进行调整,并试着采集程序输出 PNG 图片时的资源使用状况。

重新启动程序,以上文相同的方式进行测试。

# wrk -t16 -c 100 -d 10s -s add_random_alpha.lua http://localhost:3000/render\?input\=latex\&inline\=0\&output\=png\&width\=256\&source\=E\=mc\^2

Running 10s test @ http://localhost:3000/render?input=latex&inline=0&output=png&width=256&source=E=mc^2
  16 threads and 100 connections
  Thread Stats   Avg      Stdev     Max   +/- Stdev
    Latency     0.00us    0.00us   0.00us     nan%
    Req/Sec     0.00      0.00     0.00       nan%
  0 requests in 10.10s, 0.00B read
Requests/sec:      0.00
Transfer/sec:       0.00B

可以看到,面对“大量”请求,10s 内服务没有返回任何一个有效返回。虽然没有任何有效结果,但在测试过程中,观察“资源管理器”可以看到计算机启动了大量 PhantomJS 进程,整体感受到非常卡顿。

针对 PNG 图片生成进行分析

结合之前的静态分析,以及观察上面的函数调用情况,不难得出一个结论:程序使用 spawn 方式调用 PhantomJS 这个方式非常消耗资源,且不能保障服务质量,应该考虑替换方案,或者废弃这个功能。

使用 Node prof 参数摸清调用细节

使用 Chrome DevTools 辅助调试下,我们得到了程序运行状况的概览。为了更精准的验证优化效果,需要使用另外一个 Node 调试参数 prof 来获取精准的数值指标。

这里老版本的 Node 在做日志分析的时候,调用新版本 Mac OS 自带的 Apple LLVM 会出现类似“Code move event for unknown code”的错误,为了不浪费时间,这里还是升级 Node 到最新版本,继续进行分析测试。

# rm -rf node_modules

# nvm install 15.14.0 

Computing checksum with shasum -a 256
Checksums matched!
Now using node v15.14.0 (npm v7.7.6)

# nvm use 15.14.0

Now using node v15.14.0 (npm v7.7.6)

再次安装 CNPM ,并完成项目依赖安装,欣慰的是,由于升级了 Node 版本,安装过程中的依赖废弃警告相比之前会少一些。

npm install -g cnpm --registry=https://registry.npm.taobao.org && cnpm install

然后携带 prof 参数,再次启动程序,等待测试脚本模拟请求:

NODE_ENV=production node --prof bin/server.js

继续使用上面小节中的测试脚本,再次针对服务进行高强度的请求。

wrk -t16 -c 100 -d 10s -s add_random_alpha.lua http://localhost:3000/render\?input\=latex\&inline\=0\&output\=svg\&width\=256\&source\=E\=mc\^2

Running 10s test @ http://localhost:3000/render?input=latex&inline=0&output=svg&width=256&source=E=mc^2
  16 threads and 100 connections
  Thread Stats   Avg      Stdev     Max   +/- Stdev
    Latency   442.04ms   76.83ms 903.78ms   87.78%
    Req/Sec    16.79     12.34    50.00     73.74%
  2127 requests in 10.05s, 30.12MB read
Requests/sec:    211.66
Transfer/sec:      3.00MB

可以看到,在一行代码都不调整的情况下,仅仅通过更新 Node 版本,即带来了 500 QPS 的提升,印证了上文“梳理 Math-API 公式渲染计算背后的调用链”时,升级 Node 版本可以带来性能提升的猜测。

关闭程序,我们会看到程序运行目录中,会生成了一个名称类似 “isolate-0x110008000-94960-v8.log” 的日志文件,使用 prof-process 参数继续对这个日志文件进行分析。

node --prof-process isolate-0x110008000-94960-v8.log

浏览分析报告中的“概览”部分,可以看到,在新版本的 Node 中,程序相对比较高效,纯粹的 JavaScript 代码调用占比只有 17.9% ,更多的计算过程由 C++ 承担。

 [Summary]:
   ticks  total  nonlib   name
   1634   17.2%   17.9%  JavaScript
   7448   78.4%   81.7%  C++
    557    5.9%    6.1%  GC
    383    4.0%          Shared libraries
     29    0.3%          Unaccounted

继续浏览分析报告中的 JavaScript 调用细节,可以看到 xml-name-validatorjsdom 两个模块,占据了 JavaScript 调用中多数的计算时间,而通过分析 Npm 包依赖,会发现前者是 jsdom 的调用依赖之一。

如果对整体资源消耗进行求和,会发现 jsdom 占比差不多要到 10%,并且调用前两名的比例都超过了 1%,相比较其他的调用,可以说执行效率差了不少。

 [JavaScript]:
   ticks  total  nonlib   name
    155    1.6%    1.7%  LazyCompile: *module.exports._rules.NameStartChar /Users/soulteary/math-api/node_modules/_xml-name-validator@3.0.0@xml-name-validator/lib/generated-parser.js:46:32
    116    1.2%    1.3%  LazyCompile: *module.exports._rules.NCNameStartChar /Users/soulteary/math-api/node_modules/_xml-name-validator@3.0.0@xml-name-validator/lib/generated-parser.js:259:34
     69    0.7%    0.8%  LazyCompile: *_modified /Users/soulteary/math-api/node_modules/_jsdom@11.12.0@jsdom/lib/jsdom/living/nodes/Node-impl.js:227:12
     60    0.6%    0.7%  LazyCompile: *module.exports._rules.Name /Users/soulteary/math-api/node_modules/_xml-name-validator@3.0.0@xml-name-validator/lib/generated-parser.js:213:23
     47    0.5%    0.5%  LazyCompile: *module.exports._rules.NCName /Users/soulteary/math-api/node_modules/_xml-name-validator@3.0.0@xml-name-validator/lib/generated-parser.js:423:25
     46    0.5%    0.5%  LazyCompile: *get /Users/soulteary/math-api/node_modules/_jsdom@11.12.0@jsdom/lib/jsdom/living/generated/NamedNodeMap.js:289:10
     46    0.5%    0.5%  LazyCompile: *Add file:///Users/soulteary/math-api/node_modules/_mathjax@2.7.9@mathjax/unpacked/jax/output/SVG/jax.js?V=2.7.9:924:19
     42    0.4%    0.5%  LazyCompile: *setAttribute /Users/soulteary/math-api/node_modules/_jsdom@11.12.0@jsdom/lib/jsdom/living/nodes/Element-impl.js:249:15
     41    0.4%    0.5%  LazyCompile: *insertBefore /Users/soulteary/math-api/node_modules/_jsdom@11.12.0@jsdom/lib/jsdom/living/nodes/Node-impl.js:164:15
     35    0.4%    0.4%  LazyCompile: *HandleVariant file:///Users/soulteary/math-api/node_modules/_mathjax@2.7.9@mathjax/unpacked/jax/output/SVG/jax.js?V=2.7.9:637:29
...

有了直观的了解后,我们有了充足理由和方向对应用进行功能调整,以及进行依赖简化。

重新设计 Node 渲染程序

前文中提到在进行 PNG 格式功能渲染的情况下,服务响应能力非常堪忧,这部分转换,我们可以先在项目中剥离,后续使用更高效的实现来替换。此外,为了简化依赖,我们可以使用官方新版的 mathjax 模块,替换之前项目中依赖的 jsdom 和 mathjax-node 。

先来看一下优化结果,使用上一小节相同的参数启动服务,以及相同的参数进行服务压力测试,我们能够得到相比最初有 4倍以上性能提升的结果,换一个描述方式,我们可以“使用相同的资源服务4倍以上的用户请求”。

wrk -t16 -c 100 -d 10s -s add_random_alpha.lua http://localhost:3000/render\?input\=latex\&inline\=0\&output\=svg\&width\=256\&source\=E\=mc\^2

Running 10s test @ http://localhost:3000/render?input=latex&inline=0&output=svg&width=256&source=E=mc^2
  16 threads and 100 connections
  Thread Stats   Avg      Stdev     Max   +/- Stdev
    Latency   136.85ms   35.09ms 486.49ms   93.25%
    Req/Sec    47.08     16.27    60.00     70.40%
  7061 requests in 10.06s, 105.07MB read
Requests/sec:    702.19
Transfer/sec:     10.45MB

这个结果印证了我们在前面小节中针“对依赖简化”这个优化方案的猜测。那么,来看看是什么让结果有了质的变化。

再次使用 prof 参数对程序进行分析,我们可以看到 JavaScript 整体调用占比降低到了 13.6%,JavaScript 和 C++ 整体调用减少了 700 多次,在整体调用计算次数减少的同时 GC的次数和占比也都有了明显的降低。

 [Summary]:
   ticks  total  nonlib   name
   1078   12.3%   13.0%  JavaScript
   7149   81.9%   86.5%  C++
    427    4.9%    5.2%  GC
    465    5.3%          Shared libraries
     39    0.4%          Unaccounted

观察 JavaScript 调用,会看到调用不再有突兀的 1% 以上的占比,每一个计算的次数和总时间消耗都变的更加平均和用时更短。

 [JavaScript]:
   ticks  total  nonlib   name
     51    0.6%    0.6%  LazyCompile: *t.serialize /Users/soulteary/math-api/node_modules/_mathjax@3.1.2@mathjax/es5/node-main.js:1:76383
     42    0.5%    0.5%  LazyCompile: *node.<computed> /Users/soulteary/math-api/node_modules/_mathjax@3.1.2@mathjax/es5/node-main.js:1:54001
     41    0.5%    0.5%  LazyCompile: *e /Users/soulteary/math-api/node_modules/_mathjax@3.1.2@mathjax/es5/output/svg.js:1:55328
     32    0.4%    0.4%  LazyCompile: *t.create /Users/soulteary/math-api/node_modules/_mathjax@3.1.2@mathjax/es5/node-main.js:1:53752
     29    0.3%    0.4%  LazyCompile: *e.placeChar /Users/soulteary/math-api/node_modules/_mathjax@3.1.2@mathjax/es5/output/svg.js:1:4893
     24    0.3%    0.3%  LazyCompile: *e.toSVG /Users/soulteary/math-api/node_modules/_mathjax@3.1.2@mathjax/es5/output/svg.js:1:171819
     24    0.3%    0.3%  LazyCompile: *e.standardSVGnode /Users/soulteary/math-api/node_modules/_mathjax@3.1.2@mathjax/es5/output/svg.js:1:2540
     24    0.3%    0.3%  LazyCompile: *e.addChildren /Users/soulteary/math-api/node_modules/_mathjax@3.1.2@mathjax/es5/output/svg.js:1:2206
     22    0.3%    0.3%  LazyCompile: *t.serializeInner /Users/soulteary/math-api/node_modules/_mathjax@3.1.2@mathjax/es5/node-main.js:1:76697
     21    0.2%    0.3%  LazyCompile: *t.parse /Users/soulteary/math-api/node_modules/_mathjax@3.1.2@mathjax/es5/input/tex.js:1:40565

针对程序进行简单调整

还是遵循之前的“规则”,在尽可能少写代码的前提下,我们针对原来的 Math-API 项目主要文件 src/render/index.js 进行调整,删除我们不需要的逻辑后,使用新版本的 MathJax 来完成内容的渲染,不到一百行的代码即可完成我们要干的活:

const mathjax = require("mathjax");
const MathJax = mathjax.init({ loader: { load: ["input/tex", "output/svg"] } });

/**
 * Render math.
 *
 * @param {Input} event Input event.
 * @returns {Promise<Output>}
 */
exports.render = (source) => {
    return new Promise((resolve, reject) => {
        MathJax.then((MathJax) => {
            const svg = MathJax.tex2svg(source);
            const ret = MathJax.startup.adaptor.outerHTML(svg) || "";
            resolve(ret.slice(56, -16));
        }).catch((err) => {
            console.error(err);
            throw new Error(`Invalid output: ${source || ""}`);
        });
    });
};

/**
 * Render math for AWS API Gateway.
 *
 * @param {ApiGatewayProxyEvent} event Incoming event.
 * @returns {Promise<ApiGatewayProxyResponse>}
 */
exports.handler = async (event) => {
    try {
        let input = event.queryStringParameters.source;
        const data = await this.render(input);

        return {
            statusCode: 200,
            headers: {
                "Content-Type": "image/svg+xml",
            },
            body: data,
            isBase64Encoded: false,
        };
    } catch (err) {
        if (!(err instanceof Error)) {
            throw new Error(err);
        }
        if (
            !(err instanceof SyntaxError) &&
            !err.message.startsWith("Invalid ")
        ) {
            throw err;
        }

        return {
            statusCode: 400,
            headers: {
                "Content-Type": "application/json",
            },
            body: JSON.stringify({ message: err.message }),
        };
    }
};

由于输出公式图片的格式为 SVG 矢量图,并且图片会被页面直接引用渲染。所以,我们可以精简掉对于尺寸的限制、精简掉对于输入、输出格式的类型要求和判断、以及去除对于 POST 的支持。

与此同时,对 package.json 文件中的项目依赖也可以进行一些调整,只留下提供 Web 服务的 Express 和“真正干活”的 MathJax:

...
  "dependencies": {
    "body-parser": "^1.19.0",
    "express": "^4.16.4",
    "mathjax": "^3.1.2"
  },
...

因为服务最终部署使用,在容器环境中,所以我们还需要封装一个新的容器镜像。

FROM node:15.14.0

ARG NODE_ENV=production
ENV PORT=3000 NODE_ENV=${NODE_ENV}

WORKDIR /usr/src/app
COPY package.json package-lock.json /usr/src/app/
RUN npm install
COPY . /usr/src/app

EXPOSE 3000

CMD [ "npm", "start" ]

相关代码和构建完毕的容器,我已经提交至下面的地址,感兴趣可以自取:

再次进行一场简单的测试,即使在 Mac OS 网络转发受限的情况下,使用相同命令,也能在笔记本上轻松测出5千左右的QPS。

wrk -t16 -c 100 -d 10s -s scripts/add_random_alpha.lua http://localhost:3000/render\?input\=latex\&inline\=0\&output\=svg\&width\=256\&source\=E\=mc\^2

Running 10s test @ http://localhost:3000/render?input=latex&inline=0&output=svg&width=256&source=E=mc^2
  16 threads and 100 connections
  Thread Stats   Avg      Stdev     Max   +/- Stdev
    Latency   170.00ms   27.20ms 428.20ms   90.89%
    Req/Sec    35.68     17.34   118.00     66.43%
  5613 requests in 10.11s, 83.52MB read
Requests/sec:    555.46
Transfer/sec:      8.26MB

如果我们将本篇的内容和前两篇进行结合,会是怎么样的结果呢?

其他

将优化后的服务以“中篇”的方式运行起来,让服务享受到 Nginx 的“保护”,然后继续使用 wrk 模拟接近真实的请求,进行压力测试:

version: "3.0"

services:

  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
    networks:
      - formula

  math-api:
    restart: always
    image: soulteary/math-api:node-15.14
    expose:
      - 3000
    networks:
      - formula

networks:
  formula:

可以看到在 Nginx 保护之下,10秒钟内单个用户仅能进行 59 次公式获取,每秒平均获取 6 张图。

而在前面的小节中,新的服务动辄大几千的 QPS,仅单机单节点模式,即可满足至少 1000 人高频获取信息。

wrk -t16 -c 100 -d 10s -s add_random_alpha.lua http://localhost:3000/render\?input\=latex\&inline\=0\&output\=svg\&width\=256\&source\=E\=mc\^2

Running 10s test @ http://localhost:3000/render?input=latex&inline=0&output=svg&width=256&source=E=mc^2
  16 threads and 100 connections
  Thread Stats   Avg      Stdev     Max   +/- Stdev
    Latency    28.66ms   90.99ms 992.66ms   96.82%
    Req/Sec   709.50    281.79     1.27k    67.06%
  113226 requests in 10.07s, 41.22MB read
  Non-2xx or 3xx responses: 113167
Requests/sec:  11244.83
Transfer/sec:      4.09MB

至此,一个基本靠谱的公式渲染服务就就绪啦。

最后

参考最近分享的这三篇文章的思路,任何一个满足读多写少的服务都可以在接近零成本的前提下得到数倍的性能提升,用更低的成本服务更多的用户。

不过,服务优化就到此为止了吗?显然不是的,在不继续调整代码的前提下,我们至少还有三种以上简单易行的方式让服务可靠性进一步提升。

关于公式渲染,我们先聊到这里,如果有机会,或许我会提笔继续聊聊其他的方案。

–EOF