从零搭建Latex服务器


一. 什么是Latex服务器

简单来说就是一个在线的实时数学公式解析服务器。这种服务器非常常见,例如知乎、掘金等都有自己的Latex服务器。

知乎latex服务器 三分之一

这里的”三分之一”就是实时渲染的,打开浏览器的调试模式,可以看到Response的Content-Type = image/svg+xml。

Latex服务器必须高可用、低延时。

二. 搭建基本Latex服务器

这里有一篇博客:

手动搭建latex公式渲染服务器

里面使用了Linux环境下的latex软件,现在的新版安装方法应该是:

1
apt-get install texlive-full

强烈建议不要使用这种方法安装,安装任何软件之前,建议优先去官网查看安装教程,一般官网上会有详细的教程。这里推荐在镜像上下载到iso版本,然后scp到Linux环境下安装。这种方式貌似安装更加全面。

我最先使用apt-get安装,后来怎么也显示不了中文,安装了各种字体,调整了各种自定义配置,最后感觉是玩坏了,全部卸载删除,找到3.2版本的iso版本,安装之后就可以显示中文了。

期间踩了无数坑,有几篇比较好的博客推荐一下,提前说明,最后的成品没有使用TeXlive

Ubuntu下 TeX Live 2018 的安装与配置

LaTeX 中如何使用中文

Ubuntu 下安装 Texlive 并设置 CTeX 中文套装

最后,有问题,请Google

再安装好texlive之后,可以使用

1
xelatex -no-pdf --interaction=nonstopmode --output-directory %s %s

将xxx.tex文件转换为xdv文件,xelatex可以方便的处理好中文字符,然后使用:

1
dvisvgm --no-fonts --no-styles -s -c2,2 %s

可以将xdv文件转换为svg标准输出流。

  • -s 表示svg以标准输出流输出数据,而不是输出到文件。
  • –no-fonts –no-styles 删除各种字体信息。

基本的环境已经搭好了。

接下来需要一个http服务器去解析请求,并在底层调用上述命令,最后从标准输出流中读取svg文件数据,返回给客户端。在手动搭建latex公式渲染服务器这篇博客中,作者使用了python搭建,我对java比较熟悉,就用java吧。

使用start.spring.io可以很方便的构建一个基于Spring-boot的web应用,这是一个非常简单的应用:

  • 将请求的参数使用String.Format写入tex文件
  • 调用上述第一个命令生成xdv文件
  • 调用上述第二个命令,并读取标准输出
  • 将标准输出封装成response返回

总体代码是手动搭建latex公式渲染服务器这篇博客中python代码的java翻译版,中间加了一些中文的处理,由于比较简单,不贴代码了。

三. 性能优化

1. 初步优化

Latex服务器具有以下特点:高可用、高实时性。

我们所用的底层服务是一个IO密集&CPU密集的程序,而Latex服务器对延时非常敏感,对于一篇博客,内容中的公式加载不出来或者加载过慢是不能接受的,所以我们要想办法提高服务器的承载能力。由于同一个公式往往会重复出现多次,例如一篇博客可能会被很多不同用户观看,而每次观看加载的公式都是一样的,这样,将已经出现过的公式保存到数据库是一个不错的选择。

当新的请求到来时,总是先去Mysql里面寻找TeX表达式对应的svg文件(svg文件是一种xml格式,可用TEXT保存),当然,由于公式的量会非常大,我们还需要考虑Mysql查询的优化,每次请求都去扫表是不能接受的。容易想到的是使用TeX字符串作为KEY建立索引,但是Innodb对索引长度是有限制的(是由B+Tree的节点也叫”页”的大小决定的,具体可以看MySQL索引背后的数据结构及算法原理这篇文章),而有时候的TeX表达式可能超出这个限制,所以直接以TeX作为KEY是不理想的,但是方法也很简单,对每个TeX表达式生成一个SHA256签名就行(MD5也行,但是为了防止碰撞还是采用SHA256或者更长的签名),然后对这个签名建立索引。数据库示意如下:

id (PK) tex (VARCHAR) sha (INDEX,VARCHAR) svg (TEXT) created_at (DATETIME)
1 \frac{1}{3} c45cb…..7fef6 2019-3-25 00:00:00

对于新来的tex请求:

  • 根据TeX表达式字符串生成sha
  • 在数据库中根据sha字段查找
  • 对比请求的TeX和数据库中查找到的TeX字段是否一样(防止sha碰撞)
  • 如果数据库中没有字段或者两个TeX不一样,都直接去调用命令重新生成svg。否则返回数据库中的svg。

这样随着时间的积累,TeX的数据库将越来越丰富,TeX服务器的性能也将越来越高。

2. 优化升级

有了数据库已经能够在很大程度上改善性能问题了。对于tex服务,可以想象的到,刚刚被访问过的tex公式很可能在短期内再次被访问,所以加入缓存会进一步提升我们的性能。加缓存的方式有很多,我们可以简单的加入一个GuavaCache即可。Cache的key可以直接使用tex字符串。具体的加入方法不再细说,感兴趣的同学可以移步GuavaCache的使用方法。BTW,Spring 5已经放弃对GuavaCache的支持,可以使用CaffeineCacheEhCacheCache代替。

3. 还可以优化吗?

如果在高并发环境下,如和进一步提升性能呢?比如知乎这种公司,大量的用户在编辑或者查看带有tex公式的文章时,Latex服务器的并发将达到一定的高度,在不考虑缓存的情况下,如果每次都要先去数据库查找一次才能知道用不用去调用底层的命令生成将显得臃肿,对于数据库中没有的tex公式,调用命令生成完毕后,还需要进行一次数据库写。我们有没有办法快速知道数据库中有没有记录过一个tex公式呢?

这个问题变成了一个常见的问题:

对于海量不同的简单数据,如何知道一个数据有没有在服务器登记过?

有一种东西叫做布隆过滤器,可以快速的知道一个简单数据是否在历史中出现过。关于布隆过滤器的原理不再赘述。

加入布隆过滤器之后,如果bloom的结果是false,那就不用再去数据库中查找了,可以100%肯定这个数据没有出现过,直接去调用底层命令生成。如果bloom的结果是true,那么有很大的概率(可能是99%以上)这个数据在数据库中有记录,去查找即可,如果bloom miss了,再调用命令去生成。这样,基本就把每次对数据库的操作限定为一次读或者写。加入缓存后,我们Latex服务器的单机并发将十分可观。

如果有进一步改善性能的思路,非常欢迎交流!可通过邮件,或者交换微信。

四. 对比与优化

再次回来看知乎的Latex服务器,发现和我们做的Latex服务器有明显的不同。

  • 我们的字体大小没办法限制,知乎的限制有:font-size: 15px
  • 我们的对于错误语法没有提示,知乎的有语法错误提示
  • 我们的公式生成速度大幅慢于知乎
  • 我们的公式需要使用 $..$ 或者 $$…$$包起来,知乎不用
  • 同样的tex,知乎生成的svg和我们生成的svg不同
  • 知乎对于中文没有处理成svg,而是作为一个CJK字符,我们的是把中文也转换为了svg
  • 等等其他细节

这让我对底层使用的公式解析工具产生了怀疑,再次仔细调研知乎的Latex服务器,发现另外一个值得尝试的Latex数学公式解析工具:MathJax。这是一个JS库,使用以下开源工程可以帮助我们快速的开发:

https://github.com/mathjax/MathJax-node 用node.js调用

https://github.com/mathjax/mathjax-node-cli/ 可以安装在本地当做命令调用

原来一切都是这么简单,赶紧搭建一个简单的node服务验证一下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
var mjAPI = require("./lib/main.js");
var express = require('express');
var app = express();

mjAPI.config({MathJax: {SVG: {
font: "TeX",
}}});
mjAPI.start();

app.get('/equation', function (req, res) {
var tex = req.query.tex;
try {
mjAPI.typeset({
math: tex,
format: 'TeX', // (argv.inline ? "inline-TeX" : "TeX"),
svg: true,
speakText: false, // argv.speech,
ex: 6, // argv.ex,
width: 100, // argv.width,
cjkCharWidth: 16
}, function (data) {
res.header("Content-Type", "image/svg+xml");
res.send(data.svg);
});
} catch(e) {
res.send("error");
}
});

var server = app.listen(8080, function () {
var host = server.address().address;
var port = server.address().port;
console.log('Example app listening at http://%s:%s', host, port);
});

注意,MathJax官方没有对任何CJK字符做字体支持,只能由一个***”cjkCharWidth: 16”***属性控制CJK字符宽度,否则CJK字符在渲染成svg时,由于MathJax找不到对应的字符字体,无从得知字符的预留宽度而使用默认宽度,将使得CJK字符重叠在一起。

注意:

1
2
3
4
mjAPI.config({MathJax: {SVG: {
font: "TeX",
}}});
mjAPI.start();

这段代码千万不要放在app.get中。

之前服务器搭好后,发现每过一段时间发现CPU和内存在短时间内直接跑满,服务彻底卡住很久。打开DEBUG模式发现:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
<--- Last few GCs --->

[37603:0x104800000] 527936 ms: Scavenge 1380.3 (1428.7) -> 1372.7 (1429.2) MB, 2.0 / 0.0 ms (average mu = 0.388, current mu = 0.414) allocation failure
[37603:0x104800000] 527949 ms: Scavenge 1380.4 (1429.2) -> 1372.7 (1429.7) MB, 1.8 / 0.0 ms (average mu = 0.388, current mu = 0.414) allocation failure
[37603:0x104800000] 527962 ms: Scavenge 1380.5 (1429.7) -> 1372.7 (1430.2) MB, 1.8 / 0.0 ms (average mu = 0.388, current mu = 0.414) allocation failure


<--- JS stacktrace --->

==== JS stack trace =========================================

0: ExitFrame [pc: 0x5152ebcfc7d]
1: StubFrame [pc: 0x5152ebc872b]
Security context: 0x3739581888c9 <JSObject>
2: split [0x3739b37aa461](this=0x37393b5989a9 <String[36]: [MathJax]/jax/input/AsciiMath/jax.js>,0x37393b598a51 <JSRegExp <String[2]: \.>>)
3: Require [0x37393b416e91] [file:///Users/zhengrenjie/node/latex/node_modules/mathjax/unpacked/MathJax.js:~731] [pc=0x5152fd85892](this=0x37393b4326e9 <Object map = 0x3739d840...

FATAL ERROR: Ineffective mark-compacts near heap limit Allocation failed - JavaScript heap out of memory

Writing Node.js report to file: report.20190329.191147.37603.001.json
Node.js report completed
1: 0x100064183 node::Abort() [/usr/local/bin/node]
2: 0x1000647f1 node::OnFatalError(char const*, char const*) [/usr/local/bin/node]
3: 0x10017d12f v8::Utils::ReportOOMFailure(v8::internal::Isolate*, char const*, bool) [/usr/local/bin/node]
4: 0x10017d0d0 v8::internal::V8::FatalProcessOutOfMemory(v8::internal::Isolate*, char const*, bool) [/usr/local/bin/node]
5: 0x100439f30 v8::internal::Heap::UpdateSurvivalStatistics(int) [/usr/local/bin/node]
6: 0x10043b971 v8::internal::Heap::CheckIneffectiveMarkCompact(unsigned long, double) [/usr/local/bin/node]
7: 0x1004392b7 v8::internal::Heap::PerformGarbageCollection(v8::internal::GarbageCollector, v8::GCCallbackFlags) [/usr/local/bin/node]
8: 0x1004380a5 v8::internal::Heap::CollectGarbage(v8::internal::AllocationSpace, v8::internal::GarbageCollectionReason, v8::GCCallbackFlags) [/usr/local/bin/node]
9: 0x10043ff69 v8::internal::Heap::AllocateRawWithLightRetry(int, v8::internal::AllocationSpace, v8::internal::AllocationAlignment) [/usr/local/bin/node]
10: 0x10043ffb8 v8::internal::Heap::AllocateRawWithRetryOrFail(int, v8::internal::AllocationSpace, v8::internal::AllocationAlignment) [/usr/local/bin/node]
11: 0x1004207e7 v8::internal::Factory::NewFillerObject(int, bool, v8::internal::AllocationSpace) [/usr/local/bin/node]
12: 0x1005fd2ed v8::internal::Runtime_AllocateInNewSpace(int, v8::internal::Object**, v8::internal::Isolate*) [/usr/local/bin/node]
13: 0x5152ebcfc7d
14: 0x5152ebc872b
[1] 37603 abort node --inspect index.js

发现在频繁的耗时GC后,并且GC没有产生任何效果(1428.7) -> 1372.7后,最后抛出

1
FATAL ERROR: Ineffective mark-compacts near heap limit Allocation failed - JavaScript heap out of memory

这无疑是内存泄漏一类的问题,仔细阅读源码发现mjApi.start()函数有以下注释:

1
2
3
4
5
6
7
8
9
10
11
12
13
//
// Manually start MathJax (this is done automatically
// when the first typeset() call is made), but delay
// restart if we are already starting up (prevents
// multiple calls to start() from causing confusion).
//
exports.start = function () {
if (serverState === STATE.STARTED) {
serverState = STATE.RESTART;
} else if (serverState !== STATE.ABORT) {
RestartMathJax();
}
}

意思是说start函数只需要调用一次。而我们将mjAPI.start();放入了app.get中,这样每一个请求都调用了一次start函数,这显然是错误的。把这个start方法和config方法提出去即可。

注意,我们的代码中,var mjAPI = require("./lib/main.js");是从本地导入的库,而不是npm,这是因为MathJax-node工程将mjAPI.config的所有关于styles的自定义设置全部禁用了:

1
2
3
4
5
6
7
8
9
10
11
12
13
// MathJax-node/lib/main.js  244 行 : delete SVG.config.styles

var SVG = MathJax.OutputJax.SVG, HTML = MathJax.HTML;

//
// Don't need the styles
//
delete SVG.config.styles // 244 行

SVG.Augment({
//
// Set up the default ex-size and width
//

所以,我们无法通过MathJax官方文档中提到的使用自定义的styles设置改变svg的默认样式,那知乎中的font-size: 15px是怎么弄的呢?我猜是改了MathJax-node项目的源码,找到 MathJax-node/lib/main.js 698行 发现以下代码:

1
2
3
4
5
6
7
8
9
10
11
function GetSVG(result) {
if (!data.svg && !data.svgNode) return;
var jax = MathJax.Hub.getAllJax()[0]; if (!jax) return;
var script = jax.SourceElement(),
svg = script.previousSibling.getElementsByTagName("svg")[0];
svg.setAttribute("xmlns","http://www.w3.org/2000/svg"); // 698行
svg.style.fontSize = '15px'; // 修改源码
//
// Add the speech text and mark the SVG appropriately
//
if (data.speakText){

在698行,既然他可以通过svg.setAttribute,那我是不是可以自己令svg.style.fontSize = '15px';呢?在699行添加我们自己的代码,改完源码后,将代码组织到自己的node服务中。重启服务器后,发现设置生效!

在重新对比和知乎Latex的差别后,基本已经有模有样了,将我们的数据库挂到node服务上即可。

五. 总结

  • 有问题,请Google,如果还有问题,请逐字阅读官网文档,别嫌读文档费时间,自己瞎x弄更费时间。
  • 前期调研尽可能详细,可以少走很多弯路。
  • 绝境中,源码绝对能提供最好的帮助。

六. 成果

https://www.nowcoder.com/equation?tex=\frac{1}{3}

效果:三分之一

欢迎交流!!!