登录
推荐 文章 Go 技术 课程 下载 专题 AI
首页 >  Golang >  Go问答

Go ETag 条件请求为什么总是返回 200

来源:17golang原创

时间:2026-11-03 02:29:59 480浏览 收藏

Go 服务已经给响应加了 ETag,客户端下一次带着 If-None-Match 回来,却仍然得到 200,最常见的原因是服务端没有把这两个值放进同一个条件判断。ETag 不是“加上响应头就自动生效”的开关:handler 需要先生成当前资源标签,再读取请求头,匹配成功时在写响应体之前返回 304 Not Modified。

本文只处理 net/http 中的 ETag 条件 GET。先记住排障结论:请求头缺失、标签格式不同、资源已经变化、handler 太早写出 200,以及代理层重新生成响应,都可能让你看到 200。官方文档入口如下,便于对照 Request、Header 和 ResponseWriter 的 API。

官方文档:https://pkg.go.dev/net/http

ETag 匹配时应该在写入响应体之前返回 304;只有标签不匹配或请求不是条件 GET 时,才发送资源正文和 200。返回 200 本身不一定是 Go 出错,关键是先确认服务端收到的 If-None-Match 和当前 ETag 是否代表同一个版本。

先确认条件请求到底带了什么

排查不要先盯着浏览器的“是否命中缓存”。先把一次请求拆成四个事实:方法和路径、客户端发来的 If-None-Match、handler 计算出的当前 ETag、最终写出的状态码。如果第二项为空,服务端自然只能走 200;如果两项看起来一样却仍为 200,就继续查比较逻辑和响应写入时机。

ETag 的值通常是带引号的实体标签,例如 "article-v17",弱标签则是 W/"article-v17"。不要把它当作普通业务 ID 丢掉引号,也不要在一个请求中重新随机生成标签,否则客户端永远拿不到稳定的版本标识。

按 HTTP 语义比较 ETag

最小实现可以先处理单个标签,但生产代码至少要考虑逗号分隔的多个标签和 *。下面的辅助函数只负责“当前资源是否被 If-None-Match 覆盖”,不负责写响应,因此更容易单独检查。

package etagdemo

import "strings"

// ifNoneMatchContains 判断请求头是否包含当前实体标签。
// 这里只去掉弱标签前缀用于缓存命中判断,不改变响应中发送的原始 ETag。
func ifNoneMatchContains(headerValue, currentETag string) bool {
	if strings.TrimSpace(headerValue) == "*" {
		return true // 星号表示已有任意当前表示。
	}

	current := strings.TrimSpace(currentETag)
	if strings.HasPrefix(current, "W/") {
		current = strings.TrimSpace(strings.TrimPrefix(current, "W/"))
	}
	for _, candidate := range strings.Split(headerValue, ",") {
		value := strings.TrimSpace(candidate)
		if strings.HasPrefix(value, "W/") {
			value = strings.TrimSpace(strings.TrimPrefix(value, "W/"))
		}
		if value == current {
			return true
		}
	}
	return false
}

这段代码的边界要说清楚:它适合 GET/HEAD 的缓存命中判断,不能替代需要强校验的并发写入条件。比如更新接口通常还要严格区分弱标签,并结合 If-Match 的强比较语义。标题讨论的是“为什么总是 200”,重点是先让读取路径的判断闭环。

If-None-Match 与当前 ETag 比较后返回 304 或 200 的静态结构图
图1:ETag 条件请求的输入、比较和响应状态关系(静态说明图,不是运行截图)。

在写入响应体前返回 304

很多“总是 200”的实现已经设置了 ETag,却先调用了 WriteHeader(200)、Write 或 http.ServeContent,之后才判断请求头。HTTP 响应状态一旦写出,再改成 304 就晚了。正确顺序是:读取资源版本,设置当前 ETag,比较请求头,命中后设置缓存相关头并直接返回。

package main

import (
	"crypto/sha256"
	"encoding/hex"
	"net/http"
)

var articleBody = []byte("ETag protects a stable representation")

// makeETag 用资源内容生成稳定标签;内容没变时,跨请求标签也不变。
func makeETag(body []byte) string {
	sum := sha256.Sum256(body)
	return `"` + hex.EncodeToString(sum[:8]) + `"`
}

// articleHandler 先完成条件判断,再决定返回 304 还是带正文的 200。
func articleHandler(w http.ResponseWriter, r *http.Request) {
	currentETag := makeETag(articleBody)
	w.Header().Set("ETag", currentETag)
	w.Header().Set("Cache-Control", "private, max-age=60")

	if r.Method == http.MethodGet || r.Method == http.MethodHead {
		if ifNoneMatchContains(r.Header.Get("If-None-Match"), currentETag) {
			w.WriteHeader(http.StatusNotModified) // 304 不应写资源正文。
			return
		}
	}

	if r.Method == http.MethodHead {
		w.WriteHeader(http.StatusOK) // HEAD 只给响应头,不写正文。
		return
	}
	w.Header().Set("Content-Type", "text/plain; charset=utf-8")
	w.WriteHeader(http.StatusOK) // 未命中条件标签时才发送 200。
	_, _ = w.Write(articleBody)
}

func main() {
	http.HandleFunc("/article", articleHandler)
	_ = http.ListenAndServe(":8080", nil) // 示例服务;生产环境应配置超时和优雅退出。
}

实现中有三个容易忽略的点。第一,ETag 在 200 和 304 两条路径都要存在,方便客户端继续使用当前版本。第二,304 响应不应携带资源正文。第三,HEAD 可以复用同一套标签判断,但它本来就不发送正文,不能把 HEAD 的行为误当成 GET 缓存命中。

定位为什么仍然返回 200

如果请求已经携带 If-None-Match,可以按下面顺序定位。每一步只回答一个问题,不要同时修改缓存头、代理配置和 handler,否则很难知道是哪一层起作用。

  1. 请求是否真的到达了这个 handler:确认路径、方法和路由没有被重定向或转发到另一套处理函数。
  2. 请求头是否被保留:在服务端日志中记录是否存在和长度即可,不要记录用户凭据;反向代理可能会删除或重写条件头。
  3. 当前 ETag 是否稳定:如果标签带时间戳、随机数或每次序列化顺序都变化,比较必然失败。
  4. 比较是否发生在第一次写出之前:一旦调用 Write,Go 通常会隐式写出 200,后续再调用 WriteHeader(304) 不会得到想要的结果。
  5. 客户端看到的状态是否来自源站:CDN、网关或缓存服务可能替源站生成响应,观察源站日志和代理的响应头要分开。
Go ETag 条件请求返回 200 时的四组排障信号矩阵
图2:返回 200 时优先对照的四组排障信号(静态结构图,不是运行证据)。

用表格区分几种 200

现象更可能的原因处理动作
If-None-Match 为空客户端尚未缓存,或代理删除请求头先检查响应是否确实发出 ETag,再检查转发规则
请求标签与当前标签不同资源版本已经变化,或标签生成不稳定记录两者的规范化值,确认是否绑定同一份表示
两者相同但响应为 200比较分支未执行,或正文已提前写出把比较移动到所有 Write/WriteHeader 之前
源站日志为 304,客户端为 200中间层重新请求或改写了响应分别查看源站和代理的请求 ID、状态和缓存头

注意,调试日志不要把完整的 Cookie、Authorization 或正文打印出来。ETag 通常不是秘密,但把它和用户标识、完整请求头绑定输出,仍可能让排障日志失去最小化原则。

并发更新和缓存头的边界

ETag 只描述某一份表示的版本,不会自动解决资源更新时的并发冲突。如果文章正文来自数据库或对象存储,应在读取同一版本数据时计算标签,避免正文来自版本 A、ETag 却来自版本 B。更新接口的乐观锁则应单独设计,不要因为 GET 返回过 304 就把弱标签当成写入授权。

Cache-Control 也不能省略。ETag 负责“这份表示是否仍然匹配”,缓存策略负责“客户端和中间层什么时候可以复用它”。如果响应需要用户隔离,使用合适的 private 或其他明确策略;不要为了看到 304 而把个性化正文标成公共缓存。

// cacheHeaders 给同一资源版本设置一致的缓存元数据。
func cacheHeaders(w http.ResponseWriter, etag string) {
	w.Header().Set("ETag", etag)
	w.Header().Set("Cache-Control", "private, max-age=60")
	// 资源内容会随语言变化时,必须把语言维度纳入缓存键。
	w.Header().Add("Vary", "Accept-Language")
}

常见问题

ETag 一定要用 SHA-256 吗?

不一定。它只需要稳定地标识某次表示,使用内容摘要、数据库版本号或对象存储版本号都可以。摘要要避免过度截断造成碰撞,版本号则必须和实际响应内容同步。

请求带了 If-None-Match,服务端能直接返回 304 吗?

不能只看请求头是否存在,还要比较它是否匹配当前表示。没有匹配时仍应返回完整资源,通常是 200。

为什么设置 ETag 后浏览器还是下载正文?

先确认第二次请求真的带上了 If-None-Match,再查标签是否每次变化、响应是否提前写出以及中间层是否替换了状态。只设置响应头不会替 handler 完成条件判断。

304 响应里能返回 JSON 提示吗?

不应把 304 当作业务 JSON 响应。客户端应复用自己已有的缓存表示;如果需要返回新的业务数据,就应发送对应的 200 或其他明确状态。

小结

Go ETag 条件请求总是返回 200,排查核心不是反复刷新客户端,而是核对“请求标签—当前标签—比较时机—最终写出层”这条链路。把 ETag 绑定到稳定资源版本,使用兼容的 If-None-Match 判断,并在写正文前结束 304 分支,通常就能解决服务端实现造成的 200。若源站已经返回 304,再把注意力转向代理和缓存层。

声明:本文转载于:17golang原创 如有侵犯,请联系study_golang@163.com删除
相关阅读
更多>
最新阅读
更多>
课程推荐
更多>