浅谈代码版本控制工具的前世今生

73 阅读13分钟

之所以想聊这个话题,是因为在社区里看到有人问,为什么 Git 搞这么复杂。

其实 Git 说简单也简单,常用的命令就十几条,用一张图就能概括:

Git 说复杂也复杂,reset 的三种模式,rebase 和 merge 的区别,cherry-pick 操作,submoudle 管理,可控的 push force(--force-with-lease),工作目录、暂存区、本地仓库、远程仓库和各个命令的关系,以及最让人头疼的冲突解决。

可以说,几乎所有程序员都被合并代码和解决冲突折磨过,特别是多人协作的大型项目,有时候合代码都得一个下午。

当然,Git 本身的复杂性,并不一定是问题或者缺陷,要弄明白 Git 为什么有这么多概念,需要回顾下代码版本控制工具的发展历史。

在正式开始之前,推荐一本和 Git 相关的书,是 Git 的联合创始人兼开发者写的,想系统深入学习 Git,这本书是必备读物。如果觉得 Git 不复杂的,也可以看看这本书,看看你是否掌握了全部内容。在线中文版在这里:《Pro Git 第二版》

接下来就正式开始回顾代码版本控制工具的发展史。

SCCS(Source Code Control System)

SCCS 是由贝尔实验室的马克·罗奇金德于 1972 年开发,是第一个标准化的代码版本控制工具。当时是为 IBM System/370 大型机开发,1973 年又移植到了 UNIX 系统上,并作为 Unix 系统的主要版本控制系统。

我是读《UNIX 传奇:历史与回忆》这本书,才知道有 SCCS 这个东西。另外补充个背景,UNIX 也诞生于贝尔实验室。

那个时候的电脑主要是大型机小型机,占地从一个机房到一个房间不等,总之就是比较大,不像我们今天的个人电脑。这也意味着,如果大家要写代码,就需要共用一台电脑。类比今天,就好比我们的代码都存放在云端主机,大家需要远程登录,然后在云端主机编写代码。

上图是 PD-11 小型机,UNIX 早期就是在这上面研发的。

如果多人协作时,特别是多个人维护一个项目时,就容易产生冲突。这里再补充个背景,当时的操作系统,包括 UNIX 以及其前身 Multics,都属于分时操作系统,是允许多用户协作的(主要通过 CPU 时间片轮转调度实现)。

所以马克·罗奇金德就发明了 SCCS,来避免代码编辑冲突。SCCS 的核心思路就是加锁,而且是独占锁:某个程序员签出代码库中的一部分,锁定这部分代码,其他程序员在锁持有者解锁之前不能修改它。

sccs create test.c   # 初始化SCCS文件(生成.s.test.c)
sccs edit test.c     # 锁定文件并检出可编辑版本
sccs delget test.c   # 提交修改、解锁并获取最新版本
sccs log test.c      # 查看版本日志

SCCS 虽然一定程度避免了修改冲突,但也存在比较明显的缺陷:

  • 如果锁定范围过大,比如一次性锁定了好多个文件,会导致开发效率被拖慢,因为别人都要等你修改完,相当于“锁等待”。
  • SCSS 的操作都是文件级别的,不支持目录级别的操作。
  • 当 SCCS 程序本身出错时,或者由于粗心大意(忘记解锁),就会导致代码一直被锁定,别人没法修改。

可以想象,如果我们今天开发一个需求,采用的是独占锁的方式,那得蛋疼死。比如我想安装个依赖,要更新下 package.json 或者 go.mod,都需要等锁释放。

RCS(Revision Control System)

RCS 是 SCCS 的改良版,于 1982 年由普渡大学的沃尔特·F·蒂奇首次发布,此时距离 SCCS 诞生已有十年之久。

RCS 和 SCCS 的思路差不多,也是锁机制,只是简化了命令,同时实现上有一些差异,比如存储分离增量、按逆序存储增量,使得 RCS 在大部分情况下操作更快。另外我们今天很熟悉的 co、ci 命令,也诞生自这里。

rcs -i test.c   # 初始化RCS文件(生成test.c,v)
co -l test.c    # 锁定并检出文件(co=check out)
ci test.c       # 提交修改并解锁(ci=check in)
rlog test.c     # 查看版本日志

另外 RCS 还提供了基础的分支功能,不过语法非常繁琐,以至于大家通常只在一个分支上工作。

不过 RCS 并没有解决 SCSS 的根本性缺陷,它依旧是一个单机的、文件级别、采用独占锁机制的代码版本控制工具,可协作性不强,仅一定程度保证了单机多人编辑代码的“并发安全”。

CVS(Concurrent Versions System)

CVS 是由 Dick Grune 于 1986 年开发,它建立在 RCS 之上,首次引入客户端-服务端模型。CVS 服务器通常运行在 Unix 系统上,而客户端可以在任何操作系统上。

CVS 的客户端-服务端模型,也被称为“集中式架构”,即中央服务器存储文件所有版本,客户端检出文件到本地,修改后提交到服务器

cvs checkout my-project  # 从服务器检出整个项目
cvs update               # 同步本地与服务器最新版本
cvs commit -m "修复XXbug" test.c  # 提交本地修改
cvs branch dev-branch    # 创建分支(文件级,成本高)

要知道 1985 年微软推出了搭载图形化 windows 系统的个人电脑,再早一点就是苹果的 Lisa 和 Mac(同样搭载图形化界面),微机时代到来,个人电脑开始普及。协作方式也从原来的多个人共用一台小型机,变成人人都可以在自己的电脑上工作。在新的协作方式下,CVS 这种客户端-服务端架构就很有意义

除了引入了集中式架构,CVS 也做了一些优化,比如使用差分(记录增量或差异)来存储相同文件的多个版本,对大文件修改更友好;支持仓库级别的变更记录,而非只记录单个文件等等。

然而, CVS 仍然存在一些关键性缺陷

首先就分支功能。CVS 的分支是文件级别的,比如你修改了 10 个文件,就需要为这 10 个分别创建分支。切换分支也是同理,需要 10 个文件都切换到指定分支。当然,CVS 的命令支持批量操作,但底层仍然会对每个文件执行分支创建或切换操作,速度就很慢。另外,CVS 的分支合并功能也比较弱。比如合并出现冲突,只提示“文件冲突”,需要你自己去逐行对比冲突在哪里。

其次,CVS 不支持原子性提交,这是个很严重的问题。原子性提交,指的是一次提交 N 个文件,要么全部成功,要么全部失败。CVS 采用的是逐文件独立提交的模式,比如一次提交 3 个文件到服务端,由于网络中断等原因,最终只提交了两个,甚至一个文件只提交了一半,那其他人拉取到的结果可能就是“残缺版”。

另外,CVS 不能追踪目录的变化,比如目录的新增、重命名、删除和移动。以目录重命名为例,假设目录 a 重名为 b,需要先在服务器手动执行重命名命令,然后通知团队成员删除本地的目录 a,并重新拉取服务端代码。操作繁琐不说,还会影响代码的版本历史。而且 CVS 还不支持空目录和符号链接。

SVN(Subversion)

年龄大一点的程序员大多应该听说过或者用过 SVN,我大二在学校院里做项目,用的就是 SVN,后来大三(2013 年)去公司实习,才发现互联网公司已经在用 Git 了。

SVN 诞生了 2000 年,千禧之年,由 CollabNet 这家公司开发,初衷是开发一款近似 CVS 操作方式的版本控制系统,只不过要修复 CVS 的所有缺陷。最终 CollabNet 确实做到了,也开启了代码版本控制的新时代,此时距 CVS 诞生已经近 15 年。

上图是当年用得比较多的小乌龟客户端。

SVN 解决了前面提到的 CVS 的所有缺陷,支持原子性提交,支持对目录变更的追踪,支持空目录和符号链接,提供了基于轻量复制(Copy-on-Write,写时复制)的分支机制,分支创建更快,且支持项目(目录)级别的分支,支持合并冲突提示等等。

总得来说,SVN 延续了 CVS 的集中式架构,同时又解决了 CVS 的各种缺陷,成为了一个真正易用的代码版本控制工具。

由于 SVN 对大文件比较友好(同样采用了 CVS 的差分存储),SVN 还可以用于美术资源的管理。就算是现在,也有很多公司的美术在使用 SVN 管理设计资源。而 Git 采用的是快照模式存储文件,频繁修改大文件容易造成仓库爆炸。

还有一点很重要,SVN 相比 Git 更简单,一般大家都是通过 SVN 客户端(比如前面提到的 TortoiseSVN)进行操作,命令和概念也少一些,主要就 checkout/commit/update 几个操作。虽然 Git 也有客户端,但复杂度更高一些,非技术同学可能用不明白。

另外 SVN 在权限管控上比较有优势,能够做到目录、文件级别的权限分配。相比之下,Git 需要依赖 GitLab 等第三方平台,才能支持更细粒度的权限管控。所以对于一些安全性要求比较高的公司,比如硬件公司、金融公司,仍然会使用 SVN 作为代码版本控制工具。

大文件友好、学习成本低、权限管控更细,让 SVN 在 Git 盛行的今天也没有被淘汰。

然而,作为集中式的代码版本控制工具,SVN 和它的前任 CVS 一样,存在天然缺陷。

首先就是所有版本相关操作(commit/update/分支管理/标签管理)都需要连接中央服务器,断网情况下没法操作,也没法查看完整的版本历史,比较受限于网络

其次就是数据安全性,如果中央服务器宕机或者数据损坏,在没有备份的情况下,整个项目的版本数据可能丢失。所以定期备份很重要。

再就是分支的操作效率比较低下。SVN 的分支是基于文件拷贝实现的,虽然已经基于 Copy-On-Write 进行过优化,但在分支创建时,仍然会遍历整个项目,为每个文件和目录创建元数据副本。在切换分支时,会进行文件的拷贝,把其他分支的文件拷贝到当前分支(如果有差异)。相比 Git 的指针跳转,SVN 创建和切换分支要慢很多(特别是大项目)。

最后就是冲突处理比较麻烦,比如树冲突(指在合并过程中,两个或多个更改影响了文件或目录的结构)的处理,容易出现伪冲突等等。最关键的是,SVN 每次要提交冲突处理结果需要联网,而 Git 在本地就可以完成冲突解决。

Git

虽然到这里才开始介绍 Git,但基于前面这么多铺垫,你应该能感受到 Git 要解决这些问题,必然需要引入更多的概念,从而增加工具的整体复杂性。

Git 于 2005 年由 Linus Torvalds(Linux 之父)为管理 Linux 内核开发创建。Linux 的开发者遍布全世界,当时他们用的是一款叫 BitKeeper 的商业工具来管理代码。BitKeeper 算是首个被广泛使用的分布式版本控制工具。不过 2005 年 BitKeeper 公司收回了免费授权,Linus 不得不花了 10 天时间编写了初版 Git。

Git 延续了 Bitkeeper 的思想,它是一个分布式的版本控制工具,而非集中式架构。核心是引入了本地仓库和暂存区,其中本地仓库可以当成是远端仓库的一个完整副本,包含所有分支信息及历史版本。

有了本地仓库,之前很多需要联网的操作就可以转为离线操作,比如提交代码,可以先提交到本地仓库,一段时间后再集中推送到远端仓库。创建分支、查看版本历史等操作也同理。另外也避免了对中央服务器的单点依赖,就算远端仓库没了,还可以从本地仓库恢复。

其次,Git 的分支更加轻量,本质是指向提交快照的指针,无论项目规模多大,分支的创建、切换、删除分支非常快,通常是毫秒级。同样的操作,SVN 可能需要几秒到数分钟不等。

另外,和 CVS 以及 SVN 等工具不同,Git 没有采用差分的方式存储不同版本的文件差异,而是直接存储文件快照。虽然会占用更多的空间,但切换分支、回滚、diff、合并都更快,因为不需要根据差分内容计算历史或者分支版本,只需要切换和对比快照。本质上也是一种空间换时间的思想,而且 Git 针对快照也做了一些优化,从而减少空间占用。

除了本地仓库,Git 还增加了暂存区的概念,通过 git add 可以把文件变更添加到暂存区,也可以通过 git stash 把修改存入暂存区。这样能实现更精细化的提交控制。

总之,Git 的分布式架构、离线处理能力、极致性能(处理超大规模代码库)、强大的分支合并能力,让多人协作开发变得更加方便和灵活,也特别适合开源项目的合作。 后来更是随着 Github (2008 年上线)的发展,成为当今代码版本控制工具的首选。


到这里,你应该能理解 Git 为什么复杂了。Git 要想足够灵活,且功能足够完备,还要继承前辈们的诸多优点,必然要引入很多概念和操作,来满足不同的场景。

不过不用太担心,你只要理解工作区、暂存区、本地仓库、远端仓库这些基本概念,理解分支的本质是什么,再熟练掌握开头介绍的那十几条命令,日常使用就没啥问题。

另外现在也有很多 GUI 的 Git 客户端,比如 JetBrains 系列 IDE 自带的 Git 功能就非常好用,特别是合并代码和解决冲突,非常人性化。免费的 Sourcetree 也挺好用。我自己一般是常规命令手动输入,合并代码(包括 pull 和 push)时用 GUI 工具,特别是大型项目,手动在编辑器里解决冲突太费劲了。

上图为 JetBrains IDE 自带的 Git 功能