把一个文件夹当备份反复往云上传,大多数文件其实一次都没变。每次都整份重传,是纯粹的浪费带宽和时间。这次给上传加了秒传:上传前先判断远端是否已有一份内容完全相同的,若有就跳过。
难点只有一个:怎么在不下载远端内容的前提下,确认两份一样?
ETag 早就把答案挂在那儿了
对象存储对整对象(非分片)上传的对象,返回的 ETag 就是内容的 MD5。这正是 Nebula 下载完整性校验一直在用的事实——算本地 MD5 和远端 ETag 比。上传方向反过来用同一个事实即可:
远端 ETag == 本地文件 MD5 ⟹ 两份内容一致 ⟹ 跳过上传。
于是判断复用了完整性模块里那段「ETag 是否是整体 MD5」的逻辑(去引号、32 位十六进制、无 -N 分片后缀):
let Some(expected) = worth_comparing(meta.etag.as_deref(), meta.size, local_size) else {
return Ok(false); // 无法确认相同 → 照常上传
};
Ok(dedup::file_md5(local_path).await? == expected)
保守是这里的第一原则
秒传一旦误判(把其实不同的文件当成相同而跳过),就是静默的数据丢失——用户以为传上去了,其实没有。所以整个判断的基调是:但凡不能百分百确认相同,就老老实实上传。 四种"不确定"全部退回上传:
- 远端不存在 → 传。
- 远端 ETag 是分片格式(
<md5>-<N>)或缺失 → 无法当 MD5 用 → 传。 - 远端大小与本地不同 → 内容必然不同 → 传。
- 本地读取 / stat 失败 → 不赌 → 传。
只有"远端存在 + ETag 是整体 MD5 + 大小一致 + 本地 MD5 相等"这一条全绿的路径才跳过。宁可多传,绝不漏传。
别让哈希拖慢常规路径
有个性能陷阱:算本地 MD5 要读整个文件。如果每次上传都先哈希一遍,那大文件就被读了两遍(哈希一遍、上传一遍),常规上传反而变慢。
关键在于先用大小这个便宜的信号过滤。远端 stat 顺手就返回大小,和本地文件大小一比:大小不同,内容必然不同,直接判定要传,根本不碰哈希。只有在"大小一致、ETag 又是整体 MD5"这种真有可能相同的情形,才值得读盘算 MD5 去做最终确认。于是昂贵的哈希只在"很可能命中秒传"时才发生——这时读一遍盘换掉一整趟上传,稳赚。
算 MD5 也不一次性读入内存:新加的增量哈希器 Md5Hasher 按 64 KiB 流式喂入,大文件也只占一个小缓冲。
可测的判断核心
「值不值得比对」这步是纯函数,不碰网络也不碰磁盘,于是直接测:大小不一致短路、分片 / 缺失 ETag 不比对、整体 MD5 且大小一致才给出期望值。增量哈希器也补一条「分块喂入 == 一次性 md5」。IO 那两头(读盘、发请求)交给集成路径,决策这半边钉死在单测里。
对称的另一半:下载也跳过
判断「远端那份和本地这份是否一样」是对称的——它不关心方向。所以同一个 is_unchanged 原封不动就能用在下载侧:下载前若本地目标已存在且与远端一致,就跳过下载。唯一的差别是「本地不存在」的处理:上传时源文件必然在,下载时本地目标通常还没有——而 is_unchanged 在读不到本地文件时返回错误、被 unwrap_or(false) 归为「需要传输」,于是下载侧天然把「本地没有」当成「要下」,正确。于是重新下载一个文件夹当作恢复 / 同步时,没变的文件一律秒过,面板上报一句「N 个未改动已跳过」。
小结
秒传不是什么新机制,而是把「ETag 即 MD5」这条既有事实反过来用:下载时用它校验完整性,上传时用它判断可跳过。真正要守住的是两条纪律——保守(不确定就传,绝不误跳)和别拖慢常规路径(用大小先过滤,让哈希只在可能命中时才跑)。一个对称的判断同时喂饱上传和下载两个方向,传完发现面板上一排「已跳过」,那都是省下来的时间。