---
title: "ETag 怎么验证：用 curl 跑通 200、304 和内容更新"
date: 2026-11-12
slug: http-etag-conditional-get
canonical: https://kernelranch.com/blog/2026/11/http-etag-conditional-get
markdown_url: https://kernelranch.com/blog/2026/11/http-etag-conditional-get.md
tags: ["http", "caching", "etag", "curl"]
---前端每隔几秒拉一次 `/catalog.json`，绝大多数时候数据都没变。给响应加上 `ETag`，客户端就能在下次请求时用 `If-None-Match` 带上旧标签。内容相同，服务端返回无正文的 `304`，客户端复用手里的 JSON。内容变了，服务端返回 `200`、新正文和新标签。验证时要把这两条分支都跑一遍。

## 1. 一个可改内容的本地接口

保存为 `etag_lab.py`。在同一目录创建 `catalog.json`，再启动服务：

```bash
python -c 'from pathlib import Path; Path("catalog.json").write_bytes(b"{\"version\":1}")'
python etag_lab.py
```

实验只监听回环地址。每次请求读取文件的原始字节并计算 SHA-256，同一份字节既用于生成强 `ETag`，也用于发送 `200` 的响应体。

```python
import re
from hashlib import sha256
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from pathlib import Path


CATALOG = Path(__file__).with_name("catalog.json")
# 标签的引号内允许逗号，因此不能对整个字段直接 split(',')。
OPAQUE_TAG = r'"[\x21\x23-\x7e\x80-\xff]*"'
ENTITY_TAG = rf'(?:W/)?{OPAQUE_TAG}'
TAG_LIST = re.compile(rf'[ \t]*(?:{ENTITY_TAG}[ \t]*)?(?:,[ \t]*(?:{ENTITY_TAG}[ \t]*)?)*')


def matches_if_none_match(fields, etag):
    value = ",".join(fields).strip(" \t")
    if value == "*":
        return True  # 调用前已确认资源存在。
    if not TAG_LIST.fullmatch(value):
        return False  # 演示服务将格式无效的条件视为不匹配。
    return etag in re.findall(OPAQUE_TAG, value)  # 弱比较只比较 opaque-tag。


class CatalogHandler(BaseHTTPRequestHandler):
    protocol_version = "HTTP/1.1"

    def do_GET(self):
        self.serve_catalog(include_body=True)

    def do_HEAD(self):
        self.serve_catalog(include_body=False)

    def serve_catalog(self, include_body):
        if self.path != "/catalog.json":
            self.send_error(404)
            return
        try:
            body = CATALOG.read_bytes()
        except FileNotFoundError:
            self.send_error(404)
            return
        except OSError:
            self.send_error(500)
            return

        etag = f'"{sha256(body).hexdigest()}"'
        unchanged = matches_if_none_match(self.headers.get_all("If-None-Match", []), etag)
        self.send_response(304 if unchanged else 200)
        self.send_header("ETag", etag)
        self.send_header("Cache-Control", "no-cache")
        if not unchanged:
            self.send_header("Content-Type", "application/json")
            self.send_header("Content-Length", str(len(body)))
        self.end_headers()
        if include_body and not unchanged:
            self.wfile.write(body)


if __name__ == "__main__":
    with ThreadingHTTPServer(("127.0.0.1", 8765), CatalogHandler) as server:
        server.serve_forever()
```

`If-None-Match` 使用弱比较，所以 `W/"v1"` 可以匹配 `"v1"`。字段可以包含多个标签，任一匹配即可。`*` 在资源存在时匹配，缺失的资源仍走本例的 `404` 分支。代码覆盖本例用到的条件头，部署时可复用框架对 HTTP 条件请求的完整实现。

## 2. 用同一个旧标签请求两次

在另一个 Bash 窗口运行。第一条请求把响应头和正文分别落盘，从响应头提取标签时保留双引号：

```bash
url=http://127.0.0.1:8765/catalog.json
fmt='status=%{http_code} body_bytes=%{size_download}\n'

curl --noproxy '*' -sS -D catalog.headers -o catalog.body -w "$fmt" "$url"
old_tag=$(awk 'tolower($1) == "etag:" { sub(/\r$/, "", $2); print $2; exit }' catalog.headers)
test -n "$old_tag" || exit 1

curl --noproxy '*' -sS -H "If-None-Match: $old_tag" -o /dev/null -w "$fmt" "$url"

# 先改源文件，再用同一个旧标签请求。两个窗口使用同一工作目录。
python -c 'from pathlib import Path; Path("catalog.json").write_bytes(b"{\"version\":2}")'
curl --noproxy '*' -sS -H "If-None-Match: $old_tag" -D updated.headers -o updated.body -w "$fmt" "$url"
```

本地实测结果：

```text
status=200 body_bytes=13
status=304 body_bytes=0
status=200 body_bytes=13
```

第三次响应的 `ETag` 已变化，`updated.body` 是 `{"version":2}`。`size_download` 统计响应体，`304` 仍会传输状态行和响应头。这个场景用 GET 测量，`curl -I` 会发 HEAD，即使拿到 `200` 也没有正文。

再从 `updated.headers` 提取新标签，和一个不匹配的旧标签一起发送。命中列表中的弱标签时仍应返回 `304`：

```bash
new_tag=$(awk 'tolower($1) == "etag:" { sub(/\r$/, "", $2); print $2; exit }' updated.headers)
curl --noproxy '*' -sS -H "If-None-Match: \"old,tag\", W/$new_tag" -o /dev/null -w "$fmt" "$url"
```

## 3. 用缓存策略决定什么时候发请求

| 响应头 | 客户端或缓存的行为 | 适用场景 |
|---|---|---|
| `Cache-Control: no-cache` | 可以存响应，复用前要验证 | 希望每次读取都确认更新的公开配置 |
| `Cache-Control: public, max-age=60` | 新鲜期内可以直接复用，过期后可用 `ETag` 验证 | 能接受最多约一分钟缓存的公开目录 |
| `Cache-Control: private, no-cache` | 共享缓存不得存，私有缓存复用前要验证 | 允许浏览器保存且每次需验证的用户专属数据 |
| `Cache-Control: no-store` | 要求缓存不保存本次响应 | 不适合留在 HTTP 缓存中的数据 |

上面用 `curl` 手工发条件头。要验证浏览器或 CDN 实际有没有复用缓存，还要在相应客户端或边缘日志里观察请求，结合 `Age`、缓存状态和源站日志判断。`304` 仍可能伴随一次网络往返。想同时减少请求次数，可以为允许延迟更新的资源设置新鲜期。

## 4. 上线后最容易踩的两个坑

- **拿到 `304`，数据库和序列化还是照跑。** 示例每次都读文件并哈希，主要验证节省下行正文。想减少源站工作量，可以在生成快照时预计算标签，或者用能覆盖全部响应字段和变体的版本号，在加载完整数据前检查条件。
- **压缩或语言变体共用一个强标签。** 强标签应随表示数据的差异变化。如果同一路径会返回 gzip 和未压缩版本，检查各变体的 `ETag` 和 `Vary: Accept-Encoding`。语言选择基于 `Accept-Language` 时，也要让缓存知道这个请求头参与了变体选择。分别测试两种变体，确保旧标签只按设计的比较规则命中。

已有 CDN 缓存规则的项目，可以对照 [Cloudflare 缓存规则](/blog/2026/11/cache-rules.md) 检查边缘配置，再用 [curl 分段计时](/blog/2026/11/curl-timing-troubleshooting.md) 观察往返耗时。

## 小结

验收时先拿一次 `200` 和标签，再原样回传标签检查 `304` 的正文为零字节，最后修改内容并回传旧标签，确认拿到新标签和新正文。这个顺序能同时发现条件判断未生效和内容更新后标签未变化的问题。

## 数据来源与检索时间

检索时间 2026-11-12。

| 来源 | URL | 对应内容 |
|---|---|---|
| RFC 9110 §8.8.3 | https://www.rfc-editor.org/rfc/rfc9110.html#section-8.8.3 | 标签格式、强弱比较与内容编码变体 |
| RFC 9110 §13.1.2 | https://www.rfc-editor.org/rfc/rfc9110.html#section-13.1.2 | `If-None-Match` 匹配规则 |
| RFC 9110 §15.4.5 | https://www.rfc-editor.org/rfc/rfc9110.html#section-15.4.5 | `304` 的响应头和正文要求 |
| RFC 9111 §4、§5.2 | https://www.rfc-editor.org/rfc/rfc9111.html | 缓存复用、验证与缓存控制指令 |
| curl 手册 | https://curl.se/docs/manpage.html#-w | `--write-out` 和 `size_download` |
| Python 3.11 `http.server` | https://docs.python.org/3.11/library/http.server.html | 本地实验服务的请求分派和响应写入 |

## 核验说明

- 在本地回环接口通过 curl 验证首次 `200`、匹配后 `304`、更新后 `200`，并检查了响应体长度、更新后的正文和标签。另验证了弱标签列表、`*` 匹配、HEAD 以及文件缺失时的 `404`。
- 实验绕过代理，验证范围是服务端处理条件请求的行为。浏览器自动重验证、CDN 命中和压缩变体需在部署链路上单独核验。
