实战:构建分布式文件共享系统

理论结合实践,我们来构建一个真正的分布式文件共享系统。这个系统将使用前面各篇文章介绍的技术——Kademlia DHT 用于节点发现和元数据分发,Gossipsub 用于广播,以及一个自定义的文件传输协议。

系统架构设计

mermaid
flowchart TD
    subgraph 应用层
        CLI["CLI 界面"]
        API["REST API"]
    end

    subgraph 业务逻辑层
        Index["文件索引管理"]
        Scheduler["分块下载调度"]
        Verify["校验与重组"]
    end

    subgraph P2P 网络层
        Discovery["Kad-DHT<br/>节点发现 + 元数据分发"]
        Broadcast["GossipSub<br/>消息广播"]
        Transfer["自定义协议<br/>文件传输"]
    end

    CLI --> Index
    API --> Index
    Index --> Scheduler
    Scheduler --> Verify
    Index --> Discovery
    Scheduler --> Transfer
    Broadcast -->|"新节点通知"| Index

核心设计原则:

  • 分层抽象:业务逻辑与 P2P 网络层严格分离
  • 模块化:每个组件独立可测试
  • 容错设计:节点故障不影响整体系统可用性

文件元数据分发

下载者如何知道文件被分成了哪些 Piece、每个 Piece 的哈希是什么?这通过 DHT 分发 FileMetadata 实现:

mermaid
flowchart LR
    S["Seeder 节点"] -->|"1. 分块并计算哈希"| FM["FileMetadata<br/>{filename, piece_size,<br/> piece_hashes[]}"]
    FM -->|"2. hash(fileID) 作为 DHT key<br/>存入 K 个最近节点"| DHT["Kademlia DHT"]
    D["Downloader 节点"] -->|"3. 用 fileID 查询 DHT"| DHT
    DHT -->|"4. 返回 FileMetadata"| D
    D -->|"5. 开始按 Piece 下载"| S

Seeder 将文件的元数据(包含每个 Piece 的 SHA-256 哈希)以 fileID 为键存储到 DHT。Downloader 只要知道 fileID,就能从 DHT 获取完整的元数据,然后开始下载。fileID 通常取文件内容的哈希,这样相同内容的文件总是映射到相同的 ID。

Rust 核心模块实现

Piece 状态管理

首先定义 Piece 的状态枚举和线程安全的共享状态:

rust
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
use std::collections::{HashMap, HashSet};
use std::sync::Arc;
use tokio::sync::RwLock;

/// Piece 下载状态
#[derive(Debug, Clone, PartialEq)]
enum PieceStatus {
    Missing,     // 尚未开始下载
    Downloading, // 正在从某个节点下载
    Complete,    // 下载完成且校验通过
}

/// 线程安全的下载调度器
/// Arc<RwLock<>> 允许多个异步任务安全地并发读写
struct DownloadScheduler {
    metadata: FileMetadata,
    piece_status: Arc<RwLock<Vec<PieceStatus>>>,
    peer_pieces: Arc<RwLock<HashMap<PeerId, HashSet<usize>>>>,
    output_path: String,
}

为什么用 Arc<RwLock<>> 在异步 P2P 程序中,多个 Piece 可能同时从不同节点下载,每个下载任务是一个独立的异步任务。这些任务需要并发地更新 piece_status(标记某个 Piece 为 Downloading/Complete)。RwLock 允许多个任务同时读取状态(不阻塞),但写入时独占访问,避免数据竞争。Arc 提供跨任务的共享所有权。

文件分块与校验

rust
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
use sha2::{Sha256, Digest};
use std::fs::File;
use std::io::{Read, Write, Seek, SeekFrom};

const PIECE_SIZE: usize = 256 * 1024; // 256KB

#[derive(Debug, Clone, Serialize, Deserialize)]
struct FileMetadata {
    filename: String,
    total_size: u64,
    piece_size: usize,
    piece_hashes: Vec<String>,
}

impl FileMetadata {
    fn from_file(path: &str) -> anyhow::Result<Self> {
        let mut file = File::open(path)?;
        let total_size = file.metadata()?.len();
        let mut piece_hashes = Vec::new();
        let mut buffer = vec![0u8; PIECE_SIZE];

        loop {
            let n = file.read(&mut buffer)?;
            if n == 0 { break; }
            let mut hasher = Sha256::new();
            hasher.update(&buffer[..n]);
            piece_hashes.push(hex::encode(hasher.finalize()));
        }

        Ok(Self {
            filename: path.to_string(),
            total_size,
            piece_size: PIECE_SIZE,
            piece_hashes,
        })
    }

    fn verify_piece(&self, index: usize, data: &[u8]) -> bool {
        if index >= self.piece_hashes.len() { return false; }
        let mut hasher = Sha256::new();
        hasher.update(data);
        hex::encode(hasher.finalize()) == self.piece_hashes[index]
    }
}

稀有优先下载调度

BitTorrent 中的稀有优先(Rarest First)算法——优先下载整个网络中副本最少的 Piece,最大化数据可用性:

mermaid
flowchart TD
    HZ["某节点拥有 Piece 集合"] --> B["统计每个 Piece 的<br/>全网副本数量"]
    B --> C{"选择副本最少<br/>且本节点缺失的 Piece"}
    C --> D["向拥有该 Piece 的<br/>节点发起下载请求"]
    D --> E{"下载成功?"}
    E -->|"是"| F["校验 SHA-256"]
    E -->|"否"| G["标记节点不可用<br/>选择其他节点"]
    F -->|"通过"| H["标记 Complete<br/>通知其他节点"]
    F -->|"失败"| I["重新请求该 Piece"]
    H --> J{"所有 Piece<br/>Complete?"}
    J -->|"否"| A
    J -->|"是"| K["重组文件"]
rust
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
impl DownloadScheduler {
    fn new(metadata: FileMetadata, output_path: String) -> Self {
        let piece_count = metadata.piece_hashes.len();
        Self {
            metadata,
            piece_status: Arc::new(RwLock::new(vec![PieceStatus::Missing; piece_count])),
            peer_pieces: Arc::new(RwLock::new(HashMap::new())),
            output_path,
        }
    }

    /// 稀有优先选择算法
    async fn select_next_piece(&self, peer: PeerId) -> Option<usize> {
        let status = self.piece_status.read().await;
        let peer_map = self.peer_pieces.read().await;
        let peer_has = peer_map.get(&peer)?;

        let mut rarest: Vec<(usize, usize)> = status
            .iter().enumerate()
            .filter(|(i, s)| *s == PieceStatus::Missing && peer_has.contains(i))
            .map(|(i, _)| {
                let count = peer_map.values()
                    .filter(|pieces| pieces.contains(&i)).count();
                (i, count)
            }).collect();
        rarest.sort_by_key(|&(_, count)| count);
        rarest.first().map(|&(i, _)| i)
    }

    /// 安全地写入 Piece(使用写锁保护文件操作)
    async fn write_piece(&self, index: usize, data: &[u8]) -> anyhow::Result<()> {
        // 先校验哈希
        if !self.metadata.verify_piece(index, data) {
            anyhow::bail!("Piece {} hash verification failed", index);
        }

        // 写入文件(文件操作本身是同步的,但放在 async 上下文中)
        let mut file = OpenOptions::new()
            .write(true).create(true).open(&self.output_path)?;
        let offset = index as u64 * self.metadata.piece_size as u64;
        file.seek(SeekFrom::Start(offset))?;
        file.write_all(data)?;

        // 更新状态为 Complete
        let mut status = self.piece_status.write().await;
        status[index] = PieceStatus::Complete;
        Ok(())
    }

    async fn is_complete(&self) -> bool {
        let status = self.piece_status.read().await;
        status.iter().all(|s| *s == PieceStatus::Complete)
    }
}

Seeder 端:响应 Piece 请求

Seeder 节点需要监听下载请求,按 Piece 索引返回数据:

rust
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
/// Seeder 端的 Piece 服务处理器
async fn handle_piece_request(
    metadata: &FileMetadata,
    file_path: &str,
    piece_index: usize,
) -> anyhow::Result<Vec<u8>> {
    if piece_index >= metadata.piece_hashes.len() {
        anyhow::bail!("Invalid piece index");
    }

    let mut file = File::open(file_path)?;
    let offset = piece_index as u64 * metadata.piece_size as u64;
    file.seek(SeekFrom::Start(offset))?;

    // 最后一个 Piece 可能不满 piece_size
    let remaining = metadata.total_size - offset;
    let read_size = std::cmp::min(metadata.piece_size as u64, remaining) as usize;
    let mut data = vec![0u8; read_size];
    file.read_exact(&mut data)?;

    // 校验后返回
    if !metadata.verify_piece(piece_index, &data) {
        anyhow::bail!("Local file corrupted at piece {}", piece_index);
    }
    Ok(data)
}

运行与测试

启动提供者节点(种子节点):

bash
1
2
3
4
5
# Seeder:分块文件,计算哈希,将元数据存入 DHT
cargo run -- serve --file ./ubuntu-22.04.iso
# 输出:File ID: QmYyQSo1c1Ym7orWxLYvCrM2EmxFTANf8wXmmE7DWjhx5N
# 输出:Metadata published to DHT
# 输出:Listening for piece requests...

启动下载者节点:

bash
1
2
3
4
5
6
7
8
cargo run -- download \
    --peer /ip4/127.0.0.1/tcp/4001/p2p/Qm... \
    --file-id QmYyQSo1c1Ym7orWxLYvCrM2EmxFTANf8wXmmE7DWjhx5N
# 输出:Fetching metadata from DHT...
# 输出:Got metadata: 1024 pieces, 256KB each
# 输出:Downloading piece 0/1024 (rarest first)...
# 输出:Verifying piece 0... OK
# 输出:All pieces downloaded. Reassembling...

下载流程

mermaid
sequenceDiagram
    participant D as 下载者
    participant T as DHT
    participant S as 种子节点

    D->>T: 查找 fileID 对应的元数据
    T-->>D: 返回 FileMetadata (piece_hashes[])
    D->>S: 根据稀有优先算法请求 Piece 0
    S-->>D: 发送 Piece 0 数据
    Note over D: 校验 SHA-256 哈希 → 标记 Complete
    D->>S: 请求 Piece 1
    S-->>D: 发送 Piece 1 数据
    Note over D: 所有 Piece Complete
    Note over D: 按序重组为完整文件

工业级参考:Syncthing 同步引擎设计

Syncthing 作为生产级别的 P2P 文件同步引擎(85K+ stars),采用了与 BitTorrent 风格(本系统核心参考)不同的设计策略。以下从五个关键维度对比分析,为自建系统提供设计参考。

可变块大小策略

本系统使用固定 256KB 的 Piece 大小。Syncthing 则采用了动态块大小——根据文件大小自动选择最合适的块大小:

文件大小块大小最大块数
≤ 250 MB128 KiB~2000
1 GB512 KiB~2000
4 GB2 MiB~2000
> 16 GB16 MiB~2000

设计规则是:选择使块数不超过 2000 的最小块大小。这样既能保证小文件的精细度(128 KiB 块),又避免大文件的元数据过多(元数据与块数成正比,不是文件大小)。2000 块的限制源自 BEP 协议中 Index 消息的 Protobuf 编码效率,确保一次 Index 传输在典型 MTU 内完成。

版本向量冲突解决

当两个设备同时修改同一文件时,会产生冲突——这是分布式文件同步最棘手的问题之一。Syncthing 使用版本向量(Version Vector)来检测和解决冲突:

go
1
2
3
4
5
6
7
8
9
// lib/protocol/vector.go
type Vector struct {
    Counters []Counter // {ID: device_short_id, Value: counter} 有序数组
}

type Counter struct {
    ID    uint64 // 设备短 ID
    Value uint64 // 单调递增计数器
}

版本向量的比较结果是五种关系之一:

go
1
2
3
4
5
6
7
const (
    Equal             // 完全相同
    Greater           // 严格更新
    Lesser            // 严格更旧
    ConcurrentLesser  // 并发修改但排序上更旧
    ConcurrentGreater // 并发修改但排序上更新
)
mermaid
flowchart TD
    A["设备 A<br/>{A:3} → {A:4}"]
    B["设备 B<br/>{B:2} → {B:3}"]
    C{"比较结果?"}
    A --> C
    B --> C
    C -->|"Concurrent(并发)"| Conflict["保留两版本<br/>生成冲突文件"]
    C -->|"Greater/Lesser"| Sync["直接覆盖同步"]

    style Conflict fill:#FF9800,color:#fff
    style Sync fill:#4CAF50,color:#fff

与本系统中基于 DHT 单 key 的元数据分发不同,版本向量能精确描述文件变更的因果关系:当两个版本向量互不为 Greater 时即为 Concurrent,此时触发冲突处理——保留两个版本(生成 .conflict 文件),确保数据不丢失。这是 P2P 文件同步中最重要的数据安全保障。

增量索引交换

本系统使用 DHT 存储完整元数据,下载者全量拉取。Syncthing 则采用增量索引交换:

当两台设备连接时,首先交换 IndexID(标识本地索引的 UUID)和 Sequence(索引序列号)。如果对方的 IndexID 与本地记录的相同,则只需发送 Sequence 之后的变化——这就是增量同步的核心。

go
1
2
3
4
5
6
7
8
// 伪代码:增量索引交换逻辑
if local.indexID == remote.indexID {
    // 只发送 Sequence 之后的变更
    sendDelta(local.sequence, remote.sequence)
} else {
    // 全量索引
    sendFullIndex()
}

这种设计对于频繁重连的场景极为高效:设备休眠唤醒后只需交换最近的变更,而非全量同步。相同理念可以应用于自建系统——在 DHT 之外维护一个序列号驱动的增量通道。

文件夹语义类型

Syncthing 定义了四种文件夹类型,每种对应不同的同步语义:

类型本地变更远程变更适用场景
Send & Receive发送给集群从集群接收默认双向同步
Send Only发送给集群忽略NVR 录像存储(只写不读)
Receive Only忽略从集群接收边缘设备、档案副本
Receive Encrypted忽略接收但无法解密不可信设备托管

其中 Receive Encrypted 类型值得注意——设备可以存储加密数据但无法解密,私钥在授权设备本地。这为自建文件共享系统提供了"边缘存储"的设计思路:信任节点负责读写,不可信节点只提供存储空间。

并行哈希计算

Syncthing 的文件扫描器使用并行哈希池加速大文件处理:

go
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
type parallelHasher struct {
    workChan chan *hasherWork
    outChan  chan *hashResult
    workers  int // 默认 2 个并行 worker
}

func (ph *parallelHasher) HashFile(ctx context.Context, path string) {
    // 1. 初始 Stat
    // 2. 分块读取并发送到 workChan
    // 3. 并行 Worker 计算 SHA-256
    // 4. 最终 Stat 确认文件未变更
    // 5. 所有 Worker 完成后汇总结果
}

关键设计细节:哈希计算前后各做一次 Stat(),如果文件的 mtime 或 size 在计算期间发生变化,则放弃结果——确保不会使用过时的哈希值。这项检查在 Rust 实现中也可以同样应用。

相比之下,本系统使用串行哈希计算(单线程读取→哈希→下一块)。在多核环境下,可以借鉴 Syncthing 的并行哈希池来加速大文件扫描。

参考资料