Golang Gin Static 缓存大坑:embed 文件无法被 CDN 和浏览器缓存

更新日期: 2025-12-06 阅读次数: 25 字数: 2049 分类: golang

Golang 最爽的一点是,可以打包时将静态文件 embed 进二进制文件中,部署时只需一个可执行文件,极大简化了部署流程。 例如,网站的 js 和 css 文件,可以 embed 进二进制文件中,而无需发布时再额外拷贝一堆静态文件。

Golang Gin Static Embed Files Code

但是,最近我发现了一个浏览器缓存问题!

😓 问题现象

今天早晨,我还没起床,突然想看看最新开发的一个网页版手机租金计算器是否能在微信里正常打开,主要是担心微信对某些 js 特性的支持不好。 结果发现,页面一片空白,但是刷新了几次就正常了。有点懵,打开阿里云 ESA 的请求日志,发现访问 css 文件时,每次都回源站请求。 其中一次源站没有响应,导致页面空白。这里存在两个问题:

  1. 为何阿里云 ESA 没有缓存静态资源文件,需要回源站请求?
  2. 为何浏览器每次都重新下载静态资源文件,而不是使用缓存?
  3. CSS 文件没有加载成功为何会导致整个页面空白?按理说只是样式混乱罢了。

🔍 对比排查

使用 Golang Gin 框架时,使用 Static 方法为嵌入的静态文件提供服务时,无法正确处理 HTTP 缓存响应头,导致浏览器每次都重新下载文件。 即便使用了 CDN 缓存,CDN 也无法正确缓存这些静态文件。

而且是只有 golang gin 开发的页面才有这个问题,其他 php laravel 开发的页面没有这个问题 (laravel 的静态文件是使用 Nginx 提供服务的)。 另一个海外站用的 cloudflare 也是同样的问题。

⚠ 没有被缓存的静态文件响应头

使用浏览器的开发者工具查看,注意要取消勾选“禁用缓存”选项,然后刷新页面,查看静态文件的响应头:

ali-swift-global-savetime: 1764985449
alt-svc: h3=":443"; ma=86400
content-encoding: zstd
content-type: text/css; charset=utf-8

date: Sat, 06 Dec 2025 01:44:09 GMT
eagleid: d364082217649854493746454e
server: ESA
timing-allow-origin: *
vary: accept-encoding
via: ens-cache69.l2cn7144[22,0,DP], ens-cache69.l2cn7144[23,22,200-0,M], ens-cache20.l2cn7144[24,0], ens-cache33.cn8004[36,0,DP], ens-cache33.cn8004[36,36,200-0,M], ens-cache14.cn8004[37,0]
x-site-cache-status: BYPASS  👀👀👀 (bypass 是绕过、避开的意思)
x-swift-cachetime: 0
x-swift-savetime: Sat, 06 Dec 2025 01:44:09 GMT

可以看到,响应头里没有任何和缓存相关的字段,例如 Cache-ControlETagLast-Modified 等等。

🎯 正常被缓存的静态文件响应头

而使用 Nginx 提供服务的静态文件,响应头如下:

accept-ranges: bytes
age: 1209
ali-swift-global-savetime: 1764984157
alt-svc: h3=":443"; ma=86400
content-length: 451   👀👀👀
content-type: text/css
date: Sat, 06 Dec 2025 01:22:37 GMT
eagleid: d36408db17649853667641854e
etag: "5c45ab02-1c3"  👀👀👀
last-modified: Mon, 21 Jan 2019 11:20:34 GMT  👀👀👀
server: ESA
timing-allow-origin: *
via: ens-cache84.l2cn7144[66,0,DP], ens-cache84.l2cn7144[0,0,304-0,H], ens-cache60.l2cn7144[1,0], ens-cache31.cn8004[16,0,DP], ens-cache31.cn8004[0,0,200-0,H], ens-cache32.cn8004[8,0]
x-site-cache-status: HIT  👀👀👀 (hit 是缓存命中的意思)
x-swift-cachetime: 3600
x-swift-savetime: Sat, 06 Dec 2025 01:42:09 GMT

可以看到,响应头里有 ETagLast-Modified 字段,浏览器和 CDN 都可以根据这些字段来判断文件是否被修改,从而决定是否使用缓存。

😅 问题出在哪里?

我一开始以为是 golang gin 的 static 方法的问题,但是查看 github 上的讨论,说 static 是支持缓存的。 于是我猜测就是 embed 嵌入文件导致的,因为嵌入的文件就没有本地时间戳的概念了,而且如果没有自动计算 etag,那么浏览器和 CDN 就无法判断文件是否被修改。

直接找到了 github 上 golang 项目的 issue:

官方讨论了几年了,还在讨论中。这是 2021 年的一个讨论:

embed: modification time not set for embedded files #44854

github.com/golang/go/issues/44854

When modules are downloaded, their files are stripped of all metadata including timestamps and permission bits. That means embedded files from a module don't have that information. The only case where it would be feasible is when the embedded files are part of the main module, but then a program's behavior would subtly depend on how it was built.

This was discussed a bit in the embed proposal (#41191) (expand all comments, search for "timestamp"). The discussion there seems to recommend hashing and ETag for cache invalidation. However, ETag wasn't implemented in embed.FS or io/fs, and the proposal (#43223) was declined. It sounds like that may be reopened at some point when there's a clearer picture of how to implement that.

然后 2023 年,又开始了新的讨论:

io/fs, net/http: define interface for automatic ETag serving #60940

github.com/golang/go/issues/60940

I believe net/http/fs.go is not directly related to the new io/fs.Hash interface, so http.ServeContent should not auto add an ETag. By default, it should rely on the Last-Modified header for 304 caching. If users need a custom ETag, they should set it manually.

In HTTP, 304 caching is validated based on time or hash values, using the commonly used headers Last-Modified and ETag.
For most static files, Last-Modified alone is sufficient to enable 304 caching, and ETag is not necessary.
For embed files, since the default Last-Modified value is zero, it can be set to the app startup time and sync across multiple deploy using an env variable.

If ETag is required, the hash can be computed and set before return the response via net/http:

w.Header().Set("Content-Type", "...") // If not set, http.ServeContent will auto-detect
w.Header().Set("Etag", computeEtagFromStat(stat)) // Pass ETag to http.ServeContent
http.ServeContent(w, r, stat.Name(), stat.ModTime(), file)

划重点:

For embed files, since the default Last-Modified value is zero

即,嵌入的文件默认的 Last-Modified 时间是零值,导致浏览器和 CDN 无法使用时间戳来判断文件是否被修改,从而无法使用缓存。

✈ Nginx Etag 的计算方法

我查了一下 Nginx ETag 的计算公式:

一个典型的 Nginx ETag 值(例如上面的那个示例 "5c45ab02-1c3")由两部分组成,以连字符分隔:

ETag = {文件最后修改时间的十六进制} - {文件内容长度的十六进制}

  • 第一部分 5c45ab02:这是文件最后修改时间的十六进制表示。具体来说,这是自 Unix 纪元(1970 年 1 月 1 日)以来的秒数,转换为十六进制格式。
  • 第二部分 1c3:这是文件内容长度的十六进制表示

如果要自己在 golang gin 里实现 ETag,可以参考这个计算方法。但是 Last-Modified 时间戳具体是使用服务启动时间,还是其他部署时间,需要斟酌一下了。

🍎 解决方案

我感觉去实现一个自动计算 ETag 或者以 golang 启动时间作为 Last-Modified 时间戳的方案都不太合适。 增加了应用层的复杂度,也影响启动时间。

直接使用 Nginx,我觉得是最简单可靠的方案。唯一的麻烦就是,部署到服务器时,需要额外拷贝一堆静态文件。

但是,我想了一下,似乎也不是很复杂,因为我的后台原本就是前后端分离的。 在发布 Ant Design Pro 前端项目时,就是自动打包前端代码,然后 scp 到服务器,再自动解包。

把这个流程复制一份,来拷贝 golang gin 的静态资源文件就可以了。

总结一下,就是原来的 scp 一个二进制文件,变成 scp 一个二进制文件 + 一个静态资源文件夹的压缩包。然后 Nginx 来提供静态文件服务。

然后,开发环境继续使用 Static 非 embed 的方式调试静态文件,生产环境使用 Nginx 提供服务。

关于作者 🌱

我是来自山东烟台的一名开发者,有感兴趣的话题,或者软件开发需求,欢迎加微信 zhongwei 聊聊, 查看更多联系方式