验证器让接收方询问:自己保存的表示,是否仍与当前选中的表示相符。条件 GET 验证成功时,可以返回没有正文的 304,避免重新传输内容。新鲜度是另一个决定,它控制已存响应何时能直接复用而不发送这次请求。ETag 本身不指定缓存寿命,304 也不是一份替代正文;客户端需要保留原响应,才能利用成功验证的结果。
坏掉的样子
把所有 ETag 都叫作正文哈希,会掩盖 nginx 的一个重要实现细节。在这里测试的 1.28.0 静态文件实现中,生成的标签组合了修改时间和内容长度,并不计算文件内容哈希。如果替换字节时保留这两个输入,就可能保留同一个标签。于是,持有不同旧字节的客户端仍收到看似成功的验证,错误不能仅靠“响应里有 ETag”来排除。
强弱验证器适用于不同比较。对于需要字节身份的用途,强验证器必须能区分表示数据变化;弱验证器则可以把字节不同、但被认为语义等价的表示归在一起。W/ 并不是“接受字节等价变化”的标志,也不意味着这个标签适用于所有前提条件或字节范围续传。使用哪种比较,需要依据具体请求条件,而不是凭标签外观猜测。
条件请求不保证省掉应用计算。服务器可能先生成表示,之后才判断验证器是否匹配。这个静态 nginx 示例根本没有应用渲染器。较小的响应在这里证明节省了正文传输,却不能证明数据库查询、渲染成本或回源请求数必然减少。评估优化效果时,应把实际减少的数据传输和实际减少的后台工作分别测量。
快速验证
把脚本保存为 check-validators.py,先取得固定摘要的 nginx 镜像,再用 Python 和 Docker 运行。它通过宿主回环提供一个可丢弃的四字节静态文件,实测 nginx 版本为 1.28.0。实验只控制容器内文件的时间属性,不修改宿主时间,也不修改共享 Docker 虚拟机时钟:
from contextlib import contextmanager
from pathlib import Path
from tempfile import TemporaryDirectory
import http.client,json,socket,subprocess,time,uuid
NGINX='nginx@sha256:552e7481ca93ffccd046aa658dbbed22caefbc09c66fa7cd247cbb90b8a5c609'
PYTHON='python@sha256:528257d48c1da0dcecc2e725d1ae34498d60c965f1241e39cd6a85a8859bdf84'
def docker(*args,check=True):
result=subprocess.run(['docker',*args],capture_output=True,text=True,timeout=120)
if check:assert result.returncode==0,result.stderr
return result
def wait(predicate):
deadline=time.monotonic()+15
while time.monotonic()<deadline:
try:
if predicate():return
except OSError:pass
time.sleep(.1)
raise AssertionError('fixture did not become ready')
@contextmanager
def lab(configuration,files=None,backend=None):
name='xbcatm-nginx-'+uuid.uuid4().hex[:10];tags=[name+':proxy',name+':backend'];containers=[name+'-proxy',name+'-backend']
try:
with TemporaryDirectory(prefix=name+'-') as directory:
root=Path(directory);(root/'files').mkdir();(root/'nginx.conf').write_text(configuration)
for path,content in (files or {}).items():
target=root/'files'/path;target.parent.mkdir(parents=True,exist_ok=True)
target.write_bytes(content if isinstance(content,bytes) else content.encode())
docker('network','create',name)
if backend is not None:
(root/'backend.py').write_text(backend)
(root/'backend.Dockerfile').write_text(f'FROM {PYTHON}\nCOPY backend.py /backend.py\nCMD ["python", "/backend.py"]\n')
docker('build','--network','none','-f',str(root/'backend.Dockerfile'),'-t',tags[1],str(root))
docker('run','-d','--name',containers[1],'--network',name,'--network-alias','backend',tags[1])
wait(lambda:docker('exec',containers[1],'python','-c','import socket;s=socket.create_connection(("127.0.0.1",8081),timeout=.5);s.close()',check=False).returncode==0)
(root/'Dockerfile').write_text(f'FROM {NGINX}\nCOPY nginx.conf /etc/nginx/nginx.conf\nCOPY files /srv\n')
docker('build','--network','none','-t',tags[0],str(root))
docker('run','--rm','--network',name,tags[0],'nginx','-t')
docker('run','-d','--name',containers[0],'--network',name,'-p','127.0.0.1::8080',tags[0])
details=json.loads(docker('inspect',containers[0]).stdout)[0]
binding=details['NetworkSettings']['Ports']['8080/tcp'][0];assert binding['HostIp']=='127.0.0.1';port=int(binding['HostPort'])
def ready():
connection=socket.create_connection(('127.0.0.1',port),timeout=.5);connection.close();return True
wait(ready)
def request(path,method='GET',headers=None,body=None):
connection=http.client.HTTPConnection('127.0.0.1',port,timeout=5)
try:
connection.request(method,path,body=body,headers=headers or {})
response=connection.getresponse();return response.status,{k.lower():v for k,v in response.getheaders()},response.read()
finally:connection.close()
request.port=port
yield containers[0],request
finally:
docker('rm','-f',*containers,check=False);docker('image','rm',*tags,check=False);docker('network','rm',name,check=False)
def config(locations,extra=''):
return 'worker_processes 1;\nerror_log /tmp/error.log notice;\nevents {}\nhttp {\naccess_log off;\n'+extra+'\nserver { listen 8080; root /srv;\n'+locations+'\n}\n}\n'
files={'asset.txt':'AAAA','noetag.txt':'AAAA'}
with lab(config('location = /noetag.txt { etag off; }'),files) as (proxy,request):
def timestamp(value):docker('exec',proxy,'touch','-m','-d','@'+str(value),'/srv/asset.txt')
timestamp(1577836800)
status,headers,body=request('/asset.txt');assert status==200 and body==b'AAAA'
tag=headers['etag'];modified=headers['last-modified'];results={'etag':tag,'last_modified':modified}
cases={
'matching_tag':({'If-None-Match':tag},304),
'weak_comparison':({'If-None-Match':'W/'+tag},304),
'matching_date':({'If-Modified-Since':modified},304),
'tag_takes_precedence':({'If-None-Match':'"different"','If-Modified-Since':modified},200),
'weak_if_match_rejected':({'If-Match':'W/'+tag},412),
}
for name,(conditions,expected) in cases.items():
status,_,body=request('/asset.txt',headers=conditions);assert status==expected,(name,status)
if status==304:assert body==b''
results[name]=status
docker('exec',proxy,'sh','-c','printf BBBB > /srv/asset.txt');timestamp(1577836800)
status,current,body=request('/asset.txt');assert status==200 and body==b'BBBB' and current['etag']==tag
status,_,body=request('/asset.txt',headers={'If-None-Match':tag});assert status==304 and body==b''
results['changed_bytes_preserved_metadata']=status
timestamp(1577836802)
status,current,body=request('/asset.txt',headers={'If-None-Match':tag})
assert status==200 and body==b'BBBB' and current['etag']!=tag and current['last-modified']!=modified
results['changed_metadata']={'status':status,'etag':current['etag']}
status,current,_=request('/noetag.txt');assert status==200 and 'etag' not in current
print(json.dumps(results,sort_keys=True))
最初文件内容是 AAAA,修改时间固定为 2020-01-01,产生 ETag "5e0be100-4"。GET 在 If-None-Match 中携带这个标签时,得到正文为空的 304。把请求标签加上 W/ 仍返回 304,因为该条件采用弱比较。在本次配置中,携带相等 If-Modified-Since 日期,也会得到 304。这些结果展示标签与日期两条验证路径。
如果同时发送不匹配的 If-None-Match 和匹配日期,则返回 200。实体标签条件优先于日期条件;不能通过补一个有利时间戳,让不匹配标签被忽略。单独的 If-Match 检查会拒绝弱请求标签并返回 412。这里实际执行的是 GET 前提条件检查,并没有执行写入业务流程,因此不能把结果扩展为完整并发写保护已经验证。
随后脚本把 AAAA 改成 BBBB,并恢复原修改时间。无条件 GET 证明正文已经变化,而标签仍完全相同。再次发送旧标签,又得到 304。这是有意构造的反例:即使 nginx 正确执行自己的标签生成算法,部署时保留元数据也可能破坏期望的表示身份契约。检查正文变化和标签变化必须配对进行,不能只看一个成功状态码。
把文件时间推进两秒后,生成的标签和 Last-Modified 都变化了。携带旧标签的请求随后得到 200 和 BBBB。最后一个对照在另一资源上关闭 etag,确认不再生成 ETag 响应头。关闭标签只是移除这一验证器,不会自动得到更强的替代,也不会自动关闭所有缓存。要采取哪种验证策略,仍需与实际发布方式一起设计。
让部署保持验证器契约
如果需要不同版本的字节分别拥有稳定地址,可以用内容导出的文件名在 URL 层区分版本。对于必须保留的稳定 URL,应确保表示字节变化时部署元数据也随之变化,或者由服务应用明确提供合适的内容派生验证器。审阅时既要考虑快速发生的同长度更新,也要检查复制新内容时保留时间戳的工具,避免只覆盖普通手工修改路径。
Last-Modified 同样需要谨慎理解。它是精度有限的时间戳,不是密码学身份。不能把任何日期都直接视为续传所需的强验证器;协议规定的条件和服务器真实更新行为都有关,尤其是一个有效时间精度窗口内可能出现多个版本时。实验推进两秒是为了明确改变输入,并不是宣称等待两秒能解决所有发布一致性问题。
最后,应检查经过中间层和内容编码后真正交付的表示。在要求强比较的场景,一种表示的验证器不能错误地标识另一串不同字节。把缓存寿命、条件验证、隐私和字节完整性分别作为明确决策,并在部署检查中同时验证未变化和已变化两种情况,才能发现这类只在更新时出现的问题。
延伸阅读
来源
- RFC 9110 HTTP Semantics,强弱验证器、条件优先级及 304、412 响应,已核验 2026-09-12。
- RFC 9111 HTTP Caching,新鲜度、已存响应与成功验证,已核验 2026-09-12。
- nginx 1.28.0 core source,根据修改时间与内容长度生成静态 ETag 的实现,已核验 2026-09-12。
- nginx HTTP core module,etag 与 if_modified_since 配置,已核验 2026-09-12。