1. 从“离线下载”这四个字说起这个功能到底在解决什么问题离线下载这个词乍一听有点反直觉——既然都离线了还怎么下载其实这里的“离线”指的是用户端离线而不是任务端离线。简单说就是你把一个下载链接或者种子文件丢给系统系统在云端服务器上帮你把文件拉下来存到你的云盘空间里等你什么时候方便了再从云盘取回本地。整个过程你的设备可以关机、可以断网完全不耽误服务器那边替你干活。这个功能解决的核心痛点非常明确。第一很多资源做种的人少、速度慢用本地设备直接下载可能要挂几个小时甚至几天期间设备不能休眠、网络不能断体验极差。第二移动端设备存储空间有限动辄几十GB的资源根本放不下先存到云端再按需取回是更合理的做法。第三有些资源有时效性比如某个公开课的视频链接可能过一段时间就失效了提前让服务器帮你抓下来存好相当于做了一个资源快照。我参与的这个模块就是围绕云盘系统里的离线下载能力做功能实现和优化。它涉及的任务链路比较长用户提交任务、服务端调度、下载器执行、文件落盘、状态回传、前端展示每一环都有不少细节需要处理。下面我会按照实际开发中遇到的顺序把关键设计、踩过的坑和验证方法都展开讲一遍。2. 离线下载的任务模型与状态机设计2.1 为什么不能简单用一个布尔字段标记“完成/未完成”刚接手这个模块的时候我第一反应是任务表里加一个 status 字段0表示进行中1表示完成2表示失败够用了。但实际跑起来发现完全不够。离线下载的任务生命周期比想象中复杂得多用户提交后要先排队等待调度调度到了要建立连接连接建立后可能因为资源本身的问题一直拿不到数据也可能下载到一半速度骤降还可能因为磁盘空间不足而中断。这些状态如果都塞进“进行中”里用户看到的就是一个永远转圈的进度条出了问题也没法定位。所以我们最终设计了一个更细粒度的状态机。核心状态包括PENDING已提交待调度、SCHEDULED已分配下载器、DOWNLOADING正在拉取数据、PAUSED用户主动暂停、COMPLETED下载完成待入库、STORED已存入云盘、FAILED失败、CANCELLED用户取消。每个状态之间的流转都有明确的触发条件和守卫逻辑不是随便就能跳的。这里有个设计上的取舍值得说一下。COMPLETED和STORED为什么要分开因为下载完成和文件真正写入云盘存储系统是两件事。下载器把文件拉到本地临时目录后还需要做校验、分块、上传到分布式存储、更新元数据这一系列操作可能耗时较长而且有可能失败。如果下载完就直接标记为完成用户去取文件时发现根本取不到体验会很差。分开之后前端可以分别展示“下载完成正在入库”和“已存入云盘可查看”用户心里有数。2.2 状态流转的守卫条件与幂等处理状态机设计好之后下一个问题就是并发。同一个任务可能被多个线程或服务同时操作比如用户点取消的同时调度器正好在分配下载器如果不加控制就会出现状态错乱。我们的做法是在状态流转的入口加一层守卫每次变更前先检查当前状态是否允许目标状态不允许就直接拒绝并记录日志。具体实现上我们用了一个基于版本号的乐观锁。任务表里有一个version字段每次更新时带上当前版本号更新语句里加WHERE version ?如果影响行数为0说明期间被其他操作改过了本次更新失败调用方根据情况重试或放弃。这个方案比悲观锁轻量在并发不极端的场景下足够用。幂等性也是必须考虑的。比如用户重复点击“重试”按钮或者消息队列因为某种原因重复投递了同一条调度消息系统不能因此创建出两个下载任务。我们的做法是给每个任务生成一个唯一的业务ID在创建任务时用这个ID做去重对于调度消息则在消费端记录已处理的消息ID短时间内重复的直接丢弃。这里有个容易忽略的点幂等去重的缓存过期时间不能太短。我们一开始设了5分钟结果发现有些重试场景下用户可能在10分钟后再次提交同样的链接这时候应该允许创建新任务但如果是消息重复投递10分钟后又来一次就不合理了。最后我们把这个时间设成了和任务最大存活时间一致并且区分了“用户主动重试”和“系统消息重投”两种来源前者允许创建新任务后者严格去重。3. 下载器选型与调度策略的实战考量3.1 自研下载器还是用现成方案这是项目初期争论最多的一个点。现成的下载工具功能成熟、协议支持全但集成到我们的系统里需要做大量的适配工作而且它的状态回传机制不一定符合我们的需求。自研的话可控性强但工作量不小尤其是要支持多种协议HTTP、FTP、BT等的时候。我们最后的方案是分层处理对于HTTP/HTTPS这类简单协议直接用自研的轻量下载器基于常见的网络库实现代码量可控状态回传也方便对于BT这类复杂协议则封装一个独立的下载引擎服务通过内部接口和主系统通信。这样既保证了核心链路的可控性又不用重复造轮子去实现复杂的P2P协议。自研下载器这块有几个细节值得展开。第一是分块下载对于大文件我们按固定大小切块多线程并发拉取每个块独立记录进度最后合并。这样做的好处是单个连接断开不影响整体重试时只需要重试失败的块。第二是限速服务器带宽是共享资源如果某个任务把带宽占满其他任务就会饿死。我们实现了一个令牌桶算法的限速器每个任务可以配置最大速度调度器根据当前系统负载动态调整。3.2 调度器怎么决定谁先下载调度策略直接影响到用户体验和系统资源利用率。最简单的做法是FIFO先提交的先下载。但实际场景中用户提交的任务大小差异很大一个10MB的文件排在100GB的文件后面等几个小时显然不合理。我们采用的是基于优先级和任务大小的综合调度。优先级由用户等级和任务类型决定比如VIP用户的任务优先级更高小文件任务优先级略高于大文件。同时设置了一个并发上限避免同时下载的任务太多把带宽和磁盘IO打满。调度器每隔一段时间扫描一次待调度队列按照优先级排序后依次分配下载器资源。这里有个实际踩过的坑一开始我们没有限制单个用户的并发任务数结果有用户一次性提交了几百个任务把整个队列堵死了。后来加了每用户并发上限和每日提交上限情况才好转。上限的数值不是拍脑袋定的我们根据系统总并发能力和用户平均任务数做了测算确保单个用户即使跑满也不会影响其他用户的正常使用。调度维度策略说明优先级用户等级 任务类型VIP用户和小文件任务优先并发控制全局并发上限 单用户并发上限防止资源被单一用户占满限速令牌桶算法动态调整根据系统负载调整每任务速度重试指数退避最大重试次数限制避免失败任务无限重试占用资源4. 文件落盘、校验与云盘入库的完整链路4.1 临时文件管理与磁盘空间预警下载器把文件拉到本地临时目录这个目录的管理看似简单其实有不少讲究。首先是空间监控如果磁盘写满了所有下载任务都会失败。我们的做法是启动一个后台监控线程定期检查临时目录所在分区的剩余空间低于阈值时暂停新任务的调度同时给运维发告警。阈值设多少合适我们根据历史数据算了一下平均任务大小乘以最大并发数再留一倍余量最后定了一个相对保守的值。其次是临时文件的清理。任务完成后临时文件要删除任务失败或取消后也要删除但有时候进程崩溃或者被强制杀掉清理逻辑没来得及执行就会留下垃圾文件。我们加了一个定时清理任务扫描临时目录中超过一定时间没有被访问的文件确认没有关联的活跃任务后删除。这个“超过一定时间”不能太短否则可能误删正在下载的大文件也不能太长否则磁盘很快被占满。我们最终设的是24小时同时结合文件的最后修改时间来判断。4.2 文件完整性校验的几种手段下载完成后必须校验文件完整性否则用户拿到一个损坏的文件体验比下载失败还差。校验手段根据资源类型不同有所区别。对于HTTP下载如果服务端返回了Content-Length我们可以比对实际下载的字节数如果返回了Content-MD5或类似的校验头那就更可靠了。对于BT下载协议本身有分块哈希校验下载引擎会自己处理。我们还在系统层面加了一层兜底校验对于已知文件类型的资源比如图片、视频下载完成后尝试用对应的库打开一下能正常打开才认为文件有效。这个做法不能覆盖所有情况但能拦住大部分因为网络中断导致的半截文件。校验失败的任务会标记为FAILED并记录失败原因用户可以选择重试。4.3 入库过程中的分块上传与元数据更新文件校验通过后下一步是上传到云盘存储系统。我们的存储系统支持分块上传大文件切成固定大小的块并行上传最后合并。这里的关键是断点续传如果上传到一半失败了下次可以从已成功的块继续不用从头再来。实现上每个块上传成功后记录一个偏移量和块标识重试时先查询已上传的块列表跳过这些块。元数据更新是最后一步也是容易出问题的一步。文件实体已经存进去了但数据库里的记录还没更新这时候如果服务重启就会出现“文件存在但用户看不到”的情况。我们的做法是把元数据更新做成一个独立的事务并且在上传完成后先写一条“待确认”的记录元数据更新成功后再改为“已确认”。后台有一个补偿任务定期扫描“待确认”状态的记录检查文件实体是否存在存在就补全元数据不存在就清理掉。这里分享一个排查经验有一次用户反馈某个任务一直卡在“正在入库”查日志发现上传早就完成了但元数据更新那一步超时了。进一步排查发现是数据库连接池被打满导致更新请求排队。后来我们给元数据更新单独配了一个连接池并且加了超时重试问题就没再出现过。5. 前端交互与状态回传的那些细节5.1 进度展示别让用户觉得系统卡死了离线下载的进度展示看起来简单其实很影响用户体验。如果只显示一个转圈动画用户不知道到底下载了多少、还要等多久很容易失去耐心。我们的做法是尽量展示细粒度的进度信息已下载字节数、总字节数、当前速度、预计剩余时间。这些数据由下载器定期上报前端通过轮询或长连接获取。但这里有个问题不是所有资源都能拿到总大小。比如某些流媒体链接服务端不返回Content-Length我们就不知道总共有多少。这种情况下我们退而求其次只展示已下载量和当前速度不显示百分比和剩余时间。前端会显示“正在下载已获取XX MB”让用户知道系统在干活就行。速度的计算也有讲究。瞬时速度波动很大直接展示会让用户觉得忽快忽慢。我们做了一个滑动窗口平均取最近10秒的速度均值作为展示速度看起来就平稳多了。预计剩余时间则用剩余字节数除以平均速度得出同样做了平滑处理。5.2 状态回传的可靠性与补偿机制下载器和服务端之间的状态回传我们一开始用的是HTTP回调下载器每完成一个阶段就调一次服务端接口。这个方案简单但有个致命问题如果服务端当时不可用回调就丢了任务状态永远停在中间态。后来我们改成了消息队列下载器把状态变更事件发到队列里服务端消费后更新任务状态。消息队列本身有持久化和重试机制可靠性高很多。即便如此还是不能完全避免状态丢失。所以我们加了一个对账机制服务端定期扫描处于中间态超过一定时间的任务主动去下载器查询实际状态然后修正。这个“一定时间”根据任务类型不同而不同比如DOWNLOADING状态超过30分钟没有更新就触发对账。对账逻辑要小心处理避免和正常的状态更新冲突我们用了前面提到的乐观锁来保证一致性。6. 实测中遇到的典型问题与排查思路6.1 任务卡在“调度中”不动了这是上线初期遇到最多的一个问题。用户提交任务后状态一直是PENDING等多久都不动。排查下来发现原因有好几种。第一种是调度器挂了没有进程在消费待调度队列。这种情况看调度器的健康检查接口就能发现。第二种是队列积压任务太多处理不过来这个看队列长度指标就能判断。第三种比较隐蔽任务被调度了但分配下载器的那一步失败了而失败的原因没有正确回写状态导致任务卡在PENDING。针对第三种情况我们在调度逻辑里加了完善的错误处理和状态回写。任何一步失败都要把任务置为FAILED并记录原因不能让它无声无息地卡住。同时加了一个超时机制任务在PENDING状态超过一定时间自动转为FAILED并通知用户。6.2 下载速度远低于预期有用户反馈同样的链接用本地工具下载很快但在我们的系统里慢得像蜗牛。排查思路是这样的先看下载器所在服务器的网络状况是不是带宽被其他任务占满了再看单个任务的限速配置是不是限得太低然后看目标资源的服务端是不是对我们的IP做了限速。最后发现是限速配置的问题——我们默认给每个任务设了一个比较保守的速度上限本意是防止单任务占满带宽但实际使用中这个值偏低了。调整限速策略时我们改成了动态限速系统负载低的时候放宽单任务限速负载高的时候收紧。同时给用户提供了一个“高速下载”的选项消耗更多积分或资源配额来换取更高的速度上限。这样既保证了公平性又给了用户选择权。6.3 大文件入库失败率偏高超过一定大小的文件入库失败率明显高于小文件。查日志发现主要是两个原因一是分块上传时某个块超时导致整个上传失败二是元数据更新时数据库写入超时。第一个问题的解决办法是增加分块上传的重试次数并且把块大小调小一些这样单个块失败的影响范围更小。第二个问题则是优化数据库写入把大事务拆成小事务减少锁竞争。还有一个原因是临时目录所在磁盘的空间不足。大文件占用空间大如果同时有几个大文件在下载磁盘很快就满了。我们后来把临时目录和系统盘分开用独立的磁盘挂载并且加了更激进的清理策略任务完成后立即删除临时文件不等定时清理。问题现象可能原因排查手段解决措施任务卡在PENDING调度器异常/队列积压/状态回写失败检查调度器健康状态、队列长度、任务日志完善错误处理、加超时机制下载速度慢带宽被占满/限速过低/对端限速查看服务器网络指标、限速配置动态限速、提供高速选项大文件入库失败分块超时/数据库超时/磁盘满查看上传日志、数据库慢查询、磁盘使用率增加重试、调小块大小、独立磁盘7. 一些可以复用的经验与后续优化方向这个模块从最初跑通到相对稳定前后迭代了好几轮。回过头看有几个经验我觉得挺有价值。第一是状态机一定要设计得足够细不要怕状态多状态多了才能准确定位问题。第二是异步链路一定要有对账和补偿机制不能假设每一步都成功。第三是限速和并发控制要动态调整静态配置很难适应所有场景。后续如果继续优化我比较关注两个方向。一个是智能调度根据历史数据预测任务的下载时长和资源消耗更合理地安排队列顺序。另一个是边缘节点下载把下载任务分散到离资源更近的节点上执行减少中心节点的带宽压力。这两个方向都需要更多的数据积累和架构调整暂时还在调研阶段。最后分享一个小技巧在排查下载相关问题时养成先看下载器日志、再看服务端日志、最后看网络链路的习惯。大部分问题在前两步就能定位真正需要抓包分析的情况很少。另外给每个任务生成一个唯一的追踪ID从提交到完成的全链路日志都带上这个ID排查时一搜就能把整个链路串起来效率会高很多。
阅读完成 · 觉得有帮助?