# twosync **Repository Path**: chjgfg/twosync ## Basic Information - **Project Name**: twosync - **Description**: 服务器之间同步数据 - **Primary Language**: Unknown - **License**: Not specified - **Default Branch**: main - **Homepage**: None - **GVP Project**: No ## Statistics - **Stars**: 0 - **Forks**: 0 - **Created**: 2026-10-16 - **Last Updated**: 2026-10-27 ## Categories & Tags **Categories**: Uncategorized **Tags**: None ## README # twosync **跨操作系统同步大二进制项目的微型 git。** 两台机器(Windows / Linux)各自持有一份版本库 + 内容寻址块仓, 之间走 SSH,只传对端缺的**块**——不是整文件,也不是 rsync 那种"两边对一遍"。 它解决的具体场景:一个 2–10 GB 的工程(数据文件、构建产物、大二进制),两台机器都要改, 既要"改了什么都能取回版本",又要"改 1 MB 不要重传 10 GB"。 设计文档在同目录 `REQUIREMENTS.md`(v0.4,含全部判据与实测的出处)。下面每个数字都注明了 **盘型 + 构建 + 负载形状**,因为在这台 1 核 6 G 的机器上,脱离形状的耗时数字没有意义。 --- ## 它不是什么(三条是前提,不是待办) 1. **不是 git 的替代品。** 自定对象格式 + 自建 git 形状的命令面。想要"git 命令原样能读"就得采用 git 对象格式, 而那个格式的 blob 粒度(整文件)容不下块级增量与有界保留——两者不可兼得,所以选了后者。 顺带一条实测发现:**git 对 ≥ 4 GiB 的单文件会静默存错**(退出码 0、无警告),而本项目的核心语料就是 2–10 GB 单文件。 2. **不做内容层三方合并。** `merge` 只做两侧改动**路径无交集**时的结构合并;同一个文件被两边各改成不同东西 ⇒ 报冲突、拒绝、一个字不写,交给人选(`checkout --at` / `reset `)或交给真 git。 3. **不是备份。** 配额内(默认站点 20%)的历史会被 `gc` 回收,`gc` 是删块不是回收站。要长期留存请另开 bare git。 --- ## 五分钟上手 ```sh twosync --root /data/ts init # 建站点布局(幂等) twosync --root /data/ts project add buildout # 注册一个项目,工作树 = /data/ts/buildout twosync --root /data/ts --project buildout scan # 认一遍工作树 twosync --root /data/ts --project buildout commit -m "v1" twosync --root /data/ts --project buildout status # 该拉/该推/上次会话/引擎健康度/deferred twosync --root /data/ts --project buildout push peer-a # 传输 = ssh peer "twosync agent",不开新端口 twosync --root /data/ts --project buildout pull peer-a twosync --root /data/ts --project buildout daemon --peer peer-a # 常驻:watch + 周期兜底 + 自动 commit/pull/push/gc ``` 第一次连某个对端会走 TOFU:指纹进表;**同一份身份变了就直接拒绝会话**(要 `peer forget` 才能重连)。 `plan` / `checkout` / `gc` / `reset` / `revert` 这些会动手的命令**默认只报不写**,要 `--apply` 才落地。 --- ## 命令面(形状照 git,语义各自写清) | 层 | 命令 | |---|---| | 一:读历史 / 落盘 / 同步 | `init` `doctor` `version` `config` `project` `scan` `commit` `log` `show` `diff` `status` `plan` `apply` `restore` `checkout` `ls-files` `mv` `gc` `push` `pull` `agent` `daemon` `peer` | | 二:图上重写 / 重放 | `tag` `branch` `reset` `merge` `revert` `cherry-pick` `blame` `bisect` `stash` | 几个和 git 不同、且**故意**不同的点: - **多 head 是正常状态不是错误**。`push`/`pull` 做的是 head 集合的可达并集(git 的快进只是它的一个特例)。 - 碰撞有**三条出路**:`merge --keep-both`(路径无交集才结构合并成一笔两亲 commit)、`--take-local`/`--take-remote`、 或 `reset ` 点名。认不出唯一来源时命令**拒绝**而不是挑一个。 - `blame` 是**路径级**不是行级:4 MiB 块 + 二进制语料里"行"不是任何一层的身份。 - `bisect` **不落游标**(无状态,每次给两端):代价是抄一次命令行,换来"没有 bisect 卡住要 reset 那种坏账"。 - `branch`/`tag`/`stash` 都不参与同步(v1 的同步只看 `main`);它们的实际作用是**把历史挂住**(`gc` 收不到) 和**给 revspec 一个能敲的名字**。 --- ## 指标(每条都带口径) **站点规模**:12 GiB / 20 214 条路径 / 204 个目录 / 21 794 个块,站点在 **D: 5400 转机械盘**,release 构建。 | 项 | 实测 | 备注 | |---|---|---| | 首建 10 GiB 的墙钟 | **5 071 s(84.5 min)**,CPU 单核 **2.3%**,WorkingSet 39.5 MB | 几乎全在等盘:同机 fsync 单价 85.8 ms(机械)vs 2.6 ms(SSD)。**NFR-2 的 480 s 预算未达标,归因已完成**(是盘的函数不是代码的函数) | | 稳态 `commit`(工作树无改动) | 20 217 行交出 / 折 20 222 / 11.3 s 与 10.6 s 两读 / 堆峰 44.5 MiB | **`File::open` 次数 20 215 ⇒ 0**(改成先问库里有没有她);结论用计数不用墙钟(本机 run 间 ±2×) | | 只读命令的门面调用次数 | `plan` 6 折 / `status` 8 折 / `gc --sweep`(dry) 16 折 | 装载目录清单之前是 209 / 211 / 226;装载器带 **4 MiB 预算,超了退回点读** | | 块级增量(改 1 MiB) | 我们 **4 450 304 B**(2 块上线 / 326 跳过),同形状 git **79 518 306 B** | **省 17.9 倍,而且是下界**(那把语料可压 6.7 倍 = 偏袒 git)。跨块最坏 8.07 MiB,超的是清单帧,口径待拍板(§14 Q15) | | 首建库体积(~560 MiB 可压缩语料,两边工作树字节数完全相同 = 588 070 912) | `.git` **83.2 MiB** vs 我们 **561.1 MiB** | **这一格 git 赢**,因为它 zlib 压 6.7 倍而我们按设计不压(满熵语料下压缩是零收益)。如实放着 | | 崩溃点 | 半截文件、fsync 前后、head 更新前 `kill -9` 三刀都过 | 重启后无半文件、无"清单引用不存在的块",不变量 (a)–(g) 仍成立 | | 12 GiB 站点的**只读那一列**(`plan` / `log` / `checkout` 干跑,一引擎一进程) | `plan` **8.2 s**(6 折,中位 8.1 s / 7 个样本)· `log` 6 笔 **0.4 ms** · `checkout` 对 head 干跑 **15.5 s**(12 折)· **进程峰值 RSS 102.25 MiB** | ⚠️ **NFR-1 的 80 MB 在这一档翻了**(102.25 MiB ≈ 107 MB;此前全项目最高 72.1 MB)。归因当天做了**两轮对照实验**:① 把硬链副本的版本链压完(行数 ÷ 6)**堆峰不降反升** 86.86 → 118.47 MiB ⇒ "∝ 版本链行数"作废;② 再用"原始字节与块数完全相同、只有条目数差 16 倍"的两臂去卡系数 ⇒ 比值随**形状**散(1.26–1.94), 但**同形状跨 40 倍规模四点全是 1.58、折数全是 5** ⇒ **系数是形状的函数、不是规模的函数**(同形状可推、跨形状必须实测), 而"库 ≲ 47 MiB"那条派生阈值作废。剩两句:机制(`plan` 折数恒定 ⇒ 单次折叠变贵)+ 元凶是**条目数 × 每条内容** (目录数只值 7–20 %,装载器七档全部没退预算)。**最该盯的一格早三个数量级**:8 000 条目 / 库仅 12.78 MiB ⇒ **RSS 84.66 MiB**。只读性有凭据:跑前跑后 `status` 五项读数逐字相同;整列跑两遍堆峰与折数逐字节相同而墙钟动 −13 % ⇒ 能当证据的是内存与折数,不是时间 | **存储引擎矩阵**(`--engine lsm_tree|bplus_tree|bitcask|memory`,同一负载 1 000 条路径 / 15 MiB,C: SSD,一引擎一进程): | 引擎 | FR-21 启动自检 | 只读 `plan`(中位) | 稳态 `commit` | 库体积 / 11 047 行 | 进程峰值 RSS | |---|---|---|---|---|---| | `lsm_tree`(默认) | **过** | 85 ms | 540 ms | 1.60 MiB | 16.5 MiB | | `bplus_tree` | **过** | 1 228 ms | 3 313 ms | 1.58 MiB | 12.3 MiB | | `bitcask` | 拒:数据记录无校验和 | 156 ms | 648 ms | 2.42 MiB | 12.1 MiB | | `memory` | 拒:三项全缺 | 59 ms | 292 ms | 不落盘 | 11.8 MiB | 三个结论:**四家折数逐条相同**(1 010 / 6 015 / 5 / 1 / 10 / 9 / 2 032)⇒ 墙钟差只能是"一次门面调用在哪家贵"; `bplus_tree` 在只读密集上贵 5–13 倍 ⇒ "换引擎后计数相同"**不等于**性能等价,默认 `lsm_tree` 是有数的; CAS 那一侧四家一字不变(15.63 MiB / 4 001 文件)⇒ 文件字节根本不经过引擎。**这张表不许外推到大站** (门面每折一遍全库 ⇒ 行数才是内存的自变量)。 上面那张小站表**已经在 12 GiB 站点上补了一列**(就是指标表最后那一行),但**只有 `lsm_tree` 一家**:那个站点上只有 `db/lsm_tree/` 一份库,换引擎开的是空目录 ⇒ 第二列要在 bplus 上重建一个 12 GiB 站,是单独一批活。 另一批测量(criterion 基准,缩样档 10 样本)跨引擎**只可比三格**:`lsm_tree` 全 18 点跑完, `bplus_tree` 停在 `scan/forced_1000` —— 那一点采了 **3 天没有吐出第二个采样点**(CPU 约满一核、输出零增长), 按 PID 收掉。表里那一格写的是"**没跑完**",不是"慢 N 倍"。可比的那三格反而**没有一致方向** (bplus 在 200 条档快 1.65×/2.03×、在 1 000 条档慢 1.13×)⇒ "哪家贵"要按负载属于哪一组来说。 --- ## 已知限制(原样列出,与 `REQUIREMENTS.md` §15 同步) 1. **不兼容 git 对象格式与 git 协议**,所以真 git 命令读不了本库;手感照 git 自建(§16.2),语义相近但**不是**同一实现。原因见 §1 第 1、2 条与 §17。 2. `branch`/`tag` 只是本地命名,v1 **不参与同步**;跨站协作仍需真 git。 3. collision 只能人选一边,不合并内容。**再补一条今天真实的边界(2026-10-21)**: "一边删了这条路径、另一边改了它"**今天不报成 collision**,而是静默保留内容、丢掉那个删除 ⇒ 见 §14 Q21(改判据要动 `status` 的碰撞清单,等你点头才做)。 4. 硬链接不识别(§8)。 5. 不转 CRLF/LF,不做文本友好化。 6. v1 实现只有 1+1 个 peer;**多端、NAT 穿透、部分连通路由不做**(§2.3、M5)。 7. `commits.peer_id` 不签名(§9.5.2),历史来源可被本机伪造;对等身份靠 TOFU 指纹表。 8. **`status` 的"盘上对账"只报存在性差异,不报内容差异**:判据是目录 mtime,而"往文件里写内容" 不改父目录的 mtime。要看内容变化仍然只有 `scan` 一条路(§17 补记十二)。 9. **目录 mtime 的回写是滞后的**(NTFS 实测:写完子项后立刻读父目录,时间戳比真相旧 40 ms 量级)。 所以可疑判据带 1 s 余量(宁可多比几层 readdir),极端情况下仍可能漏一层——这一条与 git 的 `core.untrackedCache` 撞的是同一堵墙,它的文档原话是"先确认你这台机器的 mtime 靠得住"。 10. Windows 侧 symlink 需开发者模式或特权,否则按 `skip/copy-file` 降级。 11. 配额内历史会被 GC——**它不是备份**,需要长期留存请另开 bare git 或对象存储。 12. **存储基座是一个自称「教学/学习性质、不是生产级数据库」的库**(`storage-engines` 0.4.0,已在 crates.io,见 §9.7)。因此 twosync 的正确性论证**不引用它的测试数量当背书**,而是靠 §10.2 的 crash injection 直接去撞它的 WAL 恢复路径 + §9.6 的六条约束自检。撞出引擎 bug 是预期的狗粮结果,届时修在引擎侧、twosync 不叠第二层补丁。 13. **建库墙钟是磁盘 fsync 单价的函数,不是代码的函数**(§11.1:同一轮首建在 5400 转机械盘上 84.5 min、CPU 只占单核 2.3%; 同形状微基准 fsync 85.8 ms vs 不 fsync 2.6 ms)。换机器必须重测,**任何"建库要几分钟"的说法都只对给出单价的那块盘成立**。 这一条与 §14 Q11 绑在一起:策略不改,数字就跟着盘走。 14. **导出/恢复也是每文件一次 fsync**(`Cas::read_blob` 写完 `sync_all` 才 rename,FR-13 的同一套写序):100 个小文件导出 7.5 s ⇒ **75 ms/文件**,与写侧单价对上。所以"取回一个历史版本"在小文件多的项目上是**每文件一次同步等待**,不是流式常量; 大文件反而划算(1 GiB / 8 个文件 = 20.2 s,52 MiB/s,内存峰值 60.9 MB)。 15. **本仓库文档里所有耗时数字分属两块盘**:22 000 行那批在 **C: KIOXIA SSD**(`/tmp`),10 GiB 这批在 **D: TOSHIBA 5400 转机械盘**。 跨盘比较无效(同形状的 fsync 单价差 33 倍)。引用数字前先看 §11.1 的口径段。 16. **仅大小写不同的两个名字不能共存于一次检出**(目标盘不敏感时会被 `CaseCollision` 拒),但**历史里它们是两个合法文件** (大小写敏感的盘上照常提交、照常取回)。这一条是刻意的方向:**拒绝可以恢复**(去掉一个名字、或换区分大小写的目标盘), **静默覆盖不可恢复**。 17. **NFC 与 NFD 的同一个 `é` 目前是两个身份**(磁盘原始字节),只在**检出的大小写门**上被当成同一个名字。 所以 macOS 造出来的 NFD 名字搬到 Linux/Windows 会被当成另一个文件——与 git 同病。要不要把归一化做到身份上 = **§14 Q13**。 18. **`--fast-import` 期间断电,可能留下"清单说有、盘上是半截"的块**。这不是静默损坏:读回按 hash 必报 `CorruptBlock`(§9.4.7 规矩 3),恢复办法是把那一版重新导入或从对端再取一次。**默认不开**; 开了就在输出里说清楚(§14 Q11)。同理 `--max-mbps` 限的是**平均**写速率,不保证瞬时不突发。 19. **删除侧的限速只管住了"删块文件"这一半**(§9.3 两趟,2026-10-18):`gc --sweep --apply --max-mbps N` 扣的是与写入**共用的那个桶**,但**引擎版本链那一半(`vacuum()`)不受任何预算管**,仍是全库一刀 (⇒ **§14 Q14**,实测 94.8 % 单核 / 40 s)。所以"GC 与 vacuum 计入总预算"这句 NFR-3/FR-15 的承诺 现在只兑现了一半,别读成已全部成立。 20. **`pending` 表在 CLI 上还看不到非空**:这一版没有能造出**不可达历史**的命令(`reset`/`prune --expire` 属 M4), 所以 `D` 表 → 限额删除那条路径的证据目前只在库层用例里(`tests/gc_sweep.rs` 8 条,含"跨进程重开仍认")。 端到端能跑的是 `gc --sweep` 的报账、孤儿计数与配额那几行。 21. **配对只证明"两边各自确认过这个同名 project",不证明"对面是哪个对端"**(§9.5.3 + §9.5.2 不签名): `agent` 的闸门是"注册且配对",任何拿到该 project 名字的连接都过;真正的来源鉴别要等 M5 的身份签名。 22. **一次 push 的固定成本 = 门面读调用次数 × 每次全库折**(§11.3 的探针实测,2026-10-18 晚; 本条原文把大头指给"收端逐 blob 校验",那一版归因**已被自己的探针推翻**,修订过程在 §17 补记廿四)。 12 GiB 站点(6 个 commit、88 352 活键 / 177 398 行)上**一次什么都没推的会话**(0 块、120 B 帧) `push` 内部仍要 **157.6 s**(其中 25.9 s 是等对端进程退出),会话之前开两次库 + 读 head 再 13.8 s, 而数完 21 794 个块文件只要 0.8 s。⇒ 大头根本不在"推",在**读库的次数**:① **历史遍历** (`reachable`/`topo_pending`/`maximal_heads` 每走一步一次门面读,**0 字节也要付**); ② **每个变更 commit 的目录清单遍历**(`count_entries` + `build::diff` + 闭包校验按目录逐棵读)。 收端那半已改成差集(commit `072c41a`:两万遍清单读降到 O(变更)),但**它没有让墙钟变快**, 所以本条不许被读成"已解决"。**已解决的是历史遍历那一项**(commit `afc411c`:一次 `c|p` 前缀扫 装好亲代索引 + 纯函数闭包,`reachable_strict`/`topo_pending`/`maximal_heads` 共用同一份)—— 0 字节会话的 `push` 从 157.6 s 降到 55.2 / 39.9 s(同构建两读,§11.3 那段修订), 还剩 ~30–40 次折没有计数读数是下一步第一件事。 ⇒ **M2 的常驻 agent 不能按这个成本做周期推送**(1 核机 NFR-2 的 10 % 直接顶穿)。 **口径警告(本案自己踩的)**:本机墙钟 run 间是 ±2×(同形状两臂 226 s 与 327 s), 这个粒度下**计时不能当证据** ⇒ 下一个动作是把尺子换成**门面读调用次数**(计数尺子,与 §10.1(f) 那条"命中/未命中要分开数"同源),再用它决定"历史遍历能不能一次前缀扫装进内存做完"。 腐烂探测的换位也记在本条:差集校验之后 push **不再**顺带发现"继承来的块烂了",那件事归读路径 (§9.4.7 逐块按 hash 自证)、`gc` 的 missing 账与 `doctor`;用例 `an_inherited_rot_slips_the_push_gate_and_is_caught_on_read` 把它钉住——**风险换了看守,没有消失**。 23. **亲代索引一次性装进内存,代价是 `O(提交数)`**(§17 补记廿五,`object::parent_index`): 一行 ≈ 32 B 键尾 + 32 B 值 ⇒ 十万个提交约 **10 MB**,换来"一次折代替 N 次折"——这是 §9.6.1 那条"每次读调用折全库"逼出来的取舍(小而常整份需要的索引,装载永远比逐步点查便宜)。 **到百万提交级别这条会反过来咬 RSS**(NFR-1 的 80 MB),那时要按 head 分段加载,或给引擎加 "前缀流式迭代"(§14 Q10/Q14 同一族)。现在不假装这个问题不存在。 24. **`gc --sweep` 的块账同样是 `O(块数)` 一次性装载**(§9.3 的 `scan_blocks`):10 GiB 站点 21 792 条 ≈ 1 MB 量级;百万块级站点要改成分片扫。 25. **`status` / `plan` / `gc` 的门面调用次数是 O(目录数)**(§11.4 实测:211 / 209 / 226 折, 12 GiB 站点 203 个目录)。每一层目录一次点读、每次点读折一遍全库(§9.6.1), 而且**每次只读 `commit()` 还额外等一次 WAL sync**(§9.6.7)⇒ 折数 == 事务数。 ⇒ 这三条命令在十万目录级的项目上会线性变贵。修法是一次 `T|p` 批量装载(目标个位数折), **代价是内存 = 全部目录清单的字节数**,量出来之前不许当成已完成。 > **已完成(2026-10-20,`80cc30d`,§11.4d)**:先量的数 = `T|p` **211 行 / 0.88 MiB value / > 20 534 条条目**,历史倍数 **1.0×**(211 行 ÷ 快照 204 个目录)⇒ 整段装载成立。 > 复测:**`plan` 209 ⇒ 6 折、`status` 211 ⇒ 8、`gc --sweep`(dry) 226 ⇒ 16**(两次读数折数一致)。 > 内存判据按规矩用**行数与 value 字节**,不用进程高水位;预算 4 MiB value(解出来 ≈ 9 MB, > 实测放大率 2.1×)超出就**退回逐目录点读**——所以"线性变贵"这一条现在变成了 > "线性变慢,但不线性吃内存"。`W` 那一侧仍然分页(20 417 行 / 1.11 MiB value ⇒ 物化 ≈ 4.2 MB, > 一百万条目就顶穿 NFR-1),§11.4b 结论 1 说的"W 不能这么干"照旧成立。 26. **一次 push 的固定成本已经与仓库大小无关,但仍不等于零**(§11.4):12 GiB 站点上一次 **0 字节会话**是 11 折(发起端 6 + 收端 5)≈ release 6 s。⇒ M2 的周期推送要按这个数排频率, 不许按"每次几毫秒"来设计。钉住它的用例是 `the_push_fold_count_does_not_grow_with_the_repo_on_the_sending_side`:同一刀改动在 3 条目与 24 条目两个仓库上跑,**发起端折数必须一模一样**(11,绝对上界 ≤ 30); 收端此刻仍随规模涨(26 → 47),所以那条用例**故意断言它在涨**——谁修好了它就会收到一条红, 逼着回来更新本节,而不是让注释烂掉。 27. **`blame` 是路径级,不是行级**(2026-10-21 落地):本工具的语料是 2–10 GB 的大二进制, 切块单位 4 MiB,而"行"不是任何一层的身份(`B` 清单只有 `(块号, hash, 大小)`)。 硬做行级 blame 得到的是"看起来像 `git blame`、语义是猜"的东西,所以不提供; 命令给的是"哪几本动过这条路径 + 各自变成了什么",`Content`/`Mode`/`KindSwap`/`Appeared`/`Disappeared` 五种分开报。**目录也算**:她那棵子树的 hash 变了就算动过(粗,但一致)。 窗口截断(`--max`)时最老那一行**不代表**她第一次出现 —— 命令自己会打印这一句。 28. **`bisect` 不落游标,也不承诺"对折"**(2026-10-21):git 那套 `BISECT_LOG` 状态要么写库要么写文件, 就有了"bisect 卡住要先 reset"这类坏账;这里每次从 `--bad`/`--good` 两个端点重算 (一次 `c|p` 前缀扫 + 两个内存闭包),代价是人要把上一条答案抄回命令行。 能保证的只有一条:**候选属于怀疑集 ⇒ 每问一次区间严格变小**(`suspects > 1` 时;收到 1 本就改口报"结论")。 历史里有合并时**时间序不等于拓扑序**,所以**不保证**正好缩一半 —— 这句话命令自己打印,不藏在文档里。 29. **落盘类命令写完工作树之后,`W` 是旧的**(§7.2 那条"吃最近一次 `scan` 的快照"的另一面): `checkout`/`reset` 的口径本来就是"下一步 `scan`",人可以跟着做;但 `stash` 不行——她的定义就是 **把同一份改动搬出去再搬回来**,中间没有让人 `scan` 的那一步。少了补扫: ① `stash apply` 会按旧快照判"她已经在工作树上了",于是打出 `建 0 / 覆盖 0` 而盘上一字节没变 (用例 `apply_keeps_the_stash_and_pop_removes_it_only_after_landing` 第一次跑就撞上,是真 bug 不是猜测); ② `pop` 之后紧接着的 `commit` 吃的也是 `W` ⇒ 会把刚取回的改动当成"没变化"。 ⇒ `stash` 在**每次落地之后自己补一次全量 `scan`**(`m4::StashCtx::rescan_after_landing`,打印"补扫"那一行)。 代价照实说:一次全量扫(12 GiB 站点 ≈ 68 s 量级,§11.4b)。**不做窄扫**是有意为之:窄扫那一轮 不推进活性线、一个删除都不报(§11.4b 的修订),而 stash 搬进搬出最常碰的就是删除。 别的落盘命令(`checkout`/`reset`)**没有**这个自动补扫,仍然要人下一步 `scan` —— 这是边界不是遗漏。 30. **~~`plan` 的每单位成本在缩样档里是上升的,未归因~~ ⇒ 已归因,并且判平**(原判据来自 §10.6 的 `criterion` 缩样档:22.8 / 27.3 / 34.4 µs/条,看着像升 1.5×)。用能读机制的台架重测之后**换结论**: ① **装载器从没退预算**(七档 `over_budget = false`、装载只付 **1 折**、`T|p` 的 value 最大 **0.39 MiB** 对 4 MiB 预算)⇒ 头号嫌疑当场死;② **目录数不是元凶**(条目钉在 4 000, 8 层 → 400 层只贵 **7–20 %**);③ 元凶是**每条目的内容量**(单文件 1 → 16 KiB ⇒ 单价 **×2.0**); ④ **(f1) 在本机这个规模段判"平直"成立**(同形状 200→8 000 条 = 56 / 56 / 50 / 68 µs/条,±20 % 内晃)。 ⇒ `criterion` 那条上升**复现不出来**,记为"未归因、两仪表不一致、**信带机制读数的这一个**"; 顺带一条测量纪律更正:那句"三档区间各 ±6 % ⇒ 不像噪声"**把话说满了** —— 那只是**同一 run 内** 10 个样本的自散布,本台架同一批 min→p95 能散到 **3.7 倍**。(旧那张全档表 14.0/12.9/13.8 平直, 与缩样档**不许互比**:构建跨过了 209→6 折那次改写。) 31. **NFR-1 的 80 MB 在 12 GiB 站点的只读列上翻了**:一次 `plan` 堆峰 **86.86 MiB**、`checkout` 干跑 **86.85 MiB**、**进程峰值 RSS 102.25 MiB**(≈107 MB,此前最高 72.1 MB)。 ~~自变量不是"文件多"而是**库里的行数**(501 581 行、`max_chain` 50 ⇒ 版本链没被压过), 一次折叠物化的分母是"含版本链的全库行数";按 207 B/键 估 ≈101 MiB 同量级。~~ **对照实验(2026-10-25)把上面那句划掉了**:硬链副本压完链(`rows 501 581 → 84 258`、 `max_chain → 1`、**历史一条没丢**)再读同一列 ⇒ 堆峰 **86.86 → 118.47 MiB**(行数 ÷ 6 而内存升 36 %)。 同一趟里 **库磁盘 51.00 → 68.62 MiB(×1.345)**,两点上 **堆峰 ÷ 库字节 = 1.70 / 1.73**; 最大单文件(×5.37)与文件数(×1.07)两个候选在同一次实验里被排除。⇒ 当时挂在 **"一次折叠 ≈ 把整库字节读进来 ×1.7"**(两点 1.70 / 1.73,再补第三点小站 1.58,看似"跨 43 倍只动 1.58–1.73")。 ~~**派生的检查线:要过 80 MB,库磁盘得 ≲ 47 MiB。**~~ **同日第三臂把这个系数也削了**(新旋钮只拨条目数):B 臂 1 000 条 × 64 KiB 与 C 臂 16 000 条 × 4 KiB 的 **原始字节与块数完全相同**(62 MiB / 16 000 块),而库字节 4.84 → 12.55 MiB、`plan` 堆峰 9.39 → 15.84 MiB ⇒ 比值 **1.94 → 1.26**,五处实测散布 **1.26–1.94** ⇒ 当时写下**"×1.7 与库 ≲ 47 MiB 一起作废、 堆峰随规模次线性涨"**——**这句也改回来一半**(同日四档规模扫,见 §17 补记五十): **同形状**(16 KiB/条、8 层目录)从 200 条扫到 8 000 条(跨 **40 倍**规模), `plan` 堆峰 ÷ 库字节 = **1.58 四点一字不变**、折数全是 **5** ⇒ **系数在,但它是"形状"(每条目装多少内容与块)的函数,不是规模的函数**: 同形状可以乘系数推,**跨形状必须实测**(换形状 1.26–1.94,12 GiB 站 1.70)。 ① **机制**(`plan` 折数恒定 ⇒ 单次折叠变贵);② **目录数只值 7–20 %**(4 000 条从 8 层改 400 层), **装载器七档全部没退预算**(value 最大 0.39 MiB 对 4 MiB 预算)⇒ 元凶是"条目数 × 每条内容"; ③ **该盯的格子比 12 GiB 早三个数量级**:**8 000 条目 / 库仅 12.78 MiB ⇒ 峰值 RSS 84.66 MiB** (16 000 条目那档 77.36 MiB,大头是写侧首次 `commit`)⇒ **80 MB 在条目数千级就破**, 而 8 000 条目就是一个正常大小的构建产物目录。 ⚠️ 另一半不好看的:**`gc --sweep --apply` 把库写大了 34.5 %**(最大 SST 2.06 → 11.05 MiB), 所以"压版本链"这条出路**对内存是反向的**、对磁盘也不是净赚 ⇒ 之前把它当候选出路是**没做对照实验就当它成立**。 判据、选项、裁决一条都没动(这仍是 §9.6.1 的 (f2) 那条老账)。 --- ## 依赖 `storage-engines = "0.4.0"`(crates.io,纯 Rust 的 KV 存储基座,四引擎:LSM / B+ 树 / bitcask / memory)。 运行时切换引擎不用重编译:`--engine` 或站点配置。**没有 `path` 依赖**(发布前用 `cargo tree -p storage-engines` 核对)。 选它当基座的理由不是"想自己写引擎":§9.2 的访问路径全是 KV 形状的(点查、按前缀扫、按序扫), 没有 join、没有动态谓词,SQL 层本来就是浪费的。而"同一套不变量在三个引擎上都跑通"这件事 本身就是要交给读者的证据(上面那张矩阵)。 一条必须写在前面的代价:**这个基座自称"教学/学习性质、不是生产级数据库"**。所以本项目的正确性论证 **不引用它的测试数量当背书**,而是靠崩溃注入直接去撞它的 WAL 恢复路径(三刀都过)。 撞出引擎 bug 是预期的狗粮结果,修在引擎侧,本项目不叠第二层补丁。 ## 怎么验证 ```sh cargo test # 270 passed / 0 failed / 5 ignored cargo clippy --all-targets # 0 警告 TWOSYNC_TEST_ENGINE=bplus_tree cargo test # 同一套用例换引擎,270 / 0 ``` 不变量的随机化验证在 `tests/invariants_proptest.rs`:**每条 commit 都能按清单逐字节重建**、 **`K`/`k` 两本 refcnt 账永不漂移**(§10.1 的 (b)/(g)),而且两个判据 checker 都配了 "故意打坏一条就必须红"的对照组用例(`refcnt_checker_actually_catches_drift` / `rebuild_checker_actually_catches_a_damaged_block`)——没有对照组的 checker 只是一个永远绿的断言。 "只有不可达的历史才许删"在 `tests/gc_sweep.rs`(含 dry-run 一字不改、pending 表可续删、 共享块不因一个引用者消失而被删)。路径对抗(Windows UNC / 保留名 / 超长 / 跨盘)在 `tests/path_hostile.rs`; 崩溃与半程断链(对端被杀掉 ⇒ head 不动且能追平)在 `tests/net_session.rs`; "不带 `--apply` 只许读"与会话开头自动提交那三条边界在 `tests/cli_surface.rs`。 性能表要单独跑,一次一个引擎(进程峰值 RSS 只能这样归因): ```sh TWOSYNC_PERF_ENGINE=lsm_tree cargo test --release --test engine_perf -- --ignored --nocapture ``` **同一张台架也能只读地量一个现成站点**(就是上面那一列的来路)。给了 `TWOSYNC_PERF_ROOT` 就 **必须**同时给 `TWOSYNC_PERF_READ_ONLY=1`,否则命令直接拒绝——台架里 `scan`/`commit`/`gc --sweep` 三段会动库,而 `gc` 那一段**连 dry-run 都不是只读**(它会驱动引擎自己的 auto-vacuum,把测量台改小): ```sh TWOSYNC_PERF_ENGINE=lsm_tree TWOSYNC_PERF_ROOT=D:/ts10g TWOSYNC_PERF_PROJECT=lab \ TWOSYNC_PERF_READ_ONLY=1 cargo test --release --test engine_perf -- --ignored --nocapture ``` criterion 那批基准跨引擎要用缩样档(`TWOSYNC_BENCH_QUICK=1` = 10 样本 / 0.5 s 预热 / 1.5 s 测量), 它只回答"哪家贵、贵几倍",**尾延迟读不出来**;⚠️ 缩样档与全档两张表**不许互相比**(构建也不同)。 ## 还没做(M4 剩下的与不承诺的) - **CDC 变长分块**:现在按定长 4 MiB 切。变长(滚动 hash)能少传边界移位,但它**与"git 对象格式"无关**, 是块层的事。M4 增强,未动。 - **稀疏重建、批传输调优**:`§10` 的差分表已有,性能调优未做。 - **§10.8 矩阵欠的那两格现在各有一半**:12 GiB 站点上只读那一列**已落**(但只有 `lsm_tree` 一家, 第二列要在 bplus 上重建一个 12 GiB 站);criterion 的两引擎对比**可比三格**(bplus 停在 `scan/forced_1000` 没跑完)。剩下的三件:bplus 那一列补完(要先把本机 C: 空出来)、大站第二列、 **一个"库字节不变而行数大改"的站**——用来把 §15 第 31 条那条"堆峰 ≈ 1.7 × 库磁盘字节"从假设推成定律。 (§15 第 31 条那个对照实验 2026-10-25 **已经做过一刀,当场把我上一批写的归因推翻**:压完链 行数 ÷ 6 而堆峰升 36 %,过程与教训在 `REQUIREMENTS.md` §17 补记四十八。) - **72 h 真长跑 / 跨机器真 SSH**:需要第二台机器或对 sshd 的授权;`daemon` 的周期 gc 循环到净减 0 那条 判据(§14 Q19)也还悬着。 - **不承诺**:NAT 穿透、多端 mesh 路由、中心服务器、commit 签名(M5,且要重开范围评审)。 ## 待决问题(会改变判据的,都写在 `REQUIREMENTS.md` §14) `NFR-1` 的"扫描峰值 ≤ 8 MiB"在 0.4.0 上过不了(引擎每读一次折一遍全库),已按 (f1)/(f2) 两段拆开验收(§9.6.1); **而 80 MB 那条稳态上限现在也翻了**(12 GiB 站点只读列:峰值 RSS 102.25 MiB,§15 第 31 条)—— 翻的方向当天被两轮对照实验钉过:把版本链压完(行数 ÷ 6)堆峰**不降反升** 86.86 → 118.47 MiB ⇒ "∝ 版本链行数"出局; 再用"原始字节与块数完全相同、只有条目数差 16 倍"的两臂去卡 ⇒ 系数随**形状**散(1.26–1.94), 但**同形状跨 40 倍规模四点全是 1.58、折数全是 5** ⇒ 正确的说法是"**系数是形状的函数,不是规模的函数**" (同形状可以乘系数推,**跨形状必须实测**),派生的"库 ≲ 47 MiB"作废;元凶是**条目数 × 每条内容** (目录数只值 7–20 %,装载器七档全部没退预算); `gc --sweep --apply` 在这里还帮不上忙(它把库写大了 34.5 %,历史没丢)。**判据本身没动、也没换成我能过的数字**; `NFR-2` 的建库墙钟**未达标**,但归因已做完(死于 fsync 单价,不是代码); `gc` 要不要循环到净减 0(Q19)、head 的来源要不要变成库里的永久事实(Q18)、 "一边删了某路径另一边改了它"算不算碰撞(Q21,**今天不算**)。