代理服务器从后端“多线程”下载文件的初步设计

分类: 其它乱七八糟 381人阅读 评论(0) 收藏 举报
我们从服务器下载文件往往受限于速度和稳定性,如果突然间服务器down掉,那么前面的下载都作废了。如果能做一个专门的下载服务器,它去多个后台服务器并发下载文件,那么不仅速度可以提升,稳定性也会增强。(要达到的目标是用户可以直接通过访问专用下载服务器下载文件,由于服务器是从多个后端资源获取数据,所以下载速度往往能更快)

这里基于nginx的HttpLuaModule模块来实现这个功能,由于lua是一种脚本语言,本身的语言特性并不丰富,不能支持多进程多线程丰富的功能,要想模拟“多线程”从多个资源下载文件,能想到的办法就是自己实现一个任务调度,通过轮询的方式从各个资源那里下载不同分片,然后输出给客户端(浏览器)。
  1. 案例,一组可用的资源列表, (test.zip大小100M)  
  2. http://127.0.0.1:10001/down/test.zip  
  3. http://127.0.0.1:10002/down/test.zip    403 forbidden  
  4. http://127.0.0.1:10003/down/test.zip    502 down掉  
  5. http://127.0.0.1:10004/down/test.zip  
  6. http://127.0.0.1:10005/down/test.zip    404 not found  
  7. (下文简称10001,10002,10003,10004,10005服务器)  
lua-resty-http是基于ngx.cosocket封装的一个socket库,我就用它来实现模拟“多线程”从后台服务器多个资源列表同时下载一个文件,主要是考虑几个关键的业务问题。
A,如果每个资源都不可用,譬如10001~10003服务器down掉,10004服务器404错误,10005服务器403错误。那么需要通知客户端下载出错(譬如返回404错误)
1,需要设置连接500毫秒超时,不然socket会一直尝试连接服务器
  1. sock:settimeout(500)  
  2. ok, err = sock:connect(host, port or 80)  
2,在遍历每个资源尝试连接时,需要记录一个可用连接
  1. uri_available = nil  
  2. while i < n do  
  3.     if ret200 or ret206 then  
  4.         uri_available = dl_uris[i]  
  5.     end  
  6.     i = i + 1  
  7. end  
  8. -- 此处判断是否存在可用连接,不然则返回404  
  9. if uri_available == nil then  
  10.     ngx.exit(404)  
  11. end  

B,如果5个资源中只有2个资源可用,我们也应该充分考虑多连接的设计,用可用的url(前面记录的uri_available)去重启失败的三个连接,得到如下的下载状态
  1. socket[1] -> http://127.0.0.1:10001/down/test.zip  
  2. socket[2] -> http://127.0.0.1:10004/down/test.zip  
  3. socket[3] -> http://127.0.0.1:10004/down/test.zip  
  4. socket[4] -> http://127.0.0.1:10004/down/test.zip  
  5. socket[5] -> http://127.0.0.1:10004/down/test.zip    (503 not available)  
--当然,用可用连接重启也不一定会成功,譬如10004服务器端针对连接数做了控制,只允许3个连接,
--那最后一次重启会出现503错误,不管怎样,我们还是得到比较多的下载连接(4个总比2个好),
--出于程序灵活性的考虑,不强求连接数的数目一定是5个


C,有了一堆下载连接,则需要设计分片的下载业务逻辑了,为了讨论方便,先设计每个连接的文件分片大小一样(这种设计用户体验不好,后文再说)

  1. socket[1] 下载 0~100M    ====================  
  2. socket[2] 下载 20~100M       ================  
  3. socket[3] 下载 40~100M           ============  
  4. socket[4] 下载 60~100M               ========  
  5. socket[5] 下载 80~100M    (503错误,所以这个连接不会真的下载)  

--为了保证后面的连接出现下载错误,(如:突然连接down掉),
--所以前面的连接的下载范围需要覆盖掉后面的下载范围(迅雷的多连接下载也是这个机制)。
--当然前面的链接socket[1]也可能出现下载错误,所以需要设计重启机制。
  1. local data, err, partial = sock[i]:receive(16*1024)  
  2. -- 如果下载断掉,且不是因为文件尾(下载完毕时也会出现not data的情况)  
  3. if not data and 当前连接下载偏移 + 16*1024 <= 文件大小 then  
  4.     --找一个可用连接再去下载(不能用uri_available,因为有可能就是它断掉了,所以需要重新遍历5个连接,例如10003服务器突然可用),这里假定存在一个可用连接续传,不然其他正在下载的连接也会出错,整个下载就无法继续了  
  5. end  
  6. --这样,我们就起码能保证能够下载完整个文件的数据  

D,如果下载一切顺利的话,从上面的设计我们看出socket[1]其实没有必要下载20M以后的内容,为了判断所需数据是否已经下载完毕,我们需要将各个连接下载的分片写入一个缓存文件,然后比较缓存文件的对应位置是否已经存在下载数据。
比如socket[2]下载第41M时发现缓存文件中已存在且内容一致(socket[3]已经下载好了),则需要关闭socket[2]
  1. local data, err, partial = sock[i]:receive(16*1024)  
  2. out:seek("set","socket i offset")  
  3. local block = out:read(16*1024)  
  4. -- "sock_start[i] > rangesave_start" 后面再解释,参考G  
  5. if block and block == data and sock_start[i] > rangesave_start then  
  6.     --关闭当前连接  
  7. end  
E,由于用户是通过“下载服务器”下载,所以我们在服务器端帮他多连接下载时也应该实时输出下载内容,不可能等到下载完毕后再把缓存文件一股脑输出出去,这样用户会崩溃的。怎么控制各个连接是否应该将数据输出给用户呢。我们需要记录一个“最靠前连接”的变量,每当一个连接下载好一段数据后判断它是不是最靠前的链接,如果是,则输出内容给客户端,这样就能保证数据块的顺序。其他连接下载到数据后就写缓存。


比如如果socket[1]就是最靠前的连接,它在下载0~20M过程中需要输出数据给客户端,同时socket[2]~[4]继续写缓存


F,再回到D的问题上,某个连接下载数据时发现缓存中已存在,则除了关闭自身连接外,还需要看看是否应该输出缓存内容

  1.                 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1  
  2. socket[1](0-20) ====================  
  3. socket[2](21-40)=========================================  
  4. socket[3](41-60)==========  
  5. socket[4](61-100)...  
  6. a,这种情况,socket[2]在下载第41M时发现缓存已存在,是不需要输出缓存内容的,因为socket[1]还没有下载完毕  
  7.                 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1  
  8. socket[1](0-20) =========================================  
  9. socket[2](21-40)=======================  
  10. socket[3](41-60)==========  
  11. socket[4](61-100)...  
  12. b,这种情况,socket[1]在下载第21M时发现缓存已存在,则需要输出缓存到socket[2]正在下载的位置  
  13.                 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1  
  14. socket[1](0-20) =========================================  
  15. socket[2](21-40)=========================================  
  16. socket[3](41-60)==========  
  17. socket[4](61-100)...  
  18. c,这种情况,socket[1]在下载第21M时发现缓存已存在,则需要输出缓存到socket[3]正在下载的位置  

所以当我们遇到缓存中已存在时,需要判断是否是“最靠前的连接”(socket[i]),如果是,则找到下一个还在下载的连接(不一定是socket[i+1],如情况c),一直输出缓存文件到下一个连接的下载偏移,如果后面连接都下载完毕(找不到活跃的连接),则说明文件已经下载完毕,需要输出到文件结尾
  1.                 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1  
  2. socket[1](0-20) =========================================  
  3. socket[2](21-40)=========================================  
  4. socket[3](41-60)=========================================  
  5. socket[4](61-..)=========================================  
  6.                 =========================================  
处理完缓存输出后需要更新“最靠前连接”的信息,比如情况c中,需要更新为socket[3],因为我们的轮询下载是从1~4一次遍历的。如果不更新为socket[3],则这一次轮询中到socket[3]下载时它发现自己不是最靠前连接,则不会输出数据给客户端。(这个问题很重要,而且容易忽略掉)。

G,如果一切顺利,文件的下载就没有问题了,我们会看到下载进度条慢慢地到20%,然后很快就100%,因为后面的80%都是从缓存文件中获取的。但是如果用户在下载过程中点击暂停然后续传,仅靠上面的业务逻辑是远远不够的。
比如分片均匀时,四个连接分别下载到10%,30%,50%,70%时暂停续传,则续传的起始位置是10M,将后面的90M再五等分(虽然第五个连接不能用)的话四个连接的新下载偏移分别是10%,28%,46%,64%,可以看到会出现很多重复下载以及缺失下载的情况。所以我们还需要一个文件记录各个连接在暂停的时候的下载位置。(而且会有很多连接(socket[2],socket[3],socket[4])发现缓存中已存在内容,就会关闭自己)
这里还有个问题,虽然第一个连接下载了10%到缓存,可是由于网络缘故,客户端可能只收到了9M,所以在续传的时候“最靠前的连接”的起始偏移需要按照客户端发起请求的Range头来定。
所以断点续传的偏移应该是9%,30%,50%,70%
这样设计又有一个隐患,由于我们加了判断下载数据和缓存是否一致的逻辑,socket[1]在下载第10M时发现缓存已经存在,则会关闭自己,这样第10M~20M之间就没有数据了,所以需要比较客户端请求的偏移9%和我们记录的偏移10%。这就是D中我们加入sock_start[i](9%) > rangesave_start(10%)判断的缘故
if block and block == data and sock_start[i] > rangesave_start then
--关闭当前连接

end

断点的位置也是不确定的,有可能我们的连接将下载内容缓存到文件中还来不及更新偏移信息时程序就down掉了,所以还需要注意针对偏移信息不及时的处理


H,前文说到分片均匀下载的用户体验不好,用户在下载初期感觉速度并没有提升(比如0~20M),因为服务器同时在下载100M的数据。所以应该采用非均匀的设计。比如:
  1. socket[1] 下载 0~100M   ====================  
  2. socket[2] 下载 10~100M    ==================  
  3. socket[3] 下载 30~100M        ==============  
  4. socket[4] 下载 60~100               ========  
  5. socket[5] 下载 80~100M    (503错误,所以这个连接不会真的下载)   
  6.   
  7. 则速度慢的区间就是0~10M,因为10M开始可以读取缓存,同时我们可以控制不同连接每次下载数据大小  
  8.   
  9. socket[1] 下载 0~100M   ====================  每次下载1M  
  10. socket[2] 下载 10~100M    ==================  每次下载500K  
  11. socket[3] 下载 30~100M        ==============  每次下载250K  
  12. socket[4] 下载 60~100M              ========  每次下载100K  
  13. socket[5] 下载 80~100M    (503错误,所以这个连接不会真的下载)   
  14.   
  15. 则0~10M区间的下载体验会更好(因为后面的连接卡在下载的时间会被缩短),不过这需要动态调整各个连接的“下载速度”,业务逻辑复杂不少。  
总之,这种轮询方式的设计太过简单,里面存在很多问题,比如某个服务器速度很慢时整个下载都会被拖慢,所以我们还需要加入速度的判断逻辑,比如设置下载超时,想办法用速度快的连接替换速度慢的连接。倘若如果所有资源的速度都平均的话,整个下载速度会非常的快,从我简单的测试结果来看,一般5个并发连接下载的平均速度会是单个连接的3~4倍
::...
免责声明:
当前网页内容, 由 大妈 ZoomQuiet 使用工具: ScrapBook :: Firefox Extension 人工从互联网中收集并分享;
内容版权归原作者所有;
本人对内容的有效性/合法性不承担任何强制性责任.
若有不妥, 欢迎评注提醒:

或是邮件反馈可也:
askdama[AT]googlegroups.com


订阅 substack 体验古早写作:


点击注册~> 获得 100$ 体验券: DigitalOcean Referral Badge

关注公众号, 持续获得相关各种嗯哼:
zoomquiet


自怼圈/年度番新

DU22.4
关于 ~ DebugUself with DAMA ;-)
粤ICP备18025058号-1
公安备案号: 44049002000656 ...::