Git is slow

一年半之前,我有一个 ROGit 项目计划,旨在构建一个 cgit 同生态位的现代替代品

R(Read or Rust) O(Only or Online) Git

得益于技术栈发展,cgit 所使用的 CGI 协议已相当少见。ROGit 相比 cgit 有以下几个目标:

  • 现代 CSS 界面及交互设计
  • 避免 Git 源码依赖
  • 灵活的 API 接口及扩展

当前正在做后端部分,遇到了严重的性能问题:解析 aports 仓库需要 3 个小时(3 分钟是可以接受的)。

PS:这里加上火焰图

Git Object #

理论上讲 Git 中有三个大类别的对象:Commit、Tree、Blob,具体到解析时还有 Commit 的同类 Tag 及 Note。这些对象以各自 object 的 hash 作为索引,经 zlib 压缩后保存在 .git/objects 下的文件中,纯正文件系统驱动,并在需要时被 Git 解析。这种 NO-SQL 设计保证了后续一定会有性能问题(

Git Commit #

提交 Commit 指向自己的历史来源,有 0 个、1 个、2 个或者更多个 Parent Commit。不同的数量表示了这次提交的不同种类,是 root 节点还是 fast-forward 这种单线链表,或者是合并分支的 Merge 节点,超过 2 的情况不多见,是特殊的合并节点,比如内核的提交中出现的:

https://github.com/torvalds/linux/commit/9b25d604182169a08b206306b312d2df26b5f502

当然像提交信息、提交人、提交时间、以及 Committer 与 Author 之间的区别这里不再赘述。Commit 还保存了提交时的文件(树)快照,指向当前 Commit 相随的 Tree。

这就是一个 Commit 对象信息的全部。

Git Log #

Git 并没有在 Commit 中描述这次提交修改了哪些文件,所以若要知晓给定文件的最后修改日期,过程较为艰难:

  • 获取 Commit 与第一个 Parent Commit 的不同 Tree
  • 在两个 Tree Object 中遍历,查找修改的文件
  • 检查给定文件是否在本次 Diff 中修改
  • 重复上述操作,直到两次提交的树之间存在期望差异

在没有对应数据结构的情况下进行类似 SQL 的结构化查询相当消耗性能,面对稍微大一点的仓库,时间来到秒级

# aports.git
$ time git diff --quiet 20752720e52 38d77a246d4

real    0m0.007s
user    0m0.005s
sys     0m0.003s

(为什么 Git 优化这么好,libgit2 版本就没这么快,后面再看看)

Git Blame #

Blame 完全是 Log 的细节版本,不仅找到相关的提交,而且按照行定位每行的最后修改提交,类似糖豆人的蜂巢关卡,直到完成对目标文件当前所有行的修改提交定位,终止遍历。

所以这里有个 ddos 的小技巧,请求一个不存在的文件,Git 不得不遍历所有提交定位最终修改(但是似乎大家普遍缓存了每个文件的最终修改提交,所以可能没用)

Git 生态 #

尽管如 GitHub、GitLab、Gitea、Forgejo、Sourcehut等近现代托管平台欣欣向荣,但 Git 现存底层生态相当贫瘠

Libgit2 #

这是经典老牌实现库,由 C 语言实现。虽然官网写了几十种语言绑定,但真正积极维护的屈指可数,目前只推荐 C(本家)、Rust(Cargo 这边需要,不确定会不会搬到 Gitoxide)、Python(Fedora 和 Sourcehut 在用,不确定未来是否搬走)、Ruby(GitHub 和 GitLab 从前的技术栈,GitLab 已经搬走)这些语言。

尽管功能丰富,但诸如前文所提到的 git-diff 问题,以及与之相关的 git-loggit-blame 等操作并无很好解决办法,许多功能存在能跑这一阶段,性能上与原版 Git 存在 2~10 倍的差距。

Gitaly #

GitLab 公司不思进取,没有优化相关算法,反而用 GO 把原版 Git 命令完完全全包装成了 RPC 远程调用,舍弃了 libgit2 相关的所有实现。目前 Gitee(中国代码托管平台)也在使用这一开源组件。

Gitoxide #

Rust 从头开始的实现,RIIR

Gogit #

Pure Go 从头开始的实现,关注度、使用人数、实现完成度、以及最重要的性能还不如 libgit2。

Git #

很多情况下性能最好的一种方法,但点名批评 GitLab、Gitea、Gogs,把命令行调用包装起来,除了结果缓存,没有任何优化。


ROGit #

近半年来切换到 Rust 语言生态,在除 Git 缓存速度较慢外,不存在技术问题(就是要解决这个问题),目前决定对以下功能不做支持:

  • git-note
  • git-blame

针对 git-refs --contains <commit> 也没有考虑到较优美的解决办法,准备同样调用 Git 命令并解析输出。

已完成对配置文件的解析、对 sqlite 数据库的重新设计、对 commit 对象的遍历与更新、以及 tag 对象与 commit 对象的抽象等工作。下一步继续构思 git-log 相关加速:

  • 使用 sqlite 作为 libgit2 的自定义后端(已排除:经过火焰图排查,完全缓存 ODB 的情况下,仅仅会带来约 40% 的加速,无法进一步提升)
  • 实现独立解析并缓存 Tree 对象及 TreeEntry 至数据库,实现基于数据库的 git_diff_tree_to_tree() 并在请求时动态查询缓存提交变动文件
  • 偷学代码:Git、Sourcehut、Gitaly、git-sizer、fossil(sqlite 家的 CVS 系统)

相关链接: