简介FastDHT 是一套基于分布式哈希表DHT实现的分布式文件系统DFS源码包面向分布式存储学习者、后端开发人员及系统架构师用于解决海量数据在多节点间高效写入、读取、容错和扩展的核心问题。包内共 86 个文件主体为 34 个 C 源码与 33 个 C 头文件覆盖客户端、服务端、通信协议、哈希表、内存池、数据库恢复等模块另含 shell 脚本、conf 配置、PHP 客户端、Makefile 与 INSTALL 等文件组成从编译到部署的完整工程结构。压缩包体积仅 117KB轻量便于快速翻阅目前已有 211 人学习。借助这份源码读者可以深入了解 FastDHT 的元数据管理、数据分片、负载均衡、同步与恢复机制掌握分布式文件系统的实际落地写法也能够参考其中 fdht_client、fdht_test、tool 等目录组织方式为自研分布式存储方案提供直接借鉴。1. 分布式文件系统这份实现能解决什么问题几个月前我接到一个内部项目的改造任务一个带文件上传功能的业务平台单机磁盘眼看就要写满多人同时上传时CPU直接飙到90%以上备份更是只能半夜手动拷盘。那段时间我翻遍各种开源分布式文件系统方案要么部署太重要么文档绕最后索性自己动手拆了一套轻量级分布式文件系统的完整实现也就是这个项目。它是把「快速文件系统」的存储思路落地成代码的工程产物协调节点负责路由和元数据存储节点负责真正的落盘与同步客户端SDK把上传下载封装成一行调用。适合中小团队想自建文件存储、又不想引入重量级中间件的场景也适合想搞懂分布式文件系统内部机制的开发者拿来改。2. 架构设计与技术选型先想清楚再动手比写代码更省时间2.1 为什么是「协调节点 存储节点」这套设计几乎所有成熟的分布式文件系统核心思路都是把「元数据管理」和「数据存储」拆开。元数据就是文件名、大小、所在位置这些信息数据本体就是文件的二进制内容。这两种数据的访问模式完全不同元数据请求频繁但体量小数据读写体量大但路径固定。混在一起管理最后一定是互相拖累。我拆这套项目时最直观的感受是把这两个角色拆开之后水平扩展变得非常自然存储空间不够就加存储节点元数据吞吐不够就扩协调节点。常见做法是协调节点只维护文件到存储节点的映射关系不参与实际数据搬运存储节点各自管理本地磁盘目录并向协调节点上报心跳和容量。客户端上传文件时先问协调节点要一个目标存储节点地址再直连存储节点做数据写入。这样协调节点的压力被控制在很小的范围数据流不经过中间层转发整体吞吐反而比「所有流量都走网关」的架构高不少。2.2 整体架构的三个角色从部署视角看这套系统由三个角色组成职责非常清晰。协调节点集群负责文件路由、节点状态管理、故障摘除。客户端发起的每个请求第一步都是来这里。存储节点集群负责文件分片的接收、落盘、复制和读取。每个存储节点上跑着一组数据目录数据目录之间互相同步。客户端SDK把「找协调节点、连存储节点、重试、秒传」这些逻辑封装成库业务系统只需要调用 upload(file) 和 download(path) 这样的接口。为了支撑这个架构这套实现的技术栈选型是这样的协调节点用 Spring Boot 对外提供 HTTP API内部用 ZooKeeper 做协调节点的领导者选举元数据存 MySQL存储节点用 Netty 做私有传输协议的服务端因为分片上传用长连接比 HTTP 更高效存储节点和协调节点之间的心跳走的是一个独立的 Netty 长连接通道。之所以不把存储也做成 HTTP是因为文件分片传输需要连接复用和背压控制原生 HTTP 封装在流式读写上并不顺手。2.3 工程目录结构一个 Maven 多模块项目拿到源码后先看目录结构。这是一个标准的 Maven 多模块工程分为四个子模块互相之间的依赖关系很清晰。dfs-project ├── dfs-common # 公共模块传输协议、序列化、工具类 ├── dfs-coordinator # 协调节点路由、注册、元数据管理 ├── dfs-storage # 存储节点数据落盘、分片接收、复制 ├── dfs-client # 客户端SDK上传、下载、断点续传 └── deploy # 部署脚本与配置文件模板dfs-common 定义了存储节点之间、客户端与存储节点之间的通信协议用 Protobuf 序列化字段变动时能保持前后兼容。dfs-coordinator 里最核心的类是 RouteService它维护一张「文件路径 - 存储节点列表」的内存路由表每次上传分配节点时直接查表。dfs-storage 里最核心的是 ChunkReceiver它负责把网络来的字节流切成块写入磁盘并同步到同组的副本节点。dfs-client 则是业务方唯一需要依赖的模块它把协调节点的 HTTP 接口和存储节点的 Netty 协议都封装好了。2.4 核心元数据设计一张路由表一张文件表协调节点的 MySQL 里最主要的两张表一张存储节点注册表一张文件路由表。我摘一段核心建表 SQL 出来看。CREATE TABLE storage_node ( node_id BIGINT PRIMARY KEY AUTO_INCREMENT, node_group VARCHAR(32) NOT NULL COMMENT 组名副本按组隔离, host VARCHAR(64) NOT NULL, data_port INT NOT NULL, disk_path VARCHAR(256) NOT NULL COMMENT 本地数据目录, total_capacity BIGINT NOT NULL DEFAULT 0, used_capacity BIGINT NOT NULL DEFAULT 0, status TINYINT NOT NULL DEFAULT 1 COMMENT 1在线 0离线, last_heartbeat DATETIME NOT NULL, KEY idx_group (node_group) ); CREATE TABLE file_route ( file_id BIGINT PRIMARY KEY AUTO_INCREMENT, file_path VARCHAR(256) NOT NULL, file_size BIGINT NOT NULL, file_md5 CHAR(32) NOT NULL, node_ids VARCHAR(128) NOT NULL COMMENT 存储节点ID列表(逗号分隔), chunk_count INT NOT NULL DEFAULT 1, create_time DATETIME NOT NULL, UNIQUE KEY uk_path (file_path) );storage_node 表中的 node_group 字段很关键它决定了数据副本的分布策略。同一组的存储节点互为副本关系一组里写三份任何一个节点挂了数据不丢。file_route 表保存的是文件逻辑路径到物理节点的映射node_ids 字段里顺序很重要第一个节点是主节点后续节点是副本节点。这套表结构没有过度设计但足够支撑「上传找节点、下载找文件、故障换节点」三个核心流程。3. 核心模块拆解一个文件从上传到返回的完整链路3.1 上传第一步协调节点如何分配存储节点客户端调用 SDK 的 upload 方法时SDK 内部先向协调节点发起一个路由请求。协调节点收到请求后根据文件大小、各节点的剩余容量和当前在线状态选出一个最合适的存储节点返回。这个分配逻辑是整套系统最容易翻车的地方分配不均衡会导致集群里某些磁盘写满、某些磁盘闲着。public AllocResult allocateNode(String group, long fileSize) { ListStorageNode nodes nodeRegistry.getOnlineNodes(group); // 选出剩余容量最大的节点作为主节点 StorageNode primary nodes.stream() .filter(n - n.getAvailable() fileSize) .max(Comparator.comparingLong(StorageNode::getAvailable)) .orElseThrow(() - new NoEnoughSpaceException(no node has enough space)); // 副本节点按剩余容量从大到小取前两个 ListStorageNode replicas nodes.stream() .filter(n - !n.getNodeId().equals(primary.getNodeId())) .filter(n - n.getAvailable() fileSize) .sorted(Comparator.comparingLong(StorageNode::getAvailable).reversed()) .limit(2) .collect(Collectors.toList()); return new AllocResult(primary, replicas); }这里的逻辑是主节点选剩余空间最大的副本节点选剩余空间最大的另外两台。这样做的效果是让容量大的节点优先被写入避免小容量节点过早写满。如果当前组在线节点少于 2 个replicas 列表会小于 2这时协调节点还是会返回成功但会在响应里带一个 warning 字段提示副本数不足。客户端拿到结果后会把主节点和副本节点列表缓存在本地后续分片上传直接复用这个分配结果。3.2 秒传与分片怎么避免重复传大文件分配完节点之后客户端并不会直接上传整个文件而是先计算文件的 MD5向协调节点询问这个文件是否已经存在。协调节点查到 file_md5 命中时直接把原文件路径返回整个上传过程在几十毫秒内结束这就是秒传逻辑。public UploadTicket prepareUpload(String filePath, String md5, long size) { FileRoute exist fileRouteMapper.findByMd5(md5); if (exist ! null) { // 秒传命中不重新传文件只建立路径映射 fileRouteMapper.insert(new FileRoute(filePath, exist.getFileSize(), md5, exist.getNodeIds(), exist.getChunkCount())); return UploadTicket.instantHit(exist.getNodeIds()); } // 未命中走正常上传流程 AllocResult alloc allocateNode(defaultGroup, size); String ticketId UUID.randomUUID().toString().replace(-, ); return UploadTicket.create(ticketId, alloc.getPrimary(), alloc.getReplicas()); }秒传的逻辑看似简单但有一个细节容易被忽略MD5 碰撞。虽然概率极低但大型文件系统里不能完全依赖 MD5 作为唯一判断依据。所以代码里秒传命中后还有一个后台校验任务会定期抽样比对文件大小和部分分片内容。分片这边客户端会把文件按 4MB 一个分片切分每个分片单独计算 CRC32上传时带上分片序号和分片校验码这样任何一个分片传坏了都能精准定位重传。3.3 分片写入Netty 接收端如何落盘存储节点收到客户端的分片数据后先校验分片序号是否连续、校验码是否匹配再写入本地磁盘。这里最忌讳的是每收到一个分片就开一个新文件句柄文件多时句柄会耗尽。常见做法是每个文件一个分片目录目录内按序号写分片文件所有分片落齐后再合并。Override protected void channelRead0(ChannelHandlerContext ctx, ChunkPacket packet) { // 校验分片序号防止乱序写入 if (packet.getChunkIndex() ! expectedChunk.getAndIncrement()) { ctx.writeAndFlush(ErrorResp.sequenceMismatch(packet.getChunkIndex())); return; } // 写入本地暂存目录 Path chunkPath Paths.get(storageRoot, ticketId, String.format(%05d.chunk, packet.getChunkIndex())); try (FileChannel channel FileChannel.open(chunkPath, StandardOpenOption.CREATE, StandardOpenOption.WRITE)) { ByteBuffer buffer ByteBuffer.wrap(packet.getData()); while (buffer.hasRemaining()) { channel.write(buffer); } // 同步到同组副本节点 replicateToPeers(packet); ctx.writeAndFlush(OkResp.of(packet.getChunkIndex())); } catch (IOException e) { ctx.writeAndFlush(ErrorResp.ioError(e.getMessage())); } }这里有一个关键设计每个分片写入成功后立即同步到副本节点而不是等全部写完后才同步。这么做的原因是减少最终合并时的时间窗口如果所有分片写完了才发现副本节点同步失败重新同步整个文件的代价会很大。分片级别同步让每次同步的数据量被限制在 4MB 以内失败重试的成本也可控。expectedChunk 使用 AtomicInteger 保证多线程下分片序号校验的正确性Netty 的 channelRead0 方法虽然默认是串行执行的但为了防止未来引入多线程 handler 时出问题提前做了一层保护。3.4 下载与文件读取从副本节点补拉下载流程比上传简单一些客户端向协调节点要文件路径对应的节点列表然后直连主节点取分片。主节点缺失某个分片时会触发一个「补拉」逻辑从副本节点把缺失的分片拉回来再返回给客户端。public FileChunk readChunk(String filePath, int chunkIndex) { FileRoute route routeCache.get(filePath); // 先从本地磁盘读 Path chunkPath buildChunkPath(route.getFileId(), chunkIndex); if (Files.exists(chunkPath)) { return FileChunk.fromFile(chunkPath); } // 本地缺失从副本节点拉取并补写 for (Long peerId : route.getReplicaNodeIds()) { FileChunk chunk peerClient.fetchChunk(peerId, filePath, chunkIndex); if (chunk ! null) { writeChunkToLocal(chunkPath, chunk); return chunk; } } throw new ChunkNotFoundException(filePath, chunkIndex); }这个补拉逻辑是应对「磁盘坏道读不出某个文件」这类局部故障的保底手段。它的核心价值是让客户端感知不到副本的存在只要还有一台节点有完整数据就能下载成功。注意补拉成功后写入本地磁盘相当于自动修复了本地节点缺失的分片下次再读同一分片就不需要走网络了。这里的 hash 回源机制在文件系统里有一个专门的说法叫「局部自愈」是分布式存储里很实用的设计。4. 本地三节点部署启动一次记住一辈子4.1 环境准备与配置文件要点部署这套系统最少需要三个节点一个协调节点、两个存储节点。为了在本地模拟集群效果我建议用三个不同端口的本地实例来部署而不是真的开三台虚拟机。项目 deploy 目录下已经准备好了三个 YAML 配置文件模板分别是 coordinator.yml、storage-1.yml、storage-2.yml。存储节点的配置差异只在端口和数据目录上其余配置保持一致。# storage-1.yml 的核心配置 server: port: 8021 storage: node-id: 1 group: group-a ># 启动协调节点 java -jar dfs-coordinator/target/dfs-coordinator.jar --spring.config.locationdeploy/coordinator.yml # 启动存储节点 java -jar dfs-storage/target/dfs-storage.jar --spring.config.locationdeploy/storage-1.yml java -jar dfs-storage/target/dfs-storage.jar --spring.config.locationdeploy/storage-2.yml # 验证上传 java -jar dfs-client/target/dfs-client-demo.jar --actionupload --file./test.pdf三个进程都起来后可以先用客户端 demo 做一次完整的上传下载验证。如果上传成功但下载时 404大概率是协调节点缓存了旧的路由信息等 30 秒缓存过期后重试即可。验证通过后把 test.pdf 的下载内容用 md5sum 和本地原文件比对一致性没问题才算部署真正成功。到这里整套系统已经能跑起来完成正常的文件读写。4.3 避坑记录三节点部署踩过的五个坑坑一端口占用导致存储节点无法启动。现象是启动 storage-2.jar 时直接报 Address already in use。原因是我把 storage-1 和 storage-2 的数据传输端口写成了同一个都没注意到配置里的>#!/bin/bash # 冒烟压测20并发上传500个文件 for i in $(seq 1 500); do dd if/dev/urandom of/tmp/testdata/file_${i}.bin bs4M count1 2/dev/null java -jar dfs-client/target/dfs-client-demo.jar \ --actionupload --file/tmp/testdata/file_${i}.bin /tmp/upload_${i}.log # 每执行25个任务等待一轮完成控制并发数 if (( i % 25 0 )); then wait; fi done wait grep -h cost /tmp/upload_*.log | awk {sum$2; cnt} END {print avg, sum/cnt, count, cnt}每个日志文件里都会记录上传耗时awk 最后算出平均耗时。第一次跑这个脚本时我看到的平均耗时是 480ms但单文件 4MB 在本地网络环境下理论耗时应该在 30ms 以内。问题在于每个上传进程都要重新初始化一次客户端 SDK加载路由信息、建立 TCP 连接这些开销都被算进了耗时里。真实业务场景中 SDK 是常驻内存复用的所以冒烟脚本得出的平均值只能作为下界参考不能当成线上的真实性能指标。5.2 关键参数怎么调才有效压测过程中发现瓶颈后去调整下面几个核心参数。这里列一张参数调整表适合直接照抄到项目配置里。参数位置参数名建议值说明系统层面fs.file-max655360文件句柄数存储节点默认 1024 远远不够JVM 层面-Xms / -Xmx2g / 2g存储节点内存固定堆避免动态扩容抖动Netty 层面bossGroup 线程数1接受连接线程一台机器一个就够了Netty 层面workerGroup 线程数8处理 IO 事件按 CPU 核数 2 倍配置存储节点同步队列容量2000副本同步队列积压上限超过则阻塞上游文件句柄是压测时最先触顶的参数。每个分片写入要占一个句柄排重、校验又要额外占用一次并发 20 路上传就可能把默认的 1024 个句柄耗尽。解决办法是改 /etc/security/limits.conf 里的 nofile 上限然后重启存储节点进程让配置生效。workerGroup 线程数不是越大越好8 线程在四核机器上已经能跑满网卡调成 32 反而会因为上下文切换降低吞吐。同步队列容量这个参数设置太小会导致副本节点来不及处理而丢分片设置太大则积压过多内存占用2000 是一个比较均衡的值。5.3 结果怎么解读从数据里判断瓶颈一次完整的压测跑完会得到四类数据上行吞吐、下行吞吐、99 分位延迟、错误率。我一般先看错误率错误率超过 0.1% 就说明系统有稳定性问题吞吐再高也没有意义。之后看 99 分位延迟和平均延迟的差距如果差距过大说明有少量请求被卡了很久通常是同步队列积压或 GC 停顿导致的。规避 GC 停顿的办法是给存储节点启用 G1 垃圾回收器并把最大停顿时间设置在 100ms 以内。下图示例解锁压测结果表格实际压测中我会把同一组文件分别放在 HDD 和 SSD 上跑对比结果非常直观。文件大小SSD 平均延迟HDD 平均延迟差异原因4MB18ms45ms随机写放大效应64MB220ms890ms大文件顺序写差距小但寻道时间叠加512MB1.8s5.6s分片数量多后同步队列成为瓶颈SSD 上小文件优势巨大但大文件差距反而不那么明显因为大文件的写入是顺序的HDD 的顺序写性能并不差。这个结果告诉我们如果业务以小文件为主存储节点一定要用 SSD这比优化代码的收益大得多。如果以 100MB 以上的大文件为主HDD 加多点副本可能更划算成本低而且吞吐差距没那么大。调优的终点不是让某个指标变得多好看而是找到瓶颈在哪花最少的钱解决最大的问题。6. 二次开发从「能跑」到「好用」的四个改造方向这个分布式文件系统已经能完整跑通上传、下载、副本同步但作为一份可以深入改造的工程还留了几个值得动手的方向。第一个方向是小文件合并。如果业务里大量上传的是几十 KB 的小文件每个文件独立落盘会非常浪费磁盘 inode 和元数据开销。常见做法是引入一个「小文件合并层」把一批小文件拼接成一个大的合并文件再单独维护一份偏移索引读取时按索引取出对应片段。我在本地做过一个简易版10000 个 8KB 小文件合并后磁盘占用减少约 40%读取耗时基本没变化。第二个方向是冷热数据分层。存储节点可以扫描文件的最近访问时间超过 30 天没被读过的文件自动迁移到独立的冷存储目录冷目录放在大容量 HDD 上热目录留在 SSD 上。这个改造需要协调节点配合在 file_route 表里加一个 storage_tier 字段客户端读文件时先走热目录没命中再回源到冷目录。做完之后SSD 的磨损明显下降整体存储成本也降了不少属于很值得投入的改造。第三个方向是回收站与软删除。当前版本删除文件是直接删元数据和分片一旦误删没有后悔药可吃。改造思路是给 file_route 表加一个 deleted_at 字段删除时只把 deleted_at 置为当前时间不清理底层分片由后台任务在 7 天后真正清理。这个改动的难点在于协调节点查询文件时要过滤掉已删除的记录并且不能把已删除的文件路径用来新建覆盖。我当时在做这个功能时因为没处理好并发出现了一次「删掉的文件路径又传了同名的文件导致旧分片被新文件覆盖」的事故从那以后我每次加状态字段都会强制走一遍并发场景的单测。第四个方向是可观测性指标。目前存储节点只有心跳上报在线状态没有上报写入耗时、磁盘 IO 等待、同步队列长度这些指标。我建议在存储节点里接一个 Prometheus 客户端把每个分片写入的耗时分布、队列深度、磁盘空间剩余率都暴露出来再用 Grafana 做一张总览面板。这个改造工程量不大但对线上排查问题帮助极大告警也比靠人工盯日志靠谱得多。分布式系统的复杂性几乎都藏在看不到的地方可观测性就是让你的系统「看得见」的唯一手段。希望这些改造思路能帮到你一点哪怕只是避开一个我踩过的坑。本文还有配套的精品资源点击获取