问题本身很简单
同步和备份类应用都绕不开一个问题:怎么知道某个文件在远端已经存在了? 判错的代价是不对称的 —— 误判为「不存在」只是多传一次,浪费点带宽; 误判为「已存在」则可能导致文件永远不会被真正备份,是数据丢失级别的错误。
所以判重策略的目标不是「最快」,而是在几乎不出错的前提下尽量快。
最朴素的做法,以及它为什么不够
最直接的办法是算文件内容的哈希,拿哈希去比对:
hash = SHA-256(读取文件全部字节)
这个办法正确性没问题,但在「判断一万张照片是否需要备份」的场景下代价太高: 为了判断需不需要传,得先把所有文件完整读一遍。 而绝大多数情况下结论都是「不需要传」—— 等于每次同步都白白把整个相册读一遍。
问题的本质是:我们用了最贵的手段去做最常见的判断。
两级策略:粗筛 + 确认
改法是把判断拆成两级,越便宜的越先做:
L1:元数据指纹(粗筛)
只用文件的元数据字段拼出一个指纹,完全不读文件内容:
L1 = SHA1( 拍摄时间_秒 + "|" + 宽 + "x" + 高
+ "|" + 时长毫秒 + "|" + 媒体子类型 )
它的特点:
- 零 IO。相册的元数据通常已经加载在内存里,十万张照片算一遍是秒级的事
- 不需要精确。它的职责只是把候选范围缩小,不是给出最终结论
- 字段必须选跨设备稳定的值 —— 这一点下面单独说
L2:内容哈希(确认)
L2 = SHA-256(文件原始字节)
只在 L1 命中候选时才计算。 这是整个方案性能的关键点:绝大多数文件在 L1 阶段就被判定为「远端没有」, 直接进上传队列,根本不需要读内容。
现代 ARM 处理器普遍带 SHA 扩展指令,主流平台的密码学库 (iOS 的 CryptoKit、HarmonyOS 的 cryptoFramework)都会走硬件加速, 吞吐可以到 100MB/s 以上,单张照片的哈希耗时基本可以忽略。
关于「碰撞」:它不是 bug,是设计的一部分
L1 指纹会有碰撞 —— 最典型的是同秒连拍、动态照片的静帧与封面、连续截图。 这些文件的拍摄时间、尺寸、时长往往完全一致,指纹自然相同。
关键在于:这些恰好正是需要 L2 确认的场景。 粗筛的职责就是把「拿不准的」挑出来交给下一级, 而不是自己下结论。如果 L1 永远不碰撞,那说明它选的字段太细,已经接近在做内容哈希了,反而失去了速度优势。
判重决策表
| L1 是否命中 | L2 比对结果 | 动作 |
|---|---|---|
| 无命中 | 不计算 | 直接上传,省掉一次全文件读取 |
| 命中 1 条 | 一致 | 跳过,建立映射关系 |
| 命中 1 条 | 不一致 | 上传(元数据巧合,实际是不同文件) |
| 命中 N 条 | 逐条比对 | 一致则跳过;全部不一致则上传 |
| 本地已有 L2 且远端确认过 | 不计算 | 直接跳过 —— 最快的路径 |
注意第一行:L1 没命中就不算 L2。 这条规则让首轮全量同步的额外哈希开销被压到很小 —— 只有 L1 撞车的那一小部分文件才需要读内容。 第二轮开始走本地缓存,开销近乎为零。
哪些字段不能进指纹
这是很容易踩的一个坑。移动端相册会给每个资源一个本地唯一标识
(iOS 的 localIdentifier、HarmonyOS 的 uri),
看起来是天然的完美主键,但它换设备或重装后会变。
所以正确的用法是分开:
- 进 L1 指纹的:拍摄时间、尺寸、时长、类型 —— 这些跟着文件走,换机不变
- 不进 L1 的:本地唯一标识 —— 单独存一列,只用于同一台设备上的增量识别
秒传:把判断提前到传输之前
上面的流程仍是「客户端算好再决定传不传」。秒传更进一步: 客户端在发起上传请求时,就把已知的内容哈希一起报上去, 服务端查索引,命中则直接返回「已存在」,零字节传输。
POST /upload/init
{ "name": "IMG_0421.HEIC", "size": 3145728,
"content_sha256": "9f86d081884c7d65..." }
→ 200 { "skipped": true, "path": "/photo/2026/09/IMG_0421.HEIC" }
这个机制也解释了为什么网盘能做到「几秒传完一个 GB 级文件」—— 传的从来不是文件,只是一个哈希值。
把哈希计算放到常电设备上
一个容易被忽略的优化:手机算哈希要耗电,而 NAS 常年插着电。 所以让服务端在文件落盘时顺手算一次哈希并存进索引, 之后所有客户端都能直接命中这个索引,不需要各自重复计算。
代价是首次落盘时服务端要多做一次全文件读取 —— 但那是它本来就写过的数据, 大概率还在页缓存里,成本远低于让每台客户端各算一遍。
哈希算法怎么选
- MD5 / SHA-1:已经不适合任何安全用途(碰撞攻击早就有实用化的成果), 但在纯判重场景仍常见,因为快。要用的话需要清楚知道自己在依赖「碰撞极难发生」这个统计假设。
- SHA-256:当前的稳妥默认值,硬件加速支持好, 在判重这种「碰撞即丢数据」的场景下没有理由省这一点开销。
我的结论很简单:判重场景下哈希碰撞的后果是文件被认为已备份而实际上没有, 这是数据丢失。宁可慢一点,也要用抗碰撞的算法。
小结
两级判重其实是一个很通用的模式: 先用廉价特征把候选集缩小,再用昂贵手段做最终确认。 这个思路在缓存失效判断、去重存储、增量同步里都反复出现。 真正需要想清楚的是两件事:粗筛用的特征在什么条件下会失效,以及 判错的代价由谁来承担。