404 响应有两类读者:软件需要准确的状态码,访客需要一条能继续前进的路径。错误页即使设计精美,也不能用正文弥补错误的 200;状态码正确,也不代表页面可以难以阅读或没有导航。应当同时保证协议和内容诚实,而不是把一个缺失地址误写成成功,或者把正常的缺失当成整个业务故障。
在渲染正文前确定状态
RFC 9110 对 404 的定义是:源站没有找到目标资源的当前表示,或者不愿透露它确实存在。这并不说明缺失是暂时还是永久。如果服务端知道资源很可能已永久删除,可以使用 410;如果地址确实迁移,就把它重定向到对应的新资源。不要把所有未知地址都送往首页,因为这样既掩盖坏链,也会把访客带到与原意无关的地方。
依赖故障和资源缺失不是一回事。数据库连接失败,导致应用无法判断记录是否存在时,应保留对应的服务端错误语义,不能把所有异常统一变成 404。另一方面,权限策略有时会故意用 404 隐藏资源存在性;这种策略要在契约中说明,而不能在错误正文里泄露对象编号、账号归属或权限细节。相同状态码的选择,必须能由业务事实和安全策略解释。
Google 可能把带错误内容的成功响应识别为 soft 404。返回 200 不保证页面被收录,返回 404 也不保证搜索结果立刻消失。工程上可以承诺的是准确状态、明确内容和正确链接,不能把搜索引擎的后续抓取与索引处理说成即时、确定的操作。修复坏链以后,还需要观察实际抓取和访问结果,而不是仅凭模板名称宣布问题消失。
让错误正文独立且安全
页面用一句简短说明、一个真实首页或目录链接帮助读者恢复方向即可。静态站没有必要为了错误页新增搜索表单、第三方组件或数据库查询;本地静态资源和简单模板更容易在故障时继续工作。API 则应返回已经约定的机器可读错误格式,并保留 404,不能返回一份状态为成功的 HTML 让调用方误判。
状态必须先于正文写入。在 Go 中,如果还没有调用 WriteHeader,第一次写正文会隐式选择 200,后面再试图设置 404 就已经太晚。HEAD 应保持与 GET 相同的状态和适用的表示元数据,但不能传输正文。直接打开某个错误模板文件,只证明那个文件能显示,并不证明真实的缺失请求会进入正确处理器。
坏掉的样子
把所有路径重写到首页,会让缺失文章、脚本和样式表都得到假成功;反向代理也可能把源站的 404 改成自己的 200 错误页。另一个常见问题是处理器先输出页面框架,随后才发现文件不存在。排查时应在实际入口请求一个确定不存在的地址,先禁止自动跟随重定向,再对照正常地址的第一份响应,而不是只看浏览器最终显示了什么。
404 还可能进入缓存。标准允许它在合适条件下进行启发式缓存,过长的负缓存寿命会让刚创建或恢复的页面继续显示不存在。应该明确选择缓存策略;no-store 可以阻止新的存储,却不会主动清除之前已经存入缓存的响应。正文不要未经转义回显查询串,日志也不要记录可能携带认证信息的完整地址。按规范化路径统计缺失请求,避免把每一个随机 URL 都变成新的指标标签。
快速验证
把下面程序保存到临时工作目录的 not_found_probe.go,运行 go run not_found_probe.go。它只启动一个回环 HTTP/1.1 服务,不自动跟随跳转,恢复动作仅改变内存标志。/soft-miss 是故意留下的反例,用于证明“正文像错误页”不足以验证状态,不能把该路由当成推荐实现。
package main
import (
"fmt"
"io"
"net/http"
"net/http/httptest"
"runtime"
"strconv"
"strings"
"sync/atomic"
"time"
)
func must(err error) {
if err != nil {
panic(err)
}
}
func check(ok bool, message string) {
if !ok {
panic(message)
}
}
func main() {
const missing = `<!doctype html><title>Not found</title><h1>Page not found</h1><a href="/">Home</a>`
var restored atomic.Bool
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
status, body, media := 404, missing, "text/html; charset=utf-8"
switch r.URL.Path {
case "/":
status, body = 200, "<!doctype html><title>Home</title><h1>Home</h1>"
case "/gone":
status = 410
case "/moved":
status = 308
w.Header().Set("Location", "/")
case "/offline":
status, body = 503, "Service temporarily unavailable"
case "/soft-miss":
status = 200 // Deliberate bug: the body alone cannot identify success.
case "/restored":
if restored.Load() {
status, body = 200, "Restored resource"
}
default:
if strings.HasPrefix(r.URL.Path, "/api/") {
media, body = "application/json", `{"error":"not_found"}`
}
}
if r.Method != "GET" && r.Method != "HEAD" {
status, body = 405, "Method not allowed"
w.Header().Set("Allow", "GET, HEAD")
if media == "application/json" {
body = `{"error":"method_not_allowed"}`
}
}
w.Header().Set("Content-Type", media)
w.Header().Set("Cache-Control", "no-store")
w.Header().Set("Content-Length", strconv.Itoa(len(body)))
w.WriteHeader(status) // Status must be committed before writing the body.
if r.Method != "HEAD" {
_, err := io.WriteString(w, body)
must(err)
}
}))
defer server.Close()
transport := &http.Transport{}
defer transport.CloseIdleConnections()
client := &http.Client{Transport: transport, Timeout: 2 * time.Second,
CheckRedirect: func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse },
}
probe := func(method, route string, status int, media string) (*http.Response, string) {
request, err := http.NewRequest(method, server.URL+route, nil)
must(err)
response, err := client.Do(request)
must(err)
body, readError := io.ReadAll(response.Body)
closeError := response.Body.Close()
must(readError)
must(closeError)
check(response.StatusCode == status && response.Proto == "HTTP/1.1", "wrong status or protocol")
check(response.Header.Get("Content-Type") == media, "wrong media type")
check(response.Header.Get("Cache-Control") == "no-store", "fixture must not reuse negative responses")
check(!strings.Contains(string(body), "do-not-echo"), "request query leaked into error page")
fmt.Printf("%s %s: %d, body=%d bytes\n", method, request.URL.Path, status, len(body))
return response, string(body)
}
const html = "text/html; charset=utf-8"
probe("GET", "/", 200, html)
get, body := probe("GET", "/missing?hint=do-not-echo", 404, html)
check(body == missing && strings.Contains(body, `href="/"`), "missing page must offer a real home link")
head, empty := probe("HEAD", "/missing", 404, html)
check(empty == "" && head.ContentLength == get.ContentLength, "HEAD must preserve status and length without a body")
probe("GET", "/assets/missing.css", 404, html)
_, api := probe("GET", "/api/missing", 404, "application/json")
check(api == `{"error":"not_found"}`, "API must not receive an HTML success page")
probe("GET", "/gone", 410, html)
move, _ := probe("GET", "/moved", 308, html)
check(move.Header.Get("Location") == "/", "known move must name its target")
probe("GET", "/offline", 503, html)
soft, softBody := probe("GET", "/soft-miss", 200, html)
check(softBody == missing && soft.StatusCode != get.StatusCode, "negative control must expose a soft 404")
probe("GET", "/restored", 404, html)
restored.Store(true)
_, recovered := probe("GET", "/restored", 200, html)
check(recovered == "Restored resource", "restored resource must no longer return 404")
rejected, _ := probe("POST", "/missing", 405, html)
check(rejected.Header.Get("Allow") == "GET, HEAD", "method rejection must describe allowed methods")
apiRejected, apiError := probe("POST", "/api/missing", 405, "application/json")
check(apiRejected.Header.Get("Allow") == "GET, HEAD" && apiError == `{"error":"method_not_allowed"}`, "API method errors must retain JSON")
fmt.Println("PASS: thirteen requests; missing, moved, unavailable, and restored remain distinct")
fmt.Println(runtime.Version())
}
十三次请求覆盖正常首页、HTML 缺失、API 缺失、丢失的静态文件、已删除资源、已知迁移和暂时不可用。GET 与 HEAD 对缺失资源返回相同状态和声明长度,但 HEAD 没有正文;查询参数标记不会出现在页面中。soft-miss 的正文和真实 404 完全一致,错误的 200 才揭示它的问题。随后同一个恢复地址从 404 变为 200,说明资源重新出现后不应继续返回缺失;POST 则独立返回 405 和 Allow,避免把方法错误混为不存在,API 的方法错误也保持 JSON 格式。
这些断言已在 macOS、Go 1.27.0 下运行通过。实验使用 no-store 声明,并不实现真实浏览器或 CDN 缓存,因此不能据此证明负缓存已经失效,也没有验证搜索引擎索引和线上代理配置。发布前还需要在网站真实服务链路上分别检查中英文正常路径和虚构路径,确认服务器没有因为美化错误页而改变协议语义。
延伸阅读
来源
- RFC 9110:HTTP 语义,404、410、HEAD 和表示语义,已核验 2026-09-09。
- RFC 9111:HTTP 缓存,负响应存储和缓存控制,已核验 2026-09-09。
- MDN:404 Not Found,资源缺失和自定义错误页,已核验 2026-09-09。
- Google Search:HTTP 与网络错误,soft 404 和索引行为,已核验 2026-09-09。
- Go net/http,响应状态提交和重定向控制,已核验 2026-09-09。