
一个带 ETag 的接口顺手也返回 Last-Modified,某个客户端下一次请求把两个校验头一起带了上来。这时候服务端代码里 if (ifModifiedSince) 写在前面还是 if (ifNoneMatch) 写在前面,会让同一组请求拿到 200 或 304 两种答案。规范对顺序没有留余地,先把原文摆出来。
规范怎么写的
A recipient MUST ignore If-Modified-Since if the request contains an
If-None-Match header field; the condition in If-None-Match is
considered to be a more accurate replacement for the condition in If-
Modified-Since, and the two are only combined for the sake of
interoperating with older intermediaries that might not implement
这段话的后半句解释了原因:两个条件同时出现只是为了兼容不认识 If-None-Match 的老中间层,不是为了给服务端多一个判断依据。紧接着还有一句同等硬的:
A recipient MUST ignore the If-Modified-Since header field if the
received field value is not a valid HTTP-date, the field value has
more than one member, or if the request method is neither GET nor
比条件本身还早一步:如果 If-Modified-Since 的值不是一个合法 HTTP-date、带了多个成员、或者请求方法不是 GET / HEAD,这个头就整体作废。日期格式不合法时按「没带」处理,而不是按「时间对不上」处理。
本机两个实现
同一个资源给出 ETag: "v1" 与 Last-Modified: Sun, 20 Sep 2026 10:00:00 GMT。朴素实现按 If-Modified-Since 优先判断,教科书实现按上面的规范,先把 If-None-Match 处理完再考虑要不要看 If-Modified-Since。七组请求一起发过去,只比状态码。
ETag: "v1" | Last-Modified: Sun, 20 Sep 2026 10:00:00 GMT
案例 朴素实现 按规范实现
--------------------------------------------------------------------------
只有 If-None-Match,且命中 304 304
只有 If-None-Match,没命中 200 200
只有 If-Modified-Since,时间等于最后修改 304 304
只有 If-Modified-Since,时间早于最后修改 200 200
两个都带:ETag 没命中,IMS 说没改过 304 200
两个都带:ETag 命中,IMS 说改过了 304 304
两个都带:ETag 写成弱校验 W/"v1" 200 304
只给 Last-Modified 的资源(ETag 关掉),文件在 10:00:00.900 被改过
服务端回的 Last-Modified: Sun, 20 Sep 2026 10:00:00 GMT
客户端带 If-Modified-Since: Sun, 20 Sep 2026 09:59:00 GMT → 200
客户端带 If-Modified-Since: Sun, 20 Sep 2026 10:00:00 GMT → 304
客户端带 If-Modified-Since: Sun, 20 Sep 2026 10:00:00 GMT → 304
文件真实修改时间: 2026-09-20T10:00:00.900Z
Last-Modified 头能表达的精度: Sun, 20 Sep 2026 10:00:00 GMT (毫秒被截掉)
分叉的那一格
七格里只有一格两个实现给出不同答案:请求同时带上一个对不上的 If-None-Match("v0")和一个表示「没改过」的 If-Modified-Since。朴素实现回 304,客户端于是继续用缓存里那份旧内容;按规范实现回 200,把新内容发出去。这一格的差别不是快慢,是内容对不对得上。
另一格方向相反:ETag 明明对得上,但 If-Modified-Since 的日期比资源修改时间早,朴素实现会绕过 ETag 直接发 200,多传一遍完整内容。这里丢的是带宽,好歹内容是对的。
弱校验也算命中
第三格值得单独说。服务端给的是强 ETag "v1",请求里带的是弱形式 W/"v1"。朴素实现做字符串比较,判为不匹配,回 200;按规范实现用弱比较,判为命中,回 304。规范里写得很清楚:
A recipient MUST use the weak comparison function when comparing
entity tags for If-None-Match (Section 8.8.3.2), since weak entity
tags can be used for cache validation even if there have been changes
to the representation data.
There are two entity tag comparison functions, depending on whether
or not the comparison context allows the use of weak validators:
"Strong comparison": two entity tags are equivalent if both are not
weak and their opaque-tags match character-by-character.
落到代码上,比较之前先剥掉 W/ 前缀,再比引号里的内容,别拿整个头的字符串去比。
只给 Last-Modified 的资源
关掉 ETag、只留 Last-Modified 时还有第二个坑,出在精度上。文件在 10:00:00.900 被改过,服务端回的 Last-Modified 只能是 Sun, 20 Sep 2026 10:00:00 GMT,毫秒位置没有地方放。客户端把这个值原样发回来,服务端按秒比较,得出「没改过」,回 304。
也就是说,同一个自然秒内的第二次修改,靠 Last-Modified 是分辨不出来的。第 900 毫秒写入的文件与第 950 毫秒写入的文件,对外是同一个版本号。改得越勤的资源越容易撞上,一秒内发布多次的前端静态文件就是典型。
我在这条上倾向于不给纯静态资源留这个口子:能算 ETag 就算 ETag,内容哈希或者构建号都行,Last-Modified 只当作兜底。真的只能用 Last-Modified 时,把写入间隔摊开一点,别让两次发布落在同一秒。
服务端实现的顺序
// meta = { etag, lastModified };只有 GET / HEAD 才走校验
function isFresh(req, meta) {
if (req.method !== "GET" && req.method !== "HEAD") return false;
const inm = req.headers["if-none-match"];
if (inm) {
const wanted = inm.split(",").map((s) => s.trim());
const mine = meta.etag.replace(/^W\//, "");
if (wanted.some((t) => t.replace(/^W\//, "") === mine || t === "*")) return true;
return false; // 带了 If-None-Match 就不再往下看
}
const ims = req.headers["if-modified-since"];
if (ims) {
const t = Date.parse(ims);
if (!Number.isNaN(t)) return Math.floor(meta.lastModified / 1000) * 1000 <= t;
}
return false;
}
判断顺序固定:先 If-None-Match,命中就 304 直接返回;没带才轮到 If-Modified-Since。日期解析失败时返回 false,也就是当作需要重新发送内容,比把非法日期当成「已修改」更安全,至少不会把过期内容留在客户端。
客户端那一侧没什么可做的,缓存库按规范走就行;自己手写请求时,两个校验头都带上不会更准,只会在服务端实现有 bug 时暴露出来。真要验证缓存是否生效,看响应里 Age 与状态码,比看请求头可靠。