文档结构  
翻译进度:已翻译     翻译赏金:0 元 (?)    ¥ 我要打赏
参与翻译: toypipi (23), InsideOut (1), luke (1)

任何人运用足够大的代码库工作过的人都知道,技术债务是一个无法回避的现实:一项应用增长规模越大,复杂程度越高,则技术债务也越高。随着GitHub过去七年的发展,我们发现我们的代码库中有很多角落和缝隙,必然低于我们的最好的工程标准。但我们也发现了有效和高效的偿还技术债务的方式,甚至在我们的系统中最活跃的部分。

在GitHub上,我们尽量不去吹嘘我们过去几年将的Web应用程序用户扩展到超过12万的“捷径”。事实上,我们做的正好相反:我们有意识地努力研究代码库,以寻求可将其改写得更清洁、更简单的办法,成为更高效的系统,我们开发工具和工作流程,使我们能够进行有效、可靠地执行改写任务。

第 1 段(可获 2 积分)

例如,两个星期前,我们替换了我们的基础结构中最关键的代码路径之一:当您在合并请求中按合并按钮时执行合并的代码。 虽然我们在整个Web应用程序中经常执行这些重构,合并代码的重要性使它成为一个有趣的故事来展示我们的工作流程。

在Git中合并

我们已经在过去详细讨论了GitHub在我们的平台和我们的企业产品中使用的存储模型。 有许多的实现细节,使这个模型在性能和磁盘使用上非常高效,但最相关的是,事实上存储库总是存储“空的”。

第 2 段(可获 2 积分)

这意味着存储库中的实际文件(你在克隆存储库时将在工作目录中看到的文件)在我们的基础架构中的磁盘上实际并不可用:它们在packfiles中被压缩和增量。

因此,在生产环境中执行合并是一项非常重要的工作。 Git知道多个合并策略,但是默认情况下使用git merge来合并本地仓库中的两个分支时,递归合并策略假定存储库存在一个工作树,并且在其中检出所有文件。

第 3 段(可获 2 积分)

我们在GitHub的早期开发的这种限制的解决方法是有效的,但不是特别优雅:而不是使用默认的git-merge-recursive策略,我们写了我们自己的合并策略,基于Git早期使用的原始策略:git-git-merge-resolve。做了一些调整,旧的策略可以适应于不需要将磁盘上的文件实际检出。

为了实现这一点,我们编写了一个shell脚本来设置一个临时工作目录,合并引擎执行内容级别的合并。 一旦这些合并完成,文件将与生成的树一起写回原始存储库。

第 4 段(可获 2 积分)

这个合并帮助器的核心看起来像这样:

git read-tree -i -m --aggressive $merge-base $head1 $head2
git merge-index git-merge-one-file -a || exit 2
git write-tree >&3

这将裸存储库中的两棵树进行有效合并,但它有几个缺点:

  1. 它在磁盘上创建临时目录; 因此,它需要自己清理。
  2. 它在磁盘上创建一个临时索引,这也需要清理。
  3. 它不是特别快,尽管Git的高度优化的合并引擎:旧的git-merge-one-file脚本为每个需要合并的文件产生了几个进程。 需要使用磁盘作为临时暂存空间也会成为瓶颈。
  4. 它与Git客户端中的git merge的行为不完全相同,因为我们使用过时的合并策略(而不是git merge默认情况下执行的递归合并策略)。
第 5 段(可获 2 积分)

在libgit2中合并

在我们的平台上,libgit2是我们拥有的最尖锐的用于处理Git相关的技术债务的武器。 围绕Git构建一个Web服务是非常困难的,因为Core Git项目的工具是围绕本地命令行使用设计的,并且对服务器端运行Git操作的用例没有太多的考虑。

考虑到这些限制,五年前,我们开始开发libgit2,将大多数Git的低级API重新实现为可以在服务器端进程中直接链接和使用的库。 在它存在的前3年,我个人领导了libgit2的发展,,虽然现在库掌握在非常能干的Carlos Martín 和Ed Thomson的手中。 由于那些也使用它来构建Git基础设施的其他公司的可靠的持续的外部贡献,它也获得了很大的吸引力 - 特别是我们的微软的朋友,他们使用它来实现Visual Studio中的所有Git功能。

第 6 段(可获 2 积分)

尽管是它一个C语言库,libgit2包含许多强大的抽象来完成Git根本不能做的复杂的任务。 这些特性之一是仅存在于内存中的索引,并且允许在没有实际工作目录的情况下执行工作树相关操作。 基于这种抽象,Ed Thomson 实现了一个令人难以置信的优雅合并引擎 作为他对库的第一个贡献。

使用内存索引,libgit2能够合并存储库中的两个树,而无需检出其中任意一个文件到磁盘上。 理论上,这种实现是我们用例的理想选择:它比我们目前使用Git做的更快,更简单和更高效。 但它也是大量重要的应该取代我们网站的一些最重要功能的新代码。

第 7 段(可获 2 积分)

信任问题

在这个特定的情况下,虽然我作为libgit2的维护者已经彻底地审查了合并实现,假设将它准备用于生产环境将是非常鲁莽的。 事实上,如果我自己写了整个实现,后果将更加鲁莽。 在旧的和新的实现中,合并过程是非常复杂的,GitHub的操作处于一种看似不起眼的“极端情况”将永远在默认情况下发生的范围内。 当涉及到用户数据的时候,不能选择忽略极端情况。

更糟糕的是,这不是一个新功能; 这是一个现有的、运行没有重大问题的功能的替代品(除了技术债务和糟糕的性能特点)。 没有容纳错误或性能回归的空间。 切换需要没有瑕疵,并且重要的是 - 它需要非常高效。

第 8 段(可获 2 积分)

效率是这种项目的基础,因为即使它们带来了性能改进,如果我们不能在紧迫的时间内完成重构,也很难证明时间投资的正确性。

没有一个明确的截止日期和一个定义良好的工作流,很容易浪费几周的工作重写代码,将最终导致更多的bug,比旧的实现更不可靠。 为了防止这种情况,并使这些重写可持续,我们需要能够以一种几乎完美无缺的方式有条不紊地执行它们。 这是一个难以解决的问题。

第 9 段(可获 2 积分)

幸运的是,这个挑战并不是GitHub中的系统代码所独有的。 我们所有的工程团队都像我们一样关心代码质量问题,对于与我们的主要Rails应用程序(例如在这个特定示例中,PR合并功能)接口的重写,我们的核心应用程序团队已经建立了大量的工具, 使这个极为复杂的过程成为现实。

准备实验

新实施的部署过程的第一步是实现RPC对新功能的调用。 我们平台中的所有Git相关操作都通过一个称为GitRPC的服务来实现,它智能地从前端服务器请求路由,并在存储相应存储库的文件服务器上执行操作。

第 10 段(可获 2 积分)

对于旧的实现,我们只是使用一个通用的git_spawn RPC调用,它运行相应的Git命令(在这种情况下,是我们自定义的合并策略的脚本),并返回stdout,stderr并退出代码到前端。 事实上,我们使用通用spawn,而不是执行文件服务器上的整个操作并返回结果的专用RPC调用,这是技术债务的另一个迹象:我们的主要应用程序需要自己的逻辑来解析和理解合并结果。

因此,对于我们的新实现,我们编写了一个特定的create_merge_commit调用,它将在文件服务器端执行内存中和进程中的合并操作。 在我们的文件服务器上运行的RPC服务是用Ruby编写的,就像我们主要的Rails应用程序,所以所有的libgit2操作实际上是使用Rugged,我们在内部开发时将Ruby绑定到libgit2。

第 11 段(可获 2 积分)

由于Rugged的设计,将libgit 2的低级API变成Ruby-land中的可用接口,编写合并实现非常简单:

def create_merge_commit(base, head, author, commit_message)
  base = resolve_commit(base)
  head = resolve_commit(head)
  commit_message = Rugged.prettify_message(commit_message)

  merge_base = rugged.merge_base(base, head)
  return [nil, "already_merged"] if merge_base == head.oid

  ancestor_tree = merge_base && Rugged::Commit.lookup(rugged, merge_base).tree
  merge_options = {
    :fail_on_conflict => true,
    :skip_reuc => true,
    :no_recursive => true,
  }
  index = base.tree.merge(head.tree, ancestor_tree, merge_options)
  return [nil, "merge_conflict"] if (index.nil? || index.conflicts?)

  options = {
    :message    => commit_message,
    :committer  => author,
    :author     => author,
    :parents    => [base, head],
    :tree       => index.write_tree(rugged)
  }

  [Rugged::Commit.create(rugged, options), nil]
end
第 12 段(可获 2 积分)

虽然GitRPC在我们的文件服务器上作为一个单独的服务运行,它的源代码是我们的主存储库的一部分,它通常是与前端主要的Rails应用程序同步部署。 在我们开始切换实现之前,我们将新的RPC调用合并并部署到生产环境,即使它在任何地方都没有被使用。

我们重构了Rails应用程序中合并提交创建的主要路径,以将Git特定的功能提取到自己的方法中。 然后我们实现了一个与基于Git的代码相同签名的第二个方法,但是这个方法通过Rugged / libgit2执行合并提交。

第 13 段(可获 2 积分)

由于在GitHub上部署到生产环境是非常简单的(对于我们的主要应用程序,我们每天执行大约60次部署),我马上部署了初始重构。 这样,我们不必担心我们广泛的测试套件中的潜在差距:如果小的重构在现有行为中引入了任何问题,则可以通过部署到服务生产流量的机器中的一小部分来快速发现它们。 一旦我认为重构安全,就全部部署到所有的前端机器,并合并到主线。

第 14 段(可获 2 积分)

一旦我们在主应用程序和RPC服务器中准备好两个代码路径,我们就可以开始合并:我们将在生产环境中测试和基准化这两个实现,但不会以任何方式影响现有用户,这要感谢Scientist的力量。

Scientist是一个用于测试Ruby中重构,重写和性能改进的库。 它最初是我们的Rails应用程序的一部分,但我们已经提取并将之开源。 要开始我们的Scientist测试,我们做一个实验,并将旧的代码路径设置为控制样本,新的代码路径作为候选。

第 15 段(可获 2 积分)
def create_merge_commit(author, base, head, options = {})
  commit_message = options[:commit_message] || "Merge #{head} into #{base}"
  now = Time.current

  science "create_merge_commit" do |e|
    e.context :base => base.to_s, :head => head.to_s, :repo => repository.nwo
    e.use { create_merge_commit_git(author, now, base, head, commit_message) }
    e.try { create_merge_commit_rugged(author, now, base, head, commit_message) }
  end
end

一旦这种微小的变化到位,我们就可以安全地部署到生产环境中的机器。 由于实验尚未启用,代码将像以前一样工作:控制(旧代码路径)将被执行,并且其结果直接返回给用户。 但是随着实验代码部署在生产环境中,我们只需点击一下按钮即可让SCIENCE!发生。

第 16 段(可获 2 积分)

合并提交后的SCIENCE!

我们在开始实验时所做的第一件事是将其作为所有请求的一小部分(1%)。 当实验在请求中“运行”时,Scientist在后台执行许多事情:它运行控件和候选(随机化它们运行的顺序,以防止掩盖错误或性能回归),存储控制的结果并将它返回给用户,存储候选的结果并且处理任何可能的异常(以确保新代码中的错误或崩溃不会影响用户),并且将控制的结果与候选者进行比较,在我们的Scientist Web UI记录异常或不匹配。

第 17 段(可获 2 积分)

Scientist实验的核心概念是,即使新的代码路径崩溃,或给出一个坏的结果,也不会影响用户,因为他们总是接收旧代码的结果。

通过运行实验的所有请求的一小部分,我们开始获取有价值的数据。 运行1%的实验有助于捕获新代码中明显的错误和崩溃:我们通过可视化性能图表,看看我们是否朝着一个良好的方向和性能发展,我们可以观察不匹配图,看看新的代码是否更多或更少地做正确的事情。 一旦这些问题解决了,我们就增加实验的频率开始捕捉实际的极端情况。

第 18 段(可获 2 积分)

Initial deploy graph - Accuracy

部署后不久,精度图显示实验以正确的频率运行,并且不匹配很少。

Initial deploy graph - Errors

自己绘制错误/不匹配更详细地显示它们的频率。 我们的工具会捕获所有这些不匹配的元数据,以便稍后进行分析。

Initial deploy graph - Performance

虽然现在还为时过早,但在初始部署后不久,性能特征看起来非常有前途。 垂直轴是时间,以毫秒为单位。 旧代码和新代码的百分位数分别以蓝色和绿色显示。

我们注意到查看实验结果的主要原因是,大多数不匹配来自旧代码超时,而新代码准时完成。 这是一个伟大的消息 - 这意味着我们正在解决一个真正的用户面临的性能问题,不只是使图表看起来更漂亮。

第 19 段(可获 2 积分)

忽略所有控件超时的实验,剩余的不匹配是由生成的提交中不同的时间戳引起的:我们的新RPC API缺少一个“time”参数(“time”指代创建合并提交的时间),并且使用文件服务器中的本地时间。 由于实验是顺序执行的,很容易导致控件和候选者之间的时间戳不匹配,特别是对于超过1秒的合并。 我们通过向RPC调用添加一个显式time参数来修复这个问题,在部署之后,所有微小的不匹配都从图中消失了。

第 20 段(可获 2 积分)

一旦我们对新实现有了一些基本的信任,就到了增加实验运行请求百分比的时候了。 更多的请求导致更准确的性能统计信息和更多的极端情况,其中新的实现方式给出不正确的结果。

Graph after increasing the science percentage

只需查看性能图就可以明显看出,即使对于平均情况来说,新代码的速度明显快于旧的实现,但仍然需要处理性能峰值。

因为我们的工具,找到这些尖峰的原因是非常简单的:我们可以配置Scientist ,使其记录候选比Xms需要更长的运行时间(在我们的情况下,我们的目标是5000ms)的实验日志,并让它整夜运行以捕获这些数据。

第 21 段(可获 2 积分)

我们做了4天实验,每天早上醒来的案例清单中,有新代码的结果与旧代码不同的情况,以及新代码只是给出良好结果但运行太慢的情况。

因此,我们的工作流程是直接的:我们修复了不匹配和性能问题,我们再次部署到生产环境,我们继续运行实验,直到没有不匹配和没有出现运行缓慢。 值得注意的是,我们同时解决了性能问题和不匹配问题,而不是优先考虑其中之一:古老的格言“使它工作,再使它快速工作”在这里是毫无意义的 - 在所有GitHub的系统中都是毫无意义的。 如果它不快,它就是不工作,至少是不正常的。

第 22 段(可获 2 积分)

比如,这里列出了一些超过4天才被我们修复的问题:

  • 优越的性能, 我们注意到很多合并是“冲突”的 (例如,他们不能被Git或libgit2合并), 然而libgit2花费了大量的时间在进行合并冲突: Git只需3ms而libgit2却超过了8秒.

    通过跟踪代码执行的路径我们注意到,Git之所以这么快: 我们的git shell脚本一旦发现冲突就会退出, 而libgit2 将继续解决合并,直到有了完整的冲突索引。 我们没有使用它的冲突索引, 因此,这个性能问题的解决方案很简单: 添加一个标志  让libgit2发现冲突就中止合并

  • 我们还发现了libgit2中存在一个严重的O(n) 算法问题:在大型仓库中REUC的代码 (一个包含额外的信息的扩展索引——通常用于Git gui) 运行时间很长。

    在我们的实现中没有使用REUC , 因为我们在写了树和提交后直接丢弃索引,因此解决该问题的办法是添加一个标记来跳过写REUC。解决了 O(n)问题后,在我们的代码库中运行速度提升了上百倍。

  • 我们发现的第一个问题是不匹配的结果问题,而不是一系列合并的性能问题,Git完成成功但libgit2被标记为冲突。

    所有不匹配的合并都有相同的情况: 祖先有一个文件与一个给定的FileMode,而一方的合并已经删除了文件,另一边却改变了FileMode。

    这种情况应该标记为一个冲突, 但是Git是错误地解决合并且合并结果中删除了文件(而事实上,合并一方改变了相同的文件filemode).

    当比较libgit2和Git这些操作的代码时, 我们注意到,旧的合并策略git没有处理而libgit2是正确. 因此我们在Git的上游修复了这个bug

  • 一个更有趣的案例发生在合并,在libgit2中显然是一个冲突但Git合并成功了。 经过一些调试,我们发现,Git的合并有一些问题— 被合并的单一文件肯定不是一个有效的合并, 因为它甚至包括了输出中的冲突标记!

    更进一步的研究才发现了Git合并这个文件尾成功的原因。我们注意到,这个文件的新旧两个版本的冲突恰好有768个冲突。这是一个非常特殊的数字 。 git-merge-one-file 帮助文档证实了我们的猜测:

    这个程序的退出值是错误的,正确的情况这个值是冲突数量。 如果合并是干净的,退出值为0。

    由于shells只会使用到程序退出码的低8位,Git能合并这个文件的原因:768个冲突被shell看作是0,因为768是256的倍数!

    这个bug非常严重。 但再一次表明 libgit2是做正确了,因此我们再次在Git上修复了这个问题 ,然后继续实验。

  • 最后一个有趣的案例是一个合并,对应Git来说合并是容易的但libgit2引发了成千上万的冲突。 我们花了一段时间来研究,libgit2不能合并,是因为它选择了错误的合并基础。

    在跟踪libgit2和Git的并行执行,我们注意到,libgit2缺失了合并基础计算算法一步: 冗余合并基础简化。 实现算法的额外步骤之后, libgit2实现了与Git相同的合并基础。

    我们终于在libgit2发现了一个错误, 虽然在这种情况下,不只是一个错误,这是一个整体的功能,我们忘记了实现!

第 23 段(可获 2 积分)

结论

在对此过程进行4天的迭代之后,我们设法使实验运行了24小时100%的请求,并且在整个运行期间没有不匹配或缓慢的情况。 总的来说,Scientist验证了几千万个合并提交在旧的和新的路径中几乎相同。 我们的实验是成功的。

最后要做事的是最令人兴奋的:删除Scientist代码,并切换所有前端机器默认运行新的代码。 在几个小时内,新代码被部署并运行在GitHub的100%的生产流量。 最后,我们删除了旧的实现 - 坦率地说,这是整个过程中最令人满意的部分。

第 24 段(可获 2 积分)

总结:在大约5天的兼职工作中,没有用户可见的影响下,我们替换了GitHub的一个更关键的代码路径。 作为这个过程的一部分,我们修复了原来的Git实现中的两个严重的合并相关的错误,这些错误在多年前没有被发现,新的实现中存在3个主要的性能问题。 99百分位的新实现大致相当于95百分位的旧实现。

这种积极的技术债务清理和优化工作只能作为我们工程生态系统的副产品发生。如果不能在一天内部署主应用程序超过60次,并且没有工具自动测试和在生产中对两种截然不同的实施进行基准测试,这个过程将需要几个月的工作,并且没有现实的方法来确保没有性能或行为回归。

第 25 段(可获 2 积分)

文章评论