Skip to content

P2P 下载加速 ​

你在客厅电脑上装了个整合包,同一个星云 ID 的笔记本上已经有了——那何必再从公网下一遍。我们的下载管线有第三个源:同一局域网里同账号的其他设备。这页讲它怎么工作,以及为什么它失败的时候你什么都不会看到。

只认 SHA1 ​

下载管线里每个待下文件都带着 SHA1(这是 Mojang 清单给的口径)。P2P 查找就是拿这个 SHA1 去问对端:「你有没有这个文件?」

为什么不能靠文件名:版本 jar 在不同实例里叫法不一样(versions/1.20.1/1.20.1.jar vs versions/1.20.1-fabric/1.20.1-fabric.jar),两个实例的 mod 文件名可能撞车但内容不同。文件名只能筛候选,最后必须靠内容判定。

所以安全线是:没有期望哈希一律不认。LocalFileReuse.FindByHash 里第一行就是:

csharp
if (string.IsNullOrEmpty(expectedSha1)) return null;

先比大小(省掉绝大多数哈希计算),再算 SHA1 校验,对不上就当没找到。

PeerCount == 0 时完全跳过 ​

csharp
public int PeerCount => _settings().P2pDownloadEnabled && Running ? _beacon?.Snapshot().Count ?? 0 : 0;

下载管线的接入点:

csharp
var peer = PeerSource?.Invoke();
if (peer is { PeerCount: > 0 })
{
    foreach (var f in pending.ToList())
    {
        if (string.IsNullOrEmpty(f.Sha1)) continue;   // 没哈希可比:不冒险
        if (!peer.TryProvide(f.Dest, f.Sha1, f.Size, out var via)) continue;
        pending.Remove(f);
    }
}

PeerCount == 0 时整个 for 循环都不进——不建连接、不发包、不算哈希,零开销。这是绝大多数用户的情况:没开设备链路,或者开着但局域网里只有自己一台。

失败静默回退公网 ​

TryProvide 里对每个对端:

csharp
try
{
    var has = client.CommandAsync(new { cmd = "p2p.has", sha1, size, destName });
    if (!has.Ok) continue;                    // 对端没有 → 试下一个
    var result = client.CommandAsync(new { cmd = "p2p.fetch", ... }, destFile, expectSha256: sha1);
    if (result.Ok) { viaPeer = peer.Name; return true; }
}
catch
{
    // 单个对端失败不影响其余对端与公网回退
}

任何一步失败——对端没这个文件、连接超时、校验不过——都只是「这个对端不行」,接着试下一个对端,全试完还是不行就返回 false,下载管线照常走公网。

成功时才会留下一条日志(P2P 复用 xxx.jar ← 我的笔记本)和进度文案里的 (P2P 复用 3 个)。

P2P 是加速,不是功能

界面上永远不出现「内网没找到」「P2P 不可用」这类提示。P2P 命中就快一点,没命中就照常走公网——两者都是正常路径。把加速手段的失败当错误报出来,用户会以为出了故障,实际上什么都没发生。

两层缓存 ​

直接对每个对端发请求是可行的,但有个明显问题:同一次下载里可能有几十个文件,每个文件都问一遍所有对端,问的还是同一批 SHA1。所以本地做两层缓存。

第一层:哈希 → 路径索引(进程内,_hashIndex)。现场算过一次 SHA1 → 命中路径的对应关系记住:

csharp
private readonly Dictionary<string, string> _hashIndex = new(StringComparer.OrdinalIgnoreCase);

对端答复「我有这个文件」时,第二次问同一个 SHA1 直接拿路径,不用再算。

第二层:本地复用索引(_reuseIndex),10 分钟重建一次:

csharp
if (_reuseIndex is null || Environment.TickCount64 - _reuseIndexAt > TimeSpan.FromMinutes(10).TotalMilliseconds)
{
    _reuseIndex = LocalFileReuse.Scan(s.DotMinecraft);
    _reuseIndex.AddLibraryDirectory(LauncherPaths.LibraryDir);
    _reuseIndexAt = Environment.TickCount64;
}

这一层扫的是本机已有的文件(各版本的 mod / 资源包 / 光影 / 数据包、版本 jar,加上启动器自己的资源库 library/),对端要回答「你有没有这个文件」时就在这个索引里查。

10 分钟是权衡:重建要遍历目录列文件,几十 MB 的实例每次重建都有开销;同时安装过程中本地文件在变,太久不重建会漏掉新装的文件。十分钟够反映一次安装,又不会每次问对端都重扫一遍。

LocalFileReuse 只建文件名索引 ​

这一点容易看错。LocalFileReuse.Scan 不算任何哈希,它只做一件事——把文件名映射到候选路径列表:

csharp
private readonly Dictionary<string, List<string>> _byName = new(StringComparer.OrdinalIgnoreCase);

扫描范围是各版本目录下的 mods / resourcepacks / shaderpacks / datapacks(只扫顶层文件——mods 下的子目录不是规范布局,深扫会把索引建得又慢又大),加上共享根的同名目录,以及所有 versions/<id>/<id>.jar。

哈希是查询时才算的,而且只算候选:

csharp
if (size > 0 && info.Length != size) continue;      // 先比大小:省掉绝大多数哈希
if (Sha1Matches(candidate, expectedSha1)) return candidate;

候选通常只有个位数,单文件哈希毫秒级,所以现场算完全够用。版本 jar 是唯一需要跨文件名认亲的目标——它的文件名里带版本 id,每个实例都不一样,所以单独存在 _versionJars 列表里。

FindByHash 和 TryProvide 共用这条安全线:绝不拿「文件名像」的文件凑数。

顺序:本地复用在前,P2P 在后 ​

下载管线里这两个加速是串起来的:

  1. 本地复用(LocalFileReuse)——同一份文件别的实例里已经有了,直接复制。同一版本的 client jar 是十几 MB 量级,这一项最省时间。
  2. P2P 对等复用(IPeerFileSource)——局域网同账号设备已有同 SHA1 文件,网内拉取。
  3. 公网下载。

两者都只认 SHA1、都静默失败、都不影响正确性——它们只是把「从公网下」换成「从本地拷」或「从邻居拷」,内容该校验还是校验。

相关链接:跨设备互联 · 下载引擎总览 · 分片与竞速

基于 MIT 许可证(有修改)开源