3. 实现协商缓存验证
协商缓存是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 响应处理流程
当客户端发起条件请求时,服务器按以下逻辑判断并应答:
- 客户端请求:携带
If-Modified-Since(等于上次接收的Last-Modified)和/或If-None-Match(等于上次接收的ETag)。 - 服务器比较:若
If-None-Match与当前ETag匹配 → 资源未变;若未携带ETag则检查If-Modified-Since是否等于或早于当前Last-Modified。 - 返回304:状态码304,无响应体,但可能携带新的
Cache-Control、Expires等头部。客户端收到后直接使用本地缓存副本,并更新缓存过期策略。 - 返回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)
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: 文件内容的SHA1Last-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。