引言
最近在工作中为了解决数值的多人在线编辑问题,我从零开始,参考Git的实现原理,基于Spring Boot搭建了一套多版本文件管理后台。在深入了解Git的原理后,发现Git的设计既简单又精巧,彻底摆脱了被Git分支折磨的情况。本文将主要介绍如何通过Spring Boot来开发一个支持Git所有能力的后端代码。我们可以先忘记Git的存在,专注于解决多人多版本在线编辑的过程,这样既能避免直接学习Git时的无从下手和枯燥感,又能精通Git设计的精髓,感受Git设计的巧妙之处,最终掌握Git的原理。
学习方式的不同决定了我们的学习效率。带着问题来探究过程从而获得结果的学习方式可以说是众多高效学习方式中的一种。因此,在开始文章之前,我需要先陈述清楚我遇到的实际问题。
在游戏领域,数值管理是游戏开发中非常重要的一个方面。在日常多团队开发管线的背景下,我们需要支持数值的在线多人编辑、多开发版本并行、版本归拢、版本发布测试这四个主要能力。而对于每一个数值,我们使用CSV格式来存储,CSV的本质就是文本文件,使用逗号作为分隔符。总的来说,我们需要有一个网页,网页里可以创建不同的开发版本,不同的开发版本可以编辑多个CSV文件,文件可以组合成文件夹(我们先限制最多只有两级文件夹)。当开发版本完成后,我们需要将这个版本合并回稳定的主干版本。
接下来,请带着这个问题,来看一下如何设计整个文件管理后台。
文件管理后台核心设计
文件系统
首先我们需要一个文件系统:文件和文件夹。我们设计表如下:
文件数据表 Data_File
字段 | 说明 | 备注 |
---|---|---|
Id | 文件主键ID | |
ProjectId | 归属的项目ID | |
Content | 文件的内容(CSV文本) | |
Hash | 根据CSV文本计算的Hash值 | ProjectId+Hash作为唯一索引。Hash值可以帮我们判断文件内容是否发生了变化 |
文件夹表 Data_Folder
字段 | 说明 | 备注 |
---|---|---|
Id | 文件夹主键ID | |
ProjectId | 归属的项目ID | |
FolderName | 文件夹的名称 | |
FileList | 文件列表(List数组) | |
Hash | 文件列表内容的Hash值 | 同样的,是FileList文本内容计算出来的Hash值。ProjectId+Hash作为唯一索引。Hash值可以帮我们判断文件夹内容是否发生了变化 |
对于文件列表FileList的每一项FileItemDto的具体定义如下:
json
{
"fileId": "对应的Data_Folder的Id或者是Data_File的Id",
"fileType": "file or folder 用来判断是文件还是文件夹",
"fileName": "文件的名称",
"createTime": "文件创建的时间戳"
}
用Data_File和Data_Folder就可以实现一个可以嵌套的文件管理系统。我们只需要修改对应文件的Content字段和文件夹里的FileList字段就能实现文件系统的增删查改。
但是需要注意的是,我们需要提供一个入口去查询已经推送到线上和测试的数值文件体系。如果我们在原来的文件和文件夹记录中直接修改字段内容,那么就会导致线上和测试环境拉取到的内容发生变化。因此我们需要在发布过程中,复制出一份文件系统的内容。
另外,当我们需要再开一个新的开发版本进行数值编辑时,我们不可能从一个空文件系统里从头开始编辑,所以我们需要在新创建版本时,基于最新最稳定的版本来复制一份文件系统。
因此我们总是需要保留文件系统在某一时刻的快照。这里面临一个关键问题:如何在数据库中高效地保存文件系统的历史状态?
传统的做法是直接修改现有记录,但这样做有几个问题:
- 如果直接修改文件内容,线上和测试环境拉取的内容会立即发生变化
- 无法保留历史版本,无法进行版本对比和回滚
- 多版本并行开发时容易产生数据冲突
解决方案:采用"只增不改"的设计模式
我们采用一种更巧妙的方式:当文件内容发生变化时,不修改现有记录,而是创建新的记录。具体来说:
- 文件变更处理:当CSV文件内容发生变化时,我们插入一个新的Data_File记录,生成新的文件ID
- 文件夹级联更新:找到包含该文件的文件夹,复制整个文件夹记录,只修改对应文件的fileId,其他内容保持不变
- 递归更新:如果父文件夹也需要更新,就继续向上级联,直到根文件夹
这种设计的优势:
- 天然支持快照:每个版本的文件系统状态都被完整保存
- 支持版本对比:可以轻松比较任意两个版本的文件差异
- 支持回滚操作:可以快速回到任意历史版本
- 避免数据冲突:不同版本的文件内容互不干扰
(由于我们限制了文件夹层级最多两级,级联更新的递归深度不会超过2次,性能开销可控)
这样,我们天然就支持了快照能力。
你可能会觉得这样太浪费数据库空间了。确实如此,但这是可以接受的,因为:
- 一个文件的大小最大也就10MB,对于CSV这种纯粹的文本文件,可以在存储时使用gzip进行压缩,可以节省90%的空间。
- 文件数量不会很多,我们是一个内部的数值系统,整个项目生命周期下来不会特别多,与to-C的数据库相比,压力和量级完全可以接受。
- 通过这种快照能力,我们可以回看在任意时间点的整个文件系统内容,方便后续的对比、回滚、审查需要。
最后还有一点需要注意的是,当某一次编辑产生的Hash值已经在系统里存在时,我们就可以直接复用这个记录,而不需要再额外插入数据。
版本管理
版本的数据表Data_Snap:
字段 | 名称 | 备注 |
---|---|---|
Id | 版本主键ID | |
ProjectId | 项目ID | |
Name | 版本名 | |
Desc | 描述 | |
ParentId | 父版本ID | |
RootFolderId | 根文件夹ID | 每当文件夹更新时需要更新该字段 |
当我们创建一个项目时,需要创建一个空的文件夹Data_Folder,再创建一个初始的Data_Snap,如下图所示。

初始化版本限制为不能修改,只能通过创建开发版本的方式进行修改。创建的新版本的父版本指向第一个版本的ID,如下图所示。

Data_Snap不再是一个只增不改的设计,在创建新版本时,会复制父版本的RootFolderId。后续该版本的文件系统发生变更时,只需要修改RootFolderId即可。
当编辑验证都完成后,就需要将开发版本合并回稳定版本。

合并动作将产生一个新的稳定版本,因此这个新稳定版本的父指针将有两个:一个是上一个稳定版本,另外一个是我们的开发版本。
上一个稳定版本和我们的开发版本合并,那么对应的文件系统就需要合并。这里的合并需要按照一个叫三路合并的算法来进行计算,下面我将展开说明。
版本合并 - 三路归并算法
不要被这个高大上的名字吓到,我将一步一步地阐述这个算法。
现实中,版本不会永远都那么简单,随着项目的推进、团队的扩大,数值的版本将会越来越多,之间的关系越来越复杂。比如下面的这个版本状态:
- 在C版本的基础上,checkout出D版本和F版本
- 然后C和D合并产生E版本
- 再然后F版本和E版本合并产生G版本

上面的这个图有一个专门的名字叫做:有向无环图(DAG)。在有向无环图中,满足任意两个节点有且至少有一个公共祖先 ,最近的那个祖先叫做最近公共祖先。比如:
- A和B的共同祖先是A
- D和B的共同祖先是A
- D和F的共同祖先是C
对于如何找到最近公共祖先,有多种算法:比如使用暴力求解法、并查集的方式、深度标记算法、深度优先搜索(DFS)算法等。
我们只需要知道的是,在有向无环图中,任意两个版本节点Snap_A和Snap_B都能找到一个最近的共同祖先节点Snap_C。那么当我们执行版本合并时:
- 根据Snap_A和Snap_C,我们就可以知道Snap_A更改了哪些内容
- 根据Snap_B和Snap_C,我们就可以知道Snap_B更改了哪些内容
在执行Snap_A和Snap_B的合并时,我们需要创建Snap_D,并重新计算新版本的文件内容:
- 对于Snap_C的部分,直接包含到Snap_D中
- 对于Snap_A和Snap_B各自新增、删除、修改的不共同的文件,都分别采纳变更到Snap_D中
- 对于Snap_A和Snap_B共同编辑的相同文件,就需要标记为冲突。冲突的内容需要记录Snap_A的变化和Snap_B的变化以及Snap_C的内容。冲突的内容需要交给用户,让用户来决定如何解决这个冲突
那么为了能够标识冲突的文件,我们的文件系统需要增加一个数据表:
冲突文件数据表 Data_ConflictFile
字段 | 说明 | 备注 |
---|---|---|
Id | 文件主键ID | |
ProjectId | 归属的项目ID | |
FromSnapFileId | 合并的源版本的文件ID | |
ToSnapFileId | 合并的目标版本的文件ID | |
AncestorSnapFileId | 两者共同的版本的文件ID |
文件夹的FileList中,我们新增一个类型conflict:
json
{
"fileId": "对应的Data_Folder的Id或者是Data_File的Id或者是Data_ConflictFile的id",
"fileType": "file or folder or conflict 用来判断是文件还是文件夹还是冲突文件",
"fileName": "文件的名称",
"createTime": "文件创建的时间戳"
}
当用户确认冲突的正确内容时,就创建新的Data_File并更改对应的FileItemDto的记录,从而递归生成新的文件夹。
以上就是三路归并算法的全部内容 而这也是整个多版本文件管理后台的核心设计。
没错,就那么简单。
Git核心设计
如果你坚持看到了这里,那么恭喜你,你已经掌握了Git的核心原理!!!
是的,没错。 Git中主要有三大对象:BLOB、TREE、COMMIT。分别对应着上面的 Data_File, Data_Folder 和 Data_Snap。下面我将详细介绍这三个关键对象。
Git的三大对象
-
BLOB对象用于存储文件的原始内容:
- 每个blob对象由文件内容经过SHA-1哈希生成唯一标识
- 如果两个文件内容完全相同,无论文件名如何,Git将共用一个blob对象
- BLOB是不可变的:每一次文件的变更,Git都会重新创建一个新的BLOB对象。Git会通过Hash值来判断文件是否相同
- 我们自己设计的Data_File结构就是参考的BLOB对象
-
TREE对象类似于文件夹或目录,用于组织blob对象和其他tree对象从而组成一个目录系统:
- tree保存每个文件或子目录的文件名、权限模式(mode)、类型(blob或tree)以及对应对象的SHA-1值
- 当两个目录的内容完全相同时,无论文件夹名如何,Git都将共用一个Tree对象。Git会通过Hash值来判断tree是否相同
- Tree对象是不可变的:每一次文件的变更,都会递归复制创建新的tree对象,从而新增一条新记录
- 我们自己设计的Data_Folder结构就是参考的TREE对象
-
COMMIT对象用于记录版本的历史信息,是版本管理的核心对象:
- 每个commit对象包含一个指向tree对象的SHA-1值,代表该版本的文件系统快照
- commit对象还包含父commit的SHA-1值,形成版本历史链。一个commit可以有多个父commit(合并提交)
- commit对象还包含作者信息、提交者信息、提交时间戳以及提交信息(commit message)
- commit对象是不可变的:每次提交都会创建一个新的commit对象,通过父指针链接形成完整的版本历史
- 我们自己设计的Data_Snap结构就是参考的COMMIT对象,其中RootFolderId对应commit中的tree指针,ParentId对应commit中的父指针
- 需要注意的是我们设计的Data_Snap是可变的,每一次文件系统变更,我们是更新RootFolderId而不是重新创建新的Data_Snap,这样做的目的是让生成的版本记录的深度尽可能小,展示出来的记录流程更加清晰
通过BLOB、TREE、COMMIT这三个对象的组合,Git实现了完整的版本控制系统:
- BLOB对象存储文件内容,通过内容哈希实现去重和内容完整性验证
- TREE对象组织文件结构,通过结构哈希实现目录级别的去重
- COMMIT对象记录版本历史,通过父指针链形成完整的版本图谱
这种设计使得Git能够高效地存储大量版本历史,支持快速的分支切换、版本比较和合并操作。我们的多版本文件管理系统正是借鉴了这种设计思想,将文件内容、文件结构和版本历史分离存储,实现了类似Git的版本管理能力。
实例说明
下面我们借助一个例子,来看一下Git中三大对象存储的具体内容是什么。
假设我们有以下的文件系统:
css
project/
├── README.md
└── src/
└── main.c
在执行一次git commit后,三大对象存储的内容如下:
1. BLOB对象
README.md的BLOB对象:
makefile
SHA-1: a1b2c3d4e5f6...
Type: blob
Size: 25 bytes
Content: "Hello World\nThis is a test project"
main.c的BLOB对象:
swift
SHA-1: f6e5d4c3b2a1...
Type: blob
Size: 45 bytes
Content: "#include <stdio.h>\nint main() {\n printf(\"Hello\");\n return 0;\n}"
2. TREE对象
src/目录的TREE对象:
makefile
SHA-1: b2c3d4e5f6a1...
Type: tree
Size: 35 bytes
Content:
100644 main.c f6e5d4c3b2a1...
根目录project/的TREE对象:
makefile
SHA-1: c3d4e5f6a1b2...
Type: tree
Size: 50 bytes
Content:
100644 README.md a1b2c3d4e5f6...
040000 src b2c3d4e5f6a1...
3. COMMIT对象
Git会创建一个COMMIT对象,记录这次提交的所有信息:
less
SHA-1: d4e5f6a1b2c3...
Type: commit
Size: 200 bytes
Content:
tree c3d4e5f6a1b2...
parent 0000000000000000000000000000000000000000
author John Doe <john.doe@example.com> 1640995200 +0800
committer John Doe <john.doe@example.com> 1640995200 +0800
Initial commit
Add README.md and main.c
以上就是Git的核心设计。
Git的分支
可能你会有一个很大的疑惑:在日常工作中,我们主要接触到的是branch,那么在整个Git的设计中,我们的branch在哪里呢?平时我们遇到或者听说过的Git的分离头指针又是什么意思呢?
上面说过Data_Snap和Git的commit的不同点是,commit也是不可变的,每一次执行git commit都会生成一个新的commit记录,从而生成Git的提交树。那么一般的这个树的深度都会特别深,而且每一次commit都会让树生长一节,因此我们需要一个指针来指向某一个分支的生长方向,而这个指针就是分支,如下图所示。

在绝大多数情况下,我们总是在最新的commit下面操作,然后生成新的commit。但Git支持通过任意一个commit的Hash值来回到某一个commit下查看当时的文件。
当我们切换分支时使用git checkout master
。 而当我们想回到任意一次commit时可以使用 git checkout [hash值前缀]
,这个时候我们操作的就不是分支的头commit。没有指向Git分支的头部 commit,就是分离头指针(Detached HEAD)。
Git的暂存区
前面讲过,Git的三大对象BLOB、TREE、COMMIT是不变的,每一次文件的变化,都需要递归更新生成新的TREE,并生成一个新的COMMIT。
那么如果是每一个文件的变更都要执行一遍这个递归更新的过程,是不是效率非常低? 那么我们是不是可以将多个文件的变更缓存起来,并一起变更,这样只需要执行一次递归更新的过程即可,就可以提高效率。
而Git就是通过暂存区来实现文件变更的缓存:
- 当你执行
git add
命令时,将新文件或已修改的文件添加到暂存区时,Git会为该文件的内容生成一个blob对象 - 当你执行
git commit
命令时,Git会根据暂存区的快照,生成一个tree对象,记录当前目录结构、文件名、权限以及每个文件(blob)或子目录(tree)的引用,并创建commit对象指向根tree。整个递归计算新的 Tree 的过程只需要执行一次。
Git分支合并与冲突
上面的内容已经讲过,Git的分支合并就是Git各个分支下最新的commit进行合并。那么 git 又是如何执行合并的呢?
Git处理分支合并的完整流程
Git的分支合并过程可以分为三个主要步骤:
1. 寻找最近公共祖先(LCA)
Git使用**深度优先搜索(DFS)**算法来寻找两个分支的最近公共祖先:
markdown
算法流程:
1. 从两个分支的最新commit开始,分别向上遍历父指针
2. 使用哈希表记录已访问的commit
3. 当某个commit被第二次访问时,即为最近公共祖先
4. 如果遍历到根commit仍未找到,则说明两个分支没有共同历史
这种算法的优点是时间复杂度为O(n),其中n是两个分支的commit总数。
2. 文件系统合并处理
找到最近公共祖先后,Git会进行三路合并:
对于每个文件,Git会比较三个版本:
- Base:最近公共祖先中的文件版本
- Ours:当前分支(HEAD)中的文件版本
- Theirs:要合并分支中的文件版本
合并规则:
- 如果Base = Ours ≠ Theirs:采用Theirs版本
- 如果Base = Theirs ≠ Ours:采用Ours版本
- 如果Ours = Theirs:采用任意版本(内容相同)
- 如果三个版本都不同:标记为冲突
自动合并的文件处理:
- Git会创建新的BLOB对象存储合并后的内容
- 创建新的TREE对象记录更新后的目录结构
- 创建新的COMMIT对象,包含两个父指针(合并提交)
3. 冲突展示与存储
当文件发生冲突时,Git会在工作区中创建冲突标记:
arduino
<<<<<<< HEAD
// 当前分支的内容
printf("Hello from main branch");
=======
// 要合并分支的内容
printf("Hello from feature branch");
>>>>>>> feature-branch
冲突信息的存储:
- 冲突内容存储在工作区的文件中,不会创建Git对象
- 冲突标记包含三个部分:HEAD内容、分隔符、要合并分支的内容
- Git的索引(暂存区)会记录冲突状态,阻止自动提交
- 用户必须手动解决冲突后,重新
git add
和git commit
冲突解决流程:
- Git检测到冲突,停止合并过程
- 在工作区文件中插入冲突标记
- 用户编辑文件,选择或合并冲突内容
- 用户执行
git add
将解决后的文件加入暂存区 - 用户执行
git commit
完成合并