前期提要:这只是个优化超大文档(100mb+,3000 万字 +)打开、浏览、编辑速度的优化方案,在此过程中我做了很多丧心病狂的操作以追求性能的机制。最后的成果见下:
before
getDoc: 4000ms+
getBlockBreadcrumb:800ms+
getBlockIndex: 800ms+
transactions: 5000ms+
getTreeStat: 1000ms+
memory: 9000mb+ (最后把我16g的内存挤满了,不得不强制杀掉进程)
after
getDoc: 150ms+
getBlockBreadcrumb:80ms+
getBlockIndex: 80ms+
transactions: 1500ms+
getTreeStat: 150ms+
memory: 4000mb+
这就是最终的结果了。在 100mb+ 的维基语料上浏览、编辑的效果如下:
看上去满心动的?那不妨听听我做了啥逆天操作……
方案 1:保存 ast 语法树
可能大家并不清楚的是,思源的 ast 语法树是一次性的。这也就意味着,无论是获取面包屑、读取块 index、更新文档、上下动态加载,都会完整的读取 sy 文件,解析成 ast,进行操作,然后直接丢给 gc 处理。
这太糟糕了,我们能不能保存一下 AST 树以重复使用?
嗯,想法是很好的,但可惜的是,思源的底层设计就没考虑过这一点,对于 ast 的使用完全就是一次性的用法,直接在原 ast 树上操作,连生成 html 都要 unlink ast 节点,直接保存会发现中间莫名其妙就缺了一部分。
思源的 ast 树同时也是个相当复杂的数据结构,大致可以理解为这样的东西:
每个节点都有 next、prev、parent、firstChild、lastChild,接近于一个五向的链表结构。
虽然很艰难,但我最终还是完成了这个数据结构的 clone 操作,在这个基础上才有可能完成 ast 的缓存。经此操作,目前效果:
getDoc: 1500ms+
getBlockBreadcrumb:80ms+
getBlockIndex: 80ms+
方案 2:关掉虚拟引用和折叠标题检测
getBlockIndex
和 getDoc
之间的巨大差异意味着这其中必然有诡异的情况,因为 getBlockIndex
执行的操作与 getDoc
基本相差无几,都是遍历 ast,找到对应节点,然后执行操作。区区 50 个节点的 html 无论如何也不可能带来这么离谱的差异。
通过性能检查,很容易就发现虚拟引用和折叠标题检查现在占据了绝大部分的时长。
很离谱的是,无论你是否在设置界面打开虚拟引用,都不会改变进行索引的过程。因为进行虚拟引用的部分看上去根本没有使用设置的功能,我感觉可能只影响了渲染部分。
另外,折叠标题的实现也很有问题,目前的实现中,想要检查一个节点是否在折叠标题下,需要不挺地 prev 到前一个节点,判断它是否是标题,是否折叠了。但是,这里有一个问题,假如我整个文档都没有一个最大的 1 级标题,或者 1 级标题放在最上面的部分,那会怎么样?答案是,它会一直遍历到 ast 的最上面,而且每个节点都会进行一次。这导致动态加载的节点越多、浏览越底部的文档,速度越慢。我有一个初步的优化想法,直接从上往下进行节点排查,每个节点就只用确定一次。
不过为了探究思源的极限性能,我就懒得优化这两个东西的具体逻辑的,直接代码上关闭了事。
getDoc: 150ms+
getBlockBreadcrumb:80ms+
getBlockIndex: 80ms+
方案 3:增加 JSON 缓存
本来到此就结束了,但我突然好奇这时候的编辑效果如何?效果令我差异:
transactions: 3500ms+
getTreeStat: 150ms+
怎么回事?怎么还是这么慢?赶紧看一下火焰图:
好家伙,我原来才优化掉四分之一的问题!writeFile 肯定省不了,但还有这 JSON 和数据库查询两个在等着我呢!
首先看看 json,这地方肯定有提升空间。最简单的方法就是直接换个更快的 json 库,毕竟 golang 自带的 json 哪方面都是各种花式对比的下等人,我最终选中了 https://github.com/goccy/go-json
,因为它宣传直接替换,而且 3 倍提高。改进效果嘛,如有:
transactions: 3200ms+
getTreeStat: 1000ms+
提升多少是有点,但是距离想象有很大空间。
我后来仔细思考一下,其实没必要每次都序列化嘛。检查一下节点的 id 和 updated,完全可以没有 updated 的部分直接使用缓存的内容。因此我直接写出了第一版。速度提升很多,直接进入了 2.5s+ 的时代。
……然后我就直接发现整个文档变成了第一个节点的内容。
这其中的原因是,虽然段落和各种节点都是有 id 和 updated 的,但是段落中的 nodeText,却恰恰没有 id 和 updated,它的 key 直接就是 ""
,然后就不停的命中缓存。
但是这样的节点我要怎么给他建立缓存的 key?它没有 ID,又没有 updated,甚至内容也不确定。难道我要先序列化作为 key,然后建立缓存?
我差点走投无路了,但我忽然意识到,虽然 nodeText 没有 id 和 updated,但是它的父节点有啊。接着只要能在同一个父节点的 nodeText 下互相标志,那就可以作为 key。
这个东西是什么?我苦思冥想,最终想到了 index。虽然整个数据结构里没有这个东西,但我可以靠着 node.prev 遍历来建立 index。考虑到大部分节点的 index 不会超过 20,这个方法的性能意外的不错。
靠着这样的方法建立了全部节点的缓存,在不报我明显看出来问题的情况下,获得了以下结果
transactions: 2500ms+
getTreeStat: 150ms+
方案 4:分拆数据库查询事件
优化这个数据库查询事件,对于在思源的 riff(闪卡模块)的重构中饱经 sqlite 性能优化折磨的我来看,这个问题倒不是太难。按照我的经验,sqlite 的查询速度在建立索引后快的要死,并不会成为性能瓶颈。但是在 sqlite 数据向 golang 数据结构转换的过程中,性能问题就出现了。也就是说,对于笔记软件场景,sqlite 可以随便查,但是查出来的数据量要尽量控制,越少越好。
经过分析,我发现 siyuan 在这个步骤上进行了三个操作:
- 确定 blockTrees 表中对应 tree.Root 的项,它的 path、hpath 与 tree 保持一致。
- 确定 blockTrees 表中与 tree 中对应的所有节点的 update、type 保持一致。
- 确定 tree 中是否存在不在 blockTrees 表的节点。
收集以上的节点,然后更新它们的数据。
为了实现这一点,siyuan 是获取了对应 tree.Root 的项的全部列,然后再 ast 的遍历中挨个确认。
我认为可以分成两个查询操作:
- 查询 root = tree.Root, path != tree.path, hpath != tree.hpath 的项的 id。
- 查询 root = tree.Root 的项的 id、updated、type
虽然我进行了两次查询操作,但是最终在 sql 查询上,速度快了 1 倍。
目前的结果是:
transactions: 1500ms+
getTreeStat: 150ms+
目前的火焰图如下:
结语:还能怎么改进?
首先浏览的速度已经没什么改进空间了,但写入确实还有,目前硬上限也就是磁盘的写入调用占比是 0.276,也就是把其他开销优化成空气的话还能提升 3 倍,但是也就是 500ms+ 到头了。
但是这显然是不可能的,具体到函数上进行分析,在生产 json 上,writeBuffer 和 getNodePath 显然是无论如何也去不掉的,也就是说最多优化掉 getJSON 的这一步,那可以榨出来 50% 的提升;数据库查询可以不返回 type
,在 path
、hpath
建立索引,增加 map 的初始长度……林林总总 50% 到头了,那最后能优化多少?
(1.5 - 0.5)* 50% + 0.5 = 1
最多优化到 1s!好吧,感觉也没多大差距,还是懒得动了……
日后谈
为了让对笔记软件的极限和想不开的人可以体验一下超大文本加载的体验,在这个 github 发布地址准备了一个 100mb 的维基语料数据和已打包的修改版。
欢迎来到这里!
我们正在构建一个小众社区,大家在这里相互信任,以平等 • 自由 • 奔放的价值观进行分享交流。最终,希望大家能够找到与自己志同道合的伙伴,共同成长。
注册 关于