☰
.NET Core分布式任务队列实战:文件夹分片上传与断点续传设计
2026/10/19 13:48:31 网站建设 项目流程

航空航天的数据链路里,最让人头疼的往往不是算力,而是文件搬运。遥测数据包、载荷日志、光学影像、环控测试录像,随便一个做实机验证的文件夹动辄几十GB,里面还套着好几层子目录,几百上千个文件散落其中。带宽再大也扛不住在节点之间来回倒腾,况且链路还可能抖动、断连。与其说这是上传,不如说这是一场需要精确调度的物流运输。

最近我特意把一个航空航天领域的.NET Core开源演示项目翻出来,重点看了它如何用分布式任务队列把“文件夹分片上传”这件事做成一个可断点续传、可水平扩展、可重试恢复的系统。这个Demo的主线逻辑完整,坑位也埋得很有代表性,非常适合拿来做剖析对象。这篇就从工程设计的角度,把里面的任务队列设计拆开聊,包括拆任务、存状态、调度消费、容错恢复,以及我验证过程中实际遇到的那些问题和教训。

1. 先把“文件夹分片上传”拆成可调度的任务单元

1.1 两种拆分思路,别只盯着文件切块

提到分片上传,大多数人第一反应是把大文件切成若干MB的小块,每个块作为一个请求发出去。这套思路在单文件场景确实没问题,但文件夹上传要比它复杂一个维度。文件夹里有多个文件,每个文件还要切分,两件事叠加,队列里的任务类型就不止一种。

我看到的这个Demo,源码里明确区分了两层任务粒度:

  • 第一层是目录扫描任务,负责读取文件夹结构,生成“哪些文件需要上传、每个文件多大、切多少片”的文件清单;
  • 第二层是文件分片任务,也就是真正干活的单元,每个分片对应一次独立的上传动作。

这个设计的朴素之处在于:目录扫描只做一次,分片任务却会重复执行多次。如果把目录扫描也塞进任务队列反复跑,文件清单每次都可能不一致,还会把队列塞满一堆无效负荷。所以源码的做法是,扫描阶段一次性把文件夹快照固化成记录,后边的分布式队列只关心“分片”这个最小执行单位。

如果你在自己的项目里要做文件夹分片,我建议先想清楚一个问题:你的队列任务到底是“把某个文件切出来的一部分传到远端”,还是“把某个文件传到远端,内部去切”?前者是任务驱动,后者是文件驱动。任务驱动的优势是可以对每个分片单独做重试、进度跟踪和并发控制,文件驱动则逻辑简单但断点续传很被动。这个Demo走的是前者,也是我认为更值得参考的。

1.2 分片任务的数据结构不只是几个字段

源码里描述分片任务核心模型,大概长这样。我把无关业务的代码去掉,只保留关键骨架:

public class ChunkUploadTask { public string UploadId { get; set; } public string LocalRelativePath { get; set; } public string BucketKey { get; set; } public int FileIndex { get; set; } public int ChunkIndex { get; set; } public long ChunkOffset { get; set; } public int ChunkBytes { get; set; } public string Md5 { get; set; } public int RetryCount { get; set; } public DateTime? NextRunAt { get; set; } }

这些字段看着普通,每个都有讲究。UploadId是整个上传批次的标识,比如文件夹里的所有文件共用一个UploadId,方便后续做聚合;LocalRelativePath是文件相对文件夹的路径,防止重名文件错乱;BucketKey是对象存储里的目标路径;ChunkOffset和ChunkBytes用来定位原始文件里哪一段数据要上传;Md5用于校验分片内容的完整性。

最容易被忽略的是NextRunAt。分布式队列里的任务不一定马上都能执行,某些任务可能因为依赖关系或者重试策略需要延后。有了这个字段,消费端就可以实现“定时任务”的效果,而不是把重试任务无限期地塞在队列头。

1.3 任务粒度决定了队列的吞吐上限

我在研究这个Demo时,特别留意了它是按多大来切片的。分片大小直接影响了任务总数和系统整体表现:分片太小,任务数量爆炸,数据库查询和调度开销反而成了瓶颈;分片太大,单任务执行时间长,某个分片失败时浪费的带宽也多,断点续传的粒度也不理想。

常见实践是基于单文件大小动态决策,比如64MB以上的文件按16MB到32MB切片,64MB以下的小文件整体作为一个分片任务送入队列。文件夹上传里小文件数量往往很多,如果硬把小文件也切碎,那纯粹是给自己找麻烦。演示源码里同样体现了这种动态切分逻辑:目录条目区分了普通文件和待切分文件,小文件直接整文件入队,大文件切开后每个分片单独入队。

任务粒度的选择影响的不只是吞吐量,还影响失败恢复时的“损失半径”。一个16MB的分片失败,充其量重传16MB;一个512MB的分片失败,就要重新传512MB,链路一旦抖动,恢复成本成倍上涨。这也是为什么航空场景的数据上传宁愿多造一些任务,也不愿意冒大分片重传的风险。

2. 队列中枢:分布式任务状态机与存储选型

2.1 内存队列和数据库队列,官方Demo为什么选后者

分布式任务队列的“队列”二字容易让人以为枢纽是一个内存消息中间件。但看这个Demo的实现,你会发现它的队列模型更接近“数据库表即队列”。

维度内存队列(如Channel/BlockingCollection)数据库队列(如MySQL/PostgreSQL)
吞吐能力高,微秒级入队出队中,受限于磁盘和连接池
持久性进程重启即丢失记录持久化,可恢复
任务状态跟踪弱,需要额外维护天然在表字段中体现
分布式扩展单机内存,无法多worker共享多实例竞争同一批任务
定时/重试实现困难用时间条件查询即可
适用场景高吞吐、容忍丢失的业务上传任务这种不允许丢的场景

文件上传有一个特点:任务一旦丢失,数据就可能永久残缺,靠人为重传成本极大。所以就算内存队列吞吐高,Demo依然把任务状态持久化到数据库里。这是航空航天数据链路场景特有的一种取舍——优先保证可恢复性,其次才追求极致吞吐。

当然,数据库队列需要有一点调控技巧,否则高频轮询会压垮数据库。核心做法是使用条件更新而不是无条件查询:消费者先扫描一批处于待执行状态且到达执行时间的任务,用原子更新把状态从Pending改为Running,更新成功才是抢到了任务,没抢到的就等下一轮。这样多个消费者实例可以安全竞争,不必引入复杂的独立锁组件。

2.2 任务状态机的核心流转

打开这个Demo源码时,我第一件事就是找任务状态定义。状态就是分布式队列的灵魂,状态设计合理,重试和幂等才有地方挂靠。

从源码和注释里能还原出这样一条状态流:

  1. Pending:任务创建后等待调度,此时分片还没被任何Worker拿走;
  2. Running:某个Worker抢到了任务,正在执行分片上传;
  3. Success:分片上传成功,数据库状态位更新,文件句柄释放;
  4. Failed:分片上传失败,根据重试次数决定直接终态或回到Pending并设置NextRunAt;
  5. RetryWait:失败后进入等待窗口,时间到了自动回到Pending。

这里头不容易看明白的是Failed和RetryWait很容易混为一谈。我理解的源码意图是:Failed是瞬时状态,而RetryWait是持久状态。实际项目里完全可以把Failed只当作一个记录字段,重试时把状态改回Pending并推进重试时间。状态机越简单越不容易出错,不要为了设计而设计出很多中间态。

我建议在你的数据库表里至少保留这几个字段:Status、RetryCount、NextRunAt、LastError、FinishedAt。LastError这个字段极容易被忽略,但排查问题时没有它,所有失败任务都只能再跑一次去复现,浪费大量时间。

2.3 让状态机不打架的两个隐藏机制

分布式队列最忌讳两个Worker同时处理同一个任务。源码里有两层机制避免了这个问题。

第一层是原子抢占更新。消费者拿到一个任务开始执行前,必须执行:

UPDATE chunk_upload_task SET status = 'Running', worker_id = @workerId WHERE id = @taskId AND status = 'Pending';

只有影响行数为1时,这个任务才算被当前Worker抢到。这就是乐观锁的思想,不需要为每个任务加悲观锁,也能保证同一时刻只有一个实例在处理。

第二层是租约续期。任务置为Running后,Worker需要定期向数据库发送心跳续期。如果某个Worker宕机,心跳停止,任务状态停留时间超过租约周期后,就会被另一条扫描线程重新放回Pending。Demo里一般使用一个后台清理任务,把所有Running且LastHeartbeat小于阈值的记录重置为Pending。这个机制对于文件上传这类长耗时操作尤其关键,因为任务可能执行几分钟,只靠一开始抢占并不能保证中途执行者还活着。

我当时还实验过直接给任务加一把分布式锁的方案,后来发现重度上传场景下锁的获取和释放本身就变成了瓶颈。而用状态位+租约的方式,锁的时间粒度可以放大到分钟级甚至更粗,整体协调成本低很多。读这个Demo最大的收获也在这里:任务状态本身就是最好的锁。

3. Worker消费端:从BackgroundService到多实例水平扩展

3.1 消费端核心循环长什么样

.NET Core里做后台消费任务,大家第一个想到的往往是BackgroundService。这个Demo也是这么干的,HostedService进程启动时自动拉起一个后台循环,持续从任务表里拉取可执行任务。去掉业务细节,核心骨架大致是这样:

public class UploadWorker : BackgroundService { private readonly ILogger<UploadWorker> _logger; private readonly IServiceScopeFactory _scopeFactory; private readonly ITaskRepository _taskRepo; protected override async Task ExecuteAsync(CancellationToken stoppingToken) { while (!stoppingToken.IsCancellationRequested) { using var scope = _scopeFactory.CreateScope(); var scopedRepo = scope.ServiceProvider.GetRequiredService<ITaskRepository>(); var tasks = await scopedRepo.FetchPendingTasksAsync(8, stoppingToken); if (tasks.Count == 0) { await Task.Delay(500, stoppingToken); continue; } foreach (var task in tasks) { await SafeProcessOneAsync(scopedRepo, task, stoppingToken); } } } }

注意两点。一是每次循环都用IServiceScopeFactory创建作用域,因为后台服务是长时间单例,而仓储和服务很可能注册的是Scoped生命周期,直接注入会出问题。二是拉取数量用8还是其他数值需要测试,拉得太多会无限堆积,拉得太少会浪费轮询机会。

3.2 并发度控制不是越大越好

多实例部署时,每个实例都在跑背景服务,全局并发度等于“实例数×单实例的最大并行任务数”。这个Demo在单实例内部提供了两个并行控制点:

  • 拉取批次大小:决定单轮最多取几个任务;
  • 信号量限流:决定每个Worker同时处理的任务上限。

我实际跑下来,单实例并行处理4到8个分片上传是最舒服的。并行太小时,网络带宽和磁盘IO都在闲置;并行太大时,大量分片同时读写磁盘和socket,和其他服务的连接池争抢资源,反而出现整体吞吐下降。

另外,文件上传任务通常吃IO,同一时间并发读取同一个大文件的不同偏移量是没问题的,但要注意文件句柄数量。正确的做法是任务创建时记录文件路径和偏移量,每个任务独立打开文件流并只读取自己需要的片段,处理完立即关闭,而不是把整个文件流缓存在内存或者句柄池里。

3.3 文件夹内文件编排:不急着开工

文件夹上传有个顺序问题:是先把所有文件的分片任务一股脑推到队列里,还是先建立文件夹索引任务,再逐文件生成分片任务?Demo源码的处理方式是先入“扫描任务”,扫描任务执行完才批量生成分片任务。

这个设计我一开始觉得多余,后来在调试中理解了原因。如果直接推全量分片任务,文件夹里有100个文件一个大文件占80%体积,那么小文件的分片任务可能会被大文件分片“插队”,导致同一批数据无法按预期顺序抵达远端。航空航天上传场景里,下游处理系统常常需要按时间顺序归档数据,先到的小文件打乱了归档顺序,后续还得额外排序。

生成顺序的做法是:队列里的分片任务带有优先级字段,根据文件序号和时间戳计算,同一个文件夹下的任务尽量保持相对顺序。真正执行时仍然允许并行,但任务入队顺序、任务完成后的聚合顺序都按原始文件索引来。这样既保证了吞吐,又保留了目录结构的还原性。

4. 断点续传与一致性:分布式队列真正的难点

4.1 先置成功状态还是先传完数据?顺序很重要

上传执行流程里最容易踩的坑就是状态更新和文件写入的顺序。顺序错了,轻则丢数据,重则数据错乱。

正确流程是:**先把文件分片数据上传到存储服务并做好校验,确认存储端返回200 OK后再更新任务状态为Success。**不能先更新状态再上传,因为上传失败时状态已经成功了,后续可能完全没人再处理这个分片。

反过来,也不建议在任务一开始就把状态置为Running黑等太久。如果执行任务用了10分钟,数据库里那个任务会一直显示Running,这个状态对监控系统并不友好,租约续期还要同步推进。我建议是:进入任务时置为Running做一个“开始标记”,上传前把任务行里的LastLog更新一次,上传完成后再执行一次原子Update到Success。这个过程中不要有别的线程来抢任务。

4.2 幂等设计:同一个分片被处理两次怎么办

分布式队列在重试和恢复过程中,同一个任务完全可能被两个实例各执行一次。比如Worker A处理完后还没来得及更新状态就宕机,租约过期后任务被Worker B重新放回Pending并执行上传。此时存储服务中已经存在Worker A上传的分片,Worker B再上传一次就会覆盖或产生脏数据。

Demo里用了一个非常朴素的幂等方案:给每个分片生成唯一标识,拼接方式就是UploadId + FileIndex + ChunkIndex。上传前先在数据库尝试插入一条“分片完成标记”,插入成功才执行上传;如果插入时发现唯一索引冲突,说明这个分片已经被成功处理过,直接跳过上传。

var key = $"{task.UploadId}:{task.FileIndex}:{task.ChunkIndex}"; var inserted = await _repo.TryInsertCompletedChunkAsync(key); if (!inserted) { // 分片已经被处理过,安全跳过 return; }

这个办法的好处是不需要依赖存储服务的去重能力,只要数据库唯一索引没坏,重复处理就不会造成二次数传。缺点是完成标记表会持续膨胀,需要定期清理。生产环境可以单独建一张completed_chunk表,只存主键和UploadId,配合定期归档。

4.3 重试策略:指数退避必须加抖动

看这个Demo的重试处理时,我注意到一个细节:它没有用固定3秒重试的粗暴方案,而是采用指数退避加随机抖动。这点非常符合上传链路的现实。固定重试会形成“共振”,一个网络抖动导致大量Worker同时失败、同时重试,重试请求又会加剧链路拥塞,形成恶性循环。

重试次数基础等待时间随机抖动范围
12秒0-2秒
24秒0-4秒
38秒0-8秒
416秒0-16秒
532秒0-32秒
最大重试放弃入失败队列人工介入

抖动的作用是把同一时刻失败的任务在时间轴上“打散”,让重试流量均匀分布。我当时在实验环境里故意模拟断网10秒,观察队列行为,发现没有抖动的配置下,任务表里的Running状态瞬间扎堆,日志全是超时;加上抖动后,任务恢复时间明显拉平,整个队列的波动幅度小了一个量级。

4.4 全部完成后的聚合校验

分片任务全部成功后,还需要把分片重新组装成完整文件。Demo里定义了一个“聚合任务”作为整个上传流程的终点。它的触发条件是查询同一UploadId下所有分片状态都是Success。

聚合任务需要单独做一次文件完整性校验。我看到的校验思路是两端复核:本地端在切分时记录每个分片的Md5,存储端在分片上传完成后回传自己的内容校验值,聚合时逐片比对两套值。只要有一片比对不一致,聚合任务就不完成,而是把不一致的分片重新置为Pending发起局部重传。

用两个MD5做校验看起来重复,实际上是为了覆盖两个不同环节的失败:本地切分时生成的MD5能验证存储端文件有没有被篡改或损坏,存储端返回的MD5能验证传输链路有没有丢字节。只保留一个,并发错误就排查不出来。

5. 我在验证这个Demo时踩过的坑

5.1 任务重复执行导致分片覆盖

我第一次按自己的理解改Demo时,把幂等插入放在了上传完成之后。结果模拟Worker宕机时出现了重复执行场景:一个分片刚上传完还没来得及记录状态,租约恢复后另一个Worker重新上传同一分片,因为网络不太稳定,第二次传输的数据其实不完整,但存储端没有做校验,最终聚合校验时才暴露数据不一致。

排查过程花了不少时间。日志里能看到两个Worker都在处理同一个UploadId和ChunkIndex,但各自打印的文件大小不同,这才恍然大悟。后来把幂等标记提前到上传之前,再把存储端校验值一并写入完成标记表,问题就消失了。

5.2 数据库连接池被打满

多实例部署后,我发现数据库连接池占用一直居高不下。原因是每个Worker每次循环都从连接池取连接去查询待办任务,查询频率只要提高50%,连接数就会翻倍。

解决方式是给任务拉取增加一个“批量取一次,本地排队消化”的缓冲机制。Worker每轮可以从数据库取20个任务放进内存Channel,然后本地逐个处理,处理完一轮再回数据库取下一批。这样数据库交互频率大幅降低,连接占用也稳住了。更进一步,可以把任务状态批量更新,一次提交所有已完成任务的Success标记,减少事务次数。

5.3 重试风暴:重试任务比正常任务还多

还有一次印象很深,我调整了重试上限后,上线不到半小时报警,队列里积压了几千个待重试任务。查看日志发现某文件存储服务出现短暂降级,大量分片同时失败,每个任务都进入重试等待,并且等待时间到了之后又再次集中回来。而我的批量拉取逻辑会把NextRunAt已到期的任务全部捞出来。

后来加了两个限制才能真正稳住:一是分批恢复重试任务,每轮最多把同UploadId下N个分片重新置为Pending;二是给不健康的UploadId设置“熔断标记”,一旦某个批次失败率达到阈值,该批次所有任务暂时不参与调度,等存储服务恢复后再整体放行。这种批次级别的熔断机制在单片重试之外,是很值得借鉴的补充策略。

5.4 常见问题速查表

现象可能原因处理方式
同一分片被多个Worker处理失败后状态未原子切换使用条件Update抢占状态,并建唯一索引做幂等
队列积压持续上涨消费者太少或批量太小提高单实例并发度、增加实例数、扩大拉取批次
任务重试后仍失败但原因不一缺少错误信息记录增加LastError字段,记录异常类型、消息、堆栈
聚合任务迟迟不完成某个分片状态卡在Running扫描Running超时任务并重置,检查心跳续期
数据库CPU飙高轮询太频繁加大轮询间隔,采用批量拉取+本地缓冲
Worker正在处理的任务被另一个实例抢走租约机制未实现每次抢任务后刷新LastHeartbeat,超时未续期才允许被重置

6. 日志、监控与生产化扩展

6.1 日志关联的链路ID

分布式队列在后端有多个Worker实例,日志如果不带链路ID,排查问题就是灾难。Demo里让我觉得很专业的一点,是把UploadId作为日志关联上下文的贯穿字段。整个处理流程从扫描文件夹到分片上传、聚合校验,所有日志都带着UploadId、FileIndex、ChunkIndex三元组。

我这里会额外建议在日志里加入阶段标识,比如Phase=Scan、Phase=Upload、Phase=Merge。同样是上传失败,在扫描阶段和在上传阶段失败的定位路径完全不同。加上阶段标识后,用日志检索就能快速画出任务的生命周期。

6.2 需要监控的几个关键指标

我实践下来最有价值的监控指标有四个:

  • 队列深度:当前Pending状态任务数,反映系统积压程度;
  • 分片失败率:失败任务数除以总任务数,反映链路健康状况;
  • 重试率:RetryCount大于0的任务占比,数据异常比队列积压更早报警;
  • 平均分片处理耗时:衡量每个分片从入队到Success的延迟,帮助判断是否需要扩容。

这些指标可以做成挂在Prometheus上的直方图和Counter,再配一个简单的Grafana看板。Demo源码在航空场景的演示里其实只给出了数据结构,没有把这些采集出来,但我实际运维过的上传系统,没有这些指标基本靠猜。

6.3 生产化必须补的改造项

如果你打算把这类Demo思路用到正式项目,我有一个清单可以参考:

  • 任务表和完成标记表必须做分区,按UploadId哈希分库或按创建时间分表;
  • 数据库任务扫描要带索引,否则Pending任务一多,普通扫描直接全表;
  • 上传客户端要支持断点续传,不能只靠服务端队列,还要在本地记录已传分片的缓存;
  • 消费者实例要支持优雅停机,注意处理托管服务取消Token,把正在进行的任务状态退回Pending再退出;
  • 存储端的最终一致性校验要独立成服务,定时扫描没有完成聚合的UploadId并告警。

这几点里,临时表索引是我特别要提醒的。任务表如果只有一个主键索引,FetchPendingTasks会变成主键范围扫描,任务一多后端就开始脱慢。必须建一个复合索引,覆盖Status、NextRunAt、Id三个字段,这样查询条件Status=Pending AND NextRunAt<=NOW才能走索引。

文件上传这个场景和普通计算任务不一样,它追求的不只是“任务被处理”,而是“数据处理正确且不重复”。分布式任务队列的价值在航空这种数据链路里体现得很充分:通过分片任务把一次大上传变成多次小传输,通过持久化状态扛住任何节点宕机,通过幂等和聚合校验保证数据完整性。如果你把标题里那套源码的设计逻辑吃透了,再结合我自己整理的那些调优经验,自己用.NET Core搭一条支持断点续传的文件夹上传链路是完全可行的。真正复杂的地方从来不是某个点,而是状态、并发和恢复之间如何衔接。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询