如何有效地维护Git存储库查询的缓存?

时间:2013-12-31 21:10:51

标签: git caching github memcached

我有一个wiki应用程序,可以根据用户操作(查看,编辑,移动,删除文档)查询和修改Git存储库。该应用程序通过解析用户的命令并将它们转换为Git CLI命令(git show,git log,git ls-tree,git add,git rm,git commit ...)来完成此任务。

对于给定的页面加载,可能(可能)有数百个Git命令。虽然命令通常可以在<100ms内完成,但重复的调用会加起来,导致某些页面加载令人讨厌。越来越棘手的是,一些呈现的页面内容本质上是动态的,这意味着仅缓存呈现的页面输出通常是不可能的。

我在项目早期就认识到了这个扩展问题,并实现了一种缓存机制来解决最糟糕的问题。由于Git repo在下一次提交之前是静态的,因此任何查询(具有作为memoization的一部分的适当参数)都可以在当前HEAD提交下缓存。这很好用,我的页面渲染有足够的缓存命中,可以完全跳过调用Git CLI。

此方法成为问题的地方是缓存失效期间。假设您有一个更新文件内容的提交。对于我的提交程序,我必须使其无效并重新计算:

  1. 存储库的HEAD提交(git rev-parse HEAD
  2. 的缓存值
  3. 文件最新提交(git log -n1 FILE
  4. 的缓存值
  5. 从提交X(git ls-tree -r NEW_COMMIT -- FILE
  6. 询问此文件是否存在的缓存值

    这些缓存失效向外传播,因为依赖于最近移动的HEAD提交的其他函数必须重新计算自身(例如,从新HEAD提交开始,存储库中的总文件数)。但是,根据我的示例,因为我只更新页面而不移动或删除它,所以我的总文件数不必重新计算,但我的天真缓存机制无法解释这一点,无论如何都必须这样做。

    我的问题:在应用程序上点击“保存”,强制git commit和大量缓存失效,需要100倍的正常页面加载时间,并且由于向外传播而减轻其他页面加载的负担缓存失效。

    尽管围绕我的Git存储库的HEAD提交调整了我的缓存,但该机制仍然很幼稚。我觉得智能Git缓存应该能够只注意分支HEAD的移动/修改,并能够更新其缓存。在2009年的幻灯片(http://www.slideshare.net/err/inside-github,页面#107)中,Github谈到了Walker缓存做类似的事情,但我找不到有关它如何完成这些任务的更多信息。我把我的天真缓存机制从基于文件的缓存,到基于内存的文件系统缓存,现在都带到了Memcached,我怀疑我的方法已达到极限。

    是否有更理想/有效/可扩展的方法从Git存储库生成有效的缓存值而不玩缓存失效游戏whackamole?

    感谢。

1 个答案:

答案 0 :(得分:1)

在回答我自己的问题时,我无法提供比我更好的缓存方法,但我可以提供我是如何解决问题的,以及我是如何努力解决的。< / p>

我支持的wiki应用程序具有Mediawiki内部链接语法supported by Mediawiki,如下所示:

[[ArticleName]]

...生成以下HTML(或关闭的东西):

<a href="/ArticleName">ArticleName</a>

它还支持&#34;管道链接&#34;语法,您可以在其中指定链接文本:

[[ArticleName|Text for hyperlink]]

...生成以下内容:

<a href="/ArticleName">Text for hyperlink</a>

wiki还支持&#34; hierarchical&#34;文章,这样人们可以像在目录结构中那样组织你的文章。所以你可以这样做:

[[Dogs/Breeds/Spaniel/Water|Water Spaniel]]

...它会生成如下链接:

<a href="/Dogs/Breeds/Spaniel/Water">Water Spaniel</a>

但是,使用非管道语法将为分层路径中的每个组件生成链接。也就是说,如果您没有指定链接文本,请执行以下操作:

[[Dogs/Breeds/Spaniel/Water]]

该页面将呈现内部链接,如下所示:

<a href="/Dogs">Dogs</a>/
<a href="/Dogs/Breeds">Breeds</a>/
<a href="/Dogs/Breeds/Spaniel">Spaniel</a>/
<a href="/Dogs/Breeds/Spaniel/Water">Water</a>

除此之外,文件的每个链接都有一个与之关联的CSS类,以指示该文件是否存在于当前的HEAD提交中。这有助于显示哪些文件是&#34;想要的,&#34;并且一目了然地指出在点击它们时您希望返回内容的文件,或者您是否要创建新内容。

例如,如果文件Dogs不存在,则内部链接指定如下:

[[Dogs]]

...将生成与&#34;编辑&#34;的链接。 CSS类表示单击链接将进入编辑器(在我的例子中,将链接着色为红色)。

<a class="edit" href="Dogs">Dogs</a>

必须为&#34;非管道&#34;,分层链接执行类似的任务。我们假设您已创建DogsDogs/Breeds/Spaniel/Water,,但您尚未创建父文章Dogs/BreedsDogs/Breeds/Spaniels。非管道链接指定如下:

[[Dogs/Breeds/Spaniels/Water]]

...将生成与适当的CSS类的路径组件链接:

<a href="/Dogs">Dogs</a>/
<a class="edit" href="/Dogs/Breeds">Breeds</a>/
<a class="edit" href="/Dogs/Breeds/Spaniel">Spaniel</a>/
<a href="/Dogs/Breeds/Spaniel/Water">Water</a>

这意味着对于每个内部链接,我们必须向Git存储库询问以下问题:

  1. HEAD提交中是否存在完整路径?
  2. 如果这是一个分层路径,并且链接没有指定管道链接文本,那么在HEAD提交中是否存在导致完整路径的每个路径组件?
  3. 乍一看,这似乎不是一个大问题。但是,想象一下可能有数百个自动生成内部链接的页面(想象一下:可以列出文件或执行微小搜索的导航宏等)。根据文件结构的复杂程度,您可能需要为每个内部链接检查5-10个路径组件。这意味着当您执行新提交并移动HEAD时,每页渲染数百个Git调用,以及数百个缓存失效。

    没有办法绕过Git存储库支付价格。问题在于如何保持查询的有效和准确缓存,以及您可以以何种方式作弊以获得答案。

    我从两个方向解决这个问题:一个来自&#34;按需&#34;透视,一个来自&#34;批量加载&#34;以一定间隔运行的视角。

    按需缓存,Naive

    Git可以回答&#34;这个文件是否存在的问题?&#34;通过要求它列出HEAD提交或其SHA-1总和的文件(或未能这样做),相当方便。但是,Git存储库不是以它包含的文件为中心,而是围绕它的#34;提交。&#34;为了询问&#34;这个文件是否存在?&#34;对于不是HEAD的提交,并且不代表工作目录,Git必须遍历其日志并记录每一步的文件内容。这是我最好的理论,为什么这个操作对Git来说很难,并且往往很昂贵。

    所以,让我们说我们在HEAD提交ABCD中有100个文件,我们已经回答并缓存了问题&#34;这个文件是否存在于ABCD中?&#34;所有100个文件。

    用户想要进行编辑和提交。这将HEAD从ABCD移动到BEEF。突然之间,所有缓存的答案都是&#34;这个文件是否适用于ABCD?&#34;不再相关,因为BEEF可能代表一组全新的文件。我们需要回答&#34; BEEF是否存在此文件的问题?&#34;对于所有100个文件,加上或减去添加或删除的文件。

    我们真的无法做到这一点,因为这需要永远,并且需要提交用户等待所有~100个文件缓存在重新开始之前重建。

    我们可以根据需要简单地询问答案,&#34;这意味着我们掷骰子是否在编辑后呈现的页面是否具有少于约100个内部链接,这样用户就不会注意到高速缓存失效带来的损失。这不太理想,因为您将从编辑中获得截然不同的响应时间,并且某些页面最终将变得无法使用。我不推荐这种方法。

    按需缓存,稍微好一些

    上述方法之所以不太好,是因为Git存储库的分支HEAD会频繁移动,使缓存无效。但是,当HEAD可能移动时,可能会出现存储库中的大多数文件不会被提交触及的情况。如果我们假设可能有许多提交,但触及的文件相对较少,我们可以产生一些缓存增益。

    而不是缓存问题&#34; HEAD提交ABCD时是否存在文件?&#34;,而是问&#34;文件是否存在于最新提交时?&# 34;那就是:对于修改文件的最后一次提交,它是否在提交结束时存在?

    这意味着每次我们提出问题时,我们都可以执行git log -n1 -- FILEPATH来获取文件提交日志中的最后一项,然后在提交时询问问题。

    例如:我们假设对于文件Dogs我们查找最新的提交,并且它是ACED。我们问问题&#34;文件狗是否存在作为提交ACED的结束?&#34;答案是肯定的,我们存储了答案。

    然后,有人对Water Spaniels页面Dogs/Breeds/Spaniels/Water进行了修改,移动了存储库的HEAD提交。文章Dogs没有改变,所以&#34;最新的&#34; Dogs的提交仍然是ACED。这意味着问题&#34;文件Dogs是否作为提交ACED的结束而存在?&#34;仍然有效,我们可以从缓存中检索它,尽管HEAD提交已经移动。

    虽然这样做要好得多,并且不易受缓存失效的影响,但我们引入了一个新问题。对于每次缓存未命中,我们现在要执行2个Git查询(一个用于最新提交,一个用于存在的文件)而不是一个。 git log -n1 -- FILEPATH查询受到&#34;此文件存在的相同性能/提交存储的影响&#34;查询,因为我们必须遍历提交日志(有时很长的路)来回答问题。

    由于我们现在在文件级别而不是存储库级别进行缓存,因此我们至少可以在较小的文件级别而不是在存储库级别上遇到缓存失效的性能命中。更好的是:在编辑和提交文件时,我们知道要编辑哪些文件,并且我们只能使这些查询的缓存无效。重建1个文件的缓存比大约100个文件便宜得多。

    批量缓存,天真

    我们可以对按需缓存加载进行改进以解决我们的内部链接问题,但是每当加载页面时我们仍然需要询问Git存储库的问题。如果一个页面的必要查询已经从缓存的末尾掉了下来(或者从一开始就没有访问过),我们仍会招致缓存未命中的惩罚。

    我们还需要一种方法来引导&#34;缓存,即预测哪些查询需要回答,并尝试提前回答。

    一种天真的方法是简单地询问&#34;文件X的最新提交是什么?&#34;和#34;文件X是否存在提交Y?&#34;对于存储库中的每个文件。这在技术上是足够的,但可能非常耗时,因为它需要Git调用每个查询。

    但请记住,并非所有存储库的查询都会针对存在的文件进行应答。可能存在指向不存在的路径的链接,尤其是在生成分层内部链接的分解组件时。

    批量缓存,使用ls-tree

    我们已经确定每个文件至少有2个Git查询,并且您可以通过缓存两个查询来降低性能命中率。但是,如果你能以不同的方式回答大量问题,那么可以作弊。

    Git可以使用git ls-tree -r COMMIT返回存储库中提交时所有文件的列表。此Git结果中列出的任何文件都可以被视为&#34;现有&#34;截至最新的HEAD。我们还可以将其与文件X&#34;的最新提交配对。查询刷新缓存以回答文件是否仍然存在(好像它存在于HEAD提交的ls-tree中,它应该仍然存在于最新的HEAD提交中。这使我们可以减少每个文件的2个必要查询为1(只有初始ls-tree命令的开销。

    然而,这种方法还不完善。对于内部链接,我们仍然需要获得答案的插页式路径组件(Dogs/BreedsDogs/Breeds/Spaniels等)。

    我们也可以滥用ls-tree输出来帮助解决此问题。 ls-tree列表仅包含已知在提交时存在的文件。它不会列出任何不在存储库中的文件。这意味着对于我们可以问的所有插页式组件,&#34;在ls-tree结果中,这个组件路径是否存在?&#34;如果是,我们可以将其添加到&#34;是的,此文件存在于HEAD&#34;要缓存的项目,如果没有,我们可以将其添加到&#34;不,此文件不存在,因为HEAD&#34;高速缓存中。

    批量缓存,日志行走

    反复询问git log -n1 -- FILE文件的最新提交效率低下。对于每个文件,必须从头开始重新遍历提交日志。

    这一步我正在试验,但似乎非常有效。 Git可以轻松列出在给定提交中受影响的文件。如果可以获得所有分支提交引用的列表,则可以遍历存储库列出受提交影响的文件。这样做,我们可以存储我们看到文件被触摸的最后一次提交。

    这似乎是一种蛮力,但它确保提交只进行一次(而不是在HEAD提交时反复重复,这需要更长的时间)。

    -

    嗯,这最终超长了。希望能在这里投入,希望能帮助其他人解决这些问题。