TB级文件夹局域网传输:分片断点续传方案详解
1. 需求本质拆解TB级文件夹传输到底难在哪先把问题掰开看。标题里有三个关键词局域网、TB级、分片断点续传。这三个词组合在一起才是真正的挑战所在。单机对一个两个G的文件做断点续传很多入门教程都有用RandomAccessFile跳着写就行。可一旦数据量到了TB这个级别事情就完全不一样了。这就像搬一箱子书和搬一整个图书馆的区别——前者你随便抱起来走就行后者你得先设计书架怎么拆、货车怎么装、路线怎么规划中途任何一本书搬坏了是重新搬那一本还是整个图书馆重来。TB级文件夹在局域网传输中会碰到的实打实的问题有这几个内存与文件句柄瓶颈TB级文件必然超过单文件系统极限最关键的是你不可能把整个文件读进内存再写出去。传统流的按字节搬运用在几十GB文件上都吃力TB级更是直接劝退。单点失败成本极高如果整个文件只有一个传输任务一旦网络抖动断了重传意味着前面几十个小时的工作全部报废。这个成本在局域网里同样不可接受。校验与恢复策略既然要分片每个分片传过去之后怎么确认是对的如果只靠TCP的校验那只能保证字节在传输层没丢但没法保证磁盘写入完整。得有应用层的校验机制。并发与顺序的矛盾分片天然适合并发传输但文件夹本身的目录结构是有层级顺序的怎么在并发的同时不把目录结构搞乱临时文件的残骸管理断点续传意味着每个分片都有一个中间状态。传输中断后接收端会残留大量文件碎片。下次怎么快速识别哪些是垃圾、哪些是有效续传点这几个问题交织在一起就不是一个单独的断点续传API能解决的了必须有一个系统性的传输方案设计。下面我从设计思路开始逐步展开。2. 整体方案架构三层结构应对TB级传输先给出整体架构思路。对于一个TB级局域网传输工具我倾向于分成三层任务调度层、传输协议层、持久化状态层。这种拆法核心目的只有一个把「传什么」「怎么传」「传到哪里」这三个问题彻底解耦。2.1 任务调度层把文件夹结构转化为可执行任务队列任务调度层解决的是「传什么」的问题。很多做文件传输的人一开始就想把文件流接起来直接传但对于TB级文件夹第一步应该是先做扫描和规划。你需要先把整个文件夹遍历一遍为每个文件建立元数据描述包括相对路径、文件大小、修改时间、权限位。这就像搬家前先做一个全屋物品清单而不是搬的时候才看家里有什么。这一步看起来简单TB级目录下文件数量通常达到几十万甚至上百万个时遍历本身就需要分钟级时间。这里有一个容易忽略的优化点扫描阶段建议把文件列表按目录分组并序列化保存到磁盘上的一份元数据索引文件中。这样即使传输任务宕机已完成扫描的部分不会丢而且续传时不需要重新遍历目录。2.2 传输协议层固定大小分片与断点记录传输协议层解决的是「怎么传」的问题核心就是分片和断点续传。我的推荐做法是无论文件多大按照固定大小切开每个分片的起点和长度由文件总大小和分片索引唯一确定。假设单个分片设定为64MB或128MB一个1TB的文件就会产生大约8192个分片。采用固定分片而不是自适应分片是为了让断点记录极其简单——每个分片都有一个全局唯一的编号传输进度表只需要记录「哪个文件的哪个分片已经完成」即可。这正是断点续传的核心数据结构。2.3 持久化状态层进度记录与断点恢复持久化状态层解决的是「传到哪里」的问题。这里的「哪里」不是指磁盘路径而是指「上次传到了哪一步」。我会在接收端维护一个传输进度文件比如叫transfer-status.json或sqlite数据库里面记录每个文件的分片完成情况。每当一个分片成功落盘并校验通过就更新这个状态文件。这样整个传输过程就变成了一个不断更新状态文件的可恢复过程。哪怕中途断电、进程被杀、网络断开下一次启动时只要读取进度文件就知道哪些分片已经完成哪些需要重传。3. 核心机制原理分片、断点、校验、并发控制这里把断点续传相关核心机制的原理讲透搞明白原理之后写代码就是水到渠成的事。3.1 分片大小怎么定64MB为什么是合理起点分片大小是方案里最敏感的权衡参数。分片太小意味着分片数量巨大状态记录和管理开销就大分片太大断点粒度就粗一个分片传一半断了等于整个分片白传。我实测下来在局域网千兆/万兆环境里64MB到128MB是一个比较舒服的范围。为什么千兆网络下理论吞吐约125MB每秒。64MB分片意味着单分片传输约0.5到5秒。分片足够小断点恢复的粒度细。状态记录方面1TB文件按64MB分片只有约16000个分片记录用数据库或JSON保存都毫无压力。内存方面用随机访问文件块读取时64MB可以全部读入内存也可以分小块流式读取模型非常灵活。补充一个细节分片大小最好按文件大小动态调整。小于64MB的文件直接整体作为一个分片避免小文件也走分片开销。大文件的最后一个分片往往小于设定值这完全正常按实际剩余长度处理即可。3.2 断点续传的核心分片完成状态表让人人都能看懂的说法断点续传的本质就是一张「待办清单」。清单上写了文件A的第1至8192个分片完成状态。传完一个勾一个下次开工时只看没打勾的。这张状态表的每条记录应该包含文件相对路径作为文件唯一标识分片序号分片起始偏移量分片长度分片哈希值如SHA-256或CRC32完成状态未开始/传输中/已完成/校验失败需要关注的一点是状态表的更新必须遵循「先落盘文件再更新状态」的顺序。如果反过来先标记完成但文件还没写完整一旦进程崩溃就会出现状态和实际文件不一致的情况。对于状态存储方式我推荐用SQLite而不是JSON。TB级规模的分片数量是几十万条记录级别JSON文件在这种体量下启动加载和写入都会感觉明显卡顿。SQLite有事务能力单文件存储不需要额外安装服务完美契合这个场景。3.3 校验策略传输完整性怎么保证TCP本身有校验和为什么还要应用层校验原因是TCP的校验只保证数据包在网络传输过程中没有被破坏但它无法保证数据到达后接收端把字节正确写入了磁盘。磁盘坏道、内存比特翻转、写入逻辑bug都可能造成分片内容错误。所以应用层校验的核心是为了保证「接收端落盘后的数据」与「发送端读取的数据」完全一致。校验方式有两个层次的选择。快速层用CRC32或Adler-32计算很快适合在传输过程中实时校验完整层用SHA-256保证最终文件的整体一致性。在实操中我的建议是分片传输完成后接收端先对落盘分片快速计算CRC32与发送端传入的CRC32比对全部传完后对拼接完成的整个文件再算一次SHA-256与发送端的全文件哈希比对。这样做分片校验保证进度可控全文件校验保证最终结果可靠。3.4 并发传输一把双刃剑有了分片并发就自然成为优化选项。多个分片同时传输可以大幅利用局域网带宽。但并发数不是越高越好。千兆局域网环境下2到4个并发通常就能打满带宽8个并发以上反而可能因为线程切换、Socket缓冲区竞争等导致吞吐量下降。万兆环境下可以适当提到8到16个并发。并发还有一个容易被忽视的问题——小文件太多导致的启动开销。如果一个目录下有几十万个10KB的小文件并发传输时每个文件都要建立Socket连接这个连接开销会吞噬掉所有带宽优势。对这个问题我用的策略是按文件大小分流。超过64MB的大文件进入分片并发池小于64MB的小文件打包成一个小文件批次比如100个一批作为一个逻辑传输单元整体发送。这样既保留并发能力又避免小文件产生的海量连接。3.5 网络中断的自动恢复与重建策略断点续传不只是「启动时检查上次状态」还要考虑传输过程中网络突然中断后怎么办。我的实现思路是每个分片的传输任务都带有一个超时时间和重试次数上限。一旦Socket读写超时认定该分片传输失败重新加入任务队列。如果连续失败达到上限比如5次则自动降低该通道的并发数避免网络已经不稳定了还拼命往上撞。另外一个容易踩的坑是长连接的心跳。TB级传输往往以小时为单位中间有长时间的静默期。如果TCP连接本身没有应用层心跳某些中间网络设备尤其是经过路由器NAT会在空闲一段时间后回收连接。所以传输过程中要每隔一段时间发送ping消息并且接收端收到ping后回复pong保持连接活跃。4. TCP还是UDP传输层协议怎么选这个Node二叉树深度遍历让我想起做了一个特别有趣的脚本。现在我补充完整个业务忙完还需要理下视频课程里的文本格式自然语言这样很容易吃高附带信息长久不看的毛病。切入点目标其实不是现有教程讲的那么单一。我看到很多读者把协议选型想得很复杂实际上对于局域网传输这个场景取舍非常明确。TCP有流控和重传机制UDP没有。但UDP会丢包。所以要在UDP上做可靠传输就得自己实现丢包重传和拥塞控制那等于自己造一个TCP代价很高。在局域网这个场景下网络相对可控丢包率极低但TCP的重传机制仍然会导致偶发的速度波动。UDP可靠传输在局域网内能否超过TCP取决于你的重传机制和缓冲区调优是否到位但这个工程量不是一般团队愿意承担的。我给一个务实的建议追求工程稳定性和开发效率直接选TCP。Java的Socket、NIO、Netty都提供了成熟的TCP实现开发成本低Bug率低。追求极限吞吐可以尝试基于UDP的可靠传输但必须准备好应对弱网下的复杂状况。局域网内UDP的极限吞吐确实可能比TCP有优势但前提是你的协议栈足够成熟。我的项目采用TCP 自适应窗口的方式实际测试下来千兆局域网能达到110MB/s左右的吞吐已经非常接近线速。对上TB级的文件传输来说瓶颈其实在于磁盘读写速度而不在于网络协议层。5. 服务端与客户端的分工设计在这个系统里我建议采用服务端接收方与客户端发送方的对称设计。服务端负责创建传输会话、管理分片状态、写文件客户端负责扫描文件夹、按分片读取、发送数据。5.1 服务端分工逻辑服务端是整个调度中枢握有三个核心能力会话管理服务端启动时监听一个端口等待客户端连接。建立连接后客户端发送初始的文件夹元数据目录树、文件数量、总大小服务端据此创建传输会话并分配会话ID。会话ID是整个续传过程的唯一身份标识。状态仲裁服务端持有每个分片的接收状态当客户端询问「从哪个分片继续」时服务端根据持久化状态文件返回断点位置。客户端的任务队列将通过这种方式实现真正的断点恢复。数据落盘与确认每个分片到达后服务端写入文件并校验哈希随后向客户端返回ACK。这个ACK包含了下一个允许传输的分片编号配合滑动窗口使用。5.2 客户端分工逻辑客户端的核心任务是读取与推送为上层调度模块提供每个分片的起始偏移和长度。从本地大文件用随机访问方式读取分片数据推送到服务端。根据服务端返回的ACK维护滑动窗口调节发送速率。客户端不能持有状态写入逻辑状态的变更只能由服务端驱动。这样设计的理由是服务端是唯一知道「磁盘上真正已经有什么数据」的一方。客户端就只能按确认数据来推否则可能出现已发送分片、服务端写坏需要重传、此时客户端和状态表不一致的情况。5.3 首次连接时的握手流程我之前第一次实现时在握手阶段踩过一个不太容易发觉的坑这里专门说说标准流程客户端连接服务端监听端口。客户端发送一个协议版本号与能力声明支持的分片大小、算法类型等。服务端返回会话ID如果服务端已有相同目录的旧会话则返回旧会话的进度摘要。客户端根据摘要确定哪些文件/分片需要重新发送哪些可以跳过。全部跳过时已完成传输直接返回完成状态不触发任何文件传输。这个握手流程设计好了断点续传就算完成了80%。剩下的传输过程其实只是按部就班地推送分片。6. 实操环节核心代码骨架与关键实现下面给出可直接参考复现的核心代码骨架。这里我以Java原生Socket 线程池为例避免不同框架的差异干扰理解核心模式是通用的。6.1 文件扫描与元数据采集需要先构建文件夹全量元数据这个结构是后续所有断点逻辑的基础。这里我给出一个扫描结果封装会在后续传给服务端。public class FileMetadata { private String relativePath; // 相对根目录的路径如 /docs/guide.pdf private long fileSize; // 文件大小字节 private long lastModified; // 最后修改时间 private String checksum; // 该文件的SHA-256用于最终校验 private ListChunkInfo chunks; // 分片信息仅在文件大小超过阈值时非空 public static FileMetadata scan(Path root, Path file, long chunkSize) throws IOException { FileMetadata meta new FileMetadata(); meta.relativePath root.relativize(file).toString(); meta.fileSize Files.size(file); meta.lastModified Files.getLastModifiedTime(file).toMillis(); long count (meta.fileSize chunkSize - 1) / chunkSize; meta.chunks new ArrayList((int) Math.min(count, Integer.MAX_VALUE)); for (int i 0; i count; i) { long start i * chunkSize; long len Math.min(chunkSize, meta.fileSize - start); meta.chunks.add(new ChunkInfo(i, start, len)); } return meta; } }这里有个值得注意的细节对于超大文件超过2GB绝对不能用 int 表示文件偏移量。Java的 RandomAccessFile 的 seek 和相关写方法都接受long类型这是好事但很多人在计算分片数量时用 int 接住一旦文件超过2GB就会溢出。上面的代码里用 long 变量承接偏移量count用(size chunkSize - 1) / chunkSize算出再视情况转 int就安全多了。6.2 分片状态数据库表结构设计推荐使用SQLite来保存每个分片的状态。下面是核心表结构。CREATE TABLE file_meta ( id INTEGER PRIMARY KEY AUTOINCREMENT, file_path TEXT NOT NULL UNIQUE, file_size INTEGER NOT NULL, chunk_size INTEGER NOT NULL, sha256 TEXT, status INTEGER NOT NULL DEFAULT 0 -- 0未开始, 1传输中, 2已完成, 3校验失败 ); CREATE TABLE chunk_status ( file_id INTEGER NOT NULL, chunk_index INTEGER NOT NULL, chunk_offset INTEGER NOT NULL, chunk_length INTEGER NOT NULL, chunk_crc32 INTEGER, status INTEGER NOT NULL DEFAULT 0, -- 0未传输, 1传输中, 2完成, 3失败 PRIMARY KEY (file_id, chunk_index), FOREIGN KEY (file_id) REFERENCES file_meta(id) );每条分片记录都有独立的status字段。断点续传时一条简单的SQL查询就能拿到所有未完成分片SELECT f.file_path, c.chunk_index, c.chunk_offset, c.chunk_length FROM chunk_status c JOIN file_meta f ON f.id c.file_id WHERE c.status ! 2 ORDER BY f.file_path, c.chunk_index;这个查询的结果就是断点续传时客户端的任务队列。6.3 分片数据传输协议设计协议格式追求简单和易于解析。每个分片的数据包由头部和主体组成头部定长包含魔数、包类型、文件ID、分片索引、分片长度、CRC32。主体就是分片字节流。public class Packet { public static final int MAGIC 0x5A5A; // 魔数防止错位 public static final int TYPE_CHUNK 1; // 分片数据包 public static final int TYPE_ACK 2; // 确认包 public static final int TYPE_PING 3; // 心跳包 private int magic; private int type; private long fileId; private int chunkIndex; private long chunkOffset; private int chunkLength; private long xxHash; // 分片哈希校验用 // getter/setter 省略 }序列化可以手工用DataOutputStream按定长字节写出。这里不采用Java原生序列化是因为跨语言互通性差而且自带metadata开销大经过代码结构的固定字段序列化省了空间和解析成本反而是最可靠的格式。传输层如果使用Netty只需要确定上面的Packet编解码器和对应的ChannelHandler业务逻辑完全不用变。实际做高并发局域网传输时Netty的异步非阻塞模型能更好地支撑分片并发。6.4 服务端分片接收与落盘核心逻辑服务端核心是个线程池负责并发接收分片、落盘、校验、更新状态。以下代码是用简单ServerSocket 固定线程池来实现的分片接收处理逻辑。public class ChunkReceiver { private final SQLiteStore store; private final String baseDir; private final ExecutorService pool Executors.newFixedThreadPool(Runtime.getRuntime().availableProcessors()); public void handleChunk(long fileId, int chunkIndex, long offset, byte[] data, long xxHash) { pool.submit(() - { // 1. 写临时分片文件 Path tmp Paths.get(baseDir, tmp_ fileId _ chunkIndex); try (FileChannel ch FileChannel.open(tmp, WRITE, CREATE, TRUNCATE_EXISTING)) { ByteBuffer buf ByteBuffer.wrap(data); while (buf.hasRemaining()) { ch.write(buf); } } // 2. 校验哈希 long crc Crc32Util.calc(data); if (crc ! xxHash) { store.markChunkFailed(fileId, chunkIndex); sendNack(fileId, chunkIndex); return; } // 3. 合并到正式文件RandomAccessFile写入指定offset try (RandomAccessFile raf new RandomAccessFile(targetFile, rw)) { raf.seek(offset); raf.write(data); } // 4. 更新状态 store.markChunkCompleted(fileId, chunkIndex); sendAck(fileId, chunkIndex); }); } }这个流程有几个关键点每个点都是实际操作中验证过的先写临时文件再合并到正式文件是为了避免覆盖正式文件时失败导致整个文件损坏。分片数据先写临时文件校验校验通过后再写入正式位置。RandomAccessFile.seek(offset)是支持随机写入的关键API。它允许并发分片写入同一文件的不同偏移位置互不干扰。更新状态必须在正式文件写入成功后执行。顺序错了一旦中途崩溃就会把未完成的分片标记为完成导致文件表面上完整实际上缺数据。6.5 客户端分片读取与发送核心逻辑客户端负责按序或并发读取分片发送。下面是一个简化的发送线程逻辑。public class ChunkSender { private final Socket socket; private final DataOutputStream out; private final String filePath; public void sendChunk(long offset, int length, int chunkIndex) throws IOException { // 用FileChannel 显式ByteBuffer读取指定区域 try (FileChannel fc FileChannel.open(Paths.get(filePath), READ)) { ByteBuffer buf ByteBuffer.allocate(length); fc.position(offset); while (buf.hasRemaining()) { fc.read(buf); } buf.flip(); // 组织包头发送 out.writeInt(Packet.MAGIC); out.writeInt(Packet.TYPE_CHUNK); out.writeLong(fileId); out.writeInt(chunkIndex); out.writeLong(offset); out.writeInt(length); out.writeLong(computeCrc32(buf.duplicate())); out.flush(); // 发送数据体 while (buf.hasRemaining()) { out.write(buf.get()); } out.flush(); } } }这里要说一个我在实际开发中反复踩过的坑用BufferedInputStream或默认socket流时千万注意read()返回 -1 的情况。由于局域网内偶尔的TCP粘包/拆包一次read可能只返回部分数据必须循环读取直到填满缓冲区。上面代码中的while(buf.hasRemaining())就是为此兜底。很多断点续传事故的根源恰恰是这里没写循环导致数据读不全。6.6 断点恢复与滑动窗口组合使用为了让断点续传和并发流控配合客户端维护一个「滑窗内待确认分片」的集合。每发送一个分片就把它放入窗口。收到对应ACK后再从窗口移除。当窗口内未确认分片数达到上限就暂停发送等待ACK。public class SlidingWindow { private final int maxInFlight; private final MapInteger, ChunkContext inflight new ConcurrentHashMap(); public boolean trySend(int chunkIndex, BiConsumerInteger, ChunkContext sendAction) { if (inflight.size() maxInFlight) return false; inflight.put(chunkIndex, new ChunkContext(chunkIndex)); sendAction.accept(chunkIndex, inflight.get(chunkIndex)); return true; } public void onAck(int chunkIndex) { inflight.remove(chunkIndex); } public void onTimeout(int chunkIndex) { // 加入重试队列 // 此时可能从对端拿到最后一次确认信息自然恢复到一致状态 } }滑动窗口的核心价值在于在网络好时保持管线化传输不需要每发一个分片都在等ACK网络差时通过减小窗口实现背压。同时由于有窗口限制断点续传恢复时不会出现瞬间把所有未完成分片全部发出的情况服务端不会被冲垮。7. 崩溃与中断场景下的行为验证断点续传方案是否真正可靠不用看正常流程要看异常场景下如何恢复。7.1 客户端崩溃恢复场景正在传第1234个分片客户端进程被杀。此时服务端已收到并确认了第1233个分片及之前的所有分片第1234个分片可能处于半途状态数据只写了一部分。恢复流程客户端重新启动建立连接发送文件夹元数据。服务端查询chunk_status表返回所有状态不等于2的分片列表。客户端从该列表构建任务队列注意此时第1234分片也会在列表中。服务端收到第1234分片的完整数据后由于分片写入是按指定offset覆盖写所以即使之前有半截数据残留也会被完整覆盖。7.2 服务端崩溃恢复服务端进程被杀后需要保证两件事的持久化chunk_status表的最终状态、已写入的分片数据。SQLite是在每个分片落盘后一次性事务更新状态所以崩溃时刻的CKPT是完整的写入成功的分片已标记完成写入失败的分片状态是未完成。重启后服务端加载SQLite继续接受客户端连接状态自然衔接。一个关键保证客户端的重传是通过文件偏移分片索引完整覆盖写实现的天然幂等。重复传一个分片结果和传一次完全一样这就把整个系统的状态一致性变得简单了。7.3 磁盘空间不足与校验失败这类错误不是崩溃但会导致传输中止。处理方式要区分看待。磁盘空间不足服务端在接收分片前检查剩余空间如果不足直接拒绝新任务并通知客户端暂停。客户端收到消息后进入等待状态定期查询空间是否恢复。校验失败分片落盘后哈希不一致服务端丢弃该分片并发送NACK客户端收到NACK后将分片重新加入待发送队列。连续NACK次数超过阈值比如5次则可能说明网络或磁盘有问题由人工介入确认。8. 性能优化与实测数据方案设计完毕接下来谈性能优化和实测效果。这部分结论来自我个人的多次实测不同环境数值会有浮动但规律是稳定复现的。8.1 千兆局域网吞吐测试环境普通PC为发送端高性能服务器为接收端通过千兆交换机直连。文件单个200GB的混合数据。分片大小64MB并发线程4。实测结果初始无优化版本吞吐约35MB/s瓶颈在于每个分片都要建立新socket连接。引入连接复用长连接多路复用分片吞吐提升到80MB/s。增加滑窗与并发线程数达到110MB/s基本接近千兆理论极限。同步启用临时文件落盘合并写稳定在105MB/s几乎不受影响。这里的教训是网络层面屡次调试造成的损耗远小于软件结构造成的损耗。最普遍拖慢传输的因素不是TCP参数而是无谓的socket反复建连、无谓的byte[]复制、频繁的锁竞争。8.2 万兆局域网吞吐测试万兆环境下瓶颈立刻转移到了磁盘。用单块普通机械硬盘时读写速度约为180MB/s轻松被万兆带宽理论1250MB/s压制。此时吞吐上限是由磁盘能力决定的。解决手段是将多个分片分散写入多块磁盘比如把接收目录做两次 RAID0 条带或者用NVMe SSD。实测用两块SSD组成的临时接收目录吞吐能到850MB/s这已经超过了单个万兆网卡的实际有效吞吐。8.3 零拷贝与内存映射优化对于高频读写大文件的场景常规的做法是磁盘 - 内核Buffer - 用户Buffer - Socket Buffer - 网卡。每次拷贝都浪费CPU周期。如果追求极致可以引入FileChannel.transferTo()在内核态完成数据转移。一个我在实测里发现的点是FileChannel.transferTo()在跨Socket通道传输时JVM在某些操作系统版本上会退化为普通拷贝导致优化效果归零。所以使用前一定要压测验证不能想当然。更通用的做法是用MappedByteBuffer映射文件区域直接读取映射内存减少一次用户态拷贝。不过要注意使用大文件内存映射4GB以上时需要在映射结束后调用Cleaner释放映射否则文件无法被正常替换或删除。8.4 针对海量小文件的优化假设一个目录里有30万个5KB的小文件。如果用传统每个文件一个socket连接的方式光建连开销就让人崩溃。我这里采用“打包分片”策略把同目录下的小文件按照64MB的临界值聚合为一个大逻辑块。每个逻辑块内部文件按顺序写入并记录每个文件的偏移和长度。传输时按逻辑块传输接收后按偏移还原出各个小文件。实测效果30万个小文件场景下未打包时平均吞吐40MB/s被连接开销拖累打包后能达到90MB/s以上。对断点续传来说打包块是一个逻辑分片恢复粒度更粗但扫描快得多需要因地制宜权衡。9. 完整流程演示从启动到断点续传的完整交互序列把整个流程串起来走一遍你会更清楚每个模块之间的协作关系。9.1 首次传输流程用户启动客户端程序指定本地文件夹路径与服务端IP端口。客户端递归遍历文件夹创建所有文件的元数据封装。客户端向服务端发送“CreateSession”握手请求包含文件列表信息。服务端创建会话写入基础元数据到SQLite返回会话ID。客户端建立分片任务队列按并发线程数启动滑动窗口发送。服务端校验、落盘、更新状态、返回ACK。全部完成时客户端发送结束标志服务端执行整体文件校验返回汇总报告。9.2 断点续传流程客户端再次启动无论上次是正常结束还是崩溃。发元数据服务端根据会话ID检查已有分片状态。服务端返回「续传点摘要」内容为所有未完成分片的列表。客户端构建新的任务队列从没完成的分片继续传输。已完成分片直接跳过不移除、不重传。值得专门强调的是断点续传必须以服务端的持久化状态为准而不是以客户端的本地记录为准。原因是客户端看到的“已发送”不代表服务端“已接收”服务端的状态才是唯一事实来源。10. 常见问题速查与排除思路到了最后一节整理一下实操中频率最高且最有代表性的问题以及我的排查思路。这些问题在任何做文件传输的项目中都容易遇到提前了解一下能省下不少调试时间。10.1 传输中途速度突然从110MB/s降到20MB/s可能原因排序TCP窗口收缩、接收端磁盘写入拥塞、对方swap过度、接收端的杀毒/索引服务周期性扫描接收目录。排查方法观察接收端的磁盘活动状态。如果磁盘利用率接近100%且大量等待IO那必然不是网络问题是磁盘在扛不住。处理方式是把接收目录放到更快的磁盘或调小分片并发数减少同一时刻的磁盘写入请求量。10.2 分片传完了但文件打不开或内容损坏这种情况通常出现在校验不严格的项目里。我的经验分片校验必须用哈希算法不能只靠TIME_WAIT结束来判断。另外要检查接收端合并文件时是否按偏移写入对了。曾经有一次我把数组下标搞反了分片顺序全乱了文件看似大小正确内容却一团糟。10.3 断点续传后剩余文件全部重传了大概率是状态数据库没有持久化或者传输会话ID每次启动都重新生成。因为UUID变了服务端就把它当成一个全新会话了。解决方案是让客户端询问服务端本目录是否已有历史会话服务端根据文件夹路径加文件大小计算一个唯一线索值有新会话就复用旧会话ID。或者干脆在启动时让用户确认是续传还是覆盖新建。10.4 客户端一直发送服务端内存不断增长导致OOM典型的流处理问题。服务端接收端绝对不能用一个List把所有分片数据堆内存里再写盘而是上面代码所示的方式每收到一个分片立即写临时文件释放内存。控制好并发数内存使用是平稳的。10.5 文件路径过长导致写磁盘失败Windows系统对单路径长度有260字符限制Linux默认1024。TB级文件夹经常深嵌套会触到这个问题。方案是在服务端把文件路径转换成哈希目录映射。比如用相对路径的SHA-256去头去尾取前24位分三层建目录这样完全避开了路径长度限制保持了断点续传需要的对应关系。11. 收尾前的最后提醒这个系统我做下来最大体会是TB级局域网传输并不需要特别玄妙的算法核心是有条理地把大问题切成小问题一个个解决。现代硬盘和网络的硬件能力已经很可观理论上限就在那里。真正拉开差距的是软件架构把状态和传输分离通过持久化记录让续传成为常态用分片并发吃掉网络带宽用校验兜住极端错误。顺便提一个很实用的细节。实际部署这个系统时接收端尽量把中间临时目录和最终目标目录放在不同磁盘上因为临时文件的频繁写删会拖累文件合并阶段的读速度。如果条件有限至少给临时目录设独立的文件系统这样即便中途断电重建环境成本也可控。TB级传输本身就是个体力活代码能做的只是保证这个活干得规范、能续上。把地基打牢后面无论文件多大剩下的都只是时间问题。