先看一个每天全量拉一遍的阅读器
一个阅读器里放了几千个订阅源。如果它每小时无条件地把每个源完整取一遍,一天就是几万次请求,而其中绝大多数返回的东西和昨天一模一样:没有新条目,只是把同一份 XML 重新传了一次。对阅读器是带宽和电量,对站点是流量。
可阅读器又不能不拉。第一章说过 RSS 是拉模型:站点不知道谁订阅了,也就不会告诉任何人「我更新了」。阅读器手里只有两个可调的旋钮——多久拉一次,以及每次拉多大。
站点能给的两种建议:ttl 与缓存头
站点可以在 channel 里写 ttl(time to live),单位是分钟,规范对它的定义是「a number of minutes that indicates how long a channel can be cached before refreshing from the source」(RSS 2.0 规范,访问于 2026-09-28)。需要注意的是这只是站点的声明:规范没有要求聚合器必须遵守,实际听不听由阅读器决定。
HTTP 层有更通用的说法。Cache-Control 响应头定义在 RFC 9111 §5.2(HTTP Caching,访问于 2026-09-28),本站 RSS 响应里给的是:
Cache-Control: public, max-age=3600, s-maxage=3600, stale-while-revalidate=86400max-age=3600 表示这份响应一小时内可以直接用本地副本,不必回源;s-maxage 含义相同,只是针对共享缓存;stale-while-revalidate=86400 来自 RFC 5861 §3,允许缓存在过期后的一天之内先把旧副本发出去、同时在后台刷新(RFC 5861,访问于 2026-09-28)。
这三个值只回答了「多久之内可以不复用回源」,没有回答「过期之后回源要花多少代价」——那要交给条件请求。
条件请求:让「没变化」只花一次 304
过期之后确实要再问一次,但这一问可以很便宜。如果第一次响应带了校验器(validator),第二次请求就能带上它;服务器发现内容没变,回一个 304 Not Modified 加一个空的响应体。
有两个校验器可用:
| 校验器 | 响应头 | 请求头 | 规范位置 |
|---|---|---|---|
| 内容版本 | ETag | If-None-Match | RFC 9110 §8.8.3、§13.1.2 |
| 时间戳 | Last-Modified | If-Modified-Since | RFC 9110 §8.8.2、§13.1.3 |
304 本身的定义在 RFC 9110 §15.4.5(HTTP Semantics,访问于 2026-09-28)。它省下的是整份 XML 的传输,同时也让站点不必重新生成内容。
查看 Mermaid 源码
flowchart LR
first["第一次请求"] -->|"200 + ETag: v1 + 全部 item"| local["本地保存副本<br/>并记住 ETag=v1"]
second["第二次请求<br/>If-None-Match: v1"] -->|"304 + 空响应体"| reuse["沿用本地副本<br/>不产生新条目"]
classDef net fill:#eff6ff,stroke:#2563eb,color:#0f172a
classDef disk fill:#fff7ed,stroke:#ea580c,color:#0f172a
class first,second net
class local,reuse disk图中两轮请求拿到的最终列表是同一份,但第二轮只传了几百字节的响应头。Last-Modified 的精度只到秒,同一秒内改了两次就分不出来,所以它比 ETag 弱一档。
这里有个容易含糊过去的点,值得拆成两步看。
第一步:响应上有没有校验器。 早期本站的 route.ts 只声明了 Cache-Control,不生成 ETag。加上校验器(正文的内容哈希 + 最新一篇文章的时间)之后,响应头是:
content-type: application/rss+xml; charset=utf-8
cache-control: public, max-age=3600, s-maxage=3600, stale-while-revalidate=86400
etag: "7954426882e082cd9b326ed08a7013203c3936f6"
last-modified: Mon, 28 Sep 2026 00:00:00 GMT(本机 next start 实测;线上要等下一次部署才会变。)
第二步:这一层会不会把校验器用起来。 把两个条件请求头各试一次:
curl -s -o /dev/null -w '%{http_code} %{size_download}\n' \
-H 'If-None-Match: "7954426882e082cd9b326ed08a7013203c3936f6"' http://127.0.0.1:3001/api/rss
curl -s -o /dev/null -w '%{http_code} %{size_download}\n' \
-H 'If-Modified-Since: Wed, 01 Jan 2031 00:00:00 GMT' http://127.0.0.1:3001/api/rss两行都仍然是 200 加完整正文。这一步的结论比第一步重要:给出了校验器,不等于会返回 304。304 由部署层决定 —— Next 的静态 route handler 自己不比较 If-None-Match(handler 在构建期就跑完了,运行时由框架直接吐静态内容),单纯反代的 nginx 不做条件判断、不开 proxy_cache 时同样照常 200。
所以本站目前生效的省流手段仍然只有 Cache-Control 那一小时有效期;ETag 与 Last-Modified 是留给 CDN / 反向代理做条件回源的。要补上这一层,动作在部署侧,不在生成侧 —— 生成侧能做的只有「把校验器给出去」,那一步已经做完。任何「加了 ETag 所以省流量」的说法,都得先跑一次上面这两条命令再讲。
guid 去重与未读状态:全部在客户端
feed 本身是无状态的,它不记录谁读过什么。阅读器在本地维护一份映射:guid → 已读或未读。每次取回列表后做一次差集,本地没有的 guid 就是新条目。
查看 Mermaid 源码
flowchart LR
fetch["取回 feed"] --> diff["与本地 guid 集合求差集"]
store["本地:guid → 已读 / 未读"] --> diff
diff -->|本地没有的 guid| unread["新增为未读"]
diff -->|已存在的 guid| keep["保持原状态"]
classDef op fill:#eff6ff,stroke:#2563eb,color:#0f172a
classDef disk fill:#fff7ed,stroke:#ea580c,color:#0f172a
class fetch,diff op
class store,unread,keep disk这解释了两个常见现象:换一个阅读器,未读状态不会跟着走;清掉本地数据重新订阅,会看到全部历史条目都变成未读。我的判断是这属于设计取舍而不是缺陷——服务端根本不知道读者是谁,也就没有任何地方能存放「已读」这个事实。
它还解释了第一章那条约束为什么重要:规范只说「guid 存在时,聚合器可以用它判断条目是否新的」,没有规定 guid 缺失时怎么办。所以缺 guid 的源在不同阅读器里的去重行为并不一致,这本身就是把 guid 写稳的理由。
轮询的天花板,与 WebSub
到目前为止,新条目可见的延迟完全由轮询间隔决定。间隔缩短,浪费就增加;间隔拉长,延迟就变大。把某个源做到分钟级已经需要不少请求,做到秒级则不可能——因为站点连「现在有新内容」都无法开口。
要打破这个上限,就得让站点能主动说话。这正是 WebSub 的位置,它之前叫 PubSubHubbub,现在是 W3C Recommendation(W3C WebSub,访问于 2026-09-28)。
查看 Mermaid 源码
flowchart LR
pub["发布者<br/>内容更新时通知 hub"] -->|"hub.mode=publish"| hub["hub<br/>中继与验证"]
sub["订阅者<br/>必须先注册一个<br/>可公开访问的回调地址"] -->|"POST hub.callback / mode / topic"| hub
hub -->|"先回调校验,回显 hub.challenge"| sub
hub -->|"内容变化时 POST 全文"| sub
classDef role fill:#eff6ff,stroke:#2563eb,color:#0f172a
classDef hubnode fill:#fff7ed,stroke:#ea580c,color:#0f172a
class pub,sub role
class hub hubnode规范里几个关键点:订阅由订阅者向 hub 发一个 POST 发起,携带 hub.callback、hub.mode、hub.topic;hub 必须回调订阅者的地址校验这次订阅确实由它发起(订阅者要回显 hub.challenge);投递时 Content-Type 必须与 topic 的 Content-Type 一致,订阅者返回 2xx 才算成功。
那为什么个人静态博客通常不需要它?三个理由:
- WebSub 要求发布者在内容更新时通知 hub,而静态博客的「发布」是一次构建。要么在部署脚本里多写一段 ping hub 的逻辑,要么依赖托管服务代劳。
- 订阅者必须先有一个可公开访问的回调地址。这对托管型聚合服务没问题,对读者装在手机或桌面上的普通阅读器不成立——也就是说,推送只能服务一部分读者。
- 它把「小时级」提到「秒级」,而一个更新频率按周计的博客并不需要这个量级。
我的判断是:先把 ttl、Cache-Control 和条件请求这套基础做对,收益远大于引入一个 hub。
三种刷新方式的取舍
| 方式 | 新条目可见延迟 | 站点侧成本 | 阅读器侧成本 | 适合的源 |
|---|---|---|---|---|
| 轮询,无缓存头与校验器 | 取决于间隔 | 每次都返回全量字节 | 高 | 少见,默认不推荐 |
| 轮询 + 缓存头 + 条件请求 | 取决于间隔,通常 15 分钟到数小时 | 命中 304 时接近零 | 低 | 静态博客与大多数站点 |
| WebSub | 秒级 | 需要 hub 与发布通知 | 需要可公开访问的回调地址 | 高频更新、有常驻服务端 |
所以「阅读器多久来一次」不是阅读器单方面能决定的事。站点给什么头、给不给校验器,直接决定了轮询的成本;而个人博客能做的,恰好就是把这套基础做对。
配套实验在仓库的 examples/rss-subscription/:node examples/rss-subscription/user_code/reader.mjs 在本地起一个 feed 服务器,模拟阅读器连续几轮同步——无条件 GET 拿到 200 并记下 ETag,第二次带着 If-None-Match 命中 304(正文 0 字节、不重新解析),换一批内容后 ETag 变化、按 guid 做差集只把新条目记为未读。未读状态从头到尾只存在于这个模拟阅读器自己手里。
参考资料
- RSS 2.0 Specification:
ttl的定义(官方规范,访问于 2026-09-28) - RFC 9110:HTTP Semantics:
ETag§8.8.3、Last-Modified§8.8.2、If-None-Match§13.1.2、If-Modified-Since§13.1.3、304 §15.4.5(访问于 2026-09-28) - RFC 9111:HTTP Caching:
Cache-Control§5.2(访问于 2026-09-28) - RFC 5861:HTTP Cache-Control Extensions for Stale Content:
stale-while-revalidate§3(访问于 2026-09-28) - W3C WebSub(W3C Recommendation,访问于 2026-09-28)