基于 Rust 的高性能 S3 over NFS 系统设计

背景

在边缘计算场景下,我们的服务器部署在客户环境中,需要对接客户自有的存储基础设施(往往是几百万购买的)。这些存储服务器通常只提供NFS挂载接口,而我们的对象存储方案基于MinIO实现,需要 同时支持裸磁盘部署和NFS挂载两种模式。

在裸磁盘部署场景下,MinIO运行稳定,性能表现良好。然而,当切换到NFS挂载模式时, 我们遇到了严重的性能瓶颈。

核心问题分析:MinIO的许多内置功能(如数据扫描、健康检查、元数据维护等)需要频 繁遍历文件系统。在本地磁盘上,这些操作的开销可以忽略不计;但在NFS环境下,每次 文件系统操作都涉及网络往返,大量操作需要对文件夹加锁,导致性能急剧下降,成为系统的性能灾难。

本文会包含下面这些内容:

  • 通过Linux内核源码分析和eBPF追踪,定位MinIO在NFS环境下的锁竞争问题
  • 直接使用NFS协议与服务器通信,避开VFS层的锁竞争,实现S3到NFS的协议转换
  • 基于unfold+map+buffered组合,用简洁代码实现高效的文件预取机制
  • 分片上传与异步合并、大目录遍历的状态复用和分页、List Objects流式的实现原理
  • 支持多种存储后端抽象分层
  • NFS 可视化监控的实现方式

NFS 可视化监控

代码未写,监控先行。这部分的监控是缺失,我们基于 NFS 暴露的 stat 以及 EBPF 实现了一个比较完善的监控看板,可以看到 NFS 的事件、延迟、命令测试等,可以直观看到性能瓶颈时,NFS 的详细监控数据。

内核问题分析

这部分是内核源码分析,不感兴趣的同学可以跳过这一节。

当 minio 在扫描大目录时,此时一个简单的文件创建和写入都会卡住,内核堆栈如下

bash 复制代码
PID: 21060    TASK: ffff8fad248a3fc0  CPU: 3    COMMAND: "file_rw_test"
 #0 [ffffacbbcd747c38] __schedule at ffffffffa3a9600e
 #1 [ffffacbbcd747c88] schedule at ffffffffa3a9649c
 #2 [ffffacbbcd747c98] rwsem_down_write_slowpath at ffffffffa3150a29
 #3 [ffffacbbcd747d40] path_openat at ffffffffa3398b49
 #4 [ffffacbbcd747dd8] do_filp_open at ffffffffa339ada1
 #5 [ffffacbbcd747ee8] do_sys_openat2 at ffffffffa3384d3d
 #6 [ffffacbbcd747f20] do_sys_open at ffffffffa338616b
 #7 [ffffacbbcd747f40] do_syscall_64 at ffffffffa3a88500

其中 open 系统阻塞在 open_last_lookups 内核函数

c 复制代码
static struct file *path_openat(struct nameidata *nd,
			const struct open_flags *op, unsigned flags)
{
	struct file *file;
	int error;

	file = alloc_empty_file(op->open_flag, current_cred());
	if (IS_ERR(file))
		return file;

	if (unlikely(file->f_flags & __O_TMPFILE)) {
		error = do_tmpfile(nd, flags, op, file);
	} else if (unlikely(file->f_flags & O_PATH)) {
		error = do_o_path(nd, flags, file);
	} else {
		const char *s = path_init(nd, flags);
		// 阻塞在这里
		while (!(error = link_path_walk(s, nd)) &&
		       (s = open_last_lookups(nd, file, op)) != NULL)
			;
		if (!error)
			error = do_open(nd, file, op);
		terminate_walk(nd);
	}
}


static const char *open_last_lookups(struct nameidata *nd,
		   struct file *file, const struct open_flags *op)
{
	struct dentry *dir = nd->path.dentry;
	int open_flag = op->open_flag;
	bool got_write = false;
	unsigned seq;
	struct inode *inode;
	struct dentry *dentry;
	const char *res;

	nd->flags |= op->intent;


	// 对目录 inode 加锁
	if (open_flag & O_CREAT)
		inode_lock(dir->d_inode); // 阻塞在这里
	else
		inode_lock_shared(dir->d_inode);
	// 执行真正的 open 操作
	dentry = lookup_open(nd, file, op, got_write);
	if (!IS_ERR(dentry) && (file->f_mode & FMODE_CREATED))
		fsnotify_create(dir->d_inode, dentry);
	if (open_flag & O_CREAT)
		inode_unlock(dir->d_inode);
	else
		inode_unlock_shared(dir->d_inode);
		
}

static inline void inode_lock(struct inode *inode)
{
	down_write(&inode->i_rwsem); // 对信号量加写锁
}

阻塞 rwsem_down_write_slowpath 函数上,源码如下:

arduino 复制代码
/*
 * Wait until we successfully acquire the write lock
 */
static struct rw_semaphore *
rwsem_down_write_slowpath(struct rw_semaphore *sem, int state)

其中 rw_semaphore 是 linux 内核中的读写信号量,用来实现读写锁。

arduino 复制代码
struct rw_semaphore {
	atomic_long_t count;
	/*
	 * Write owner or one of the read owners as well flags regarding
	 * the current state of the rwsem. Can be used as a speculative
	 * check to see if the write owner is running on the cpu.
	 */
	atomic_long_t owner;
}

其中 owner 指向持有写锁的进程,我们来写一个 bpf 程序来打印

ini 复制代码
SEC("kprobe/rwsem_down_write_slowpath")
int BPF_KPROBE(trace_rwsem_down_write_slowpath) {
    char comm[16];
    bpf_get_current_comm(&comm, sizeof(comm));
    if ((strncmp(comm, "file_rw_test", 12) != 0) &&
        (strncmp(comm, "minio", 5) != 0)) {
        return 0;
    }
 

    struct rw_semaphore *rwsem = (struct rw_semaphore *) PT_REGS_PARM1(ctx);

    s64 count = BPF_CORE_READ(rwsem, count.counter);
    u64 owner = BPF_CORE_READ(rwsem, owner.counter);

    // clear the lower 3 bits
    owner &= ~((s64) 7);
    struct task_struct *owner_task = (struct task_struct *) owner;
    if (owner_task == NULL) {
        return 0;
    }
    pid_t owner_pid = BPF_CORE_READ(owner_task, pid);
    char owner_comm[16];
    int len = bpf_probe_read_kernel_str(owner_comm, sizeof(owner_comm),
                                        &owner_task->comm);
    bpf_printk("[enter]write lock, owner_comm: %s, owner_pid: %d", owner_comm, owner_pid);
    return 0;
}

输出如下,可以看到这是一个 minio 线程。

yaml 复制代码
bpf_trace_printk: [enter]write lock, owner_comm: minio, owner_pid: 12708

通过 crash 工具,查看 12708 线程的堆栈

less 复制代码
crash> bt 12708
PID: 12708    TASK: ffff8fa941a7aa80  CPU: 5    COMMAND: "minio"
 #0 [ffffacbbc41ebab0] __schedule at ffffffffa3a9600e
 #1 [ffffacbbc41ebb00] schedule at ffffffffa3a9649c
 #2 [ffffacbbc41ebb10] io_schedule at ffffffffa3a96952
 #3 [ffffacbbc41ebb20] __lock_page at ffffffffa328d0d9
 #4 [ffffacbbc41ebbb0] invalidate_inode_pages2_range at ffffffffa32a742e
 #5 [ffffacbbc41ebd30] nfs_readdir_filler at ffffffffc0aba60d [nfs]
 #6 [ffffacbbc41ebd50] do_read_cache_page at ffffffffa3291e04
 #7 [ffffacbbc41ebe00] nfs_readdir at ffffffffc0aba783 [nfs]
 #8 [ffffacbbc41ebea0] iterate_dir at ffffffffa339e222
 #9 [ffffacbbc41ebed8] __x64_sys_getdents64 at ffffffffa339f071
#10 [ffffacbbc41ebf40] do_syscall_64 at ffffffffa3a88500
#11 [ffffacbbc41ebf50] entry_SYSCALL_64_after_hwframe at ffffffffa3c00099
    RIP: 000000000040720e  RSP: 000000c202c2d8e8  RFLAGS: 00000206
    RAX: ffffffffffffffda  RBX: 0000000000000014  RCX: 000000000040720e
    RDX: 0000000000100000  RSI: 000000c227916000  RDI: 0000000000000014
    RBP: 000000c202c2d928   R8: 0000000000000000   R9: 0000000000000000
    R10: 0000000000000000  R11: 0000000000000206  R12: 0000000000000000
    R13: 000000c001100000  R14: 000000c0014d1a00  R15: 0000000000000106
    ORIG_RAX: 00000000000000d9  CS: 0033  SS: 002b

iterate_dir 在非 shared 模式下会对 dir 目录加写锁,然后开始执行文件系统的 iterate(调用 nfs 的nfs_readdir)

ini 复制代码
int iterate_dir(struct file *file, struct dir_context *ctx)
{
	struct inode *inode = file_inode(file);
	bool shared = false;
	int res = -ENOTDIR;
	if (file->f_op->iterate_shared)
		shared = true;
	else if (!file->f_op->iterate)
		goto out;

	res = security_file_permission(file, MAY_READ);
	if (res)
		goto out;

	if (shared)
		res = down_read_killable(&inode->i_rwsem);
	else
		res = down_write_killable(&inode->i_rwsem); // 对目录的 inode 加写锁
	if (res)
		goto out;

	res = -ENOENT;
	if (!IS_DEADDIR(inode)) {
		ctx->pos = file->f_pos;
		if (shared)
			res = file->f_op->iterate_shared(file, ctx);
		else
			res = file->f_op->iterate(file, ctx);
		file->f_pos = ctx->pos;
		fsnotify_access(file);
		file_accessed(file);
	}
	if (shared)
		inode_unlock_shared(inode);
	else
		inode_unlock(inode);
out:
	return res;
}

因为目录遍历执行的特别快,我们程序抢不到锁,就会出现卡顿。当然除了遍历目录,还有很多地方会有锁的问题,比如 rename 文件等。

less 复制代码
crash> bt 12733
PID: 12733    TASK: ffff8fa9821f2a80  CPU: 4    COMMAND: "minio"
 #0 [ffffacbbc4263d38] __schedule at ffffffffa3a9600e
 #1 [ffffacbbc4263d88] schedule at ffffffffa3a9649c
 #2 [ffffacbbc4263d98] rwsem_down_write_slowpath at ffffffffa3150a29
 #3 [ffffacbbc4263e48] lock_rename at ffffffffa33948a5
 #4 [ffffacbbc4263e68] do_renameat2 at ffffffffa339b7b6
 #5 [ffffacbbc4263f18] __x64_sys_renameat at ffffffffa339bbe5
 #6 [ffffacbbc4263f40] do_syscall_64 at ffffffffa3a88500

解决方案:绕过 vfs 和系统调用

技术方案如下图所示:

简单来说这个项目的设计如下:

  • S3 API层:处理传入的S3协议请求(基于开源的 s3s,做了一些 fix)
  • 协议转换:将 S3 操作转换为相应的 NFS 操作
  • NFS协议实现:使用 NFS 协议直接与 NFS 服务器通信

一句话来说总结就是,把 S3 请求翻译为 NFS 的网络调用,就这么简单!下面是一些实现的细节。

MINIO 明确表示不处理任何与 NFS 挂载带来的问题(大家可以 github 搜 issue),可能也是 他们觉得 MINIO+NFS 不是一个好的组合吧。

关于接口抽象

ObjectLayer 是 maxio 项目中的核心存储抽象层,它定义了一个统一的接口来处理对象存储操作,支持不同的底层存储实现(如 NFS、本地文件系统等)。这个抽象层使得系统能够轻松切换存储后端,同时提供一致的 S3 兼容 API。

rust 复制代码
#[async_trait::async_trait]
pub trait ObjectLayer: Send + Sync {
    
    // 桶操作
    async fn list_buckets(&self) -> anyhow::Result<Vec<BucketInfo>>;
    async fn make_bucket(&self, bucket: &str, opt: Option<MakeBucketOptions>) -> MResult<()>;
    async fn get_bucket_info(&self, bucket: &str) -> MResult<Arc<BucketInfo>>;
    async fn delete_bucket(&self, bucket: &str) -> MResult<()>;
    
    // 对象操作
    async fn put_object(&self, bucket: &str, key: &str, stream: HashReaderStream, opts: ObjectOptions) -> MResult<ObjectInfo>;
    async fn get_object(&self, bucket: &str, key: &str, range: Option<Range>) -> MResult<(GetObjectReader, u64, u64)>;
    async fn delete_object(&self, bucket: &str, key: &str) -> MResult<()>;
    async fn delete_objects(&self, bucket: &str, objects: Vec<ObjectToDelete>) -> anyhow::Result<(Vec<DeletedObject>, Vec<(String, MaxioError)>)>;
    async fn list_objects(&self, bucket: &str, prefix: Option<String>, marker: Option<String>, delimiter: Option<String>, max_keys: Option<i32>) -> MResult<ListObjectsInfo>;
    async fn get_object_info(&self, bucket: &str, key: &str) -> MResult<Option<ObjectInfo>>;
    
    // 分片上传操作
    async fn new_multipart_upload(&self, bucket: &str, key: &str) -> MResult<UploadId>;
    async fn upload_part(&self, bucket: &str, key: &str, upload_id: UploadId, part_id: i32, stream: HashReaderStream, content_length: Option<i64>) -> MResult<PartInfo>;
    async fn complete_multipart_upload(&self, bucket: &str, key: &str, upload_id: &str, parts: Vec<CompletePart>, opts: ObjectOptions) -> MResult<()>;
    async fn abort_multipart_upload(&self, bucket: &str, key: &str, upload_id: &str) -> MResult<()>;
    async fn list_multipart_uploads(&self, bucket: &str, prefix: &Option<String>, delimiter: &Option<String>, upload_id_marker: &Option<String>, key_marker: &Option<String>, encoding_type: &Option<String>, max_uploads: &Option<i32>) -> MResult<ListMultipartUploadsInfo>;
}

除了对象存储层的抽象,还有一个非常重要的文件操作层的抽象 Fs trait,这样我们只需要实现不同的 fs 后端实现就可以支持不同的存储类型,比如 NFS、单机多磁盘、分布式多磁盘等。

核心抽象:Fs Trait

rust 复制代码
#[async_trait::async_trait(Sync)]
pub trait Fs: Send + Sync {
    // 目录操作
    async fn create_dir(&self, path: &Path) -> MResult<()>;
    async fn remove_dir(self: Arc<Self>, path: &Path, options: RemoveOptions) -> MResult<()>;
    
    // 文件操作
    async fn create_file(&self, path: &Path, options: CreateOptions) -> MResult<()>;
    async fn remove_file(&self, path: &Path) -> MResult<()>;
    async fn rename(&self, source: &Path, target: &Path, options: RenameOptions) -> MResult<()>;
    async fn copy_file(&self, source: &Path, target: &Path, options: CopyOptions) -> MResult<()>;
    
    // 读写操作
    async fn read(&self, path: &Path, offset: u64, count: u64) -> MResult<Bytes>;
    async fn read_to_bytes(&self, path: &Path) -> MResult<Bytes>;
    async fn read_as_stream(self: Arc<Self>, path: &Path, offset: u64, count: u64) -> MResult<DataStream>;
    async fn write_bytes(&self, path: &Path, content: Bytes, start_offset: u64) -> MResult<u64>;
    async fn write_stream(&self, path: &Path, stream: &mut Pin<Box<dyn Stream<Item = Result<Bytes, Box<dyn std::error::Error + Send + Sync>>> + Send + Sync>>, start_offset: u64) -> MResult<u64>;
    
    // 元数据操作
    async fn metadata(&self, path: &Path) -> MResult<Option<Metadata>>;
    async fn read_dir(self: Arc<Self>, path: &Path) -> MResult<ReaddirStream>;
    async fn walk_file(&self, path: &Path, prefix: &str, recursive: bool) -> MResult<Pin<Box<dyn Stream<Item = MResult<FileEntry>> + Send + Sync>>>;
}

分片上传与合并

关于分片上传的实现,我们参考了 minio 的实现细节,每当有新分片上传完成时,立即在后台异步合并连续的分片,然后在 complete 的时候等候所有分片合并完成。

1. 分片上传完成触发

当单个分片上传完成后,系统会触发后台增量合并:

rust 复制代码
// 在 upload_part 方法中
self.spawn_incremental_merge(&bucket, &key, &upload_id);

fn spawn_incremental_merge(&self, bucket: &str, key: &str, upload_id: &str) {
    let ttl = Duration::from_secs(self.config.merge_state_ttl_sec);
    
    tokio::spawn(async move {
        if let Err(e) = incremental_merge_parts(
            nfs3_op, merge_states, &fs_uuid, 
            &bucket, &key, &upload_id, ttl
        ).await {
            info!("Background merge failed: {:?}", e);
        }
    });
}

2. 增量合并逻辑

增量合并只合并连续的分片,避免不必要的等待。只合并从 last_merged_part + 1 开始的连续分片,遇到分片间隙立即停止,等待缺失分片上传,保证合并文件的连续性和正确性。

rust 复制代码
// 增量合并:只合并顺序连续的分片
let mut parts = Vec::new();
let mut expected_part = last_merged_part + 1;

for (part_number, etag, actual_size, file_name) in part_files {
    if part_number == expected_part {
        parts.push(MergePart { part_number, _etag: etag, size: actual_size, file_name });
        expected_part += 1;
    } else if part_number > expected_part {
        // 发现间隙,停止增量合并
        break;
    }
}

3. 完成时全量合并

用户调用完成上传时,执行全量合并确保所有分片都被处理,同步执行,确保完成前所有数据已合并

rust 复制代码
async fn complete_merge_all_parts(&self, bucket: &str, key: &str, upload_id: &str) -> MResult<PathBuf> {
    let ttl = std::time::Duration::from_secs(self.config.merge_state_ttl_sec);
    let merge_file_path = merge_parts_with_ttl(
        self.fs.clone(),
        self.merge_states.clone(),
        &self.fs_uuid,
        bucket, key, upload_id,
        true, // complete_all = true
        ttl,
    ).await?;
}

4. 核心合并操作

perform_merge_operation_with_state 是实际执行合并的核心函数:

rust 复制代码
async fn perform_merge_operation_with_state(
    fs: FsType,
    merge_state: &mut MutexGuard<MergeState>,
    fs_uuid: &str,
    bucket: &str, key: &str, upload_id: &str,
    complete_all: bool,
) -> anyhow::Result<Option<PathBuf>> {
    // 1. 读取上传目录中的所有分片文件
    let upload_dir = get_upload_id_dir(bucket, key, upload_id);
    let entries = fs.clone().read_dir(&upload_dir).await?.try_collect::<Vec<_>>().await?;
    
    // 2. 解析分片文件名,提取分片信息
    for entry in entries {
        if let Ok((part_number, etag, actual_size)) = decode_part_file(&entry.name) {
            part_files.push((part_number as i32, etag, actual_size, entry.name));
        }
    }
    part_files.sort_by_key(|&(part_number, _, _, _)| part_number);
    
    // 3. 根据合并模式确定要合并的分片
    let parts_to_merge = if complete_all {
        // 全量合并:所有未合并的分片
        part_files.into_iter()
            .filter(|(part_number, _, _, _)| *part_number > last_merged_part)
            .map(|(part_number, etag, actual_size, file_name)| 
                MergePart { part_number, _etag: etag, size: actual_size, file_name })
            .collect()
    } else {
        // 增量合并:只合并连续分片
        // ... (如前所述的连续性检查逻辑)
    };
    
    // 4. 执行实际的文件合并
    let mut offset = merge_state.total_merged_size as u64;
    
    for part in &parts_to_merge {
        let part_path = path_join![upload_dir.as_str(), &part.file_name];
        let mut stream = fs.clone().read_as_stream(&part_path, 0, part.size as u64).await?;
        let n = fs.write_stream(&merge_file_path, &mut stream, offset).await?;
        offset += n;
    }
    
    // 5. 更新合并状态(仅增量合并)
    if !complete_all {
        merge_state.merged_parts.extend(parts_to_merge);
        merge_state.total_merged_size = offset as i64;
        merge_state.last_merged_part = merge_state.merged_parts.last()
            .map(|p| p.part_number).unwrap_or(0);
        merge_state.merge_file_path = Some(merge_file_path.clone());
    }
}

这里我们还需要考虑,分片上传最终未完成的情况,避免脏文件。系统启动时会启动后台清理过期上传任务。

自动清理任务

rust 复制代码
fn start_merge_state_cleanup(&self) {
    let ttl = Duration::from_secs(self.config.merge_state_ttl_sec);
    let cleanup_interval = Duration::from_secs(self.config.merge_state_cleanup_interval_sec);
    
    tokio::spawn(async move {
        let mut interval = tokio::time::interval(cleanup_interval);
        loop {
            interval.tick().await;
            if let Err(e) = cleanup_expired_merge_states(merge_states, ttl, &fs, &fs_uuid).await {
                error!("Failed to cleanup expired merge states: {:?}", e);
            }
        }
    });
}

清理流程

  1. 识别过期状态: 检查所有合并状态的创建时间
  2. 完整清理: 清理过期状态及其关联的所有文件系统资源
  3. 原子性删除: 先移动再删除,避免影响正在进行的操作
rust 复制代码
pub async fn cleanup_expired_merge_states(
    merge_states: MergeStateManager, 
    ttl: Duration, 
    fs: &FsType, 
    fs_uuid: &str
) -> MResult<()> {
    let expired_uploads = merge_states.get_all_expired(ttl).await;
    
    for expired in &expired_uploads {
        // 完整的 multipart 清理
        clean_multipart(fs, fs_uuid, 
            &expired.bucket, &expired.key, &expired.upload_id, 
            merge_states.clone()
        ).await?;
    }
}

list_objects 实现

list_objects 涉及到复杂的文件遍历,接口参数也比较多,delimiter、prefix 等,当然核心的问题是如何遍历文件。

传统的目录遍历在处理大目录时面临两个主要问题:

  • 内存爆炸: 一次性加载所有文件信息,我们的超大目录,可能有上亿个文件
  • 不可中断: 无法支持分页和断点续传

我们的实现也部分参考了 minio 的实现

核心架构

1. TreeWalkPool - 遍历状态池

rust 复制代码
#[derive(Clone)]
pub struct TreeWalkPool {
    pool: Arc<Mutex<HashMap<SharedListParams, VecDeque<TreeWalk>>>>,
    fs_op: FsType,
    timeout: Duration,
    id_counter: Arc<AtomicU64>,
}

核心特性:

  • 状态复用: 相同参数的遍历任务可以复用之前的状态
  • 超时管理: 自动清理过期的遍历状态
  • 并发安全: 使用细粒度锁保证线程安全

2. TreeWalk - 遍历实例

rust 复制代码
pub struct TreeWalk {
    pub id: u64,                                    // 唯一标识符
    pub result_rx: mpsc::Receiver<TreeWalkResult>,  // 结果接收通道
    pub added: DateTime<Utc>,                       // 创建时间
    pub end_timer_tx: Option<oneshot::Sender<()>>,  // 超时控制信号
    pub end_walk_tx: Option<oneshot::Sender<()>>,   // 遍历结束信号
}

3. ListParams - 遍历参数

rust 复制代码
#[derive(Debug, Clone, Eq, PartialEq, Hash)]
pub struct ListParams {
    pub bucket: String,    // 桶名
    pub recursive: bool,   // 是否递归
    pub marker: String,    // 分页标记
    pub prefix: String,    // 前缀过滤
}

实现原理详解

1. 对象列举流程 (list_objects)

rust 复制代码
async fn list_objects(&self, bucket: &str, prefix: Option<String>, marker: Option<String>, 
                     delimiter: Option<String>, max_keys: Option<i32>) -> MResult<ListObjectsInfo> {
    // 1. 参数验证和标准化
    let recursive = delimiter != SLASH_SEPARATOR;
    let params = Arc::new(ListParams::new(bucket.to_string(), recursive, 
                          marker.clone(), prefix.clone().unwrap_or_default()));
    
    // 2. 尝试从池中获取现有的遍历状态
    let mut tree_walk = match self.tree_walk_pool.release(params.clone()) {
        Some(tree_walk) => {
            info!("release tree walk from pool, params: {:?}", params);
            tree_walk
        }
        None => {
            // 3. 创建新的遍历任务
            info!("start a new tree walk, params: {:?}", params);
            self.tree_walk_pool.start_tree_walk(params.clone(), recursive, max_keys)?
        }
    };
    
    // 4. 从通道接收遍历结果
    let mut result = Vec::new();
    let mut prefixes = Vec::new();
    let mut eof = false;
    
    for _ in 0..max_keys {
        let item = match tree_walk.result_rx.recv().await {
            Some(item) => item,
            None => {
                eof = true;
                break;
            }
        };
        
        // 5. 根据 delimiter 处理目录和文件
        if item.is_dir && delimiter == SLASH_SEPARATOR {
            prefixes.push(item.path());
        } else {
            result.push(ObjectInfo { /* ... */ });
        }
    }
    
    // 6. 处理分页和状态保存
    if !eof {
        ret.is_truncated = true;
        let param = ListParams::new(bucket.to_string(), recursive, 
                                   ret.next_marker.clone(), prefix.clone().unwrap_or_default());
        // 将未完成的遍历状态返回到池中
        self.tree_walk_pool.set(param, tree_walk);
    }
}

2. TreeWalk 生命周期管理

创建阶段 (start_tree_walk)

rust 复制代码
pub fn start_tree_walk(&self, params: SharedListParams, recursive: bool, max_keys: u32) -> anyhow::Result<TreeWalk> {
    // 1. 解析前缀参数
    let (prefix_dir, entry_prefix_match) = if let Some(last_index) = prefix.rfind(SLASH_SEPARATOR) {
        let (dir, p_match) = prefix.split_at(last_index + 1);
        (dir.to_string(), p_match.to_string())
    } else {
        ("".to_string(), prefix)
    };
    
    // 2. 创建通道
    let (result_tx, result_rx) = mpsc::channel(max_keys as usize);
    let (end_walk_tx, end_walk_rx) = oneshot::channel();
    
    // 3. 启动后台遍历任务
    tokio::spawn({
        let fs_op = self.fs_op.clone();
        async move {
            Self::do_tree_walk(fs_op, recursive, bucket, prefix_dir, 
                              entry_prefix_match, result_tx, end_walk_rx).await
        }
    });
    
    // 4. 返回 TreeWalk 实例
    Ok(TreeWalk {
        id: self.alloc_id(),
        result_rx,
        added: Utc::now(),
        end_walk_tx: Some(end_walk_tx),
        end_timer_tx: None,
    })
}

后台遍历任务 (do_tree_walk)

rust 复制代码
async fn do_tree_walk(
    fs_op: FsType,
    recursive: bool,
    bucket: String,
    prefix_dir: String,
    entry_prefix_match: String,
    result_tx: mpsc::Sender<TreeWalkResult>,
    end_walk_rx: oneshot::Receiver<()>,
) -> MResult<()> {
    let path = path_join!(&bucket, &prefix_dir);
    let stream = fs_op.walk_file(&path, &prefix_dir, recursive).await?;
    
    let mut end_walk_rx_fused = end_walk_rx.fuse();
    let mut stream_fused = stream.fuse();
    let mut pending_entry: Option<FileEntry> = None;
    
    loop {
        tokio::select! {
            biased;
            // 1. 响应取消信号
            _ = &mut end_walk_rx_fused => {
                trace!("received signal to end walk");
                return Ok(());
            }
            // 2. 从文件系统流读取条目
            entry_result = stream_fused.next(), if pending_entry.is_none() => {
                match entry_result {
                    Some(Ok(entry)) => {
                        pending_entry = Some(entry);
                    }
                    Some(Err(e)) => return Err(e),
                    None => break, // 流结束
                }
            }
            // 3. 获取发送许可并发送结果
            permit = result_tx.reserve(), if pending_entry.is_some() => {
                match permit {
                    Ok(permit) => {
                        let entry = pending_entry.take().unwrap();
                        if let Some(result) = Self::process_file_entry_non_blocking(
                            entry, &prefix_dir, &entry_prefix_match)? {
                            permit.send(result);
                        }
                    }
                    Err(e) => return Err(MaxioError::Other(format!("failed to reserve permit: {}", e))),
                }
            }
        }
    }
    
    Ok(())
}

3. 状态池管理机制

状态保存 (set)

rust 复制代码
pub fn set(&self, params: ListParams, mut tree_walk: TreeWalk) {
    let params = Arc::new(params);
    let mut _guard = self.pool.lock().unwrap();
    
    // 1. 容量控制 - 防止内存无限增长
    if _guard.len() > TREE_WALK_ENTRY_LIMIT {
        // 删除最旧的条目
        let oldest_walk_dq = _guard.values_mut()
            .min_by_key(|v| v.front().map(|w| w.added).unwrap_or(Utc::now()));
        
        if let Some(dq) = oldest_walk_dq {
            if let Some(mut oldest_walk) = dq.pop_front() {
                // 发送结束信号
                oldest_walk.end_timer_tx.take().map(|tx| tx.send(()));
                oldest_walk.end_walk_tx.take().map(|tx| tx.send(()));
            };
        }
    }
    
    // 2. 设置超时管理
    let (end_timer_tx, end_timer_rx) = oneshot::channel();
    tree_walk.id = self.alloc_id();
    tree_walk.added = Utc::now();
    tree_walk.end_timer_tx = Some(end_timer_tx);
    
    // 3. 添加到池中
    let walks = _guard.entry(params.clone()).or_insert_with(VecDeque::new);
    if walks.len() > TREE_WALK_SAME_ENTRY_LIMIT {
        // 同样参数的遍历任务数量限制
        if let Some(mut oldest_walk) = walks.pop_front() {
            oldest_walk.end_timer_tx.take().map(|tx| tx.send(()));
        };
    }
    walks.push_back(tree_walk);
    
    // 4. 启动超时任务
    tokio::spawn({
        let pool = self.pool.clone();
        let timeout = self.timeout;
        async move {
            tokio::select! {
                _ = tokio::time::sleep(timeout) => {
                    // 超时清理
                    let mut _guard = pool.lock().unwrap();
                    // 清理逻辑...
                }
                _ = end_timer_rx => {
                    // 正常结束
                }
            }
        }
    });
}

状态获取 (release)

rust 复制代码
pub fn release(&self, params: SharedListParams) -> Option<TreeWalk> {
    let mut _guard = self.pool.lock().unwrap();
    
    let walks = _guard.get_mut(&params)?;
    
    // 获取第一个遍历实例
    let walk = walks.pop_front();
    
    if walks.is_empty() {
        _guard.remove(&params);
    }
    
    let Some(mut walk) = walk else {
        return None;
    };
    
    // 取消超时定时器
    walk.end_timer_tx.take().map(|tx| tx.send(()));
    
    Some(walk)
}

4. 前缀处理机制

系统支持复杂的前缀匹配逻辑:

rust 复制代码
// 示例 1:
// prefix = "one/two/three/" 
// marker = "one/two/three/four/five.txt"
// => prefix_dir = "one/two/three/"
// => entry_prefix_match = ""

// 示例 2:
// prefix = "one/two/th"
// marker = "one/two/three/four/five.txt"  
// => prefix_dir = "one/two/"
// => entry_prefix_match = "th"

let (prefix_dir, entry_prefix_match) = if let Some(last_index) = prefix.rfind(SLASH_SEPARATOR) {
    let (dir, p_match) = prefix.split_at(last_index + 1);
    (dir.to_string(), p_match.to_string())
} else {
    ("".to_string(), prefix)
};

5. 递归与非递归模式

系统根据 delimiter 参数决定遍历模式:

rust 复制代码
let recursive = delimiter != SLASH_SEPARATOR;

// recursive = true:  深度遍历,返回所有文件
// recursive = false: 只遍历当前目录层级,目录作为前缀返回

递归模式 (delimiter != "/"):

  • 遍历所有子目录
  • 返回所有文件对象
  • 适用于完整列举场景

非递归模式 (delimiter == "/"):

  • 只遍历当前层级
  • 子目录作为 common_prefixes 返回
  • 适用于目录浏览场景

6. 内存控制与背压机制

Channel 容量控制

rust 复制代码
let (result_tx, result_rx) = mpsc::channel(max_keys as usize);

通道容量等于 max_keys,确保内存使用有界。

背压处理

rust 复制代码
permit = result_tx.reserve(), if pending_entry.is_some() => {
    match permit {
        Ok(permit) => {
            // 获得许可后才处理条目
            let entry = pending_entry.take().unwrap();
            if let Some(result) = Self::process_file_entry_non_blocking(...) {
                permit.send(result);
            }
        }
        Err(e) => return Err(...),
    }
}

使用 reserve() 方法实现背压控制,当接收端处理不及时时自动暂停生产。

file handle 的实现

我们都知道,linux 使用 inode 来关联具体的文件,在 NFS 中,文件句柄是 NFS File Handle ,它是服务器为每个文件和目录分配的唯一标识符。与传统文件系统中使用路径名不同,NFS使用文件句柄来标识和访问文件系统对象。

NFS File Handle 的特点:

  1. 唯一性: 每个文件/目录都有唯一的文件句柄
  2. 持久性: 文件句柄在文件生命周期内保持不变
  3. 不透明性: 对客户端来说是不透明的二进制数据
  4. 高效性: 直接定位文件,无需路径解析

典型的NFS操作流程:

  1. 路径解析: 客户端需要将路径 /a/b/c/file.txt 转换为文件句柄
  2. 逐级查找: 需要依次执行 LOOKUP 操作:
  • LOOKUP(root_fh, "a") → a_fh
  • LOOKUP(a_fh, "b") → b_fh
  • LOOKUP(b_fh, "c") → c_fh
  • LOOKUP(c_fh, "file.txt") → file_fh
  1. 文件操作: 使用最终的file_fh进行READ/WRITE等操作

没有缓存的情况下,每次文件访问都需要完整的路径解析,深层路径访问需要多次网络往返,严重影响性能

scss 复制代码
// 访问 /deep/nested/directory/structure/file.txt 需要5次网络往返
LOOKUP(root, "deep") → deep_fh           // 网络往返 1
LOOKUP(deep_fh, "nested") → nested_fh    // 网络往返 2  
LOOKUP(nested_fh, "directory") → dir_fh  // 网络往返 3
LOOKUP(dir_fh, "structure") → struct_fh  // 网络往返 4
LOOKUP(struct_fh, "file.txt") → file_fh  // 网络往返 5

项目中的 File Handle LRU 缓存设计

核心数据结构

FhCacheEntry - 缓存条目

rust 复制代码
#[derive(Debug)]
pub struct FhCacheEntry {
    pub fh: SharedFh3,        // NFS3 文件句柄
    pub is_dir: bool,         // 是否为目录
}

FhLruCache - LRU缓存容器

rust 复制代码
pub struct FhLruCache {
    path_cache: Mutex<LruCache<PathBuf, SharedFhCacheEntry>>,
}

通过祖先路径查找最大化缓存命中:

rust 复制代码
// 从最长路径到最短路径查找缓存
for ancestor in path.ancestors() {
    if let Some(cached_fh) = self.path_cache.get(ancestor) {
        existing_fh = Some(cached_fh.fh.clone());
        cached_ancestor = Some(ancestor);
        break;  // 找到最长匹配的祖先路径
    }
}

文件 prefetch 实现

在完成这个项目的 demo 版本以后,实测文件下载速度比 minio 要慢,经过分析发现因为 minio 基于文件 fs,Linux系统采用预读(readahead)技术以加速顺序文件读取,会自动往后 readahead,对于 NFS 来说,就是提前发起了后续文件内容网络请求。于是 maxio 也实现了类似的 prefetch 特性。

1.1 核心设计理念

尽可能使用 rust 的 stream 或者 future 的高级特性,减少自定义 stream 或者 future 的实现,减少状态机的管理成本。于是基于 stream 的 fold、map 和 buffered 使用非常简短的代码就实现了这个特性。在这个之前我还写过一个比较复杂的基于状态机的版本,代码行数比这个多很多。

1.2 核心数据结构

rust 复制代码
pub(crate) struct FileReadOp {
    start_offset: u64,              // 读取起始偏移
    length: u64,                    // 总读取长度
    file_fh: SharedFh3,             // NFS 文件句柄
    nfs_op: SharedFsInner,          // NFS 操作实例
    prefetch_chunk_size: u64,       // 预取块大小 (默认 128KB)
    prefetch_chunk_count: usize,    // 并发预取块数量 (默认 4)
}

设计要点:

  • prefetch_chunk_size: 单次预取的数据块大小,平衡网络效率和内存使用
  • prefetch_chunk_count: 并发预取的块数量,控制网络并发度

1.3 配置参数

rust 复制代码
// 默认配置常量
pub const DEFAULT_PREFETCH_CHUNK_COUNT: usize = 4;        // 最大并发预取块数
pub const DEFAULT_PREFETCH_CHUNK_SIZE: u64 = 128 * 1024;  // 128KB 块大小
pub const MAX_READ_PACKET_SIZE: u64 = 128 * 1024;         // NFS 读取包最大尺寸

// 运行时配置
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct NfsConfig {
    #[serde(default = "default_prefetch_chunk_size")]
    pub prefetch_chunk_size: u64,      // 可动态配置的块大小
    #[serde(default = "default_prefetch_chunk_count")]
    pub prefetch_chunk_count: usize,   // 可动态配置的并发数
    // ... 其他配置项
}

二、Prefetch 实现原理详解

2.1 流式预取架构

rust 复制代码
pub fn to_prefetch_stream(self) -> impl Stream<Item = Result<Bytes, Box<dyn std::error::Error + Send + Sync>>> {
    let chunk_size = self.prefetch_chunk_size;
    let init_start_offset = self.start_offset;
    let stream_len = self.length;
    
    // 1. 创建偏移量生成器
    let offset_stream = unfold(init_start_offset, move |start| async move {
        if start >= stream_len + init_start_offset {
            return None;  // 达到文件末尾
        }
        Some((start, start + chunk_size))  // 返回当前偏移量,更新下一个偏移量
    });
    
    // 2. 将偏移量转换为异步读取任务
    offset_stream
        .map(move |start_offset| {
            let fs = self.nfs_op.clone();
            let fh = self.file_fh.clone();
            let n = min(stream_len + init_start_offset - start_offset, chunk_size);
            
            // 返回一个 Future,表示一个异步 NFS 读取操作
            async move { 
                fs.do_nfs_read(fh.clone(), start_offset, n).await
                  .map_err(|e| Box::new(e) as Box<dyn std::error::Error + Send + Sync>) 
            }
        })
        // 3. 并发执行读取任务,最大并发数为 prefetch_chunk_count
        .buffered(self.prefetch_chunk_count)
}

2.2 关键技术解析

A. 流式数据生成 (unfold)

rust 复制代码
let offset_stream = unfold(init_start_offset, move |start| async move {
    if start >= stream_len + init_start_offset {
        return None;  // 流结束
    }
    Some((start, start + chunk_size))  // (当前值, 下一个状态)
});

原理:

  • unfold 是一个状态驱动的流生成器
  • 每次产生一个偏移量,并计算下一个状态
  • 当达到文件末尾时自动终止流

B. 异步任务映射 (map)

rust 复制代码
.map(move |start_offset| {
    let fs = self.nfs_op.clone();
    let fh = self.file_fh.clone();
    let n = min(stream_len + init_start_offset - start_offset, chunk_size);
    
    async move { fs.do_nfs_read(fh.clone(), start_offset, n).await }
})

原理:

  • 将每个偏移量转换为一个异步读取任务
  • 使用 Arc 智能指针避免数据复制
  • 动态计算读取长度,处理文件末尾情况

C. 并发缓冲执行 (buffered)

rust 复制代码
.buffered(self.prefetch_chunk_count)

核心优势:

  • 同时维护最多 prefetch_chunk_count 个并发请求
  • 自动背压控制:当缓冲区满时,暂停新任务的启动
  • 保持结果顺序:尽管请求可能乱序完成,但输出按偏移量顺序排列

2.3 内存管理策略

rust 复制代码
impl FileReadOp {
    pub fn new(fh: SharedFh3, start_offset: u64, length: u64, max_read_buffer_size: u64, nfs_op: SharedFsInner) -> Self {
        Self {
            file_fh: fh,
            start_offset,
            length,
            // 关键:动态选择较小的块大小,避免内存过度使用
            prefetch_chunk_size: min(nfs_op.nfs_config.prefetch_chunk_size, max_read_buffer_size),
            prefetch_chunk_count: nfs_op.nfs_config.prefetch_chunk_count,
            nfs_op: nfs_op.clone(),
        }
    }
}

经过这个优化以后,文件的读取就比 NFS 原生的更快了,而且我们可以根据我们服务器的配置和 RTT 来做更好的参数配置预读的大小、预读的任务数。

测试与兼容

为了保证我们实现的正确性和与 minio 的兼容性,我们把 minio 的单元测试和接口测试几乎全部移植了过来,我们 2 行多行代码中大概有一万多行是测试代码,感谢 AI 编程吗喽(Cursor、Claude Code)

markdown 复制代码
------------------------------------------------------
Language                     files          blank        comment           code
------------------------------------------------------
Rust                           145           4619           3949          22327
XML                              6              0              0            790
TOML                            14             28             23            438
Markdown                         3            123              0            417
YAML                             5            224            424            323
Text                             1             21              0            184
JSON                             5              0              0            107
Python                           1             16             14             46
-------------------------------------------------
SUM:                           180           5031           4410          24632
-------------------------------------------------

扩展功能

我们在原有 minio 支持的功能上,还支持了文件类型白黑名单、文件防覆盖。以及后续会增加的文件生命周期管理、上传接口回调等。

性能对比

压测还在进行中,下面是一个简单的测试结果(测试工具:minio 的压测工具 warp github.com/minio/warp

测试场景 系统 吞吐量 (MiB/s) QPS (obj/s) 说明
1KB 文件上传(锁竞争) minio 0.00 0 严重锁竞争
maxio 5.56 ▲ 5834.53 ▲ 高并发优势明显
1KB 文件上传 minio 0.90 947.18
maxio 5.44 ▲ 5702.19 ▲ QPS 高 6 倍
10MB 文件不分片上传 minio 70.43 7.04
maxio 103.70 ▲ 10.36 ▲ 吞吐量高 47%
10MB 文件分片上传 minio 39.88 3.99 分片降低性能
maxio 40.53 4.05 分片后性能接近
100KB 文件下载 minio 106.45 ▲ 996.17
maxio 97.28 1090.01 ▲ 读取性能数据相当

在有目录锁竞争时,minio 无响应,qps 降到 0,而 maxio 基于无影响。

更优竞争力的是 Rust 的内存占用,压测过程中,几乎很少有超过 100M 的内存占用,这和 minio 动辄 10 几个 G 的内存占用没法比。

关于 Rust

用 Rust 来写大型项目很有挑战,但带来的性能收益是很明显的,尤其是在边缘计算等资源受限的场景。

熟悉了,跟写 Go 和 Java 也没什么太大区别。

相关推荐
IT_陈寒13 分钟前
Python 3.12 新特性实战:5个让你的代码效率提升50%的技巧!🔥
前端·人工智能·后端
Apifox15 分钟前
Apifox 8 月更新|新增测试用例、支持自定义请求示例代码、提升导入/导出 OpenAPI/Swagger 数据的兼容性
前端·后端·测试
风飘百里26 分钟前
Go语言DDD架构的务实之路
后端·架构
郭庆汝27 分钟前
GraphRAG——v0.3.5版本
后端·python·flask
轻松Ai享生活34 分钟前
Linux Swap 详解 (2) - 配置与优化
后端
xiguolangzi37 分钟前
springBoot3 生成订单号
后端
用户6757049885021 小时前
从入门到实战:一文掌握微服务监控系统 Prometheus + Grafana
后端
ruokkk1 小时前
AI 编程真香!我用 Next.js + AI 助手,给孩子们做了个专属绘本网站
前端·后端·ai编程
乘风破浪酱524361 小时前
Bearer Token介绍
前端·后端
AAA修煤气灶刘哥1 小时前
定时任务从入门到防坑,cron 表达式看这篇就够
java·后端