资源指纹把文件字节的摘要放进地址。样式表发生变化,构建就为新内容生成另一个地址,页面因此能够选择新版本,而不用等待所有旧缓存同时过期。这项方法依赖发布流程遵守承诺:某个地址一旦对外提供,就不能随意替换成与名称摘要不符的内容。文件名看起来像摘要,不代表服务器已经落实这种约束。
名称配合新鲜度契约
构建程序可以对生成文件计算完整的 SHA-256,再把页面中的逻辑名称替换成相应地址。同样的字节保留相同名称,变化的字节通常得到不同名称;摘要截断越短,碰撞风险越需要认真评估。浏览器不会因为名称含有一串十六进制字符,就自动计算文件摘要验证完整性。实际缓存键还受到请求上下文和 Vary 等因素影响,命名方法不能取代 HTTP 缓存规则。
公开指纹资源常用的响应头是 Cache-Control: public, max-age=31536000, immutable。前者给出一年新鲜期,后者承诺该表示在新鲜期内不会改变,因此 RFC 8246 建议客户端避免不必要的条件请求。这不是永久保存许可:缓存可能被淘汰,用户可以强制刷新,客户端也可能不支持扩展。规范还建议在 HTTPS 等经过认证的上下文之外忽略 immutable,不能把它当作内容可信性的替代品。
负责引用这些资源的 HTML 需要独立缓存策略,例如较短新鲜期或合适的重新验证,让用户能够发现新版本。发布顺序应先让新资源可用,再暴露引用它们的页面或清单。旧页面、旧标签页和回退版本仍可能使用旧地址,资源保留期限必须覆盖这些真实使用方式。仅有指纹,不能让整个多文件发布自动成为原子事务。
public 只适合允许共享的表示。某份个性化下载即使带有内容摘要,也不会因此变成可供共享缓存保存的公开资源。响应策略仍然必须服从身份鉴别与保密要求;摘要只是命名依据,不能充当授权或隔离用户数据的措施。
坏掉的样子
覆盖旧指纹路径会制造两套互相矛盾的事实:已有用户继续从缓存读取红色样式,新客户端却在同一个地址读到蓝色字节。清理一个 CDN 节点,也不能把所有浏览器里的私有副本同步删除。发布检查应核对真实文件字节和记录的摘要,在开放流量之前拒绝这种变更,而不是依赖上线后提醒用户刷新。
立即删除上一版资源会产生另一类故障。缓存中的页面还可能引用昨日脚本,回退后的 HTML 也可能依赖已经移除的文件。保留源码提交不等于保留可直接恢复的构建产物。即使所有摘要都正确,先发布页面、后上传文件仍会造成暂时的缺失资源;多节点传播期间也需要检查依赖是否已经就绪。
快速验证
把下面代码保存为 immutable.py,使用 Python 3 执行 python3 immutable.py。程序启动回环服务,检查样式字节的摘要和缓存响应头,故意改写旧地址背后的内容,确认摘要不匹配,然后恢复原文并关闭服务。预期三个契约检查标志为真,而 browser_cache_tested_by_cli 为假。这个命令读取了真实 HTTP 响应,但没有实现浏览器缓存,因此不能单凭命令结果宣称已经验证客户端复用行为。
from hashlib import sha256
from http.client import HTTPConnection
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from threading import Event, Lock, Thread
from urllib.parse import urlsplit
import json
import sys
styles = {'one': b'body{background:rgb(127,29,29);color:white;font-family:sans-serif;margin:3rem}',
'two': b'body{background:rgb(30,64,175);color:white;font-family:sans-serif;margin:3rem}'}
paths = {name: '/assets/' + sha256(data).hexdigest() + '.css' for name, data in styles.items()}
state = {'tampered': False, 'requests': []}
lock = Lock()
class Handler(BaseHTTPRequestHandler):
def do_POST(self):
if self.path not in ('/tamper', '/restore'):
self.send_error(404)
return
with lock:
state['tampered'] = self.path == '/tamper'
self.send_response(204)
self.end_headers()
def do_GET(self):
path = urlsplit(self.path).path
cache = 'no-store'
if path in ('/one', '/two'):
name = path[1:]
data = f'<!doctype html><html><head><meta name="viewport" content="width=device-width,initial-scale=1"><title>Immutable asset lab</title><link rel="icon" href="data:,"><link rel="stylesheet" href="{paths[name]}"></head><body><h1>Release {name}</h1><p>The background comes from the referenced CSS artifact.</p></body></html>'.encode()
kind = 'text/html; charset=utf-8'
elif path in paths.values():
with lock:
state['requests'].append(path)
data = styles['two'] if path == paths['two'] or state['tampered'] else styles['one']
kind, cache = 'text/css', 'public, max-age=31536000, immutable'
elif path == '/events':
with lock:
data = json.dumps(state).encode()
kind = 'application/json'
else:
self.send_error(404)
return
self.send_response(200)
self.send_header('Content-Type', kind)
self.send_header('Cache-Control', cache)
self.send_header('Content-Length', str(len(data)))
self.end_headers()
self.wfile.write(data)
def log_message(self, *args):
pass
server = ThreadingHTTPServer(('127.0.0.1', 0), Handler)
thread = Thread(target=server.serve_forever)
thread.start()
try:
origin = f'http://127.0.0.1:{server.server_port}'
if '--serve' in sys.argv:
print(json.dumps({'origin': origin, 'assets': paths}), flush=True)
try:
Event().wait()
except KeyboardInterrupt:
pass
else:
def request(method, path):
connection = HTTPConnection('127.0.0.1', server.server_port, timeout=5)
try:
connection.request(method, path)
response = connection.getresponse()
return response.status, response.getheader('Cache-Control'), response.read()
finally:
connection.close()
status, cache, original = request('GET', paths['one'])
assert status == 200 and cache == 'public, max-age=31536000, immutable'
assert sha256(original).hexdigest() in paths['one']
assert request('POST', '/tamper')[0] == 204
changed = request('GET', paths['one'])[2]
assert sha256(changed).hexdigest() not in paths['one']
assert request('GET', paths['two'])[2] == styles['two']
assert request('POST', '/restore')[0] == 204
assert request('GET', paths['one'])[2] == original
print(json.dumps({'hash_name_contract_checked': True,
'changed_old_url_detected': True, 'original_restored': True,
'browser_cache_tested_by_cli': False}))
finally:
server.shutdown()
thread.join()
server.server_close()
浏览器实验另行执行 python3 immutable.py --serve,把打印的完整源地址存入终端变量 ASSET_ORIGIN。用新的浏览器配置目录并保持正常缓存,先打开该源的 /one。随后执行 curl -X POST "$ASSET_ORIGIN/tamper",再导航到 /one?after=mutation,最后打开 /two。访问 /events 可以查看服务端收到的资源请求;实验结束后按 Ctrl-C 关闭服务。不要对真实站点执行这个故意改写资源的步骤。
在 Chrome 152.0.7977.83 中,第一次页面为红色,改写服务器内容后的第二次导航仍为红色,旧样式路径总共只收到一次请求。新指纹路径对应的页面变为蓝色,新样式同样只请求一次。这证明了本例的缓存分离行为以及改写新鲜资源的风险。普通 max-age 新鲜度就足以解释这次复用,不能把结果全部归因于 immutable,也没有隔离测试刷新时的验证行为或 HTTPS。其他浏览器仍需独立验证;如果新地址此前已经缓存,换引用也不保证每次都产生新的网络传输。
延伸阅读
来源
- RFC 8246: HTTP Immutable Responses,新鲜期内的不可变承诺及认证上下文建议,已核验 2026-09-13。
- MDN: HTTP caching,版本化地址、缓存新鲜度与验证,已核验 2026-09-13。