[开源] 我在自己写的下载器里造了个 bug:为了修「最后 1% 变慢」,活跃连接反而从 48 掉到了 16

发布于

clow:
我做了个下载器 FluxDown,Rust 写的引擎,Flutter 做的界面。不打算在这儿详细列功能,想讲一个我自己制造又自己修掉的 bug,觉得挺有意思。

多线程下载尾部时会出现落后者的问题:快速的连接下载完成后会闲置,整体时间取决于最慢的那条。常规解决办法是从中点将剩余最多的一段再切一下给闲置的连接。但切分是有成本的(需要一次 HTTP 往返),所以我设了一个下限,定为 2MB,并根据实时吞吐量调整到最低 512KB。

后来发现有一个场景没考虑到:当下载一个 1GB 文件分成 16 段时,最后一段剩下 1.5MB,门槛 2MB 切不动,15 个 worker 只能干等一个。因此我加入了一个「尾部微拆分」的措施——在常规切不动的情况下降到 64KB 再试一次。

上线后发现 99% 的下载速度变得更慢了。

经过排查发现是这样:在下载一个 50MB 的文件尾部时,48 个 worker 的每段都只剩约 66KB。66KB > 64KB,因此每段都「可以」被微拆分。结果是 worker 们互相把对方的段切成 33KB 的碎片,切完后发现再也找不到 ≥64KB 的段可切,最后活跃的 worker 从 48 直接掉到了 16。为了修「最后 1% 变慢」的情况,我却产生了一个更严重的「最后 1% 变慢」。

修法是增加了一道落后者判据:只有当 **最大剩余的活跃段 ≥ 128KB(即微拆粒度的两倍)**时才允许进行微拆分。意思是「只有在存在真实不均衡时才采取抢救」,如果所有段都一样小,那都是带宽的问题,切碎问题并不能解决。加这个判据后,整个过程自然停止:下完 33KB 的 worker 发现最大剩余只有 66KB,优雅退休而不是继续切割同伴。

事后想想这个坑其实相当普适。比如 Spark 的推测执行判据也必须是「显著慢于其他任务」而不是「没有完成」;CI 测试分片再切一刀的收益也需要减去容器启动的成本。**重新切分的收益必须大于切分本身的固定成本,并且只对显著不均衡的情况进行操作。**

顺带提一下:最大连接数并不是越大越快。有些服务器对多连接会进行惩罚性限速,我实际测试中见过 59MB/s 降到 2MB/s。因此我的引擎从 2 条连接起步,每 2 秒依据真实吞吐量投票——如果增长超过 5% 就继续翻倍,如果跌破一半则判定崩塌,立刻回滚,并且将这个域名学到的连接上限缓存 24 小时。基本上将 AIMD 的原理搬到了应用层。

项目情况:AGPL-3.0,支持 Windows / macOS / Linux / Android,NAS 也有 Docker 和群晖 QNAP 的原生包,headless 版本兼容 aria2 的 RPC(AriaNg 可以直接连接)。无广告无追踪,不需要账号。

代码在 [native/engine/src/segment_coordinator.rs](http://segment_coordinator.rs),上面这段护栏的注释比代码长,因为记录的是一次真实事故。

- [FluxDown GitHub](https://github.com/zerx-lab/FluxDown)
- [FluxDown 官网](https://fluxdown.zerx.dev/)

有做过并行分片调度的朋友,你们是怎么处理尾部落后者的?想听听其他的思路。

---

原文链接:[点击查看](https://www.v2ex.com/t/1230009)

评论(2)

楼主补充一个同源的坑:64个 worker 各自持有指向同一个文件的句柄,如果周期性执行 fdatasync,会把整个文件的脏页重复刷 N 遍,直接把磁盘 IO 打满了。
后来我加了个全局合并闸,整个文件级别每 2 秒最多刷一次 fdatasync。并且只有当这次 fsync 的起始时刻晚于某个段自己 flush 的时刻,才允许把进度偏移写进 DB,维持“DB里记录的偏移 <= 已经被 fsync 覆盖的字节”这个不变式。不然崩溃恢复的时候文件里就会拼出一个洞,断电重启后续传的文件 md5 经常对不上,巨难查。

· 0 个赞

感觉这整个排查过程和总结的口吻,太像 AI 生成的了。是不是 AI 搞出来的 bug 然后再用 AI 排查复述了一遍?

· 0 个赞