媒体内容哈希键:不可变与去重
将图片以其字节的 sha256 值而非文件名进行存储。你可以获得天然的不可变性、自动去重,以及仅用一个环境变量即可迁移的存储。
当你将图片上传到对象存储时,最显而易见的键就是其文件名:img/hero.png。它可读性好,且易于推断,但它会悄悄地让你陷入缓存失效、重复对象以及 URL 硬编码到数据库等问题。有另一种键可以避免这三个问题。不要根据文件名来命名对象,而是根据其字节内容来命名。计算文件内容的 sha256 值,并将其存储在 img/{sha256}.{ext}。内容决定键,而非名称。本文将完整阐述这一选择的理由、实现它的 Go 代码,以及文件名键仍然是正确选择的那一种情况。
内容哈希键的工作原理
规则只有一行:存储键是文件内容的哈希值。读取字节流,对其运行 sha256 算法,对摘要进行十六进制编码,然后将对象置于 img/{digest}.{ext}。一个内容哈希值为 9f86d0... 的文件会被存放在 img/9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08.png。
由此直接产生两个属性,本文中的其他所有内容都是这两个属性的结果。
- 相同的字节流总是产生相同的键。两次上传相同的文件,即使来自两个不同的来源,使用两个不同的原始名称,两者也都会指向完全相同的对象。
- 不同的字节流会产生不同的键。更改一个像素,摘要就会完全改变,因此编辑后的文件会成为一个位于新键的新对象,而旧键仍然指向旧的字节流。
这就是内容寻址。数据的地址源自数据本身。Git 对 blob 也做了同样的事情,原因也相同。
不可变性:永久缓存,免费失效
因为一个键只能指向一个确切的字节序列,所以该键对应的对象永远不会改变。正是这一事实,解锁了 Web 所拥有的最强缓存标头:
Cache-Control: public, immutable, max-age=31536000
max-age=31536000 表示一年。immutable 告诉浏览器,即使用户重新加载,也无需使用 If-None-Match 进行重新验证。这两者在这里都是安全的,因为它们所编码的承诺实际上是真实的:只要该对象存在,此 URL 背后的字节就不会改变。R2 或 S3 前端的 CDN 会将其缓存一次,并从边缘提供服务直至被逐出,而源站几乎看不到该文件的重复流量。
现在来比较一下缓存失效的故事。使用文件名作为键时,更新 hero.png 意味着覆盖该对象,而每个已持有旧版本的缓存现在都是错误的。你必须清除 CDN 缓存,并且希望没有浏览器在同一 URL 下持有过期的副本。而使用内容哈希作为键,你永远不会覆盖任何东西。编辑后的图像是一个新的键,因此它是一个新的 URL,而这个新 URL 从未被缓存过,因为它刚才还不存在。旧的 URL 会继续向任何仍然引用它的人提供旧的字节。缓存失效不再是你需要执行的一项操作。它变成了一件不可能发生的事情,因为 URL 的含义永远不会改变。
去重:先 HeadObject,再考虑 PutObject
如果相同的字节总是映射到同一个键,那么存储同一个文件两次就相当于只存储了一次。上传路径在写入对象前会先检查它是否存在:
- 计算内容哈希键。
- 对该键执行
HeadObject操作。 - 如果对象已存在,则不执行任何操作。字节已经在那儿了。
- 如果对象不存在,则执行
PutObject操作。
这使得上传操作具有幂等性。再次运行该操作既廉价又安全,这在提取作业重试或同一资产出现在三个不同帖子中时非常重要。一个在四十篇文章中引用的共享徽标是一个对象,而不是四十个。你只需为 HeadObject 付费,它是一个元数据调用,远比冗余上传整个文件内容便宜。
// putImmutable stores b under a content-hash key, skipping the upload
// if an object with that key already exists.
func putImmutable(ctx context.Context, cli *s3.Client, bucket string, b []byte, ext string) (string, error) {
sum := sha256.Sum256(b)
key := fmt.Sprintf("img/%s.%s", hex.EncodeToString(sum[:]), ext)
_, err := cli.HeadObject(ctx, &s3.HeadObjectInput{
Bucket: &bucket,
Key: &key,
})
if err == nil {
return key, nil // already present, dedup hit
}
var nf *types.NotFound
if !errors.As(err, &nf) {
return "", fmt.Errorf("head %s: %w", key, err)
}
_, err = cli.PutObject(ctx, &s3.PutObjectInput{
Bucket: &bucket,
Key: &key,
Body: bytes.NewReader(b),
ContentType: aws.String(contentType(ext)),
CacheControl: aws.String("public, immutable, max-age=31536000"),
})
if err != nil {
return "", fmt.Errorf("put %s: %w", key, err)
}
return key, nil
}
请注意,HeadObject 会为缺失的键返回一个类型化的 NotFound 错误,而其他一切都是你不应忽略的真正失败。将任何错误都视为不存在,会将暂时的网络故障变成重复上传,这就违背了初衷。
将存储与数据库分离
第三个好处与缓存无关。它关乎 URL 的存放位置。
一个常见的错误是将完整的媒体 URL 存储在数据库中:https://media.example.com/img/9f86d0....png 存放在帖子旁边的列中。这种方法一直有效,直到有一天你更换主机、重命名存储桶或迁移域名。那时,所有这些绝对 URL 都将失效,修复它们意味着需要对你的内容进行迁移。
内容哈希键为你提供了一个清晰的分离。数据库只存储键 img/9f86d0....png,而从不存储主机。绝对 URL 是在渲染时根据环境变量组装而成的:
func mediaURL(base, key string) string {
return strings.TrimRight(base, "/") + "/" + key
}
// base comes from MEDIA_BASE_URL, e.g. https://media.example.com
url := mediaURL(cfg.MediaBaseURL, post.HeaderKey)
从 R2 迁移到其他提供商,或在前面放置一个新的 CDN,现在是一个两步操作,不触及任何行或文章正文。将对象复制到新位置,然后更改 MEDIA_BASE_URL。两边的键是相同的,因为它们是根据内容派生的,所以无论托管在何处,同一个键都会解析为相同的字节。你的数据库不知道也不关心今天是哪个存储桶在提供文件。
上传管道,端到端
以下是在处理 Markdown 文章的提取步骤中,各个部分如何协同工作的。作者编写像  这样的相对引用,而管道在上传时将它们重写为内容哈希键。
- 解析文章并收集每个本地图片引用。
- 对于每个被引用的文件,读取其字节并计算内容哈希键。
- 先执行
HeadObject,仅在未命中时才执行PutObject,并附带不可变缓存头。 - 将正文中的相对引用替换为存储的键。
- 持久化文章。正文和数据库现在携带的是键,绝不使用绝对 URL。
重写步骤是保持作者体验简单的关键。编写者处理的是在仓库中有意义的相对路径,而管道是唯一知道哈希和存储桶的地方。
func processBody(ctx context.Context, up *Uploader, body string, refs []ImageRef) (string, error) {
for _, ref := range refs {
b, err := os.ReadFile(ref.LocalPath)
if err != nil {
return "", fmt.Errorf("read %s: %w", ref.LocalPath, err)
}
key, err := up.PutImmutable(ctx, b, ref.Ext)
if err != nil {
return "", err
}
body = strings.ReplaceAll(body, ref.Original, key)
}
return body, nil
}
权衡:孤立对象与无法原地更新
内容寻址并非没有代价,使其优秀的特性也在某个特定方面带来了不便。因为对字节的任何更改都会产生一个新的键,所以你无法原地更新文件。覆盖的概念不复存在。编辑一张图片,你会得到一个位于新键的新对象。旧对象则原封不动地留在原处。
如果不再有任何地方引用旧的键,该对象就成了孤立对象。它会占用存储空间,但无人读取。随着时间的推移,被编辑和替换的资产会像无用之物一样堆积起来。没有自动清理机制,因为存储系统无法判断是否还有其他文章或旧的缓存页面指向该键。你需要一个垃圾回收器:定期列出存储桶中的所有对象,再列出你线上内容实际引用的所有键,然后删除那些出现在第一组但未出现在第二组中的对象。按计划运行它,并在删除前给孤立对象一个宽限期,这样进行中的调用者就不会收到 404 错误。
下表并列展示了这两种模型。
| 关注点 | 文件名键 (img/hero.png) |
内容哈希键 (img/{sha256}.png) |
|---|---|---|
| Cache-Control | 必须重新验证或设置较短的 TTL | immutable, max-age=31536000 |
| 更新图片 | 覆盖相同的键,清除 CDN 缓存 | 新的键,新的 URL,无需清除缓存 |
| 重复上传 | 独立的对象 | 单一对象,已去重 |
| 缓存过期的风险 | 真实存在,需要作废处理 | 不存在,URL 的含义永不改变 |
| 数据库存储 | 通常是绝对 URL | 仅存储键,主机名来自环境变量 |
| 清理负担 | 覆盖即可回收空间 | 孤立对象会累积,需要 GC |
| 原地更新 | 自然支持 | 设计上不可能 |
最后两行是代价。上面所有行都是收益。这项权衡是否值得,取决于你的文件实际更改的频率,以及你对作为回报获得的缓存和去重效果的重视程度。
何时文件名键仍是更好的选择
内容寻址对于一次写入、多次读取的媒体来说是更优的选择,这描述了内容网站上几乎所有的图像、视频、字体和静态下载文件。当一个槽位的身份比字节本身的身份更重要时,它就不是一个合适的工具。
在以下情况下,请使用稳定的文件名键:
- 你想要一个始终提供最新版本的规范 URL,并且你宁愿清除 CDN 缓存也不愿生成一个新的 URL。一个由市场部门原地覆盖更新的
logo/current.svg,其调用者又不能更新他们的引用,这就是一个适合使用文件名的任务。 - 路径本身带有你的系统所依赖的含义,例如
users/{id}/avatar.png,其中位置是一个查找键,并且每个槽位只有一个活动对象。内容寻址会将每次头像编辑分散到新的键上,最终你还是需要追踪哪一个是当前版本。 - 对象体积大、可变,并且被频繁编辑,因此孤立对象的堆积速度会快到任何合理的垃圾回收(GC)都无法应对。
对于这些情况,请接受缓存失效的工作并保留文件名。对于所有实际上是“一次写入”的内容,让内容本身来决定键。你用一个你很少需要的原地更新,换来了不变性、去重,以及一个在存储迁移后无需更改任何一行记录的数据库。