HTTP 缓存头 ETag 与 Last-Modified 协作

解读

在国内高并发、高可用业务场景(如电商秒杀、内容平台)中,面试官问“ETag 与 Last-Modified 如何协作”并不是想听背诵定义,而是考察:

  1. 能否把两个头放在一次完整请求-响应链里讲清楚;
  2. 能否指出二者优先级、差异点以及 PHP 侧如何生成与校验;
  3. 能否结合 Nginx / CDN / 浏览器真实行为,说明在 304 回源、缓存穿透、负载均衡下的风险与优化。

一句话:让候选人证明“我能用 PHP 少跑 SQL、让 CDN 少回源、让用户秒开,还不踩坑”。

知识点

  1. 语义与格式

    • Last-Modified:GMT 格式时间,精度秒级,如 Wed, 21 Oct 2015 07:28:00 GMT
    • ETag:实体标签,强校验器,常见 "{inode-size-mtime}""{version-hash}",双引号包裹。
  2. 协作流程(RFC 9110)

    1. 客户端首次请求无缓存头,服务端返回 200 并携带 Last-Modified + ETag
    2. 后续刷新:
      • 若浏览器只存 Last-Modified,则发 If-Modified-Since
      • 若同时存 ETag,则额外发 If-None-Match
    3. 服务端校验顺序:先 If-None-Match,存在且匹配 → 304;否则再看 If-Modified-Since
    4. 任一条件失败即返回 200 带新实体与新的 ETag/Last-Modified
  3. 强弱校验器

    • ETag 是强校验器,字节级一致才命中;
    • Last-Modified 是弱校验器,1 秒粒度,可能误命中。
  4. PHP 生产环境生成策略

    • 静态文件:用 filemtime() 生成 Last-Modified,用 md5_file()crc32b 生成 ETag
    • 动态接口:把“版本号 + 用户ID + 更新时间”拼成哈希,避免 inode 泄露;
    • 框架层:Laravel ->etag()->lastModified() 自动设置,Symfony HttpCache 提供 Response::setETag()
  5. 坑点

    • 分布式节点 inode 不同导致 ETag 不一致,需关闭 Nginx file_etag 改用应用层生成;
    • 毫秒级更新场景,Last-Modified 精度不足,必须依赖 ETag;
    • 反向代理默认剔除 ETag 仅留 Last-Modified,需要显式 gzip_static off; etag on;
    • 微信、QQ 内置浏览器对 304 支持不完整,需双保险或短缓存 + ETag。

答案

“我一般在 PHP 里分三步实现二者协作,确保 CDN 回源率 <1%、数据库 QPS 降 80%。

第一步:响应出口统一封装

function sendCacheResponse($data, DateTime $lastModified, string $etagSeed): void
{
    $etag = '"' . hash('crc32b', $etagSeed) . '"';
    $lastModStr = $lastModified->format('D, d M Y H:i:s') . ' GMT';

    header('Cache-Control: public, max-age=0, s-maxage=60');
    header("Last-Modified: $lastModStr");
    header("ETag: $etag");

    // 校验阶段
    if (@$_SERVER['HTTP_IF_NONE_MATCH'] === $etag) {
        http_response_code(304);
        exit;
    }
    $ifModifiedSince = @$_SERVER['HTTP_IF_MODIFIED_SINCE'];
    if ($ifModifiedSince && strtotime($ifModifiedSince) >= $lastModified->getTimestamp()) {
        http_response_code(304);
        exit;
    }

    // 返回新内容
    echo json_encode($data, JSON_UNESCAPED_UNICODE);
}

第二步:数据源统一版本号
把“表更新时间 + 用户维度版本”放 Redis,毫秒级变更即重新生成 ETag,避免 Last-Modified 1 秒误差带来的脏缓存。

第三步:运维层对齐
Nginx 配置关闭默认 file_etag,统一走应用层;CDN 侧打开“忽略客户端 Last-Modified 仅认 ETag”开关,防止多节点时钟不一致造成 200 误发。

上线后效果:商品详情页 99% 流量 304 回包,首字节时间从 180 ms 降到 25 ms,大促期间带宽节省 35%。”

拓展思考

  1. 如果业务允许 1 秒内重复提交,ETag 是否还需要?
    答:需要。Last-Modified 只能到秒,无法区分同一秒内的两次变更;此时用 ETag 做“秒级内版本”是唯一办法。

  2. 微服务场景下,BFF(Node)聚合 PHP 接口,如何透传 ETag?
    答:BFF 把上游 PHP 返回的 ETag/Last-Modified 原样带回去,并在自身层再用 SURROGATE-KEY 做标签缓存,实现多级缓存一致性。

  3. 当资源同时存在 Range 请求(视频拖拽)时,ETag 需要加 -range 后缀吗?
    答:必须。RFC 规定强校验器在 206 响应中需体现范围,否则同一 ETag 会被缓存节点误用于完整文件,导致花屏。

  4. PHP 8 的 hash() 使用 xxh128 算法生成 ETag,性能提升 2 倍,但 CDN 仅支持 ASCII 双引号内 128 字节,如何取舍?
    答:折中做法:base64_encode(substr(xxh128,0,16)),既压缩长度又保留冲突率 2^-64,已在国内头部短视频平台验证。