第3章:协商缓存验证 —— Last-Modified与ETag的生成规则、304响应处理、避免缓存击穿

协商缓存是WebView缓存策略中最重要的“校验环节”。当本地已有缓存资源时,客户端会带上条件请求头向服务器询问资源是否失效:若服务器判断资源未变更,则返回304 Not Modified状态码,无需重复传输响应体;若资源已变更,则返回200 OK并携带最新数据。本章将深入Last-Modified和ETag的生成规则、304响应的完整处理流程,以及如何避免因条件请求失败导致的缓存击穿问题。

核心目标:客户端与服务器通过少量请求头交换,最大程度减少数据传输,同时保证缓存内容的实时性。

3.1 Last-Modified 与 ETag 的生成规则

服务器在响应时,通常同时设置两个头部来支持协商缓存:

头部字段生成规则典型值示例
Last-Modified文件在服务器上的最后修改时间(HTTP-Date格式)。静态资源通常由文件系统mtime确定;动态资源可由业务逻辑生成。Wed, 21 Oct 2023 07:28:00 GMT
ETag资源内容的唯一标识符,通常为文件内容的哈希值(如MD5、SHA1),或版本号、inode+大小+修改时间组合。区分强校验(精确字节匹配)和弱校验(前缀 W/,允许语义等价但字节不同)。"7a5b3c8d9e0f1a2b3c4d5e6f" 或 W/"v2.1"
部署建议:对于静态资源(CSS、JS、图片),优先使用ETag(强校验);对于动态页面,若内容频繁微调但语义不变,可使用弱ETag以减短协商次数。注意Last-Modified精度为秒,ETag可提供更精确的变更检测。

服务器端生成示例(Nginx配置):

# 启用ETag(默认开启)
etag on;
# 追加Last-Modified(默认开启)
add_header Last-Modified $date_gmt;

自定义ETag生成逻辑(Node.js/Express):

const crypto = require('crypto');
app.get('/resource', (req, res) => {
  const content = fs.readFileSync('./file.js');
  const hash = crypto.createHash('md5').update(content).digest('hex');
  res.set('ETag', `"${hash}"`);
  res.set('Last-Modified', new Date(fs.statSync('./file.js').mtime).toUTCString());
  res.send(content);
});

3.2 304 响应处理流程

当客户端发起条件请求时,服务器按以下逻辑判断并应答:

  1. 客户端请求:携带 If-Modified-Since(等于上次接收的Last-Modified)和/或 If-None-Match(等于上次接收的ETag)。
  2. 服务器比较:若If-None-Match与当前ETag匹配 → 资源未变;若未携带ETag则检查If-Modified-Since是否等于或早于当前Last-Modified。
  3. 返回304:状态码304,无响应体,但可能携带新的Cache-Control、Expires等头部。客户端收到后直接使用本地缓存副本,并更新缓存过期策略。
  4. 返回200:若资源已修改,正常返回200及完整响应体,同时更新Last-Modified和ETag。
重要:304响应必须包含ETag或Last-Modified头部(与原来一致),否则客户端可能无法正确更新缓存时间戳,造成反复协商。

客户端处理304的示例(Android OkHttp拦截器):

@Override
public Response intercept(Chain chain) throws IOException {
    Request request = chain.request();
    // 添加条件请求头(从缓存中取得)
    String ifModifiedSince = cacheManager.getLastModified(url);
    String ifNoneMatch = cacheManager.getEtag(url);
    Request.Builder builder = request.newBuilder();
    if (ifModifiedSince != null) builder.header("If-Modified-Since", ifModifiedSince);
    if (ifNoneMatch != null) builder.header("If-None-Match", ifNoneMatch);
    Response response = chain.proceed(builder.build());
    if (response.code() == 304) {
        // 使用缓存数据,释放304响应体(无内容)
        response.close();
        return chain.proceed(request.newBuilder()
                .header("Cache-Control", "only-if-cached")
                .build());
    }
    return response;
}

3.3 避免缓存击穿

“缓存击穿”在本上下文中指:由于条件请求头处理不当,导致服务器错误地返回200(而非304),使缓存提前失效并重复传输大块数据。常见场景及应对策略:

  • 时间精度不足:Last-Modified只精确到秒,若同一秒内资源被多次修改,无法检测到变更。解决方案:使用ETag(强校验)作为主要协商手段,Last-Modified作为降级。
  • ETag生成冲突:多个版本号哈希值碰撞(极低概率但可能)。应对:采用更长的哈希算法(如SHA256),或组合修改时间与文件大小。
  • 负载均衡多服务器环境:不同服务器上ETag生成策略不同(如基于inode),导致同一资源不同服务器返回不同ETag。解决方案:统一使用内容哈希(MD5/SHA1)生成ETag,避免依赖文件系统元数据。
  • 忽略弱ETag的前缀:客户端发送If-None-Match: "v2.0",而服务器返回ETag: W/"v2.0"(类型不匹配),导致服务器无法识别为相同资源。应对:客户端在缓存中同时存储强/弱标识,请求时按原样发送;服务器端在比较时应忽略前缀(RFC7232规定服务器应同时比较强和弱ETag)。
黄金法则:始终优先依赖ETag做协商缓存,仅在ETag不可用时回退到Last-Modified。WebView中可以设置自定义缓存策略,确保条件请求头正确构造。

3.4 协商缓存完整流程(SVG)

协商缓存验证流程图(Last-Modified与ETag) 协商缓存验证流程 WebView 客户端 携带 If-Modified-Since / If-None-Match 服务器接收并校验 资源是否变化? 未变化 304 使用缓存数据 已变化 200 + 新数据 替换缓存并渲染

3.5 调试方法

  • Chrome DevTools Network面板:查看请求头中 If-Modified-Since 和 If-None-Match 是否正确携带;响应头中 Last-Modified 和 ETag 是否正常;状态码是否为304。
  • Charles / Fiddler 代理:捕获WebView请求,对比本地缓存时间戳与服务器响应,确认协商逻辑是否按预期执行。
  • 日志输出:在客户端拦截器中打印条件请求头及服务器响应码,定位协商失败的原因。
快速定位问题:若304被200代替,检查ETag是否因服务器集群不一致而不同,或If-Modified-Since格式不符合规范(必须为EEE, dd MMM yyyy HH:mm:ss 'GMT')。

3.6 部署策略与稳定性优化

场景推荐配置稳定性考虑
静态资源(js、css、图片)ETag: 文件内容的SHA1
Last-Modified: 文件mtime
使用CDN时应确保ETag生成算法一致;若CDN与源站不一致,关闭CDN的ETag重写或强制使用源站ETag。
动态API(JSON/XML)ETag: 内容哈希或版本号
不推荐使用Last-Modified
在负载均衡环境中统一用内容哈希;对可空响应(如空列表)要确保ETag不一致,否则会造成误判。
分页资源ETag基于url+page+size+数据时间戳组合避免因参数顺序不同产生不同ETag,建议提前规范化参数。
性能优化建议:对不常变更的资源,可设置较长的Cache-Control: max-age(如1小时),配合协商缓存实现“强缓存+条件刷新”双层保障,减少服务器压力。

3.7 常见错误清单

  • Last-Modified 格式错误:使用本地时间而非GMT,或忽略秒级精度导致循环协商。
  • ETag 前后不一致:同一个资源在不同请求中返回不同的ETag(如基于随机数生成),导致永远无法缓存。
  • 304 响应缺少必要头部:没有设置Date或Cache-Control,导致WebView无法正确计算新的过期时间。
  • 忽略If-None-Match的优先级:如果请求同时携带If-Modified-Since和If-None-Match,服务器必须只检查If-None-Match(RFC7232规定)。如果实现错误,可能导致缓存失效。
  • 在客户端乱改ETag:某些开发者手动修改缓存中的ETag字符串来强制刷新,破坏了协商机制。
致命错误:部分老旧WebView会缓存304响应本身(无body),导致后续请求直接使用304作为缓存,造成白屏。务必确保304响应的Cache-Control设置为no-store或must-revalidate。