同一个仓库,同时发起150个建分支的进程,每个进程各自执行一次引用更新。结果只有56个成功,另外94个直接报错退出,错误信息是"cannot lock references"。换成旧的引用存储后端,同样的150个进程,每一次都全部成功。

这是Git 2.55.0带来的新引用存储格式reftable,在并发写入场景下暴露出的另一面。官方发布说明里强调它更快,但很少有人提到它在高并发下会直接失败。

打开网易新闻 查看精彩图片

快是真的快,但快在写不在读

Git 2.55.0于2026年6月29日发布,搭载的reftable引用存储格式,被2.51版本的发布说明描述为"足够成熟",Git 3.0将把它设为新仓库的默认格式。它的做法是不再为每个分支或标签在.git/refs/下建一个小文件,而是把整个引用数据库存成.git/reftable/下少量经过排序、可二分查找的表文件。

批量写入的差距非常明显。在1万个引用的规模下,旧格式的.git/refs/占用40MB、分散在1万个文件里,而reftable只有一个266KB的表加一个43字节的清单,合计272KB。到5万个引用时,这个对比变成198MB对1.4MB。

写入速度上,reftable的表现稳定得多。旧格式在5万个引用时,同一仓库的三次运行分别耗时2.1秒、5.3秒和12.4秒,尾延迟完全不可预测;reftable没有这种失败模式,因为它不是每个引用写一个文件。

但读取并没有跟上这个倍数。对1万个引用的仓库执行git for-each-ref,旧格式180到183毫秒,reftable 132到134毫秒,差距约1.4倍。5万个引用时是897到943毫秒对636到714毫秒,约1.3倍。

官方说的22倍,实测没跑出来

2.51版本的发布说明曾声称,在1万个引用的仓库上,reftable让git fetch快22倍、git push快18倍。实测用--mirror克隆,再用--no-local强制走真实的引用通告代码路径,并单独计时git ls-remote,最好情况出现在5万个引用规模:从342到376毫秒降到73到189毫秒,是2到4倍的提升,不是22倍。1万个引用时差距几乎消失,174到178毫秒对119到169毫秒。

在单机file://环境下,无法接近官方给出的倍数。一个合理的推测是,发布说明的基准测试测的是网络传输场景,其中固定的往返开销相对于引用通告开销要小得多。22倍和18倍这类数字,更适合理解为在特定测试环境下测得的结果,而不是一个可以到处套用的数字。

另一个卖点"原子引用事务"也没有体现出差异。向两个后端发送一个包含四次更新的批次,其中一次更新的预期旧值不匹配,两个后端都拒绝了整个批次,一个更新都没应用,行为完全一致。update-ref --stdin在旧后端上多年来就是全有或全无,这对已经在使用批量更新的用户来说不构成新差异。

50到100个并发之间,从"慢"变成"失败"

真正需要提前规划的是并发写入的结果。在50个并发写入者以下,reftable只是更慢,这符合预期:每次写入都要锁一个tables.list文件并追加到共享栈上,而旧格式每个引用锁一个文件,互不相关的分支可以各自推进。

但在50到100个写入者之间的某个位置,情况从"更慢"变成了"失败"。在100和150个并发写入者下,七次独立试验中失败率在30%到63%之间,从未为零,也从未全部失败,错误始终是"cannot lock references"。测试使用的是全新初始化的仓库,目标引用各不相同,不存在Git需要检测的真实冲突。

查看.git/reftable/tables.list可以理解原因。正常顺序负载下它只有一到三个条目,Git的几何压缩机制(reftable.geometricFactor,默认值为2)几乎在每次写入后都会把小表重新合并。这个压缩步骤需要和每个写入者相同的锁,于是一批真正同时到达的写入者会排队等同一个文件,而Git对这个队列的默认耐心很短。

把超时调大能解决失败,但解决不了排队

相关设置是reftable.lockTimeout,文档说明"值为0表示完全不重试,-1表示无限重试,默认值为100"。把它设为5000后重跑150个写入者的测试三次,每次都是150个全部成功,零错误。但耗时变成1.05到1.15秒,而旧格式完成同样的任务只要106到119毫秒。

这个变通办法消除了失败,但没有消除底层的串行化,它只是让所有人礼貌排队,而不是让其中三分之一的人放弃。

如果工作负载是"一个进程推送到一个仓库",这个问题永远不会触发。如果工作负载是"CI为每个任务创建一个分支,多个任务同时完成",那就需要提前考虑reftable.lockTimeout这个参数,以及它带来的时间成本。