简介:这是一套基于UDP协议实现的大文件传输软件源码,包含服务端与客户端两部分,面向学习网络编程、Socket通信与高性能文件传输的开发者,尤其适合想深入理解UDP在可靠传输场景下如何落地的人群。压缩包共102个文件,以32个h头文件与32个cpp源文件为核心,辅以24个png界面截图、2个ui与2个qrc等Qt界面资源,以及makefile、pro工程文件,整体约482KB,结构清晰便于阅读与二次开发。客户端可循环向服务端发送文件,速率可达10MB/s以上,支持按分钟创建以时间戳命名的文件并自定义大小(默认6GB),传输后自动删除;服务端支持多客户端并发上传、本地存储、定时清理,并能动态计算速率写入日志。已有2715人学习下载,读者可从中掌握UDP大文件分片、并发接收、速率统计与日志记录等完整实现思路。
1. 为什么 UDP 传大文件不是「找罪受」:从一次 40GB 日志回传说起
机房搬迁那晚,运维把 40GB 的归档日志从旧存储节点往新集群搬,用现成的 TCP 工具跑了三个小时,进度条卡在 62% 反复重传,最后整条链路超时断开,只能从头再来。这件事让我彻底理解了「基于 UDP 协议设计的大文件传输软件」到底在解决什么:它不是要取代 TCP,而是在高丢包、长肥管道、跨机房弱网这些 TCP 会「越堵越慢」的场景里,用应用层自己掌控重传、拥塞和校验,把大文件传输的吞吐和可控性拿回来。
这套「服务器 + 客户端」的软件,核心就是两件事:服务端负责分片、调度、校验和状态管理,客户端负责拉取、重组、断点续传。它适合谁?适合需要在内网、专线或跨地域链路上搬几十 GB 到几 TB 文件的人——做数据迁移的、做备份归档的、做日志归集的。如果你只是偶尔传个几百 MB,TCP 的 scp、rsync 完全够用,别折腾。但只要文件上了几十 GB、链路 RTT 超过 50ms、丢包率超过 1%,UDP 方案的价值就出来了。下面我按「协议怎么设计 → 服务端客户端怎么落地 → 参数怎么调 → 坑在哪」的顺序,把这套东西讲透。
2. 协议层怎么设计:分片、确认、重传与拥塞控制
UDP 本身只提供「发出去就不管」的能力,所有可靠性都得在应用层自己造。这一步设计错了,后面代码写得再漂亮也白搭。我一般把协议拆成四个模块:分片与序号、ACK 与选择性重传、滑动窗口与拥塞控制、完整性校验。
2.1 分片大小与序号设计:为什么我选 1200 字节而不是 1500
分片大小直接决定传输效率和丢包代价。以太网 MTU 是 1500 字节,减去 IP 头 20 字节、UDP 头 8 字节,理论上载荷能到 1472 字节。但实际链路里常有 PPPoE、VXLAN 封装,再叠加几层隧道,1472 就会触发 IP 分片,一旦分片丢失,整个 UDP 包全废,重传代价翻倍。
我的经验值是1200 字节:留出足够余量,避免路径上任何一层封装导致 IP 分片。代价是头部开销略高,但换来的是重传粒度更细、丢包影响更小。序号用 64 位无符号整数,从 0 开始递增,每个分片一个序号,这样即使传 TB 级文件也不会回绕。
# 分片结构定义:固定头部 + 变长载荷 import struct MAGIC = 0x55445031 # "UDP1" 魔数,用于快速丢弃非法包 HEADER_FORMAT = "!IQQIH" # 魔数(4) 文件ID(8) 分片序号(8) 总片数(4) 载荷长度(2) HEADER_SIZE = struct.calcsize(HEADER_FORMAT) # 26 字节 CHUNK_SIZE = 1200 # 单包最大载荷,含头部 def pack_chunk(file_id: int, seq: int, total: int, payload: bytes) -> bytes: # 头部 + 载荷,载荷长度写入头部便于接收端校验 header = struct.pack(HEADER_FORMAT, MAGIC, file_id, seq, total, len(payload)) return header + payload def unpack_chunk(data: bytes): if len(data) < HEADER_SIZE: return None magic, file_id, seq, total, plen = struct.unpack(HEADER_FORMAT, data[:HEADER_SIZE]) if magic != MAGIC: return None payload = data[HEADER_SIZE:HEADER_SIZE + plen] return file_id, seq, total, payload这段代码里,!IQQIH的!表示网络字节序,避免大小端问题;file_id用来区分同一客户端并发传多个文件;total让接收端提前知道要收多少片,方便预分配和进度计算。参数上,CHUNK_SIZE我固定 1200,如果你确定链路是纯内网无封装,可以调到 1400 换一点吞吐,但跨公网千万别。
2.2 ACK 与选择性重传:别用「全停等」,也别用「全量重传」
最朴素的做法是每发一片等一个 ACK,这叫停等协议,RTT 50ms 时吞吐只有 1200/0.05 ≈ 24KB/s,传 40GB 要 20 天,显然不行。另一种极端是接收端发现丢包就要求重传整个文件,那更灾难。
正确做法是选择性重传(Selective Repeat):接收端维护一个位图(bitmap),记录哪些序号已收到,定期把缺失的序号列表回给发送端,发送端只重传这些片。ACK 本身也要能丢,所以接收端要周期性发送,而不是「收到一片回一个」。
# 接收端:位图记录 + 周期性 NACK 上报缺失分片 class Receiver: def __init__(self, total_chunks: int): self.total = total_chunks self.bitmap = bytearray((total_chunks + 7) // 8) # 每 bit 代表一片 self.received = 0 def on_chunk(self, seq: int, payload: bytes): byte_idx, bit_idx = divmod(seq, 8) mask = 1 << bit_idx if not (self.bitmap[byte_idx] & mask): self.bitmap[byte_idx] |= mask self.received += 1 self.write_payload(seq, payload) def missing_seqs(self, max_report: int = 256): # 只上报前 max_report 个缺失,避免 NACK 包过大 result = [] for seq in range(self.total): byte_idx, bit_idx = divmod(seq, 8) if not (self.bitmap[byte_idx] & (1 << bit_idx)): result.append(seq) if len(result) >= max_report: break return resultmax_report这个参数很关键:如果缺失分片有几万个,一次性塞进一个 UDP 包会超 MTU 被丢弃,反而让重传请求本身丢失。我一般限制在 256 个序号以内,分多包上报。接收端每收到 64 片或每 20ms 触发一次 NACK,两个条件谁先到用谁,兼顾延迟和开销。
2.3 滑动窗口与拥塞控制:把「发多快」交给 RTT 和丢包率
UDP 没有 TCP 的拥塞控制,发太快会把中间链路打爆,丢包率飙升,重传风暴反而更慢。我一般实现一个简化的AIMD(加性增、乘性减)窗口:初始窗口 16 片,每收到一轮完整 ACK 窗口加 1,检测到丢包率超过 5% 就把窗口砍半。
窗口大小和带宽的换算关系是:吞吐 ≈ 窗口片数 × 1200 / RTT。比如 RTT 40ms、想跑满 100Mbps,需要窗口 ≈ 100e6 × 0.04 / (1200 × 8) ≈ 417 片。所以窗口上限我设 1024,够跑千兆内网,又不至于把弱网打崩。
# 发送端:AIMD 窗口调整 class CongestionController: def __init__(self): self.cwnd = 16 # 初始窗口(片) self.ssthresh = 512 # 慢启动阈值 self.max_cwnd = 1024 def on_ack_round(self, loss_rate: float): if loss_rate > 0.05: # 乘性减:丢包严重,窗口砍半,但不低于 4 self.ssthresh = max(self.cwnd // 2, 4) self.cwnd = self.ssthresh elif self.cwnd < self.ssthresh: # 慢启动:指数增长 self.cwnd = min(self.cwnd * 2, self.max_cwnd) else: # 拥塞避免:线性增长 self.cwnd = min(self.cwnd + 1, self.max_cwnd)loss_rate由接收端在 ACK 里带回,发送端按 RTT 周期统计。这里有个坑:如果 RTT 本身波动大,窗口调整会震荡,我一般对 RTT 做指数移动平均(EWMA,α=0.2),用平滑后的值算吞吐,避免被单个尖峰带偏。
2.4 完整性校验:分片校验 + 整文件校验两层
UDP 自带 16 位校验和,但很弱,大文件传输必须自己加。我的做法是两层:每个分片带 CRC32,接收端收到就校验,错了直接丢弃并计入缺失;整个文件传完后,发送端把文件的 SHA-256 通过一个独立控制通道发给接收端,接收端重组完比对,不一致就触发全量重传或按块重传。
import zlib, hashlib def chunk_crc(payload: bytes) -> int: return zlib.crc32(payload) & 0xFFFFFFFF def file_sha256(path: str, buf_size: int = 1 << 20) -> str: h = hashlib.sha256() with open(path, "rb") as f: while True: block = f.read(buf_size) if not block: break h.update(block) return h.hexdigest()CRC32 放在分片头部之后、载荷之前,接收端先验 CRC 再写盘。SHA-256 只在最后算一次,开销可接受。注意别用 MD5,虽然快但碰撞风险在文件校验场景不可接受。
3. 服务端与客户端怎么落地:从文件索引到断点续传
协议定好了,接下来是工程落地。服务端和客户端的分工要清晰:服务端管文件元数据、分片调度、会话状态;客户端管拉取、重组、落盘、续传。这一章我按「服务端索引 → 客户端拉取 → 断点续传 → 并发与限速」四步走。
3.1 服务端:文件索引与分片调度
服务端启动时扫描指定目录,为每个文件建立索引:文件 ID、路径、大小、分片总数、SHA-256。客户端先发一个「请求文件元数据」的控制包,服务端返回这些信息,客户端据此决定从哪片开始拉。
# 服务端:文件索引与元数据响应 import os, json, hashlib class FileIndex: def __init__(self, root: str): self.root = root self.files = {} # file_id -> metadata self._scan() def _scan(self): fid = 1 for dirpath, _, filenames in os.walk(self.root): for name in filenames: path = os.path.join(dirpath, name) size = os.path.getsize(path) total = (size + 1199) // 1200 # 向上取整 self.files[fid] = { "id": fid, "path": path, "size": size, "total": total, "sha256": None # 懒计算,首次请求时再算 } fid += 1 def meta(self, file_id: int) -> bytes: info = self.files.get(file_id) if not info: return json.dumps({"err": "not_found"}).encode() if info["sha256"] is None: info["sha256"] = file_sha256(info["path"]) return json.dumps({ "id": info["id"], "size": info["size"], "total": info["total"], "sha256": info["sha256"] }).encode()total用(size + 1199) // 1200算,保证最后一片不足 1200 也能正确计数。SHA-256 懒计算是为了避免启动时扫描大目录卡住,首次请求再算,算完缓存。服务端还要维护每个客户端的会话:已确认收到的分片位图、最后活跃时间,用于断点续传。
3.2 客户端:拉取、重组与落盘
客户端拿到元数据后,先检查本地是否已有部分文件(断点续传场景),有的话读取已落盘的分片位图,只请求缺失的。落盘用「预分配 + 定位写」:先ftruncate到文件总大小,收到一片就seek到对应偏移写入,避免频繁扩容。
# 客户端:预分配 + 定位写 + 位图持久化 import os, struct class FileWriter: def __init__(self, path: str, total_size: int, total_chunks: int): self.path = path self.total_chunks = total_chunks self.f = open(path, "r+b") if os.path.exists(path) else open(path, "wb") self.f.truncate(total_size) # 预分配,避免写时扩容 self.bitmap = bytearray((total_chunks + 7) // 8) self._load_bitmap() def _load_bitmap(self): bm_path = self.path + ".bm" if os.path.exists(bm_path): with open(bm_path, "rb") as bf: data = bf.read() self.bitmap[:len(data)] = data def write_chunk(self, seq: int, payload: bytes): self.f.seek(seq * 1200) self.f.write(payload) byte_idx, bit_idx = divmod(seq, 8) self.bitmap[byte_idx] |= (1 << bit_idx) def flush_bitmap(self): with open(self.path + ".bm", "wb") as bf: bf.write(self.bitmap)位图单独存一个.bm文件,每收到 64 片或每 500ms 刷一次盘,进程崩溃后重启能接着传。truncate预分配在 ext4/XFS 上是稀疏的,不会真的占满磁盘,但能保证seek写不越界。注意:如果文件系统不支持稀疏文件(比如某些 FAT),预分配会真占空间,这时改成按需扩容。
3.3 断点续传:会话恢复的三个关键状态
断点续传要恢复三样东西:文件元数据(大小、分片数、SHA-256)、已收分片位图、当前拥塞窗口。前两个存在客户端本地,第三个可以重置为初始值,影响不大。服务端也要能识别「这个客户端之前传过这个文件」,把它的会话位图取出来,只补发缺失的。
# 服务端:会话恢复 class SessionManager: def __init__(self): self.sessions = {} # (client_id, file_id) -> bitmap def get_or_create(self, client_id: str, file_id: int, total: int): key = (client_id, file_id) if key not in self.sessions: self.sessions[key] = bytearray((total + 7) // 8) return self.sessions[key] def mark_received(self, client_id: str, file_id: int, seq: int): bm = self.sessions[(client_id, file_id)] byte_idx, bit_idx = divmod(seq, 8) bm[byte_idx] |= (1 << bit_idx)client_id用客户端首次连接时生成的 UUID,存在本地配置文件里,重启不变。服务端会话可以设 TTL(比如 24 小时),过期清理,避免内存泄漏。这里有个细节:客户端上报的位图和服务端记录的位图要取交集,以客户端为准,因为客户端才是最终落盘方。
3.4 并发传输与限速:别让一个客户端吃满带宽
生产环境里往往多个客户端同时拉,服务端要能限速和并发控制。我的做法是每个会话一个令牌桶,按客户端 IP 或 client_id 分配带宽配额,发送线程从桶里取令牌,取不到就等。
# 令牌桶限速 import time class TokenBucket: def __init__(self, rate_bps: int, burst: int = 1200 * 64): self.rate = rate_bps # 字节/秒 self.burst = burst # 桶容量(字节) self.tokens = burst self.last = time.monotonic() def consume(self, nbytes: int) -> bool: now = time.monotonic() self.tokens = min(self.burst, self.tokens + (now - self.last) * self.rate) self.last = now if self.tokens >= nbytes: self.tokens -= nbytes return True return Falserate_bps按客户端配额设,比如总带宽 1Gbps、10 个客户端,每个给 100Mbps。burst设 64 片大小,允许短时突发,避免限速太死导致吞吐上不去。发送线程用非阻塞 socket,consume返回 False 就select等一小会儿再试,别死循环空转 CPU。
4. 参数怎么调:分片、窗口、超时与重传的实战取值
协议和代码都有了,真正决定跑得快不快的,是那几个参数。这一章我把关键参数列成表,再讲怎么根据链路特征调。
4.1 核心参数速查表
| 参数 | 默认值 | 适用场景 | 调整建议 |
|---|---|---|---|
| 分片载荷 | 1200 字节 | 跨公网/有封装 | 纯内网可到 1400,跨公网别超 1200 |
| 初始窗口 | 16 片 | 通用 | 高 RTT 链路可提到 64,快速起步 |
| 最大窗口 | 1024 片 | 千兆内网 | 百兆链路 256 够用,别盲目调大 |
| NACK 间隔 | 20ms | 通用 | 高丢包链路降到 10ms,低丢包可到 50ms |
| NACK 批量 | 256 序号 | 通用 | 缺失多时拆多包,别超 MTU |
| 重传超时 | 3×RTT | 通用 | RTT 波动大时用 4×RTT,避免误重传 |
| 位图刷盘 | 500ms | 通用 | 对可靠性要求极高可降到 100ms |
| 会话 TTL | 24 小时 | 通用 | 内网可延长到 7 天,公网缩短到 1 小时 |
4.2 按链路特征调参:三个典型场景
场景一:同机房内网,RTT < 1ms,丢包 ≈ 0。分片可以拉到 1400,初始窗口直接 128,最大窗口 2048,NACK 间隔 50ms 都行。这种链路瓶颈在磁盘 IO 和 CPU,不在网络,参数往「减少中断和系统调用」方向调,比如用recvmmsg/sendmmsg批量收发。
场景二:跨地域专线,RTT 30~80ms,丢包 0.1%~1%。这是 UDP 方案的主场。分片 1200,初始窗口 64,最大窗口 1024,NACK 间隔 20ms,重传超时 3×RTT。重点是把窗口开够,让 BDP(带宽时延积)被填满。比如 100Mbps × 60ms = 750KB,约 625 片,窗口至少 640 才能跑满。
场景三:公网弱网,RTT > 100ms,丢包 > 3%。分片 1200 甚至 1000,初始窗口 16,最大窗口 256,NACK 间隔 10ms,重传超时 4×RTT。这种链路别追求跑满,追求「稳定传完」。拥塞控制要更激进地降窗,丢包率超 3% 就砍半,避免重传风暴。
4.3 怎么测:用 iperf3 和自建统计定位瓶颈
调参不能拍脑袋,得有数据。我一般先用iperf3 -u测链路 UDP 吞吐上限,再跑自己的传输软件,对比两者差距。如果自己的软件只有 iperf3 的一半,问题多半在窗口太小或 NACK 太频繁;如果两者都低,那是链路本身的问题。
# 测 UDP 链路上限:服务端 iperf3 -s -p 5201 # 客户端:100Mbps,1200 字节包,跑 30 秒 iperf3 -c 10.0.0.1 -p 5201 -u -b 100M -l 1200 -t 30 # 看丢包和抖动,如果丢包 > 5%,先解决链路问题再调软件软件侧要打点统计:发送片数、重传片数、平均 RTT、当前窗口、丢包率。我习惯每 5 秒往日志打一行,传完后汇总。重传率超过 10% 说明窗口太大或链路太差,低于 1% 说明窗口还有上调空间。
5. 避坑与排查:那些让我熬夜的翻车现场
这套东西我前后迭代了七八个版本,踩过的坑能写一本书。挑五个最典型的,按「现象 → 原因 → 解决」讲清楚。
5.1 现象:传小文件正常,传大文件到 2GB 左右必断
原因:序号或偏移用了 32 位整数,2GB 处溢出。seq * 1200在 32 位下回绕,seek到错误位置,位图也错乱。
解决:所有和文件大小、偏移、序号相关的变量一律用 64 位。Python 里 int 天然大整数,但struct.pack要显式用Q(64 位无符号),别用I。C/C++ 里用uint64_t,别用size_t(32 位平台上是 32 位)。
5.2 现象:接收端 CPU 跑满,吞吐却上不去
原因:每收一片就seek + write + 刷位图,系统调用太频繁,磁盘 IOPS 打满。位图每片都刷盘,更是雪上加霜。
解决:批量写。收满 64 片或攒够 256KB 再一次性写,位图每 500ms 刷一次。用os.pwrite或writev减少系统调用。如果磁盘是机械盘,考虑先写内存缓冲,再顺序落盘。
5.3 现象:NACK 发出去没反应,重传一直不来
原因:NACK 包本身丢了,或者服务端把 NACK 当成了普通数据包丢弃。UDP 不保证送达,NACK 也会丢。
解决:NACK 要周期性重发,直到对应分片收到或超时。服务端要能识别 NACK 包类型(在头部加一个 type 字段),别和 DATA 包混在一起解析。我一般设 NACK 重发间隔 100ms,最多重发 5 次。
5.4 现象:多客户端并发时,某个客户端特别慢
原因:服务端单线程处理所有会话,某个客户端的重传请求把线程占住,其他客户端饿死。
解决:每个会话独立线程或协程,发送队列分离。限速用令牌桶按会话隔离,别用全局锁。如果客户端数量大(>100),用 epoll/kqueue 做事件驱动,别一会话一线程。
5.5 现象:传完 SHA-256 对不上,但位图显示全收到了
原因:位图标记「收到」是在写盘之前,如果写盘失败(磁盘满、权限错),位图却已置位,最后校验才发现缺数据。
解决:位图置位必须在write成功返回之后。写盘失败要捕获异常,把对应位清零并重新加入缺失列表。另外,flush和fsync要分清:flush只刷 Python 缓冲,fsync才落盘。对可靠性要求高的场景,每批写完调一次fsync。
6. 进阶技巧:用 FEC 抗丢包和用多路径聚合带宽
基础版跑通后,如果还想在弱网里再榨一点性能,有两个方向值得投入:前向纠错(FEC)和多路径聚合。
FEC 的思路是发数据时额外发一些冗余片,接收端丢少量片时不用重传,直接用冗余片恢复。最简单的实现是 XOR FEC:每 K 个数据片配 1 个冗余片,冗余片是这 K 片的异或。丢 1 片时,用其余 K-1 片和冗余片异或就能还原。代价是带宽开销增加 1/K,K=10 时开销 10%,但能把重传率从 5% 降到 1% 以下,弱网下很划算。
# XOR FEC:K 个数据片生成 1 个冗余片 def xor_fec(data_chunks: list) -> bytes: # data_chunks 长度必须为 K,每片等长(不足补零) k = len(data_chunks) size = max(len(c) for c in data_chunks) parity = bytearray(size) for chunk in data_chunks: padded = chunk + b"\x00" * (size - len(chunk)) for i in range(size): parity[i] ^= padded[i] return bytes(parity) def recover_one(data_chunks: list, parity: bytes, missing_idx: int) -> bytes: # 已知缺失片的下标,用其余片和冗余片还原 size = len(parity) recovered = bytearray(parity) for idx, chunk in enumerate(data_chunks): if idx == missing_idx: continue padded = chunk + b"\x00" * (size - len(chunk)) for i in range(size): recovered[i] ^= padded[i] return bytes(recovered)K的取值要权衡:K 太小冗余开销大,K 太大恢复能力弱。我一般 K=10,丢包率 5% 以内基本不用重传。注意 FEC 只适合「丢包随机、不连续」的场景,如果链路是突发丢包(一丢一大片),FEC 救不回来,还是得靠重传。
多路径聚合是另一个方向:客户端同时走多条链路(比如有线 + 无线,或者多条专线),把分片分散到各路径发送,接收端按序号重组。难点在于各路径 RTT 不同,乱序严重,接收端缓冲要够大。我一般设乱序窗口为最大 RTT 差的 2 倍,超过就触发重传。这个方案实现复杂度高,建议先把单路径调优到极限再考虑。
最后说个我自己的习惯:每次改完参数,先在小文件(1GB)上跑三遍,确认稳定后再上大文件。大文件传输最怕「跑了两小时才翻车」,小文件快速迭代能省大量时间。还有,日志一定要打全,发送片数、重传片数、窗口变化、RTT 采样,一个都别省,出问题时这些就是唯一的后悔药。希望帮到你。
本文还有配套的精品资源,点击获取