Apache Subversion 1.15 源码深度剖析-第 3 章 文件系统层(FS 抽象与 FSFS 后端)

Apache Subversion 1.15 源码深度剖析-第 3 章 文件系统层(FS 抽象与 FSFS 后端)

3.0 本章导读

前两章我们站在「仓库(repository)」与「版本库服务」的高度俯瞰了 Subversion 的整体分层:客户端 → 访问层(ra)→ 仓库层(repos)→ 文件系统层(fs)→ 存储介质。仓库层负责钩子(hook)、权限(authz)、UUID、修订号分配与转储/装载等「策略性」事务;而真正回答「某个 revision 的某个 path 究竟对应哪些字节」的,是本章的主角------文件系统层。

文件系统层由两个互相咬合的部件组成:

  1. FS 抽象(facade + vtable) ,位于 libsvn_fs。它用一组与后端无关的 C 结构体(svn_fs_t、svn_fs_root_t、svn_fs_txn_t、svn_fs_id_t)和函数指针表(vtable)把「文件系统」这个概念抽象出来,使得上层 repos、ra、svnadmin 完全不关心数据是存在普通文件里还是存在 Berkeley DB 里。
  2. FSFS 后端 ,位于 libsvn_fs_fs。它是 1.15 时代 Subversion 默认且唯一被推荐的存储引擎:一个修订 = 一个(或打包后若干修订拼接成的)不可变文件。它没有外部数据库依赖,仅用 APR 的文件与锁原语即可实现「读不阻塞写、写不阻塞读」的并发模型。

本章只讲这两层,不讲 repos 如何触发钩子,也不讲 ra 如何走 HTTP。目标是把每一个结构体字段、每一行磁盘格式、每一次地址换算都落到可回溯的源码行号与文件路径上。

3.0.1 涉及文件清单

FS 抽象层(libsvn_fs)

  • [subversion/include/svn_fs.h](subversion-1.15.0/subversion/include/svn_fs.h)------公开 API。节点/节点修订的经典注释在 L882-L1016,关系判定 API 在 L965-L1014,事务模型在 L1019-L1164。
  • [subversion/libsvn_fs/fs-loader.h](subversion-1.15.0/subversion/libsvn_fs/fs-loader.h)------FSAP 抽象的核心:六张 vtable(library/fs/txn/root/history/id/changes_iterator)与全部不透明对象结构体的真实定义(L68-L169 与 L470-L577)。
  • [subversion/libsvn_fs/fs-loader.c](subversion-1.15.0/subversion/libsvn_fs/fs-loader.c)------后端按 fs-type 动态选择的实现。

FSFS 后端(libsvn_fs_fs)

  • [subversion/libsvn_fs_fs/structure](subversion-1.15.0/subversion/libsvn_fs_fs/structure)------本章最重要的一手资料,一份完整的磁盘格式设计文档(无扩展名)。
  • [subversion/libsvn_fs_fs/structure-indexes](subversion-1.15.0/subversion/libsvn_fs_fs/structure-indexes)------format 7+ 逻辑寻址下 L2P/P2L 索引的二进制格式。
  • [subversion/libsvn_fs_fs/fs.h](subversion-1.15.0/subversion/libsvn_fs_fs/fs.h)------后端私有结构:representation_t(L543-L596)、node_revision_t(L602-L648)、fs_fs_data_t(L323-L520)、全部 MIN_*_FORMAT 常量(L130-L201)。
  • [subversion/libsvn_fs_fs/low_level.c](subversion-1.15.0/subversion/libsvn_fs_fs/low_level.c) / [low_level.h](subversion-1.15.0/subversion/libsvn_fs_fs/low_level.h)------node-rev 文本 marshalling、PLAIN/DELTA 头、trailer/footer、changed-path 列表的读写。
  • [subversion/libsvn_fs_fs/id.c](subversion-1.15.0/subversion/libsvn_fs_fs/id.c) / [id.h](subversion-1.15.0/subversion/libsvn_fs_fs/id.h)------节点修订 ID 的解析、反解析与关系判定。
  • [subversion/libsvn_fs_fs/index.c](subversion-1.15.0/subversion/libsvn_fs_fs/index.c) / [index.h](subversion-1.15.0/subversion/libsvn_fs_fs/index.h)------逻辑寻址的地址换算、索引构建与缓存。
  • [subversion/libsvn_fs_fs/rev_file.h](subversion-1.15.0/subversion/libsvn_fs_fs/rev_file.h)------rev/pack 文件句柄结构。
  • [subversion/libsvn_fs_fs/transaction.c](subversion-1.15.0/subversion/libsvn_fs_fs/transaction.c)------提交的最终整合阶段(write_final_rev)。
  • [subversion/libsvn_fs_fs/pack.c](subversion-1.15.0/subversion/libsvn_fs_fs/pack.c)------svnadmin pack 的逻辑寻址重排算法。
  • [subversion/libsvn_fs_fs/recovery.c](subversion-1.15.0/subversion/libsvn_fs_fs/recovery.c)------崩溃恢复。
  • [subversion/libsvn_fs_fs/verify.c](subversion-1.15.0/subversion/libsvn_fs_fs/verify.c)------svnadmin verify 后端实现。
  • [subversion/libsvn_fs_fs/stats.c](subversion-1.15.0/subversion/libsvn_fs_fs/stats.c)------svnfsfs stats 后端实现。
  • [subversion/svnfsfs/svnfsfs.c](subversion-1.15.0/subversion/svnfsfs/svnfsfs.c)------svnfsfs 工具入口。

skel 编码

  • [subversion/include/private/svn_skel.h](subversion-1.15.0/subversion/include/private/svn_skel.h)------svn_skel_t 与词法规则(L35-L104)。
  • [subversion/libsvn_fs_base/notes/structure](subversion-1.15.0/subversion/libsvn_fs_base/notes/structure)------BDB 后端用 skel 表示 node-rev 与 representation 的权威描述(L104-L330)。

变体后端

  • [subversion/libsvn_fs_x/structure](subversion-1.15.0/subversion/libsvn_fs_x/structure)------FSX 的设计文档。
  • [subversion/libsvn_fs_x/fs.h](subversion-1.15.0/subversion/libsvn_fs_x/fs.h)------FSX 的 SVN_FS_X__FORMAT_NUMBER。

3.0.2 读完本章你将能够

  • 说清 svn_fs_t/svn_fs_root_t/svn_fs_txn_t/svn_fs_id_t 各自拥有哪些字段,以及 loader 层字段与 fsap_data 的职责边界;
  • 沿着 svn_fs_type() 的代码路径,解释一个 db/fs-type 里的一个字符串如何最终被翻译成一组函数指针;
  • 手工还原 revision 0 在两个寻址模式下的逐字节内容 ,并解释 text: 0 3 4 4 2d29... 中每个数字的含义;
  • 写出 node-rev 的文本格式字段表,并把它与 BDB 后端的 skel 形式一一对应;
  • 在 format 7+ 逻辑寻址下,用 page_size/page_table_index/item_index 手工推算出某个 item 的物理偏移;
  • 解释 svnadmin pack 为何在逻辑寻址下能重排 item,而物理寻址不能;
  • 从格式 1 到格式 8 逐版说出「这一版新增了什么、为什么」。

3.0.3 一个 FSFS 仓库的目录树

下面这张图对应开启 sharding(sharded 1000)且已执行 svnadmin pack 的 FSFS 仓库。注意 db/ 才是 FS 层的边界,conf/、hooks/ 属于仓库层:

text 复制代码
$REPOS/
├── conf/                       仓库层配置(svnserve.conf / passwd / authz)
├── hooks/                      仓库层钩子
├── format                      仓库级 format(不是 FS format)
└── db/                         ← 本章的边界从这里开始
    ├── revs/                   修订数据
    │   ├── 0/                  分片目录:rev 0..999
    │   │   ├── 0               未打包的修订文件 r0
    │   │   └── 1
    │   ├── 1.pack/             已打包分片目录
    │   │   ├── pack            拼接后的 pack 文件
    │   │   └── manifest        物理寻址:每个 rev 在 pack 内的偏移清单
    │   └── 2/                  尚未凑满一个分片,仍以散文件存在
    │       ├── 2000
    │       └── 2001
    ├── revprops/               修订属性(与 revs 平行的 shard/pack 结构)
    │   ├── 0/
    │   │   └── 0
    │   └── 1.pack/
    │       ├── 1000.3          <rev>.<counter> 命名的 revprop 包
    │       └── manifest
    ├── transactions/           进行中的事务目录
    │   └── 12-1.txn/
    │       ├── props
    │       ├── next-ids
    │       ├── changes
    │       └── node._1.0
    ├── txn-protorevs/          事务的原型修订文件
    │   ├── 12-1.rev
    │   └── 12-1.rev-lock
    ├── txn-current             下一个事务序号(base-36)
    ├── txn-current-lock        序列化 txn-current 的 get&increment
    ├── write-lock              全局写锁(提交末段 / revprop 修改 / pack)
    ├── pack-lock               pack 专用锁(仅 format 7+)
    ├── current                 单个数字:youngest revision
    ├── fs-type                 内容为 "fsfs"
    ├── format                  内容如 "8\nlayout sharded 1000\naddressing logical\n"
    ├── fsfs.conf               后端配置
    ├── min-unpacked-rev        最老的未打包修订号
    ├── rep-cache.db            表示共享用的 SQLite 数据库
    ├── locks/                  锁(名字是 FS path 的 MD5)
    │   └── 3b1/3b1b011fed614a263986b5c4869604e8
    └── node-origins/           节点 ID → 起源 node-rev 的懒缓存
        └── <partial-nodeid>

有了这张地图,后面所有的代码分析都能落到具体的文件名上。


3.1 对象模型:svn_fs.h 与 loader 结构体

3.1.1 公开 API 里的「节点」与「节点修订」

svn_fs.h 用一段非常经典的注释定义了整套数据模型的语义,位于 L882-L925。它的核心论断可以用四句话概括:

  • 一个 node 大致相当于 Unix 的 inode:它是文件或目录,内容会变,改名后还是同一个 node;
  • 一个 node revision 是 node 内容在「某个时间段(一个或多个 revision)」里的具体版本;一旦创建,其内容永不改变;
  • 改变一个 node 的内容总是产生一个新的 node revision;
  • 删除文件时,我们不删除 node 或它的任何 revision,而只是把目录里对它的引用摘掉------因为要能重建历史版本。

这段注释还点了两个 @note:其一,在同一 revision/transaction 的语境下说「the node」时,通常指的是「node 在这一 revision 中的样子」,也就是 node revision;其二,不同 node(以及同一 node 的不同 revision)经常共享相同内容,FS 库允许它们共享同一份数据而不重复存储------这正是 §3.4 representation 共享的语义基础。

紧接着 L927-L963 定义了 1.9 才引入的 svn_fs_node_relation_t,它把过去只有三态(无关/相等/相关)的判定细化为三种明确的语义:

c 复制代码
typedef enum svn_fs_node_relation_t
{
  /* 无关:若两个 root 来自不同的 svn_fs_t 实例,永远算无关 */
  svn_fs_node_unrelated = 0,

  /* 未改变:子树里对应节点内容、属性、created-path、created-rev 全同 */
  svn_fs_node_unchanged,

  /* 有共同祖先:可能是其中之一,但两者之间存在改动 */
  svn_fs_node_common_ancestor
} svn_fs_node_relation_t;

注意枚举注释里反复出现的一句免责声明:出于效率考虑,实现可能 把某些其实「未改变」的组合上报为「仅有共同祖先」。也就是说 svn_fs_node_common_ancestor 是「未改变」的超集,调用方不能反过来假设「common_ancestor ⇒ 一定变了」。这一「允许保守上报」的设计,使得 FSFS 可以只比较 node-id 而不必真正展开两个子树做内容比对,从而保持 O(1) 判定。

公开 API 一共提供了三代关系判定接口,全部依赖某个后端实现:

  • [svn_fs_compare_ids](subversion-1.15.0/subversion/include/svn_fs.h#L969-L978):返回 -1/0/1,分别表示「无关/等价/相关」。注释明确建议新代码改用 svn_fs_node_relation()。
  • [svn_fs_check_related](subversion-1.15.0/subversion/include/svn_fs.h#L982-L991):返回布尔,「是否属于同一 node」。
  • [svn_fs_parse_id / svn_fs_unparse_id](subversion-1.15.0/subversion/include/svn_fs.h#L994-L1014):ID 的字符串化与反字符串化;svn_fs_parse_id 已被标记 SVN_DEPRECATED,注释甚至写明它「不保证对所有后端可用」,因为 loader 层的 library vtable 中该槽位对 FSFS 是 NULL(见 §3.2)。

loader 把 compare 与 unparse 放在 id_vtable_t(fs-loader.h L451-L457):

c 复制代码
typedef struct id_vtable_t
{
  svn_string_t *(*unparse)(const svn_fs_id_t *id, apr_pool_t *pool);
  svn_fs_node_relation_t (*compare)(const svn_fs_id_t *a,
                                    const svn_fs_id_t *b);
} id_vtable_t;

FSFS 把这两个槽位填成 svn_fs_fs__id_unparse 与 svn_fs_fs__id_compare(id.c L418-L421)。

3.1.3 不透明对象背后:loader 的结构体定义

svn_fs.h 里 svn_fs_t 等只以 typedef struct svn_fs_t svn_fs_t; 出现,真正的定义在 fs-loader.h L470-L564。这是理解「抽象层与后端如何分工」的关键:每个对象都拆成「loader 拥有的通用字段」+「fsap_data 指向的后端私有数据」+「一张 vtable 指针」。

svn_fs_t(L470-L494):

c 复制代码
struct svn_fs_t
{
  apr_pool_t *pool;                      /* 对象自身所在的 pool */
  char *path;                            /* 仓库顶层目录(注意:指向 repos 根,不是 db) */
  svn_fs_warning_callback_t warning;     /* 警告回调 */
  void *warning_baton;
  apr_hash_t *config;                    /* fs_config(含 FS_TYPE、shard size、log addressing 等) */
  svn_fs_access_t *access_ctx;           /* 谁在用这个 fs(用户名 + lock token) */
  const fs_vtable_t *vtable;             /* FSAP 函数表 */
  void *fsap_data;                       /* 后端私有:FSFS 是 fs_fs_data_t */
  const char *uuid;                      /* open/create/set_uuid 时写入 */
};

值得留意两点。第一,path 存的是仓库根 (如 /srv/svn/repo),而 FSFS 实际数据在 path/db------这解释了为什么 svnfsfs 的 open_fs() 要先 svn_dirent_join(path, "db", pool) 再调 svn_fs_open2(svnfsfs.c L176-L195)。第二,uuid 由 loader 保存,源码注释(fs_vtable_t 里)特地指出「没有 get_uuid,因为它就在 svn_fs_t.uuid 里」。

svn_fs_txn_t(L497-L513):持有 fs、base_rev(若非基于某 revision 则为 SVN_INVALID_REVISION)、id(事务名字符串)、vtable(txn_vtable_t)、fsap_data(FSFS 是 transaction_t,见 fs.h L523-L537)。

svn_fs_root_t(L516-L541):这里有个非常实用的细节------is_txn_root 是布尔,txn_flags、txn 只在事务 root 时有效,而 rev 字段在两种 root 下都有意义:对 revision root 它是该修订号,对 transaction root 它是事务所基于的修订号 。因此 svn_fs_revision_root_revision(root) 对事务 root 返回的是 base revision(参见 svn_fs.h L1441-L1448)。

svn_fs_id_t(L558-L563)只有一个 vtable 和一个 fsap_data------这意味着「ID 是什么」完全由后端定义,loader 只认 compare 与 unparse 两个操作。FSFS 的 ID 实际结构在 id.c L34-L47:

c 复制代码
typedef struct fs_fs__id_t
{
  svn_fs_id_t generic_id;           /* API 可见部分(vtable + fsap_data) */
  struct
    {
      svn_fs_fs__id_part_t node_id;
      svn_fs_fs__id_part_t copy_id;
      svn_fs_fs__id_part_t txn_id;
      svn_fs_fs__id_part_t rev_item;   /* rev + item_index(逻辑寻址专用) */
    } private_id;
} fs_fs__id_t;

也就是说 FSFS 的一个 svn_fs_id_t 实际携带了四个 「二元组」(每个 svn_fs_fs__id_part_t 是 {revision, number})。前三个 node_id/copy_id/txn_id 与文本形式 node_id.copy_id.txn_id 一一对应;第四个 rev_item 是 1.9 逻辑寻址引入的「直接定位」字段,它在文本形式里以 r<rev>/<item_index> 出现(详见 §3.5.7)。

3.1.4 事务与不可变性的 API 语义

svn_fs.h 事务段(L1019-L1104)给出了 FS 层最关键的一致性承诺:提交要么完全成功,要么完全失败,绝不半途。此外它还定义了两个术语:

  • immutable nodes :revision 里的节点,内容永不变;它们绝不引用 mutable node;
  • mutable nodes :事务里已改动、尚未提交的节点;它们可以引用 immutable node 或其他 mutable node。

这段注释还解释了 FSFS 会「best effort」避免事务名重用:因为事务名由一个存储在数据库里的计数器生成,每次 begin_txn 都会自增。但转储/装载会重置这个计数器 ,因此 dump/load 之后事务名可能被重用(这一点直接对应 §3.5.4 的 txn-current 文件设计)。事务的三个行为 bit 定义在 L1114-L1139:SVN_FS_TXN_CHECK_OOD(@warning ### Not yet implemented.)、SVN_FS_TXN_CHECK_LOCKS、SVN_FS_TXN_CLIENT_DATE(1.9 新增,允许客户端自定义 svn:date)。

svn_fs_commit_txn 的契约在 L1181-L1243:冲突时返回 SVN_ERR_FS_CONFLICT,成功时把新修订号写入 *new_rev;若事务带 SVN_FS_TXN_CLIENT_DATE 且设置了 svn:date,则新修订用该值,否则用「当前时间」。

至此我们已经把「对象模型」讲清楚:语义在 svn_fs.h,结构在 fs-loader.h,实现细节(尤其 ID 的四元组)在后端头文件。下一节我们顺着 loader 的代码,看这些结构体是怎么被创建、vtable 是怎么被填上的。


3.2 vtable 的动态加载:fs-loader.c 全流程

3.2.1 编译期已知的三个后端

fs-loader.c 的起点是三个静态的「后端定义」节点,串成一条单链表(L100-L145):

c 复制代码
struct fs_type_defn {
  const char *fs_type;         /* "fsfs" / "fsx" / "bdb" */
  const char *fsap_name;       /* DSO 名片段:"fs" / "x" / "base" */
  fs_init_func_t initfunc;     /* 静态链接时的 init 入口 */
  void * volatile vtable;      /* fs_library_vtable_t,CAS 缓存 */
  struct fs_type_defn *next;
};

static struct fs_type_defn base_defn = { SVN_FS_TYPE_BDB, "base", ... };
static struct fs_type_defn fsx_defn  = { SVN_FS_TYPE_FSX, "x",    ..., &base_defn };
static struct fs_type_defn fsfs_defn = { SVN_FS_TYPE_FSFS,"fs",   ..., &fsx_defn };
static struct fs_type_defn *fs_modules = &fsfs_defn;   /* 链表头 */

这里有三处极其重要的信息:

  1. fsap_name 与 fs_type 不同 :FSFS 的 fs_type 是 "fsfs",但 DSO 名片段 fsap_name = "fs",因此动态库名是 libsvn_fs_fs-1.so,入口符号是 svn_fs_fs__init。而三方的自定义 FS「foo」则 fsapp_name == fs_type == "foo",库名 libsvn_fs_foo、符号 svn_fs_foo__init。BDB 的 fsap_name = "base" 对应 libsvn_fs_base。
  2. initfunc 是否非空取决于编译宏 SVN_LIBSVN_FS_LINKS_FS_BASE / _FS_X / _FS_FS:静态链接时直接写进函数指针,否则为 NULL,交给 DSO 加载。
  3. vtable 用 void * volatile + 原子 CAS 缓存,避免每次操作都做一次 strcmp 链查找。

3.2.2 DSO 加载:load_module

[load_module](subversion-1.15.0/subversion/libsvn_fs/fs-loader.c#L148-L192) 在 SVN_USE_DSO && APR_HAS_DSO 生效。它先对 name 做字母数字白名单校验 (防止用 fs-type 里的怪字符构造出非法库名),再用 apr_psprintf 拼出 libname = "libsvn_fs_%s-" SVN_DSO_SUFFIX_FMT 与 funcname = "svn_fs_%s__init",然后 svn_dso_load + apr_dso_sym。若库不存在,函数静默返回 SVN_NO_ERROR 而把 *initfunc 留为 NULL------调用方再据 NULL 决定是否报 SVN_ERR_FS_UNKNOWN_FS_TYPE。

c 复制代码
libname = apr_psprintf(pool, "libsvn_fs_%s-" SVN_DSO_SUFFIX_FMT,
                       name, SVN_VER_MAJOR, SVN_SOVERSION);
funcname = apr_psprintf(pool, "svn_fs_%s__init", name);
SVN_ERR(svn_dso_load(&dso, libname));
if (! dso)
  return SVN_NO_ERROR;

3.2.3 取到手并校验版本:get_library_vtable_direct

[get_library_vtable_direct](subversion-1.15.0/subversion/libsvn_fs/fs-loader.c#L195-L251) 是「真正加载模块」的地方。它先用 svn_atomic_casptr(&fst->vtable, NULL, NULL) 尝试命中缓存;未命中时:

  1. initfunc = fst->initfunc,若为空则 load_module;
  2. 调 svn_fs_initialize(NULL) 保证 common_pool 存在(注释坦承:API 兼容规则不保证 应用会调 svn_fs_initialize,所以这里做 best-effort,把 common pool 挂在全局 pool 下,线程碰撞窗口很小);
  3. 在 common_pool_lock 保护下 调用 initfunc(my_version, vtable, common_pool)------把 loader 版本与 common pool 传进去;
  4. 调 (*vtable)->get_version() 与 loader 版本做 svn_ver_equal 比较,要求精确相等 ,不等则报 SVN_ERR_VERSION_MISMATCH。vtable 的第一个字段永远是 get_version,源码注释在 fs-loader.h L70-L75 里写明「该字段永远排在 vtable 最前,且 loader 与 module 之间要求版本精确相等」;
  5. 最后再次 svn_atomic_casptr 把 vtable 记进缓存。

3.2.4 三方后端的兜底:get_library_vtable

[get_library_vtable](subversion-1.15.0/subversion/libsvn_fs/fs-loader.c#L279-L330) 先在不加锁 的情况下遍历三个编译期已知节点(注释解释:这样无锁开销,三方模块才需要锁)。若 fs_type 不在已知集合中且启用了 DSO,则:

c 复制代码
fst = &(*fst)->next;
SVN_ERR(svn_fs_initialize(NULL));
SVN_MUTEX__WITH_LOCK(common_pool_lock, get_or_allocate_third(fst, fs_type));
known = TRUE;

[get_or_allocate_third](subversion-1.15.0/subversion/libsvn_fs/fs-loader.c#L255-L274) 分配一个新的 fs_type_defn,fsap_name = fs_type,挂在链表尾。源码注释里有一句很有趣的「测试技巧」:注意 BDB 与 FSFS 不遵循三方命名规则,因此可以拿它们来测试三方加载器 ------把 BDB 仓库的 fs-type 改成 "base",或把 FSFS 的改成 "fs",它们就会按三方路径被加载。若最终仍 !known,报 SVN_ERR_FS_UNKNOWN_FS_TYPE。

3.2.5 后端的选择依据:svn_fs_type

[svn_fs_type](subversion-1.15.0/subversion/libsvn_fs/fs-loader.c#L332-L373) 的逻辑是理解「仓库类型判定」的关键:

  1. 读取 path/fs-type(宏 FS_TYPE_FILENAME "fs-type",L73);
  2. 若文件不存在(APR_STATUS_IS_ENOENT):检查 path 是否为目录 。若是,则认定为 SVN_FS_TYPE_BDB 并返回------因为古老的 BDB 仓库没有 fs-type 。若路径本身也不存在,则如实抛出 ENOENT;
  3. 文件存在:读第一行,去掉换行后作为 fs_type 返回。
c 复制代码
err = svn_io_file_open(&file, filename, APR_READ|APR_BUFFERED, 0, pool);
if (err && APR_STATUS_IS_ENOENT(err->apr_err))
  {
    svn_node_kind_t kind;
    ... svn_io_check_path(path, &kind, pool) ...
    if (kind == svn_node_dir)
      { svn_error_clear(err); *fs_type = SVN_FS_TYPE_BDB; return SVN_NO_ERROR; }
    return err;
  }

[fs_library_vtable](subversion-1.15.0/subversion/libsvn_fs/fs-loader.c#L375-L388) 就是这两步的组合:先 svn_fs_type 判定类型,再 get_library_vtable 取表。整个链路可以画成:

text 复制代码
svn_fs_open(path)
  └─ fs_library_vtable(&vtable, path)          /* L375 */
       ├─ svn_fs_type(&fs_type, path)          /* L332:读 db/fs-type */
       └─ get_library_vtable(vtable, fs_type)  /* L279 */
            └─ get_library_vtable_direct(...)  /* L195 */
                 ├─ load_module(...)           /* L148(DSO)*/
                 ├─ initfunc(my_version, vtable, common_pool)
                 └─ 版本精确校验 + 缓存 vtable

3.2.6 三张对象 vtable 的形状

loader 把「对 FS 的操作」拆成多张表,全部定义在 fs-loader.h:fs_vtable_t(L190-L270)、txn_vtable_t(L273-L288)、root_vtable_t(L299-L431)、history_vtable_t(L441-L448)、changes_iterator_vtable_t(L434-L438)。

fs_vtable_t 覆盖 youngest_rev、refresh_revprops、revision_prop(plist)、change_rev_prop、set_uuid、revision_root、begin_txn、open_txn、purge_txn、list_transactions、deltify、lock/unlock/get_lock/get_locks、info_format/info_config_files/info_fsap、verify_root、freeze 以及若干 BDB 专用槽(bdb_set_errcall)与 ioctl。root_vtable_t 里有一处引用规则的注释(L291-L298):当多个 root 参数分属不同后端时,「若其中之一名为 target,则用 target 的 vtable,否则用第一个 root 参数的 vtable」。

loader 还负责「模拟实现」某些可选 vtable 槽。fs-loader.c 顶部两个宏(L75-L89):SVN_FS_EMULATE_PATHS_CHANGED、SVN_FS_EMULATE_REPORT_CHANGES,默认都是 FALSE;含义是「若后端没实现 paths_changed/report_changes,loader 会自动用另一套 API 模拟」。这是一层「向后兼容垫片」:老后端只需实现其中之一。

3.2.7 FSFS 与 FSX 如何注册自己

FSFS 的注册在 [libsvn_fs_fs/fs.c](subversion-1.15.0/subversion/libsvn_fs_fs/fs.c):静态 library_vtable(L670-L687)按顺序填入 fs_version, fs_create, fs_open, fs_open_for_recovery, fs_upgrade, fs_verify, fs_delete_fs, fs_hotcopy, fs_get_description, svn_fs_fs__recover, fs_pack, fs_logfiles, NULL /* parse_id */, fs_set_svn_fs_open, fs_info_dup, NULL /* ioctl */。注意 parse_id 槽位是 NULL------这正是公开 API svn_fs_parse_id 被弃用、且源码注释说「不保证对所有后端可用」的根因。svn_fs_fs__init(L689-L699)先做一份 svn_version_checklist_t 依赖检查(svn_subr、svn_delta、svn_fs_util),再设置 *vtable = &library_vtable。FSFS 的 bdb_logfiles(L607-L617)是一个 no-op,返回空数组,仅为 1.0.x 兼容而存在。

FSX 的注册在 [libsvn_fs_x/fs.c](subversion-1.15.0/subversion/libsvn_fs_x/fs.c),同名函数 svn_fs_x__init(L651-L675),同样设置 library_vtable。FSX 的格式号 SVN_FS_X__FORMAT_NUMBER 定义在 fs.h L124,其值为 2------远小于 FSFS 的 8,因为两者是独立的格式编号空间 (structure 文档明确写「So far, there is only format 1」并注明「FSX 与 FSFS 格式号无关」)。

至此,抽象层的「人」已经全部登场:五个结构体、六张 vtable、一条按 fs-type 分派的加载链。下面我们正式进入后端,从「表示(representation)」这一层开始。


3.3 skel 编码:一种「稠密、开放」的序列化语法

在深入 representation 之前,必须先理解 Subversion 内部一个古老但极其重要的序列化机制------skel(skeleton) 。它由 svn_skel.h 定义,并被 BDB 后端用于表示 node-rev、representation、事务、锁等一切结构化记录;FSFS 后端则只在属性列表(property list / inherited property list)这一类数据上继续使用它。理解 skel 是把 BDB 与 FSFS 两种 node-rev 表示对照起来的前提。

3.3.1 为什么要发明 skel

svn_skel.h 的开头注释(L35-L77)给出了动机,原文非常直白:

Subversion needs to read a lot of structured data from database records. Instead of writing a half-dozen parsers and getting lazy about error-checking, we define a reasonably dense, open-ended syntax for strings and lists, and then use that for the concrete representation of files, directories, property lists, etc.

翻译过来就是:与其为每一种记录各写一个解析器(并且容易在错误检查上偷懒),不如定义一个单一 的、足够稠密、可扩展的「字符串与列表」语法,把那些繁琐的逐字符校验集中到一处。这正是 Subversion 工程文化的体现:把校验收敛到唯一的解析入口。

3.3.2 词法规则:空白、字符类与两种原子

注释 L50-L77 精确给出了字节级的词法:

  • 空白字节(whitespace) :9, 10, 12, 13, 32,即 ASCII 的 TAB、LF、FF、CR、SPACE。注意它不包含 11(VT)与常规的其它控制字符。
  • 数字字节(digit) :48..57,即 '0' 到 '9'。
  • 名字字节(name) :65..90(A-Z)或 97..122(a-z)。注意它不含数字、下划线、连字符。

一个 atom(原子) 有两种形式:

  1. 隐式长度形式(implicit-length form) :首字节是 name 字符,且整串不含 空白字符、'('(40)、')'(41)。此时原子内容就是整串字符本身。例:file、delta、fulltext、svndiff、add、modify。
  2. 显式长度形式(explicit-length form) :一串数字字节 ,紧跟恰好一个 空白字符,再紧跟 N 个字节,其中 N 是该数字按十进制解释的值。此时原子内容是那 N 个字节。例:5 hello 表示内容为 hello 的原子(数字 5、一个空格、5 字节)。

第二种形式的用途显而易见:当内容包含空白或括号(例如文件内容、路径、校验和、svndiff 数据)时,必须用显式长度「框住」它。

一个 list(列表) 是:'('(40)+零或多个 atom/list+')'(41)。'(' 之后、')' 之前以及任意两个元素之间都允许零或多个空白字符。有一条硬约束:若两个相邻元素都是 atom,它们之间必须至少有一个空白字符------否则第二个 atom 的首字节会被并入第一个(隐式长度 atom 只要不见空白/括号就会一直吞下去)。

3.3.3 svn_skel_t 的内存表示

解析后的对象是 struct svn_skel_t(L84-L104):

c 复制代码
struct svn_skel_t {
  svn_boolean_t is_atom;              /* TRUE 表示这是 atom,FALSE 表示 list */
  const char *data;                   /* atom:内容起点;list:整个 body 的起点 */
  apr_size_t len;                     /* atom:内容长度;list:整个 body 长度 */
  struct svn_skel_t *children;        /* list:以 NULL 结尾的链表头(经 next 串联) */
  struct svn_skel_t *next;            /* 同一父列表中的下一个兄弟 */
};

关键点:data/len 指向原始输入缓冲区 ,解析器不复制内容(注释明说「The returned skel objects point into the block indicated by DATA and LEN; we don't copy the contents」)。因此 svn_skel__parse 的入参缓冲区必须活得比 skel 久。children 是链表头,兄弟通过 next 串联;list_length 会对 atom 返回 -1(L187-L188)。

3.3.4 skel API 全览

svn_skel.h 提供的操作(L107-L230)可以分为四组:

  • 解析/构造 :svn_skel__parse(data, len, pool)、svn_skel__str_atom(str, pool)、svn_skel__mem_atom(addr, len, pool)、svn_skel__make_empty_list(pool)、svn_skel__dup(src, dup_data, pool)。
  • 拼接 :svn_skel__prepend(skel, list)、svn_skel__append(list, skel)(注释提醒 append 需遍历 list,一般应使用 prepend)、svn_skel__prepend_int(value, skel, pool)、svn_skel__prepend_str(value, skel, pool)(后者不复制 value,要求其生命期不短于 result_pool)。
  • 查询/转换 :svn_skel__parse_int(n, skel, pool)、svn_skel__unparse(skel, pool)、svn_skel__matches_atom(skel, str)、svn_skel__list_length(skel)。
  • 属性专用 :svn_skel__parse_proplist(&proplist, skel, pool)、svn_skel__parse_iprops(&iprops, skel, pool)、svn_skel__parse_prop(&propval, skel, propname, pool)、svn_skel__unparse_proplist(&skel, proplist, pool)、svn_skel__unparse_iproplist(&skel, inherited_props, pool, scratch_pool)。

注意 parse_proplist 输出的 hash 值类型是 svn_string_t *,parse_prop 则支持只取一个属性名对应的值------这是 FSFS 读属性时避免构造整个 hash 的优化路径。

3.3.5 BDB 后端里的 node-rev skel:逐字段拆解

libsvn_fs_base/notes/structure(L104-L330)完整描述了 BDB 如何用 skel 表示 node-rev。这套语法虽然不适用于 FSFS 的 node-rev 文本格式,但它定义了 Subversion 数据模型的规范形状,值得逐条精读。

一个 node-rev skel 的顶层形式是:

text 复制代码
(HEADER PROP-KEY KIND-SPECIFIC ...)

其中 HEADER 是所有节点共有的头,PROP-KEY 是「属性 representation」在 representations 表中的键,KIND-SPECIFIC 则依文件/目录而不同。HEADER 本身是:

text 复制代码
(KIND CREATED-PATH [PRED-ID [PRED-COUNT [HAS-MERGEINFO MERGEINFO-COUNT]]])

逐字段:

  • KIND:"file" 或 "dir"(隐式长度 atom)。注释特别强调「node 不能改变 kind:目录永远是目录,文件永远是文件」------kind 虽然存在每个 node-rev 里,但 Subversion 不允许它变化。
  • CREATED-PATH:该 node 被创建时的规范化绝对路径。
  • PRED-ID(可选):直接祖先 node-rev 的 ID。
  • PRED-COUNT(可选):该 node-rev 递归的祖先数量。
  • HAS-MERGEINFO、MERGEINFO-COUNT(可选):合并信息相关(文档此处标了 ### TODO)。

文件节点的 KIND-SPECIFIC 是:

text 复制代码
FILE ::= (HEADER PROP-KEY DATA-INFO [EDIT-DATA-KEY]) ;
DATA-INFO ::= DATA-KEY | (DATA-KEY DATA-KEY-UNIQID)

目录节点是:

text 复制代码
DIR ::= (HEADER PROP-KEY ENTRIES-KEY) ;
ENTRIES ::= (ENTRY ...) ;      ENTRY ::= (NAME ID)

属性列表是:

text 复制代码
PROPLIST ::= (PROP ...) ;      PROP ::= atom atom

representation 的 skel 形式是本节最需要牢记的部分:

text 复制代码
REPRESENTATION ::= FULLTEXT | DELTA ;
     FULLTEXT ::= (HEADER STRING-KEY) ;
        DELTA ::= (HEADER (OFFSET WINDOW) ...) ;
       WINDOW ::= (DIFF SIZE REP-KEY [REP-OFFSET]) ;
         DIFF ::= ("svndiff" VERSION STRING-KEY) ;
       HEADER ::= (KIND TXN [MD5 [SHA1]]) ;
         KIND ::= "fulltext" | "delta" ;

把它翻译成人话:representation 的 skel 有 FULLTEXT 与 DELTA 两种形态;FULLTEXT 由一个头 + 一个字符串键构成(数据就在该 key 指向的 strings 表项里);DELTA 由一个头 + 一串 (OFFSET WINDOW) 构成,每个窗口说明「在 fulltext 的哪个偏移处、用什么 svndiff 数据、对哪个基 representation 应用、重建多少字节」。

3.3.6 一个 BDB node-rev 的完整手工演算

文档 L331-L355 给了一个可直接逐字段演算的例子:

text 复制代码
node-rev         : (("file" REV COUNT) PROP-KEY "2345")
representations["2345"] : (("delta" TXN CHECKSUM) (0 (("svndiff" 0 "1729") 65 "2343")))
representations["2343"] : (("fulltext" TXN CHECKSUM) "1001")
strings["1729"]  : <some unprintable glob of svndiff data>
strings["1001"]  : <the fulltext>

这里 "2345"、"2343"、"1729"、"1001" 都是显式长度 atom (4 2345、4 2343、4 1729、4 1001),因为数字开头的串在隐式长度形式下不被接受(隐式形式要求首字节是 name 字符,即字母)。"svndiff" 是隐式长度 atom(全字母)。"0"、"65" 是数字,作为 atom 按显式长度写出(1 0、2 65)。阅读时的语义链条是:

  1. 这是一个 file 节点,它的内容由 representations["2345"] 给出;
  2. "2345" 是一个 delta,含一个窗口 (0 (("svndiff" 0 "1729") 65 "2343"));
  3. 该窗口在 fulltext 偏移 0 处重建 65 字节,使用版本 0 的 svndiff 数据,该数据存在 strings["1729"],应用在基 representation "2343" 上;
  4. "2343" 是 fulltext,数据在 strings["1001"];
  5. 于是「把 strings["1729"] 的 svndiff 应用于 strings["1001"]」就得到最终文件内容。

文档末尾故意让这个 fulltext 长得像目录项列表("((some text) (that looks) (deceptively like) (directory entries))"),并点出要害:从字节上无法区分它到底是文件内容还是目录项列表,只有结合「节点 kind」和「经过 representations 表」这两条上下文,Subversion 才知道如何解释它。这就是 §3.1.1 里「同一份数据可被共享」的底层原因------数据本身对使用者是「不透明字节」。

BDB node-rev ID 的 skel 语法也在附录给出(L999-L1002):NODE-REV-ID ::= NODE-ID '.' CPY '.' TXN,三个字段都是 base-36 数字。这与 FSFS 的 node_id.copy_id.txn_id 文本形式在概念上完全一致(见 §3.5.7)。

3.3.7 FSFS 与 skel 的关系

FSFS 的 node-rev 不 用 skel,而用「name: value\n 行、以空行结尾」的文本 marshalling(详见 §3.5.5)。但 FSFS 仍在以下场合使用 skel:

  • 目录项的表示 :FSFS 目录节点的 text representation 展开后是「hash dump format」,即 svn_hash_write 风格的文本,而非 skel;这与 BDB 的 ENTRIES skel 不同。
  • 属性列表 :FSFS 的属性 representation 展开后同样是 hash dump 文本;但当上层(如 svn_fs_fs__get_proplist 之外的通用代码、或 repos 层的 inherited props 缓存)需要用 skel 表示属性时,仍会走 svn_skel__parse_proplist / svn_skel__unparse_proplist。

因此 structure 文档在描述「revprops」时说的是「Files in the revprops directory are in the hash dump format used by svn_hash_write」(L70-L71),而不是 skel。记住这条界线:skel 是 BDB 的血脉,FSFS 只在属性这一支上保留它。


3.4 Representation:PLAIN、DELTA 与共享

Representation 是 FS 层「内容存储」的核心抽象。它回答的问题是:某段逻辑内容(文件正文、目录项列表、属性列表)在磁盘上究竟以「完整正文」还是「相对某个基的差分」存储。

3.4.1 representation_t 逐字段

FSFS 的 representation 结构定义在 fs.h L543-L596。文件开头有一句注释:「If you add fields to this, check to see if you need to change svn_fs_fs__rep_copy」------提醒结构体改动需同步复制函数。字段如下:

c 复制代码
typedef struct representation_t
{
  svn_boolean_t has_sha1;
  unsigned char sha1_digest[APR_SHA1_DIGESTSIZE];
  unsigned char md5_digest[APR_MD5_DIGESTSIZE];

  svn_revnum_t revision;      /* 该 representation 所在的修订号 */
  apr_uint64_t item_index;    /* 该修订内的 item index */

  svn_filesize_t size;            /* 在修订文件里「磁盘上」的字节数 */
  svn_filesize_t expanded_size;   /* 展开(去差分)后的 fulltext 字节数 */

  svn_fs_fs__id_part_t txn_id;    /* 若该 rep 仍在事务中,则为该事务 ID */

  struct {
    svn_fs_fs__id_part_t noderev_txn_id;  /* 创建该 noderev 的事务 ID */
    apr_uint64_t number;                  /* 该事务内的唯一序号 */
  } uniquifier;
} representation_t;

对 sha1_digest/has_sha1 的注释(L545-L556)说明:checksum 是「representation 展示给消费者的内容」的摘要,与底层是 fulltext 还是 delta 无关 ;has_sha1 为 FALSE 时「为兼容起见表现得好像 checksum 匹配」;MD5 除从 rep-cache 取回的 rep 外总是 填充,SHA1 只在写时计算,专为 rep-sharing 服务。

对 size/expanded_size 的注释(L566-L579)非常关键:size 是 rep 在修订文件中的字节数;expanded_size 是 fulltext 大小。特例 :对 PLAIN rep,若 expanded_size == 0 则真实 fulltext 大小等于 size;对 DELTA rep,该字段永远 是真实 fulltext 大小。注释还提醒有一个 svn_fs_fs__fixup_expanded_size() 会在读入 rep 结构后尽早修正这些特例,因此「大多数代码不必操心」。

uniquifier 的注释(L583-L595)解释了它为何存在:rep-sharing 需要区分「共享同一 representation 的不同 node-rev」。它存的是创建该 noderev(不是 rep!)时的事务 ID,再加上事务内的一个序号。

3.4.2 PLAIN 与 DELTA:磁盘上的两种表示头

一个 representation 的开头是一行「表示头」。常量定义在 low_level.c L61-L63:

c 复制代码
#define REP_PLAIN          "PLAIN"
#define REP_DELTA          "DELTA"

structure 文档 L534-L540 描述了这个头:

A representation begins with a line containing either "PLAIN\n" or "DELTA\n" or "DELTA <rev> <item_index> <length>\n" ... After the initial line comes raw svndiff data, followed by a cosmetic trailer "ENDREP\n".

于是有三种形态:

  1. PLAIN:后面直接跟 raw 数据(对目录/属性而言,展开后是 hash dump 文本);这是「完整正文」。
  2. 自差分 DELTA(self-delta) :头只有 DELTA\n,即「对空流做差分」------文档注释在 low_level.c L1268-L1273 明确写「This is a delta against the empty stream」。它也是一种压缩手段。
  3. 普通 DELTA :头是 DELTA <base_rev> <base_item_index> <base_length>,指出基 representation 的位置与长度。

解析这行的函数是 svn_fs_fs__read_rep_header:

c 复制代码
(*header)->header_size = buffer->len + 1;   /* 注意 +1 含换行 */
if (strcmp(buffer->data, REP_PLAIN) == 0)
  { (*header)->type = svn_fs_fs__rep_plain; return SVN_NO_ERROR; }

if (strcmp(buffer->data, REP_DELTA) == 0)
  { (*header)->type = svn_fs_fs__rep_self_delta; return SVN_NO_ERROR; }

(*header)->type = svn_fs_fs__rep_delta;
/* 解析 base_revision, base_item_index, base_length */

反解析是 svn_fs_fs__write_rep_header:PLAIN 写 "PLAIN\n",self-delta 写 "DELTA\n",普通 delta 写 DELTA %ld %jd %jd。svn_fs_fs__rep_header_t 的定义在 low_level.h L195-L219,字段与上面三种形态一一对应,并额外带一个 header_size(仅从磁盘读入后有效,平时为 0)。

3.4.3 rep 引用行(text: / props: 的值)逐字段解析

node-rev 里的 text:/props: 字段并不直接给出表示头,而是一个「rep 引用」字符串。它的格式在 structure L562-L589 有说明,而权威解析器是 svn_fs_fs__parse_representation。逐 token 看:

c 复制代码
SVN_ERR(parse_revnum(&rep->revision, (const char **)&string));   /* <rev> */
/* 事务中合法地只写 "-1",此时 revision == SVN_INVALID_REVNUM 直接返回 */
str = svn_cstring_tokenize(" ", &string);  rep->item_index   = ...;  /* <item_index> */
str = svn_cstring_tokenize(" ", &string);  rep->size         = ...;  /* <length/size> */
str = svn_cstring_tokenize(" ", &string);  rep->expanded_size= ...;  /* <size 展开后> */
/* MD5:必须正好 32 个 hex 字符;全零则保持默认(全零) */
/* 若后面还有 token:可能是 SHA1(或 "-") */
/* 再之后可能是 uniquifier(或 "-"),形式为 <txn>/_<base36> */

字段顺序即 structure 所写的 <rev> <item_index> <length> <size> <digest> [<sha1-digest>] [<uniquifier>]。其中:

  • parse_revnum(low_level.c L78-L101)特殊处理 "-1" → SVN_INVALID_REVNUM,其余交给 svn_revnum_parse。
  • SHA1 与 uniquifier 允许用 "-" 表示「缺失」,这是 format 8 才引入的写法 (常量 SVN_FS_FS__MIN_REP_STRING_OPTIONAL_VALUES_FORMAT,fs.h L191-L197,注释直接给了一个例子 15 0 563 7809 28ef... - 14-g/_5)。
  • uniquifier 解析拆成两段:先按 / 切出 txn 部分并 svn_fs_fs__id_txn_parse,再要求第二段以 _ 开头,其后按 base-36 解析(svn__base36toui64)。

反解析是 svn_fs_fs__unparse_representation。它的行为随 format 分三档:

c 复制代码
/* 若该 rep 仍在事务中且 mutable_rep_truncated,直接返回 "-1" */
if (svn_fs_fs__id_txn_used(&rep->txn_id) && mutable_rep_truncated)
  return svn_stringbuf_ncreate("-1", 2, result_pool);

/* 基础:<rev> <item_index> <size> <expanded-size> <md5> */
...
if (format < SVN_FS_FS__MIN_REP_SHARING_FORMAT)          /* < format 4 */
  return str;                                            /* 没有 sha1/uniquifier */

if (format < SVN_FS_FS__MIN_REP_STRING_OPTIONAL_VALUES_FORMAT)  /* < format 8 */
  { if (rep->has_sha1) 追加 "<sha1> <uniquifier>"; return str; }
  /* 即 format 4..7 只能「同时有」或「都没有」 */

/* format 8+:可分别缺省,缺省写 "-" */
sha1_str = rep->has_sha1 ? ... : "-";
uniquifier_str = (uniquifier 全 0) ? "-" : format_uniquifier(...);

源码注释(L1124-L1131)解释了为何 format 4..7 必须「成对出现」:老格式的 rep 字符串要么同时带 SHA1 与 uniquifier,要么都不带;而 format 8 起解除了这个耦合(issue #4623、#4700 相关,见 §3.9)。

3.4.4 共享:rep-cache.db 与 uniquifier

Representation 共享(rep-sharing)是 1.8 引入、format 4 起支持的省空间机制。structure L115-L122 描述:启用时,FS 用 SQLite 数据库 rep-cache.db 跟踪 checksum 与位置的映射;该库有一张表 ,以 sha1 哈希文本为主键,映射到 representation 的 revision, offset, size, expanded_size。注释强调了一句关键的维护性设计:

This file is only consulted during writes and never during reads. Consequently, it is not required, and may be removed at an arbitrary time, with the subsequent loss of rep-sharing capabilities for revisions written thereafter.

只在写时查、读时不查 ,意味着 rep-cache.db 不是数据完整性的组成部分,可以随时删除------这正是 svnadmin verify 要「检查 rep-cache」而非依赖它的原因。

建表 SQL 在 rep-cache-db.sql L24-L54:

sql 复制代码
CREATE TABLE rep_cache (
  hash TEXT NOT NULL PRIMARY KEY,
  revision INTEGER NOT NULL,
  offset INTEGER NOT NULL,
  size INTEGER NOT NULL,
  expanded_size INTEGER NOT NULL
  );                      -- V1,PRAGMA USER_VERSION = 1
-- V2 相同,但使用 WITHOUT ROWID(需 SQLite >= 3.8.2)
  ) WITHOUT ROWID;        -- PRAGMA USER_VERSION = 2

V2 schema 由 SVN_FS_FS__MIN_REP_CACHE_SCHEMA_V2_FORMAT(fs.h L199-L201,值为 format 8)启用。SQL 模板还提供了 STMT_GET_REP、STMT_SET_REP(INSERT OR IGNORE)、STMT_GET_REPS_FOR_RANGE、STMT_GET_MAX_REV、STMT_DEL_REPS_YOUNGER_THAN_REV,以及用于加锁的 STMT_LOCK_REP(BEGIN TRANSACTION; + 插入 dummy 行)与 STMT_UNLOCK_REP(ROLLBACK TRANSACTION)。

「两个 node-rev 是否共享同一 rep」的判定函数是 svn_fs_fs__noderev_same_rep_key,它比较的正是 rep 的 item_index 与 uniquifier------这就是为什么要给每个共享 rep 的 noderev 打上唯一的 uniquifier(否则两个内容相同但语义无关的 noderev 会被误判为「同一 rep」)。

3.4.5 差分链与 deltification 调参

FSFS 与 BDB 的一个根本差异写在 structure L12-L18:

In contrast to the BDB back end, the contents of recent revision of files are stored as deltas against earlier revisions, instead of the other way around.

即 BDB 让新修订存 fulltext、老修订被差分;FSFS 反过来。 收益是「提交无需写权限即可访问已有修订」,代价是 checkout 效率下降,用 skip-delta 与 delta 组合缓解。

可调参数集中在 fsfs.conf 的 [deltification] 段(fs_fs.c L764-L790 读取、L998-L1057 写入默认文本):

  • enable-dir-deltification(默认 true):目录是否像文件一样被差分;
  • enable-props-deltification(默认 true):节点属性是否被差分(注释警告:若关闭 rep-sharing 再开启它,可能反而增大仓库);
  • max-deltification-walk(默认 1023,须为 2^n-1,0 即禁用):限制「为找 base 而向上回溯历史」的深度;
  • max-linear-deltification(默认 16,须为 2 的幂):线性差分链超过该阈值后才启用 skip-delta;
  • compression-level:svndiff 数据的压缩级别(none/lz4/zlib[-1..9]),解析见 parse_compression_option。

注意「deltification 相关选项只对 format ≥ 4 生效」,常量 SVN_FS_FS__MIN_DELTIFICATION_FORMAT(fs.h L162-L164),源码注释解释了原因:这些选项虽在 1.8 才有意义,但为避免很老服务器的兼容问题,限制在 1.6+ 的格式上。

3.4.6 提交时的 rep 定稿

在 §3.5 讲完事务布局后我们会看到,事务里的 rep 引用被特殊标记。这里先给出 write_final_rev(transaction.c L3133-L3396)中对 rep 的几处关键处理,作为 3.4 的收尾:

  1. 递归写子节点:目录节点先把所有 children 写完(write_final_rev 对 dirent->id 递归),再写自己的目录 text rep。
  2. 目录 text rep:若 is_txn_rep(noderev->data_rep),把 revision 改为新 rev,然后按 deltify_directories 决定走 write_container_delta_rep 还是 write_container_rep(L3190-L3208)。
  3. 文件 data rep:若在事务中,直接 reset_txn_in_rep 并把 revision = rev;物理寻址 下还有一段针对 issue #3845 的校验------若 item_index + size > initial_offset 则报「Truncated protorev file detected」(L3241-L3251)。
  4. 属性 rep:取 proplist → 设 txn_id 与 uniquifier(set_uniquifier)→ 写容器 → reset_txn_in_rep。
  5. 写盘前对 SHA1 的取舍:目录 data rep 一律 has_sha1 = FALSE(省空间);属性 rep 在 format >= 8 时也置 has_sha1 = FALSE(因为 8 起 SHA1 与 uniquifier 解耦,可以只写 uniquifier)。参见 L3334-L3355。
  6. noderev->is_fresh_txn_root = FALSE------这是 issue #4031 的 workaround:不允许 is-fresh-txn-root 被写进修订文件。

is_txn_rep/reset_txn_in_rep 这一对函数贯穿 FSFS:前者判断 rep 是否还在事务中(txn_id 已用),后者把 txn_id 清回默认,标志「该 rep 已定稿」。理解了它,就能看懂 §3.5.4 事务布局里大量「-1 表示还在事务中」的特殊写法。


3.5 FSFS 磁盘格式精读

本节以 [subversion/libsvn_fs_fs/structure](subversion-1.15.0/subversion/libsvn_fs_fs/structure) 这份官方设计文档为纲,逐节落实,并随时对照 C 源码里的常量与结构体。

3.5.1 目录布局:db/ 下每个名字的含义

structure L28-L68 用一棵缩进树给出了 FS 目录(即仓库的 db/ 子目录)的完整布局。把每个名字与源码常量对上,能得到下面这张「无遗漏表」------常量全部来自 fs.h L52-L95:

路径 源码常量 含义
revs/ PATH_REVS_DIR 修订数据根目录
revs/<shard>/<revnum> ------ 分片目录下的单个修订文件(未打包)
revs/<shard>.pack/pack PATH_PACKED 打包后的 pack 文件
revs/<shard>.pack/manifest PATH_MANIFEST pack 内各修订的偏移清单(物理寻址)
revprops/ PATH_REVPROPS_DIR 修订属性根目录
revprops/<shard>/<revnum> ------ 单个 revprop 文件(hash dump 格式)
revprops/<shard>.pack/<rev>.<count> ------ 打包后的 revprop 文件
revprops/revprops.db ------ format 5(1.7-dev,从未正式发布)专用的 SQLite revprop 库
transactions/<txnid>.txn/ PATH_TXNS_DIR + PATH_EXT_TXN 进行中的事务目录
txn-protorevs/<txnid>.rev PATH_TXN_PROTOS_DIR + PATH_EXT_REV 事务原型修订文件
txn-protorevs/<txnid>.rev-lock PATH_EXT_REV_LOCK 原型文件写锁
txn-current PATH_TXN_CURRENT 下一个事务序号(base-36)
txn-current-lock PATH_TXN_CURRENT_LOCK 序列化 txn-current 的 get&increment
locks/<partial-digest>/<digest> PATH_LOCKS_DIR 锁文件,名字是 FS path 的 MD5
node-origins/ PATH_NODE_ORIGINS_DIR node-ID → 起源 node-rev 的懒缓存
current PATH_CURRENT youngest revision
fs-type ------ 内容 "fsfs"
write-lock PATH_LOCK_FILE 全局写锁
pack-lock PATH_PACK_LOCK_FILE pack 专用锁(format 7+)
uuid PATH_UUID 仓库 UUID(format 7+ 第二行是 instance ID)
format PATH_FORMAT FS format 号 + format 选项
fsfs.conf PATH_CONFIG 后端配置
min-unpacked-rev PATH_MIN_UNPACKED_REV 最老的未打包修订
rep-cache.db PATH_REPCACHE_DB 语义 表示共享的 SQLite 库

补充两个容易忽略的常量:PATH_EXT_L2P_INDEX(.l2p)与 PATH_EXT_P2L_INDEX(.p2l)是逻辑寻址索引文件的后缀;PATH_INDEX("index")是事务内索引文件的基名(fs.h L75-L94)。

3.5.2 单文件格式:current、write-lock、pack-lock、txn-current、uuid、format、fsfs.conf

  • current (structure L73-L82):format 3+ 是单纯一行 <youngest-revision>\n;format 2 及以下则是 <youngest-revision> <next-node-id> <next-copy-id>\n。因为 format 3 起节点/copy ID 改用「修订号」保证唯一性,不再需要全局计数器,current 就退化成一个修订号。
  • write-lock (L84-L92):空文件。在提交末段 加锁,在把新 current 就位后解锁;revprop 修改期间也加锁(读入→改写→写回);还用于序列化 svnadmin pack 的结构变更。注释点名了核心并发承诺:读者永不被任何操作阻塞,写者必须保证文件系统始终处于一致状态。
  • pack-lock (L94-L100):空文件,svnadmin pack 开始前加锁,从而「同一时间只有一个进程改结构,而其他进程在 pack 的大部分时间里仍可读写(提交)」。仅 format 7+ 存在 ;更老格式退回到全局 write-lock,代价是 pack 全过程提交被完全禁用。
  • txn-current (L102-L109):一行 base-36 数字。它和「事务所基于的修订号」一起拼出下一个事务名。它的唯一用途是「get and increment」,由 txn-current-lock 保护。这保证了事务名不被重用------即使某个事务中止、又在同一修订上开始新事务也一样。
  • fsfs.conf (L111-L113):Python 风格的配置格式,创建仓库时自动生成。默认模板文本见 fs_fs.c L957-L1090+,涵盖 [memcached-servers]、[caches]、[rep-sharing]、[deltification]、[packed-revprops]、[io](block-size、l2p-page-size、p2l-page-size)、[debug](pack-after-commit、verify-before-commit、compression)等段。
  • uuid :format 1+ 第一行是仓库 UUID;format 7+ 第二行是 instance ID (同样 UUID 格式)------用于区分「共享同一 UUID 但应可区分的实例」(如 hotcopy 产生的备份、dump/load 循环),参见 fs_fs_data_t.instance_id 的注释(fs.h L508-L512)。
  • format :见 §3.5.2 之后的 format 一节(§3.7)。其内容由 svn_fs_fs__write_format 生成(fs_fs.c L587-L634),形如 "8\nlayout sharded 1000\naddressing logical\n"。
  • rep-cache.db:见 §3.4.4。

min-unpacked-rev 与 min-unpacked-revprop(format 5 only)分别记录「最老的未打包修订/revprop」。前者在 svn_fs_fs__create_file_tree 里被初始化为 "0\n"(fs_fs.c L1892-L1895),仅当 format >= SVN_FS_FS__MIN_PACKED_FORMAT(=4)时创建。

3.5.3 修订文件结构:先给「一个 revision 里都有什么」

structure L522-L533 列出修订文件的组成:

text 复制代码
* Text and property representations
* Node-revisions
* The changed-path data
* Two offsets at the very end (physical addressing only)
* Index data (logical addressing only)
* Revision / pack file footer (logical addressing only)

也就是说,一个修订文件是「各种数据的拼接」:正文/属性表示、node-rev、changed-path 列表,然后(物理寻址)末尾两行偏移,或(逻辑寻址)索引数据 + footer。

表示(representation)的磁盘形态 在 L534-L549 已经讲清楚(PLAIN/DELTA 行 + raw svndiff + "ENDREP\n")。它还补了一条语义:目录节点的 text representation 展开后是 hash dump 格式 ,把条目名映射到 "<type> <id>"(type 为 file/dir,id 为子 node-rev 的 ID);属性 representation 展开后同样是 hash dump,映射属性名到属性值。

node-rev 的 marshalling 见 L551-L604:一系列 "<name>: <value>\n" 字段,以一个空行结束;未识别字段被忽略(为可扩展性)。定义的字段有:

text 复制代码
id        该 node-rev 的 ID
type      "file" 或 "dir"
pred      前驱 node-rev 的 ID
count     自 node 起点起的 node-rev 计数
text      "<rev> <item_index> <length> <size> <digest>"(正文 rep)
props     "<rev> <item_index> <length> <size> <digest>"(属性 rep)
cpath     node 被创建时的 FS 路径
copyfrom  "<rev> <path>"(复制来源)
copyroot  "<rev> <created-path>"(本 copy 的根)
minfo-cnt 该节点及其子孙中带 svn:mergeinfo 的节点数
minfo-here 该节点自身是否带 svn:mergeinfo(存在即真)

文档接着给出若干「可省略」规则(L591-L604):pred 只在非首个 node-rev 上出现;无属性的 node-rev 可省 props;无内容(零长文件或空目录)可省 text;真复制操作的 node-rev 有 copyfrom;copyroot 在「自身即 copy root」时可省;copy root 用 revision + created-path 标识 ,而非 node-rev ID------因为 copy root 可能是同一修订文件里稍后才出现的 node-rev,那时它的位置还不知道。

文档还特别标注了一个历史坑((*) 注释,L581-L589):早期文档称 <size> 在「实际值等于 <length>」时可为 0,但这只对属性与目录 rep 成立,且应尽量避免 ;对文件 rep,若内容非空却把 <size> 写成 0,1.7.20、1.8.12、1.9.0 之前的 SVN 可能无法正确处理 。1.8.0--1.8.11 曾错误地产生这种实例(issue #4554)。最后一行结论:0 <size> 对 DELTA rep 只在重建出的 fulltext 确实为空时合法。

changed-path 数据 (L606-L625)是「每项两行」:

text 复制代码
第一行: <id> <action> <text-mod> <prop-mod> <mergeinfo-mod> <path>\n
第二行: <rev> <path>\n    (copyfrom 信息;无则空行)

<action> 为 add/delete/replace/modify;三个 mod 标志是 true/false。对 delete,<id> 是被删 node-rev 的 ID,且 text-mod、prop-mod 恒为 false 。format 4 起 <action> 可带 kind 后缀(如 add-dir);format 7 前没有 mergeinfo-mod。

末尾偏移(物理寻址) (L625-L628):修订文件最后是两行 \n<root-offset> <cp-offset>\n,分别给出根目录 node-rev 与 changed-path 数据的偏移。解析函数是 svn_fs_fs__parse_revision_trailer------它先在最后 64 字节里找倒数第二个换行、定位最后一行、用空格切出两个数,并在最后一字节不是 \n、最后一行超过 64 字符、或缺空格时分别报错。

footer(逻辑寻址) (L630-L640):

text 复制代码
<l2p offset> <l2p checksum> <p2l offset> <p2l checksum><terminal byte>

terminal byte 是 footer 除该字节自身外的长度(8 位)。前两个值给出 log-to-phys 索引的起点与其内容的 MD5;后两个给出 phys-to-log 索引的同样信息。解析在 svn_fs_fs__parse_footer,它把结果存进 svn_fs_fs__revision_file_t 的 l2p_offset/l2p_checksum/p2l_offset/p2l_checksum 字段(rev_file.h L70-L91),并校验 p2l_offset > l2p_offset。

注意文档收尾的一句(L639-L640):修订文件格式里所有数字都是无符号的、以 ASCII 十进制表示 。这意味着没有负数、没有前导 0 之外的花样,解析可以放宽。

3.5.4 事务布局:一堆「-1 = 还在事务中」的约定

structure L642-L727 描述事务目录。基础布局:

text 复制代码
props                      事务属性
props-final                最终事务属性(可选,仅转换期存在)
next-ids                   下一个临时 node-ID 与 copy-ID
changes                    目前为止的 changed-path 信息
node.<nid>.<cid>           某节点的新 node-rev 数据
node.<nid>.<cid>.props     该 node-rev 的属性(若变更)
node.<nid>.<cid>.children  该 node-rev 的目录内容
<sha1>                     对应 sha1 的正文表示引用

有关常量:PATH_TXN_PROPS "props"、PATH_NEXT_IDS "next-ids"、PATH_CHANGES "changes"、PATH_PREFIX_NODE "node."、PATH_EXT_CHILDREN ".children"、PATH_EXT_PROPS ".props"(fs.h L82-L90)。

format 1/2 额外在事务目录里有 rev 与 rev-lock(旧格式没有独立的 txn-protorevs/);format 3+ 原型文件搬到 txn-protorevs/(L656-L661)。format 7+ 逻辑寻址 再多三个文件(L663-L669):itemidx + index.l2p + index.p2l,即「下一个 item_index 十进制整数」与两个 proto 索引。

<sha1> 文件是 format 6 引入的(L682-L685):内容是文本 rep 引用 <rev> <item_offset> <length> <size> <digest>,用来在同一事务内消除重复 rep。

next-ids 是单行 <next-temp-node-id> <next-temp-copy-id>\n(不含前导下划线),并且「下一个 node-ID」还被当作共享 rep 的 uniquifier (L687-L692)。

children 文件对某 node-rev 而言,先是「旧 node-rev 目录项的 hash dump 副本」(新目录则是空 hash 的 dump),再逐条追加每次变更的增量式 hash dump 条目(L693-L696)。

changes 文件与修订文件里的 changed-path 条目同形,除了 <id> 与 <action> 都可为 "reset"(此时两个 mod 恒为 false),表示「撤销该路径上所有改动」;reset 条目只在事务最终合并阶段 使用。并且,changes 里的 action 总是带 node kind ,即便 FS format 早于 4(L698-L704)。

最精彩的是 node-rev 文件对 text/props 字段的增补约定 (L706-L727),这也是「-1 满天飞」的来历:

  • props 字段可为 "-1":表示属性已变,且内容在同目录的 props 文件里;
  • 目录 node-rev 的 text 可为 "-1":表示条目已变,内容在同目录的 contents 文件里;
  • 事务根目录 node-rev 可有 is-fresh-txn-root 字段,表示「尚未被改为 mutable」(issue #2608);
  • 文件 node-rev 的 text 可为 "-1 <offset> <length> <size> <digest>":表示正文 rep 在原型修订文件里;
  • copyroot 可为 "-1 <created-path>":表示 copy root 属于当前事务。

对照 svn_fs_fs__parse_representation 的第一段分支(low_level.c L768-L777):token 为空且 revision == SVN_INVALID_REVNUM 时直接返回------这就是对纯 "-1" 的处理。带后续数字的 "-1 <offset> ..." 则会继续解析各字段。

3.5.5 node-rev 文本格式与源码的一一对应

low_level.c 顶部用宏固定了字段名(L37-L48):

c 复制代码
#define HEADER_ID          "id"
#define HEADER_TYPE        "type"
#define HEADER_COUNT       "count"
#define HEADER_PROPS       "props"
#define HEADER_TEXT        "text"
#define HEADER_CPATH       "cpath"
#define HEADER_PRED        "pred"
#define HEADER_COPYFROM    "copyfrom"
#define HEADER_COPYROOT    "copyroot"
#define HEADER_FRESHTXNRT  "is-fresh-txn-root"
#define HEADER_MINFO_HERE  "minfo-here"
#define HEADER_MINFO_CNT   "minfo-cnt"

写 node-rev 的是 svn_fs_fs__write_noderev,它按固定顺序输出 id、type、可选 pred、count、可选 text、可选 props、cpath、可选 copyfrom、条件 copyroot、可选 is-fresh-txn-root、以及(当 include_mergeinfo)minfo-cnt 与 minfo-here,最后 svn_stream_puts(outfile, "\n") 收尾。注意 copyroot 的写出条件是「copyroot_rev 不等于本 node-rev 所属修订,或 copyroot_path 不等于 created_path」------正好对应文档说的「自身即 copy root 时可省」。

读 node-rev 的是 svn_fs_fs__read_noderev。它先用 read_header_block 把 name: value 行读进 hash:逐行读,遇 EOF 或空行结束;找第一个 ':',把它替换成 '\0' 得到 name,跳过 '\0' 与紧随的一个空格后得到 value。找不到冒号或数据不足时报 SVN_ERR_FS_CORRUPT、错误信息里带上原始行。

read_noderev 随后逐字段处理,几处值得注意的校验与默认值:

  1. id 缺失 → 报 Missing id field in node-rev;type 缺失或不是 file/dir → 报 Missing kind field(源码注释还自嘲「### s/kind/type/」);count 缺失则 predecessor_count = 0。
  2. cpath 缺失 → 报错;且必须 svn_fspath__is_canonical,否则报 Non-canonical cpath field。
  3. copyroot 缺失时,把 copyroot_path = created_path、copyroot_rev = svn_fs_fs__id_rev(id)------即「自身即 copy root」。
  4. copyfrom 缺失时 copyfrom_path = NULL、copyfrom_rev = SVN_INVALID_REVNUM。
  5. is-fresh-txn-root 的存在性直接决定 noderev->is_fresh_txn_root(布尔),值内容不关心(写出时统一写 "y")。
  6. mergeinfo_count 缺失为 0;has_mergeinfo 由 minfo-here 是否存在决定。

字段与 node_revision_t(fs.h L602-L648)一一对应,node_revision_t 比文本字段多了 is_fresh_txn_root、mergeinfo_count、has_mergeinfo 这些「控制/统计」字段而已。

3.5.6 changed-path 条目的精确写格式

写单条 changed-path 的函数是 write_change_entry,它的格式串是理解 changed-path 的钥匙:

c 复制代码
buf = svn_stringbuf_createf(scratch_pool, "%s %s%s %s %s%s %s\n",
                            idstr,                       /* id 或 "reset" */
                            change_string,               /* modify/add/delete/replace/reset */
                            kind_string,                 /* "-dir" / "-file" 或空 */
                            change->text_mod ? "true":"false",
                            change->prop_mod ? "true":"false",
                            mergeinfo_string,            /* " true"/" false" 或空 */
                            path);

if (SVN_IS_VALID_REVNUM(change->copyfrom_rev))
  svn_stringbuf_appendcstr(buf, apr_psprintf(scratch_pool, "%ld %s",
                                             change->copyfrom_rev,
                                             change->copyfrom_path));
svn_stringbuf_appendbyte(buf, '\n');

两个关键细节:

  • idstr 若 change->node_rev_id 为空则取常量 ACTION_RESET("reset")------即前面说的 reset 条目。
  • 有 copyfrom 时,第二行写 "<rev> <path>",没有 copyfrom 时也照常写第二行,只是它为空行(appendbyte('\n') 之后就是一个空行)。这解释了文档所说「第二行无 copyfrom 时为空行」。

动作与标志常量在 L51-L59:ACTION_MODIFY "modify"、ACTION_ADD "add"、ACTION_DELETE "delete"、ACTION_REPLACE "replace"、ACTION_RESET "reset"、FLAG_TRUE "true"、FLAG_FALSE "false"。

svn_fs_fs__write_changes(L627-L678)负责把整个 changed-path hash 排序后逐条写出:为了确定性与可重放性 ,它用 svn_sort__hash(changes, svn_sort_compare_items_lexically, ...) 排序(注释特别指出这个排序只对「一次性写完」有效;对事务期间增量写入的 changes 文件,不能 打乱不同批次条目的顺序)。是否写 node kind 取决于 format >= SVN_FS_FS__MIN_KIND_IN_CHANGED_FORMAT(=4),是否写 mergeinfo-mod 取决于 format >= SVN_FS_FS__MIN_MERGEINFO_IN_CHANGED_FORMAT(=7)。最后若 terminate_list 为真,额外写一个 "\n" 空行标记列表结束。

读方向是 read_change:读一行,空行/EOF 即结束;按空格切出 id、action(可能带 -file/-dir 后缀,用 strchr(str,'-') 切分并核对 kind);然后解析 text_mod、prop_mod(及可能的 mergeinfo-mod);再取 path;最后若还有一段则解析 copyfrom 的 <rev> <path>。它不预先检查 format 号------注释写道「Don't bother to check the format number before looking for node-kinds: just read them if you find them」------这种「宽容解析」让 FSFS 能同时读新旧格式。

3.5.7 节点修订 ID:三段式与四种「前缀语法」

structure L418-L474 定义了 node-rev ID:

text 复制代码
node_revision_id ::= node_id '.' copy_id '.' txn_id

它明确说这与 BDB 同形(见 libsvn_fs_base/notes/structure 的 ID's 一节)。但 FSFS 对三个字段做了「新而有趣」的用法:

  • 事务内 :新 node-rev 的 txn-id 形如 "t<txnid>";新分配的 node-id/copy-id 形如 "_" + base36 数字,且这个数字在事务内唯一。
  • 修订内 :node-rev 的 txn-id 形如 "r<rev>/<item_index>",便于直接定位。
  • 提交末段 :把临时 ID 改写为仓库级唯一的 node-id/copy-id,并把 txn-id 变成 "r<rev>/<item_index>"。
  • format 3+ :唯一化方式是把临时的 "_<base36>" 变成 "<base36>-<rev>"。由此仅凭 node ID 就能推出某条历史线或某个 copy 的起源修订。
  • format 2 及以下 :current 文件里保存全局 base36 的 node-ID/copy-ID 计数器;提交时把计数器值加到事务内的数字上,并调整 current。

这一段还引出 §3.1 讲过的关系判定语义(L463-L467):临时 ID 的作用域受限于其事务,所以 _1.0.t1 与 _1.0.t2 不相关,即便 node-id 部分相同。

最后,node-origins 目录(L469-L474)是「node-ID → 完整 node-rev ID(其诞生处)」的懒创建缓存 :文件名是「去掉末字符的 node-ID」(单字符 node-ID 用 "0"),内容是「node-ID → node-rev ID」的序列化 hash。文档特别声明:它只用于 pre-Format 3 风格的 node-ID (因为 format 3+ 的 node-ID 本身已带 -<rev>,无需查表)。

源码中的实现落在 id.c:svn_fs_fs__id_unparse(L308-L334)逐段拼出文本,其中第三段按是否「在事务中」二选一:

c 复制代码
if (svn_fs_fs__id_txn_used(&id->private_id.txn_id))
  { *(p++) = 't'; p += svn__i64toa(p, txn_id.revision); *(p++) = '-';
    p += svn__ui64tobase36(p, txn_id.number); }
else
  { *(p++) = 'r'; p += svn__i64toa(p, rev_item.revision); *(p++) = '/';
    p += svn__i64toa(p, rev_item.number); }

unparse_id_part(L164-L188)则负责前两段:若 revision 合法则写 base36 number(revision > 0 时再写 -<rev>);否则写 _<base36>。于是 _1.0.t1、1-3.0.r5/7 这类形态都能被精确还原。

解析方向分两个函数:part_parse(L108-L139)处理前两段------_ 前缀 → revision = SVN_INVALID_REVNUM 且按 base36 取 number;单独的 "0" → {0,0};否则读 base36 number,若后面没有 - 则 revision=0,否则用自实现的 locale_independent_strtol 读 revision。id_parse(L506-L584)用 svn_cstring_tokenize(".") 切三段,第三段以 'r' 开头走 rev_item 分支(再按 / 切),以 't' 开头走 txn 分支,否则返回 NULL(→ SVN_ERR_FS_MALFORMED_NODEREV_ID)。

注意 locale_independent_strtol(L59-L104)的注释:ID 解析是「FSFS 数据访问中最耗 CPU 的部分之一」,因此作者手写了一个定基、非负、无 locale 依赖的整数解析器,并做了两处溢出检查。这是一个性能敏感的痕迹,值得记住。

3.5.8 copy-id 与 copyroot 的分配规则

structure L476-L520 完整规定了 copy-ID 的分配(与 BDB 一致):

  • 由创建操作 (无 copy 历史)产生的 node-rev,取其父目录的 copy-ID;
  • 由复制操作 产生的 node-rev,取一个新的 copy-ID;
  • 由修改操作 产生的 node-rev,视前驱是否源自 copy 而定:
    • 前驱不源自 copy:取父目录的 copy-ID(若是通过 created-path 修改,则与前驱相同;若是通过已复制祖先目录修改,即「lazy copy」,则不同);
    • 前驱源自 copy 且在 created-path 上修改:取前驱的 copy-ID;
    • 前驱源自 copy 且不在 created-path 上修改:取新的 copy-ID------这就是 soft copy ,与真正请求的 true copy 相对,目的是确保同一事务内 (node-ID, copy-ID) 不被用两次。

FSFS 没有 BDB 的 copies 表(L513-L520);替代方案是每个 node-rev 记录一个 copyroot 字段,标识「最贴近该 node-rev 的、由 true copy 产生的那个 node-rev」。若该 node-rev 自身不源自 copy,则 copyroot 指向某祖先目录的 copy;若没有祖先目录源自 copy,则 copyroot 指向 rev 0 的根目录。

3.5.9 locks 与 node-origins 的目录分片

structure L729-L756 描述锁:锁以序列化 hash 存在「以 FS path 的 MD5 为文件名」的文件里;为降低目录 inode 压力,digest 文件放在「以 digest 文件名前 3 个字符命名」的子目录下。文档给了可操作的例子:

text 复制代码
/path/to/repos/locks/3b1/3b1b011fed614a263986b5c4869604e8

并且,某个 FS path 的 digest 文件里还存有「指向其子孙 path 的 digest 文件」的指针,于是「查 FOO 下是否有锁」只需读 FOO 的 digest,再迭代其中的子 digest 列表即可。node-origins/ 采用同样的「按末字符/前缀分片」思路(见 §3.5.7)。

3.5.10 revision 0 的逐字节内容(两种寻址模式对照)

最后给一个能把前面所有线索串起来的硬例子:新仓库创建时写入的 revision 0。函数是 write_revision_zero,它对两种寻址模式分别写入。

物理寻址 (L1789-L1798)直接 svn_io_file_create 出:

text 复制代码
PLAIN
END
ENDREP
id: 0.0.r0/17
type: dir
count: 0
text: 0 0 4 4 2d2977d1c96f487abe4a1e202dd03b4e
cpath: /

17 107

逐字节/逐字段拆解:

  • PLAIN\nEND\nENDREP\n------一个 PLAIN representation,其「正文」是 END\n(4 字节),ENDREP\n 是 cosmetic trailer。为什么正文是 END\n?因为空目录的 hash dump 就是 END\n。
  • id: 0.0.r0/17------node-rev ID:node_id=0、copy_id=0、txn_id = r0/17(修订 0 内、item_index 17)。这里 17 正好是该 node-rev 在文件里的起始偏移(也见 trailer 的 17)。
  • type: dir、count: 0、cpath: /。
  • text: 0 0 4 4 2d2977d1c96f487abe4a1e202dd03b4e------对应 §3.4.3 的 <rev> <item_index> <length> <size> <digest>:rev=0、item_index=0、length=4、expanded size=4、MD5 = 2d2977d1c96f487abe4a1e202dd03b4e(即 END\n 的 MD5)。
  • 空行结束 node-rev。
  • 17 107\n------trailer:根 node-rev 偏移 17、changed-path 偏移 107。

把文件长度对上:PLAIN\nEND\nENDREP\n 是 5+4+7 = 16 字节,故 node-rev 从偏移 16 开始?------不对,实际 ID 里写的是 r0/17,而 trailer 写 17。这里的 16/17 差异来自 PLAIN\n(6 字节:P L A I N \n)而非 5 字节------"PLAIN\nEND\n" = 6+4 = 10,加 "ENDREP\n" = 7,共 17 字节。所以 node-rev 起始偏移确实是 17,与 ID 和 trailer 一致。这正好演示了「物理寻址下 item_index == 文件偏移」这条铁律(见 §3.6)。

逻辑寻址 (L1729-L1788)分两步:先写一个「无索引的骨架」:

text 复制代码
PLAIN
END
ENDREP
id: 0.0.r0/2
type: dir
count: 0
text: 0 3 4 4 2d2977d1c96f487abe4a1e202dd03b4e
cpath: /

注意差异:ID 变成 r0/2(2 == SVN_FS_FS__ITEM_INDEX_ROOT_NODE,见 index.h L34),且 text: 的 item_index 变成 3(3 == SVN_FS_FS__ITEM_INDEX_FIRST_USER)。随后源码手工 构造 3 条 P2L 条目(L1747-L1773)来描述这 3 个 item:

c 复制代码
entry->offset = 0;   entry->size = 17;  entry->type = DIR_REP;  item.number = 3;
entry->offset = 17;  entry->size = 89;  entry->type = NODEREV;  item.number = 2;
entry->offset = 106; entry->size = 1;   entry->type = CHANGES;  item.number = 1;

这套 (offset, size, type, item.number) 与 §3.6 的 P2L item 类型完全一致:item 3 是目录 rep(PLAIN\nEND\nENDREP\n 17 字节)、item 2 是根 node-rev(从偏移 17 起 89 字节)、item 1 是 changed-path 列表(从偏移 106 起的 1 字节,即那个空行)。最后 svn_fs_fs__add_index_data 把 L2P/P2L 索引与 footer 追加到文件末尾(L1775-L1787)。

这一对照把「逻辑寻址」的全部要点浓缩出来了 :ID 里 item_index 不再是字节偏移,而是抽象 item 编号;字节偏移被挪进了索引;而 pre-define 的 item 编号 1(changes)、2(root node-rev)在任何修订里都固定。

3.5.11 创建流程中生成的文件清单

svn_fs_fs__create_file_tree(fs_fs.c L1813-L1910)依次建立:分片/非分片的 revs(与 revprops)目录、transactions、txn-protorevs(仅 format ≥ 3)、空的 current 并写入 0 1 1、write-lock、uuid(svn_fs_fs__set_uuid)、fsfs.conf(仅 format ≥ 4)、revision 0、min-unpacked-rev(0\n,仅 format ≥ 4)、txn-current 与 txn-current-lock("0\n",仅 format ≥ 3)。svn_fs_fs__create(L1912-L1981)先按兼容版本选 format、按 fs->config 选 shard size,再看 SVN_FS_CONFIG_FSFS_LOG_ADDRESSING(默认 TRUE)决定寻址模式,最后 write_format。这段代码是 §3.7 格式演进表的直接证据。


3.6 物理寻址 vs 逻辑寻址:地址换算与索引

3.6.1 两种寻址模式的定义

structure L266-L311 用一节讲清了两者的区别。核心事实:

All items are addressed using <rev> <item_index> pairs. In physical addressing mode, item_index is the (ASCII decimal) number of bytes from the start of the revision file to the start of the respective item.

即:

  • 物理寻址(format 1+) :item_index 就是「相对修订文件起点的字节偏移」。对未打包的修订文件,它等于绝对文件偏移;对 pack 文件,绝对偏移 = 「manifest 里该修订的偏移」+ item_index。
  • 逻辑寻址(format 7+) :用一份索引文件把 (rev, item_index) 翻译成绝对文件偏移。每个使用逻辑寻址的 rev/pack 文件都配一个索引,两者同步创建。这使得「打包时重排 item」成为可能,尤其是把不同修订的 item 混排在一起。

原文还点出物理寻址的根本痛点(L282-L284):「This simple addressing scheme makes it hard to change the location of any item since that may break references from later revisions.」------一个 item 的偏移一旦被写进后来的修订,就再也不能挪动。逻辑寻址正是为打破这个宿命而生。

3.6.2 预定义 item_index 与 item 类型

index.h L32-L49 给出两组常量。预定义 item_index(对每个修订都适用):

c 复制代码
#define SVN_FS_FS__ITEM_INDEX_UNUSED     0  /* 无效/保留 */
#define SVN_FS_FS__ITEM_INDEX_CHANGES    1  /* changed-path 列表 */
#define SVN_FS_FS__ITEM_INDEX_ROOT_NODE  2  /* 根 node-rev */
#define SVN_FS_FS__ITEM_INDEX_FIRST_USER 3  /* 第一个可自由分配的 noderev */

item 类型(存在 P2L 索引里):

c 复制代码
#define SVN_FS_FS__ITEM_TYPE_UNUSED     0  /* 未使用 */
#define SVN_FS_FS__ITEM_TYPE_FILE_REP   1  /* 文件表示 */
#define SVN_FS_FS__ITEM_TYPE_DIR_REP    2  /* 目录表示 */
#define SVN_FS_FS__ITEM_TYPE_FILE_PROPS 3  /* 文件属性表示 */
#define SVN_FS_FS__ITEM_TYPE_DIR_PROPS  4  /* 目录属性表示 */
#define SVN_FS_FS__ITEM_TYPE_NODEREV    5  /* node-rev */
#define SVN_FS_FS__ITEM_TYPE_CHANGES    6  /* changed-path 列表 */
#define SVN_FS_FS__ITEM_TYPE_ANY_REP    7  /* 任意表示(仅 pre-format7 用) */

structure L291-L311 对这两组值有对应描述,并强调:这些表示类型「形态相同」,区分它们只为让重排启发式更有效 ;零长度的 item 是允许的。structure-indexes(L40-L49)补充:因为 UNUSED 被定义为 0,代码可以直接「与 0 比较」来判断「是否使用」。

3.6.3 索引为何必须层次化

structure-indexes L22-L38:修订大小无上限,索引可达数 MB、pack 索引可接近 1 GB;故用「header→页」分层,页大小受 fsfs.conf 限制且可整页缓存,使均摊访问近似 O(1)。

3.6.4 变长整数编码(7b/8b)

structure-indexes L52-L80 定义了索引里统一的整数编码------小端 7b/8b 变长:若某字节最高位(MSB)为 1,则下一个字节仍属于同一数值。示例:

text 复制代码
0x00 .. 0x7f     -> 0x00 .. 0x7f             ( 7 bits stored in  8 bits)
0x80 .. 0xff     -> 0x80 0x01 .. 0xff 0x01   (14 bits stored in 16 bits)
0x100 .. 0x3fff  -> 0x80 0x02 .. 0xff 0x7f   (14 bits stored in 16 bits)
0x100000000      -> 0x80 0x80 0x80 0x80 0x10 (35 bits stored in 40 bits)

有符号整数 则通过映射塞进无符号值空间(L71-L77):

text 复制代码
x >= 0 ->  2 * x
x < 0  -> -2 * x - 1

这套「zigzag」映射让「大量本质上无符号、但以差分(即可能出现负数)存储」的数据都能用同一套编码。源码里的对应实现位于 index.c 的 packed number stream(MAX_NUMBER_PREFETCH 一次预取 64 个值,见 L166-L219)。ENCODED_INT_LENGTH 被定义为 10(L43-L44),即一个 uint64 最多占 10 字节。

proto index 文件的编码则完全不同 (L83-L88):其所有记录等长、只含「小端 64 位无符号整数」。L2P 与 P2L 的 proto 记录长度不同。

3.6.5 L2P 索引的数据模型与磁盘格式

L2P 要把 (rev, item_index) 映射到 offset。它假设每个修订内的 item_index 是稠密且从 0 开始 (可以有空洞,只是越稀疏越浪费)。structure-indexes L100-L150 给出的层次是:

text 复制代码
header -> per-revision info -> page -> offset

pages   = header->pages[revision];
offsets = page = pages[item_index / page_size];
offset  = offsets[item_index % page_size];

结构要素:

  • header 含 <first revision>、<revision count>、<page size>、<page table index>(数组,每个修订记录其首页在 page table 中的下标,共 revision count + 1 项以终止最后一个修订)、<page table>(页头数组)。
  • page table 每项含 <offset>(页内容在索引内的绝对位置)、<entry count>(页内偏移条目数,除最后一个页外必须等于 page size)、<size>(页描述的磁盘长度,与 offset 冗余)。
  • page 含 <entry count> 与 <offsets>(绝对文件位置数组)。

磁盘格式(L151-L179):

text 复制代码
index := "L2P-INDEX\n" header revisions pages offsets

header    := u(first revision) u(page size) u(revision count) u(s(page table))

revisions := u( page table index[k+1] - page table index[k] )      每修订一个差分
pages     := u(page table[k].size) u(page table[k].entry count)     每页一个
offsets   := page(k)
page(k)   := i(page table[k].offsets[0])
             i(page table[k].offsets[l] - page table[k].offsets[l-1])  差分编码

注意标题行字面量是 L2P_STREAM_PREFIX "L2P-INDEX\n"(index.c L63-L64);而 u(x)/i(x) 分别是「无符号/有符号 7b8b 编码」,s(x) 是元素个数。页内偏移采用差分存储,进一步压缩。

运行期结构 l2p_header_t(index.c L92-L111)只保存「每个修订的 page table」,不保存页本身(页按需读并缓存)。l2p_page_table_entry_t(L76-L86)就是上面的 {offset, entry_count, size}。

3.6.6 手工推演一次 L2P 地址计算

l2p_page_info_copy(index.c L1115-L1171)是「把 (rev, item_index) 解成『哪一页、页内哪个位置』」的核心。逻辑如下:

c 复制代码
apr_size_t rel_revision = baton->revision - header->first_revision;
if (rel_revision >= header->revision_count)
  return SVN_ERR_FS_INDEX_REVISION;                /* 修订超出索引覆盖范围 */

if (baton->item_index < header->page_size)
  {
    /* 绝大多数修订只占一页 */
    baton->page_offset = (apr_uint32_t)item_index;
    baton->page_no = 0;
    baton->entry = page_table[page_table_index[rel_revision]];
  }
else
  {
    /* 该修订的页区间 */
    first_entry = page_table + page_table_index[rel_revision];
    last_entry  = page_table + page_table_index[rel_revision + 1];

    max_item_index = (apr_uint64_t)page_size * (last_entry - first_entry);
    if (item_index >= max_item_index)
      return SVN_ERR_FS_INDEX_OVERFLOW;

    baton->page_offset = item_index % header->page_size;
    baton->page_no     = item_index / header->page_size;
    baton->entry       = first_entry[page_no];
  }

于是拿到某页后,页内偏移就是 page->offsets[page_offset](对 L2P 页,offsets 数组直接给出绝对文件位置;差分已在解析时还原)。

一个完整数值推演 :设某仓库 fsfs.conf 的 io.l2p-page-size = 16(实际默认更大,这里取小值便于演示),某修订 revision count = 4、page_table_index = [0, 1, 3, 3, 4](即该修订(假设是第 2 个,rel_revision=2)在 page table 中的区间是下标 3..3,占 1 页)。若 item_index = 40:

  • 40 >= page_size(16),进入 else 分支;
  • last_entry - first_entry = 3 - 3 = 1,故 max_item_index = 16 * 1 = 16;
  • 40 >= 16 → 报 SVN_ERR_FS_INDEX_OVERFLOW,错误信息给出「Item index 40 exceeds l2p limit of 16 for revision ...」。

若把 page_table_index 改成 [0, 1, 1, 4, 4](该修订占下标 1..4,即 3 页),则 max_item_index = 16 * 3 = 48,40 < 48;page_offset = 40 % 16 = 8,page_no = 40 / 16 = 2,取该修订第 3 页的第 8 个偏移------即 first_entry[2]->offset 页里的 offsets[8]。这一推演说明:无论 item_index 多大,只需两次除法/取模即可定位页,再在页内 O(1) 取偏移。

svn_fs_fs__item_offset(index.c L1771-L1817)把这套机制封装成一个函数,并处理四种情形(这四分支也值得单列):

c 复制代码
if (txn_id)
  { if (use_log_addressing) 走 l2p_proto_index_lookup(txn 的 proto 索引);
    else *absolute_position = item_index; }          /* 事务内 = 偏移 */
else if (use_log_addressing)
  走 l2p_index_lookup(正式索引);
else if (rev_file->is_packed)
  { 取 manifest 偏移 rev_offset; *absolute_position = rev_offset + item_index; }
else
  *absolute_position = item_index;                   /* 物理、未打包 */

这段代码把 §3.6.1 的三种语义(逻辑、物理打包、物理未打包)一次性表达清楚了。

3.6.7 P2L 索引:反向查找

P2L 要把 offset 映射到 (rev, item_index, type, len, checksum)。structure-indexes L206-L264 的层次是:

text 复制代码
header -> page -> item info

page = header->pages[offset % page_size];
item info = binary_search(page.data, offset)

要点:

  • 索引把 rev/pack 文件按固定字节大小切成页,每页对应一个索引页;这个页大小可以不同于 FS 的 block size。
  • 当前实现只在 offset 恰好命中某 item 起点时才返回数据 。为简化查找,最后一个索引页会为「EOF 之后的区段」加一个「unused item」条目;并且不允许空洞,即 rev/pack 的每个字节都要被索引覆盖。
  • 因为 item 可能跨页甚至跨多页,数据模型用「主数组 + 页作为区间引用」来表示,于是多个页可以引用同一 item 描述。

运行期 header 是 p2l_header_t(index.c L140-L156):{first_revision, page_size, page_count, file_size, offsets[]}。

磁盘格式(L266-L300):

text 复制代码
index := "P2L-INDEX\n" header pages items

header := u(first revision) u(file size) u(page size) u(page count)
pages  := u(offsets[k+1] - offsets[k])           每页一个
items  := u(item[0].offset)
          u(item[l].size)
          i(c(item[l]) - c(item[l-1]))           c(x) = item_index*8 + type
          i(item[l].revision - item[l-1].revision)
          u(FNV checksum)

这里 c(x) 是一个复合函数 item_index * 8 + type------把 item 编号与类型打包进一个数再做差分。注意源码里的小陷阱:「Access to negative indexes gives a 0 value」(L293)。页内 item 按 offset 严格升序;从某页起点之后开始、并延伸到下一页的 item 不会存进起始页 ;运行期表示会复制跨页 item,磁盘表示不会------因此「大 item 内部的页在磁盘上条目数为 0」(L295-L300)。

P2L 页大小常量是 ffd->p2l_page_size,用于反查的部分逻辑见 index.c L2289-L2307:page_no = offset / page_size,page_start = page_size * page_no,若 page_no >= page_count 则视为「文件末尾之后的页」,page_size 置 0。

3.6.8 FNV 校验和:为什么不是普通 FNV-1a

structure-indexes L331-L354 说明 P2L 里每个 item 带一个「修改版 32 位 FNV-1a」。它故意与标准算法不同,流程分三步:

  1. 把输入字节流 [b0..bN] 拆成 4 条等长子流,余下至多 3 个字节作 remnant:[b0 b4 b8 ..]、[b1 b5 b9 ..]、[b2 b6 b10 ..]、[b3 b7 b11 ..]、[remnant];
  2. 分别算 4 条子流的 32 位 FNV-1a:h0..h3;
  3. 把这 4 个校验和的大端表示(各 4 字节)与 remnant 拼成 16~19 字节的中间量,再对它做一次 FNV-1a 折叠成最终 32 位校验和。

这样做的目的是:在保持逐字节可增量计算的同时,让 4 路并行/向量化更友好 (4 条子流彼此独立)。这个校验和在提交时由 fnv1a_wrap_stream 边写边算(见 §3.4.6 引用的 transaction.c L3364-L3390)。对「空或零长 item」校验和记为 0。

3.6.9 proto 索引:事务与打包的中间格式

structure-indexes L182-L203 描述 L2P proto:它只含 (offset, item_index) 对(各 64 位小端),所有属于同一修订的映射必须一次写完 ,但顺序任意;用 (0,0) 标记新修订开始(文件开头的 (0,0) 可选、会被忽略)。布局是:

text 复制代码
<bof>
(0, 0)        /* 标记 revision r 开始,首修订可选 */
(off, item)*  /* 随机顺序 */
(0, 0)        /* 标记 r+1 开始 */
...
<eof>

P2L proto(L302-L329)每个条目是 svn_fs_fs__p2l_entry_t 的 5 个字段,各 64 位小端:item offset、item size、item type、modified FNV1a checksum、revision(SVN_INVALID_REVNUM 映射为 0、rev >= 0 存 rev+1)、item index------共 6 个 uint64,恰好对应源码里 P2L_PROTO_INDEX_ENTRY_SIZE (6 * sizeof(apr_uint64_t))(index.c L58-L61)与 svn_fs_fs__p2l_proto_index_add_entry 的 6 次 write_uint64_to_proto_index(L1835-L1874)。所有条目必须严格按 offset 升序、不允许重叠、允许零长。关键设计(L323-L328):事务写入时最终修订号可能未知 ,所以 SVN_INVALID_REVNUM 的条目会在生成最终索引时被自动赋值------这对 rev 文件可行,对 pack 文件不可行(因为 pack 里的修订号在打包时已知)。

3.6.10 打包(packing):逻辑寻址下的重排

structure L314-L332 描述打包:把每个「满 shard」的所有修订文件拼接成一个 pack 文件,删除原 shard,读取重定向到 pack。

  • 物理寻址:为每个 shard 建一个 manifest 文件,记录各修订在 pack 中的偏移;manifest 是按行排列的 ASCII 十进制偏移列表(每个修订一行)。
  • 逻辑寻址 :不用 manifest ,而是把索引数据追加 到修订内容之后;而且 pack 内的修订会被交错(interleaved)以减少典型访问模式的 I/O。文档强调:逻辑寻址下 packed 与 non-packed 修订文件没有结构差异。

pack.c 顶部注释(L48-L90)把逻辑寻址打包的分步算法讲得非常细,值得完整摘录:

  1. 先扫描修订文件索引,确定要「放置」(即决定其在未来 pack 中理想位置)的 item 数;每个 item 需要常数内存跟踪;MAX_MEM 限制一次能放置的 item 数,超限就分批处理;最坏情况会「基本原样追加、仅在每个修订内部做少量调整」。
  2. 第二步:读取选定范围内的所有修订,建立 item 跟踪信息,并把 item 拷到临时文件充当「粗粒度桶」------把 changed-path 列表、文件属性、目录属性、以及 noderev+表示分开。
  3. 第三步:对四个桶分别求最优放置。前三个桶简单地按修订排序(从最新开始一次);放置 rep 与 noderev 更复杂(文档说详见代码)。简短结论是存储顺序为:changed-path 列表 → 节点属性 → 目录属性 → 目录表示及其 noderev(按路径字典序,对 trunk/branches 特殊处理)→ 文件表示同理。
  4. 第四步:把 item 从临时桶拷入最终 pack,并写临时索引。
  5. 最后,在处理完最后一批评修订后,生成最终索引。

path_order_t(L97-L122)描述了「某路径某修订的节点变化」,含 path、node_id、revision、predecessor_count、is_head(是否为该 rev/pack 中该路径的最新节点)、expanded_size、noderev_id、rep_id;reference_t(L128-L134)描述「FROM 引用 TO」(FROM 可为 noderev 或 rep,TO 目前总是表示)。这套结构正是「按路径与 node_id 排序 → 合并相似节点 → 路径主序存放」的实现基础。

3.6.11 用 svnfsfs dump-index 观察索引

svnfsfs 的 dump-index 子命令(帮助文本 svnfsfs.c L123-L153)把某个 rev/pack 的索引内容打印到控制台,仅对 format 7+ 可用。输出是「首行一个表头 + 按文件位置排序的每 item 一行」,列含义:

  • Byte offset (hex):item 起始的字节偏移;
  • Length (hex):item 字节数;
  • Item type(字符串) :none(未使用,文件内容应为 NUL)、frep、drep、fprop、dprop、node、chgs、rep(未知表示的占位,不应出现)、???(无效,索引损坏);
  • Revision:item 所属修订(十进制);
  • Item number:该修订内的 item 编号(十进制);
  • Modified FNV1a checksum:8 位十六进制。

帮助文本还给出一句重要澄清:frep/drep/fprop/dprop 的区分只是内部优化用的分类,不影响正确性 。这正好呼应 §3.6.2 里「形态相同、区分只为重排启发式」的说法。与之配对的 load-index(L155-L163)从控制台读回同样的格式(校验和与首行可选、会被忽略),数据必须覆盖整个 rev/pack 文件,修订号自动从输入流提取,且不要求有序。这给管理员提供了一条「索引损坏时手工重建」的通道。

3.6.12 rev/pack 文件句柄:svn_fs_fs__revision_file_t

把「打开哪个文件、是否 packed、索引在哪」封装起来的是 svn_fs_fs__revision_file_t(rev_file.h L45-L95):

c 复制代码
typedef struct svn_fs_fs__revision_file_t
{
  svn_revnum_t start_revision;   /* rev / pack 文件的首修订;txn proto-rev 为 INVALID */
  svn_boolean_t is_packed;
  apr_file_t *file;
  svn_stream_t *stream;
  svn_fs_fs__packed_number_stream_t *p2l_stream;
  svn_fs_fs__packed_number_stream_t *l2p_stream;
  apr_off_t block_size;          /* 从 FFD->BLOCK_SIZE 拷来,便于对齐 seek */
  apr_off_t l2p_offset;          /* rev 数据结束、L2P 开始处 */
  svn_checksum_t *l2p_checksum;
  apr_off_t p2l_offset;          /* L2P 结束、P2L 开始处 */
  svn_checksum_t *p2l_checksum;
  apr_off_t footer_offset;       /* P2L 结束、footer 开始处 */
  apr_pool_t *pool;
} svn_fs_fs__revision_file_t;

文件头注释(L29-L34)说明了 is_packed 的作用:format 7 的索引文件必须与对应 rev/pack 文件同步读取 ------packed 的 rev 文件要用 packed 索引、unpacked 的用 unpacked 索引。因此句柄打开时就要带上匹配的 is_packed 设置,以免后台 pack 进程造成不一致。svn_fs_fs__auto_read_footer(L123-L129)只在需要时才读 footer,并把上面的 offset/checksum 字段填好------「惰性读 footer」是一条重要的性能优化(因为多数 rev 文件访问只用 rep 数据,不需要索引位置)。

3.6.13 两个寻址模式的对照小结

维度 物理寻址(format 1--6) 逻辑寻址(format 7+)
item_index 含义 修订文件内字节偏移 抽象 item 编号
定位方式 直接 seek 先查 L2P 索引
修订末尾 两行偏移 trailer L2P + P2L 索引 + footer
pack 的定位 manifest 偏移 + item_index pack 内索引(无 manifest)
能否重排 item 不能(会破坏后续修订的引用) 能(索引随之重建)
是否允许非 sharded 允许 不允许(见 §3.7)
事务内中间文件 无 itemidx + index.l2p + index.p2l

3.7 format 演进:从 1 到 8 逐版说明

3.7.1 官方对照表

structure L144-L211 给出了格式清单与「差异矩阵」,这是本章最权威的演进证据。列出格式号与首次支持的 Subversion 版本:

text 复制代码
Format 1, understood by Subversion 1.1+
Format 2, understood by Subversion 1.4+
Format 3, understood by Subversion 1.5+
Format 4, understood by Subversion 1.6+
Format 5, understood by Subversion 1.7-dev, never released
Format 6, understood by Subversion 1.8
Format 7, understood by Subversion 1.9
Format 8, understood by Subversion 1.10

注意 format 5 是 1.7 开发期产物、从未正式发布 (文档 L335-L341 专门写了一节,说明它用 SQLite revprops.db 打包 revprop,并给出 r1143829 的历史链接);因此实践中你的仓库格式号会跳过 5。

差异矩阵(原文按维度列出):

维度 各格式行为
修订文件里的差分编码 f1:仅 svndiff0;f2--f7:svndiff0 或 svndiff1;f8:svndiff0/1/2
format options f1--f2:不允许;f3+:layout;f7+:addressing
事务名重用 f1--f2:可能重用;f3+:用 txn-current 生成
proto-rev 及其锁的位置 f1--f2:transactions/<txnid>/rev 与 rev-lock;f3+:txn-protorevs/<txnid>.rev 与 .rev-lock
node-ID/copy-ID 生成 f1--f2:用 current 里的全局 base36 单调序列;f3+:用新修订号保证唯一,current 只留 youngest
mergeinfo 元数据 f1--f2:不存 minfo-here/minfo-count,svn_fs_get_mergeinfo 报错;f3+:维护且可用
changed-path 列表 f1--f3:不含 node kind;f4+:含 kind;f7+:含 mergeinfo-mod 标志
shard 打包 f4:只对修订数据;f5:revprop 独立打包;f6+:修订数据与 revprop 同等对待(同一 min packed rev)
寻址 f1+:物理寻址(修订文件内固定位置);f7+:逻辑寻址(item 索引即时翻译,7+ 新建仓库的默认)
仓库 ID f1+:db/uuid 第一行是 UUID;f7+:第二行是 instance ID

3.7.2 MIN_*_FORMAT 常量:与源码逐条对齐

structure 表格下方有一条注记:「# Incomplete list. See SVN_FS_FS__MIN_*_FORMAT」。这些常量全在 fs.h L130-L201,逐条列出(值与注释均为原文):

  • SVN_FS_FS__FORMAT_NUMBER = 8------本代码(1.15)写入的新仓库格式 ,注释提醒「改了它要同步更新 svn_fs_fs__create() 的 switch」。
  • SVN_FS_FS__MIN_SVNDIFF1_FORMAT = 2------支持 svndiff 版本 1。
  • SVN_FS_FS__MIN_TXN_CURRENT_FORMAT = 3------用 txn-current 生成事务 ID。
  • SVN_FS_FS__MIN_LAYOUT_FORMAT_OPTION_FORMAT = 3------支持 layout format option。
  • SVN_FS_FS__MIN_PROTOREVS_DIR_FORMAT = 3------proto-rev 放在单独目录。
  • SVN_FS_FS__MIN_NO_GLOBAL_IDS_FORMAT = 3------不再保留 node/copy ID 计数器。
  • SVN_FS_FS__MIN_MERGEINFO_FORMAT = 3------维护 minfo-here/minfo-count。
  • SVN_FS_FS__MIN_REP_SHARING_FORMAT = 4------允许 rep 共享。
  • SVN_FS_FS__MIN_PACKED_FORMAT = 4------支持 packed shards。
  • SVN_FS_FS__MIN_KIND_IN_CHANGED_FORMAT = 4------changed-path 里存 node kind。
  • SVN_FS_FS__MIN_DELTIFICATION_FORMAT = 4------1.8 的 deltification 选项生效(注释解释为何限制在 1.6+ 格式)。
  • SVN_FS_FS__MIN_CONFIG_FILE = 4------支持 fsfs.conf。
  • SVN_FS_FS__PACKED_REVPROP_SQLITE_DEV_FORMAT = 5------1.7-dev 的 SQLite revprop 打包(未发布)。
  • SVN_FS_FS__MIN_PACKED_REVPROP_FORMAT = 6------支持 revprop 打包。
  • SVN_FS_FS__MIN_LOG_ADDRESSING_FORMAT = 7------支持逻辑寻址。
  • SVN_FS_FS__MIN_PACK_LOCK_FORMAT = 7------为 pack 提供独立锁文件。
  • SVN_FS_FS__MIN_MERGEINFO_IN_CHANGED_FORMAT = 7------changed-path 里存 mergeinfo-mod 标志。
  • SVN_FS_FS__MIN_INSTANCE_ID_FORMAT = 7------支持 per-instance 文件系统 ID。
  • SVN_FS_FS__MIN_SVNDIFF2_FORMAT = 8------支持 svndiff 版本 2。
  • SVN_FS_FS__MIN_REP_STRING_OPTIONAL_VALUES_FORMAT = 8------支持 rep 字符串里用 "-" 表示缺省的可选值(SHA1/uniquifier),注释给了例子 15 0 563 7809 28ef320a82e7bd11eebdf3502d69e608 - 14-g/_5。
  • SVN_FS_FS__MIN_REP_CACHE_SCHEMA_V2_FORMAT = 8------支持 rep-cache.db 的 V2 schema。

这份常量表同时回答了一个常见疑问:「为什么 format 4 一口气引入了 rep-sharing、packed shards、node kind、fsfs.conf、deltification 这么多东西?」 因为这些特性在 1.6(format 4)一次性落地;format 号是「能力集合」的版本号,而非单一特性的版本号。

3.7.3 format 文件是怎么写出来的

svn_fs_fs__write_format(fs_fs.c L587-L634):

c 复制代码
sb = svn_stringbuf_createf(pool, "%d\n", ffd->format);

if (ffd->format >= SVN_FS_FS__MIN_LAYOUT_FORMAT_OPTION_FORMAT)   /* >= 3 */
  {
    if (ffd->max_files_per_dir)
      svn_stringbuf_appendcstr(sb, apr_psprintf(pool, "layout sharded %d\n",
                                                ffd->max_files_per_dir));
    else
      svn_stringbuf_appendcstr(sb, "layout linear\n");
  }

if (ffd->format >= SVN_FS_FS__MIN_LOG_ADDRESSING_FORMAT)         /* >= 7 */
  {
    if (ffd->use_log_addressing)
      svn_stringbuf_appendcstr(sb, "addressing logical\n");
    else
      svn_stringbuf_appendcstr(sb, "addressing physical\n");
  }

于是 format 文件的三行结构一目了然:第一行格式号,第二行 layout(linear 或 sharded N),第三行 addressing(仅 format 7+)。structure L214-L263 定义了这两个 option 的语法与默认值(默认 layout 是 linear、默认 addressing 是 physical),并给出一条硬性约束:logical addressing 不允许用在非 sharded 仓库上 (原文:「It is illegal to use logical addressing on non-sharded repositories.」)。这也解释了为什么 1.15 新建仓库默认 layout sharded 1000 + addressing logical(见 §3.5.11 的 create 流程)。

structure L124-L142 还规定:format 文件是「单行格式号 + 任意多行 format option」;遇到当前格式号不允许的 option,客户端应报错;并且在 1.2 之前根本没有 format 文件,「缺席即视为 format 1」。

3.7.4 兼容版本 → 格式号的映射

svn_fs_fs__create 里的 switch(L1930-L1956)是「用户指定兼容到某旧版本时用哪个格式」的权威映射:

c 复制代码
case 0: 报错,FSFS 不兼容 1.1 之前
case 1: case 2: case 3: format = 1;
case 4:                 format = 2;
case 5:                 format = 3;
case 6: case 7:         format = 4;
case 8:                 format = 6;
case 9:                 format = 7;
default:                format = SVN_FS_FS__FORMAT_NUMBER;   /* 8 */

注意其中没有 case 5→format 5(因 format 5 从未发布);「兼容 1.8」映射到 format 6、「兼容 1.9」映射到 format 7。


3.8 变体后端:FSX 与 BDB

3.8.1 FSX:libsvn_fs_x 是什么

FSX 是 Subversion 为「更紧凑的磁盘表示」实验的后端。它的设计文档 [libsvn_fs_x/structure](subversion-1.15.0/subversion/libsvn_fs_x/structure) 开头就自我定位(L7-L12):

Since FSX is still in a very early phase of its development, all sections either subject to major change or simply "TBD". ... Similar to FSFS format 7 but using a radically different on-disk format.

「与 FSFS format 7 相似,但磁盘格式截然不同」------这就是一句话结论。其余要点:

  • 格式号是独立的 :文档 L118-L127 明言「So far, there is only format 1」,而源码 SVN_FS_X__FORMAT_NUMBER 在 fs.h L124 定义(当前实现为 2,另有 SVN_FS_X__EXPERIMENTAL_FORMAT_NUMBER)。这印证了「FSX 与 FSFS 的格式号空间互不相干」。
  • 配置文件是 fsx.conf (对应 FSFS 的 fsfs.conf),uuid 不写 instance ID,min-unpacked-revprop 是独立文件(L56-L69)。
  • 修订文件格式 标为 TBD(L220-L232),但明确「data gets aggregated in compressed containers with a binary on-disk representation」------即 FSX 用压缩容器 + 二进制格式,不再像 FSFS 那样是文本行拼接。
  • node-rev ID 的 txn-id 是 r<rev>/<offset> (L152-L166),<offset> 是「从修订文件起点到 node-rev 起点的字节数」------没有 item_index 概念。这是 FSX 与 FSFS 逻辑寻址在 ID 层面的直接差异。
  • 事务布局仍列 txn-protorevs/rev 与 rev-lock(L248-L249)。

源码侧:svn_fs_x__init(fs_x.c 见 libsvn_fs_x/fs.c L651-L675)设置 library_vtable;svn_fs_x__verify_format 之类函数(fs_x.c L100-L118)会拒绝过新或超出实验范围的格式号。公开 API 把 fsx 标为「Experimental filesystem implementation」(svn_fs.h L306),且 SVN_FS_TYPE_FSX "fsx"(L88)。结论:FSX 与 FSFS 共享对象模型与 vtable 机制,但在磁盘格式上是两套完全不同的实现。

3.8.2 BDB:libsvn_fs_base 的现状

BDB(Berkeley DB)是 Subversion 最早的 FS 后端,其设计文档在 [libsvn_fs_base/notes/structure](subversion-1.15.0/subversion/libsvn_fs_base/notes/structure)。现状要点:

  • 已被弃用 :CHANGES 的历史条目里明确有「deprecate the Berkeley DB FS backend (libsvn_fs_base) (r1464985 et al)」。1.15 的 svn_fs.h 仍保留 SVN_FS_TYPE_BDB "bdb" 与一批 svn_fs_*_berkeley 兼容 API(L76、L749-L788)。
  • 默认类型不再是 BDB :fs-loader.c 里 DEFAULT_FS_TYPE 回退值为 "fsfs"(L69-L71),注释解释这是为 Windows 补的 configure 回退。
  • 老仓库兼容判定 :svn_fs_type 在 fs-type 缺席且路径是目录时推断为 BDB (L345-L362)------即 1.1 之前创建的仓库没有 fs-type 文件,只能靠「它就是 BDB」这条历史约定识别。
  • BDB 专属槽位 :library vtable 里的 bdb_logfiles、fs vtable 里的 bdb_set_errcall(fs-loader.h L140-L142、L261-L263)。FSFS 把 bdb_logfiles 实现成 no-op、把 bdb_set_errcall 留空。
  • 数据模型差异回顾 :BDB 用 skel 表示 node-rev/representation(§3.3),有 copies/strings/representations/checksum-reps 等表(notes/structure L866-L885),且「新修订存 fulltext、老修订被差分」,与 FSFS 正好相反。

结论:BDB 是一个「保持兼容但不再推荐」的后端;新部署应使用 FSFS(默认),对极端空间敏感的场景可评估 FSX(实验性)。


3.9 健壮性:校验、锁、崩溃恢复与历史缺陷

3.9.1 svnadmin verify 在做什么

FSFS 的 verify 后端在 [verify.c](subversion-1.15.0/subversion/libsvn_fs_fs/verify.c)。它的核心是一个「遍历所有 representation 并逐个校验」的 walker(L44-L114):

c 复制代码
static svn_error_t *
verify_walker(representation_t *rep, void *baton, svn_fs_t *fs, apr_pool_t *scratch_pool)
{
  /* 定期通知与释放资源:每 1000 次迭代或打开 16 个文件后 */
  if (walker_baton->iteration_count > 1000 || walker_baton->file_count > 16)
    { 若 notify_func 且修订号变化则回调; svn_pool_clear(walker_baton->pool); ... }

  previous_hint = walker_baton->hint;
  SVN_ERR(svn_fs_fs__check_rep(rep, fs, &walker_baton->hint, walker_baton->pool));
  ...
}

三点值得强调:

  1. 以「rep」为单位 :verify 会对每个 representation 调用 svn_fs_fs__check_rep,也即真正读取并校验 rev/pack 数据与(若有)rep-cache 的一致性,而不是只看文件是否存在。
  2. 资源回收策略 :iteration_count > 1000 || file_count > 16 就清空工作 pool、丢弃文件句柄提示(hint),避免遍历大仓库时耗尽句柄/内存。file_count 只在「hint 发生变化」(即切换到另一个 rev/pack 文件)时自增(L108-L111)。
  3. 进度回调 :notify_func 只在代表性修订号变化时触发,避免刷屏。

verify 还单独有一段「校验 rep-cache.db 与 rev/pack 数据一致性」的逻辑(L116 起),这与 fsfs.conf 默认注释里「svnadmin verify will check the rep-cache regardless of this setting」(fs_fs.c L994)一致。

3.9.2 svnfsfs stats 的输出结构

svnfsfs stats(帮助 svnfsfs.c L165-L170)打印对象大小统计。后端的 print_stats(stats-cmd.c L375-L409)先输出「Global statistics」,其格式串是理解各统计量含义的最好注解:

c 复制代码
printf(_("%20s bytes in %12s revisions\n"
         "%20s bytes in %12s changes\n"
         "%20s bytes in %12s node revision records\n"
         "%20s bytes in %12s representations\n"
         "%20s bytes expanded representation size\n"
         "%20s bytes with rep-sharing off\n"),
       total_size, revision_count,
       change_len, change_count,
       total_node_stats.size, total_node_stats.count,
       total_rep_stats.total.packed_size, total_rep_stats.total.count,
       total_rep_stats.total.expanded_size,
       total_rep_stats.expanded_size);

即依次是:仓库总字节数 & 修订数、changed-path 总字节数 & 条目数、node-rev 记录总字节数 & 个数、表示总字节数 & 个数、展开后大小、以及一个有趣的指标 「bytes with rep-sharing off」 ------它估算「如果关掉 rep-sharing 会多占多少字节」。随后是「Noderev statistics」(node 总计 / 目录 node-rev / 文件 node-rev 各自字节数与个数,L400-L409),再接按扩展名的直方图与 top-16 列表(print_extensions_by_changes、print_extensions_by_nodes、print_extensions_by_reps,L230-L346)。后端收集这些数据的 rep_kind_t(stats.c L49-L67)把表示分成 unused_rep、dir_property_rep、file_property_rep、dir_rep、file_rep 五类,rep_stats_t(L71-L99)记录 item_index、size、expanded_size、revision、ref_count、header_size、kind、chain_length(差分链长度,饱和到 255)。ref_count 与 chain_length 是判断 rep-sharing 效果与差分链深度的关键指标。

3.9.3 svnadmin recover:如何找到「最大存在修订」

崩溃恢复的核心在 [recovery.c](subversion-1.15.0/subversion/libsvn_fs_fs/recovery.c)。recover_get_largest_revision(L40-L103)用了一个漂亮的先倍增、再二分搜索,避免 readdir 或线性扫描:

c 复制代码
svn_revnum_t left, right = 1;
/* 倍增 right,直到某个修订不存在 */
while (1) {
  err = svn_fs_fs__open_pack_or_rev_file(&file, fs, right, iterpool, iterpool);
  if (err && err->apr_err == SVN_ERR_FS_NO_SUCH_REVISION) { clear; break; }
  SVN_ERR(err);
  right <<= 1;
}
left = right >> 1;
/* left 存在、right 不存在,二分收敛 */
while (left + 1 < right) {
  probe = left + ((right - left) / 2);
  ... 若 probe 不存在则 right = probe; 否则 left = probe;
}
*rev = left;   /* left 即最大存在的修订 */

之所以能这么找,正是 §3.5 设计的结果:每个修订是一个独立文件(或 pack 里的一段),「存在性」可靠打开文件判定;注释也点明这样做是为避免昂贵的 readdir() 或线性扫描。

3.9.4 锁:四把进程内互斥量 + 四个锁文件

FSFS 的并发模型分两层。第一层是锁文件 (OS 级,跨进程):write-lock、pack-lock(format 7+)、txn-current-lock、以及每个 proto-rev 的 rev-lock。第二层是进程内互斥量 ,集中在 fs_fs_shared_data_t(fs.h L245-L281):

c 复制代码
/* The following lock must be taken out in reverse order of their
   declaration here.  Any subset may be acquired and held at any given
   time but their relative acquisition order must not change.

   (lock 'txn-current' before 'pack' before 'write' before 'txn-list') */
svn_mutex__t *txn_list_lock;
svn_mutex__t *fs_write_lock;
svn_mutex__t *fs_pack_lock;
svn_mutex__t *txn_current_lock;

原文注释给出了一条强制加锁顺序 :txn-current → pack → write → txn-list。任何子集都可以同时持有,但相对顺序不得改变------这是避免死锁的协议。另有 fs_fs_shared_txn_data_t(L222-L240)用 being_written 布尔充当「proto-rev 文件的进程内非递归互斥」(注释明确:不允许递归加锁)。

平台差异用一个宏表达(fs.h L203-L211):

c 复制代码
#if APR_HAS_THREADS && !defined(WIN32)
#define SVN_FS_FS__USE_LOCK_MUTEX 1
#else
#define SVN_FS_FS__USE_LOCK_MUTEX 0
#endif

注释解释:大多数操作系统上 APR 的文件锁是每进程而非每文件;Windows 上 APR 用每文件句柄锁,因此不需要额外进程内互斥。这两行是「为什么 Windows 与 Unix 的 FSFS 锁实现要分叉」的答案。

svn_fs_fs__with_write_lock / with_pack_lock / with_txn_current_lock / with_all_locks(fs_fs.c L359-L426)则是对外的组合封装。

3.9.5 崩溃场景与「自我保护」设计

FSFS 的崩溃安全性主要靠以下几条约定:

  1. 新修订从不改动老文件:因为 FSFS 让新修订相对老修订存差分(§3.4.5),提交过程只需写新文件,无需回头修改既有修订。这使「崩溃在提交中途」不会破坏既有历史。
  2. write-lock + current 的顺序 :提交末段先加 write-lock,把新修订文件就位、再原子替换 current,最后解锁(§3.5.2)。因此「读者看到的 youngest」永远对应一个完整写入的修订。
  3. proto-rev 截断检测(issue #3845) :物理寻址下 write_final_rev 校验 item_index + size 是否超过 proto-rev 已知初始偏移,超过就报「Truncated protorev file detected」(transaction.c L3241-L3251)------这是对「某些未知机制导致 proto-rev 被截断」这一历史问题的防御。
  4. rep-cache 可丢弃 :rep-cache.db 删除后只是失去后续 rep-sharing 能力,不影响读取(§3.4.4),因此它的损坏不是灾难。
  5. 索引损坏可重建 :svnfsfs dump-index / load-index(§3.6.11)为逻辑寻址索引提供手工重建通道。

3.9.6 历史缺陷与 1.15 的修复(按 r 号串联)

第 1.15 章相关的 FS 层修复,CHANGES L53-L65 列出如下:

  • r1880374 :「fsfs: Fix uninitialized read in the logical addressing code」与「fsx: Fix uninitialized read in the logical addressing code」。这条同时出现在 1.14 段落(CHANGES L245:Fix an uninitialized read in FSFS (r1880374)),说明该修复先进入 1.14.x,再延续到 1.15。它直接对应 §3.6 的逻辑寻址代码------未初始化读是索引/L2P 路径里最危险的一类缺陷(可能读到栈/堆垃圾当作偏移)。
  • r1908595 :「fsfs: Reduce spurious conflicts when file content and directory properties are changed in parallel」及其 fsx 对应条目。这对应 §3.4.4 的 uniquifier 与 svn_fs_fs__noderev_same_rep_key:当「文件正文」与「目录属性」并行修改时,若 rep 共享判定过强,会误报冲突。修复的方向正是「让共享判定更精确」,也解释了为何 uniquifier 要包含创建 noderev 的 txn。
  • r1934786:「fsx: Fix uninitialized memory access」。这是 FSX 后端(§3.8.1)里的又一个未初始化内存访问修。
  • r1908751 :「svnadmin: Minor optimization in 'svnadmin load'」。虽在 svnadmin 侧,但其收益来自 FS 层的写入路径(load 会连续提交大量修订,减少每次提交的固定开销收益放大)。

3.9.7 一处细节:changed-path 分块常量

最后补一个性能相关的常量:SVN_FS_FS__CHANGES_BLOCK_SIZE(fs.h L213-L216):

c 复制代码
/* Maximum number of changes we deliver per request when listing the
   changed paths for a given revision.   Anything > 0 will do.
   At 100..300 bytes per entry, this limits the allocation to ~30kB. */
#define SVN_FS_FS__CHANGES_BLOCK_SIZE 100

即「列出某修订的 changed paths 时,每批最多 100 条」,注释解释这是把内存分配限制在约 30kB 的设计。它与 svn_fs_fs__changes_context_t(fs.h L663-L687)配合,实现「增量、分块地读取 changed-path 列表」------这也是为什么 1.9 之后 FS API 出现 svn_fs_path_change_iterator_t 这类迭代器接口的原因。


本章至此收束:抽象层用五个结构体与六张 vtable 把后端与上层解耦,FSFS 用「一修订一文件」的朴素设计承载了 rep 共享、逻辑寻址与 pack 重排这三项复杂度,而 format 号从 1 到 8 的每一步跃迁都能在 MIN_*_FORMAT 常量与 CHANGES 条目里找到证据。下一章(第 4 章)我们将向上走一层,看 libsvn_repos 如何在 FS 之上叠加钩子、授权与修订号分配,把「文件系统」包装成一个「版本库」。


相关推荐
unconditional1 小时前
我把 nvm、jenv、pyenv 全卸了:一个 Rust 单文件管完 40+ 开发工具
开源
鶴哥只手遮天1 小时前
Apache Subversion 1.15 源码深度剖析-第 6 章 工作副本:WC-NG 与 pristineless
svn·开源
FIT2CLOUD飞致云1 小时前
学习笔记丨MaxKB PPT/PDF文档整页转图工作流知识库方案
人工智能·ai·开源·智能体·maxkb
miofly1 小时前
Step 5 Preview 用 27B 激活参数运行 600B 规模模型
开源·github
神奇的代码在哪里1 小时前
Ling-3.1-flash 正式开源:560B 激活 25B,1M 上下文、MIT 许可
人工智能·开源·大模型·ai agent·moe
miofly2 小时前
GitHub 今日推荐|open_scion:为AI代理设计的并发版本控制
开源·go·github
I'm Jie2 小时前
Rhost v2.0.0 版本发布!!!不止 SSH 终端,内置 AI Agent 支持自然语言操作远程服务器
运维·服务器·rust·开源·ssh·tauri·finalshell
miofly4 小时前
GitHub 日榜趋势速报 | 2026-10-16
开源·github