
主题:一种通用的"每次都问源、但几乎不重传"的远程数据访问设计 适用场景:需要从远程拉取一份会频繁更新的清单 / 配置 / 数据集,且过期即错误的场合
1. 一句话概括
一个 loadResource() 函数每次被调用都会真实请求 远程数据源,但会带上上次的"内容指纹"(ETag / Last-Modified / 版本号),让源用最小的代价回答"内容没变"(304 Not Modified)。只有源亲自确认过没变,内存中的数据才被允许返回;网络失败时绝不复用旧数据。
这把"强一致(每次都是最新)"和"低成本(几乎不重传)"两件事同时拿到了。
2. 问题定义:我们要解决什么
假设进程需要周期性地(或按需)读取一份托管在远程的数据:
- 一份 feature flag 配置
- 一份可下载组件清单
- 一份定价表 / 汇率表
- 一份地址库 / 黑名单
这份数据有三个特征:
- 由别人维护,自己只读------不能本地造,得问源
- 会频繁更新------今天新加的条目,明天就必须能看到
- 过期就是错误,不是降级------缺失的条目会被当成"不存在",从而做出错误决策(例如拒绝一个本该合法的项)
于是产生张力:
- 想省流量 / 省延迟 → 倾向于缓存、少请求
- 想正确 → 倾向于每次都拿最新的
朴素的两类方案都会翻车,见下一节。
3. 两种朴素方案为何都不行
3.1 "跳过请求、直接答内存"的定时缓存
每隔 1 小时拉一次,期间全部答内存
问题:"没问就假装新鲜"。缓存窗口内数据已经变了,调用方却拿到旧值。对"过期即错误"的数据,这等于持续返回错误答案------而且界面上无法区分,运维也发现不了。
3.2 "打包快照兜底"的离线缓存
连不上源时,退回随发行包发布的那份快照
问题:快照是发布时刻的副本,永不更新。一份被冻结的旧数据,在"过期即错误"的场景里同样是在持续说谎;而且不同版本的客户端带着不同年代的快照,行为不一致,难以排障。
3.3 我们想要的中道
永远问源,但让源只回答"还是同一个"(0 字节),而不是每次重发整个文件。
这就是 HTTP 条件请求 + 一个只存指纹、不存过期数据的轻量状态。
4. 核心机制:HTTP 条件请求
客户端请求时带上"上次拿到的内容的指纹",服务器比对后二选一:
- 没变 →
304 Not Modified,空 body(0 字节,几乎零成本) - 变了 →
200 OK+ 完整新内容
两种标准指纹:
| 指纹 | 是什么 | 局限 |
|---|---|---|
| ETag | 内容的强校验值(通常是哈希) | 无;最精确 |
| Last-Modified | 服务器认为的最后修改时间 | 只有 1 秒分辨率------同一秒内内容变了两次,它区分不出 |
还有第三种"指纹":应用层版本号 / 修订号。当数据通过"带版本号的发布通道"(如包仓库、带 tag 的对象存储)分发时,版本号本身就是天然验证器,且对人类读日志更友好(见第 6 节)。
5. 设计骨架
5.1 状态:验证器仓库,不是数据缓存
内存里只保存上次请求的指纹 和上次被源确认过的数据,二者成对出现、缺一不可:
STATE cached = null
// { key, // 指纹来自哪条源(绝不跨源复用)
// etag, // HTTP 指纹
// modified, // HTTP 指纹(弱)
// revision, // 应用层版本号(等价于 ETag)
// data } // 仅当源确认"没变"时才允许被返回
区分的关键:
- 这是验证器,不是"一小时内不请求"的缓存。它不跳过请求,它只让请求变便宜。
- 指纹必须绑定源 (
cached.key === source.key)。ETag 是源特定的;把 A 源给的 ETag 发给 B 源,可能从"从未见过的 body"骗到假的 304。 - 仅 200 时更新;304 命中时状态不动(反正没变)。
5.2 主流程(伪代码)
FUNCTION loadResource(region = currentRegion()) RETURNS Resource
last = null
attempts = 0
// 外层:多源回退。第一个源挂了,进程只是变慢,而不是拿不到数据。
FOR source IN routesFor(region).sources
key = sourceKey(source)
// 内层:每源最多 2 次。跨长距离的瞬断,一次重试就够。
FOR attempt = 1 TO 2
attempts += 1
TRY
// 验证器绑定源:只复用来自同一源的旧指纹
reusable = (cached != null AND cached.key == key) ? cached : null
IF source.kind == "package" // 走"带版本号的发布通道"
(rev, data) = fetchByRevision(source, reusable?.revision)
IF data == null AND reusable != null
RETURN reusable.data // 版本没变 → 复用
parsed = validate(data)
cached = { key, etag: null, modified: null, revision: rev, data: parsed }
RETURN parsed
// URL 源:标准 HTTP 条件请求
// 一次只发一个验证器,ETag 优先:
// 两个都发 → 服务器须同时满足 → 弱 ETag 匹配被拖累成不必要的 200
headers = {}
IF reusable?.etag != null
headers["if-none-match"] = reusable.etag
ELSE IF reusable?.modified != null
headers["if-modified-since"] = reusable.modified
res = fetch(source.url, { signal: timeout(15s), headers })
IF res.status == 304 // 源确认:内容没变
IF reusable == null // 防御性检查(理论上不可达)
THROW "收到 304 却没有可复用数据"
RETURN reusable.data // 0 字节,复用内存
IF NOT res.ok
THROW "HTTP " + res.status
data = validate(await res.json()) // 200 → 解析并校验外部输入
cached = { key, etag: res.etag, modified: res.last-modified,
revision: null, data }
RETURN data
CATCH error
last = error // 网络失败:绝不从 cached 兜底
END FOR
THROW describeFailure(last, attempts) // 全部失败才报错
END FUNCTION
6. 三种验证器:何时用哪个
| 验证器 | 适用通道 | 何时复用 | 优点 | 注意 |
|---|---|---|---|---|
| ETag | 普通 HTTP 资源(带 etag 头) |
响应 304 | 精确到字节 | 源必须支持 |
| Last-Modified | 普通 HTTP 资源(仅此头) | 响应 304 | 几乎都支持 | 1 秒分辨率,勿与 ETag 同发 |
| 版本号 / 修订号 | 包仓库、带 tag 的发布 | 版本未变 | 对人友好、回滚自然失效 | 需先拉一个轻量 metadata 端点 |
为什么版本号是"比 ETag 更好的验证器"(当通道支持时):
- 日志可读:"从
1.2.0升到1.2.1"比一串哈希有意义。 - 回滚会自然失效:回滚后版本号变化,验证器立即不命中,不会误判"没变"。
版本号通道的典型实现------只拉几 KB 的 metadata,版本相同就返回"没变",版本不同才拉多 MB 的内容体:
FUNCTION fetchByRevision(source, known) RETURNS { revision, data|null }
meta = fetch(source.metaUrl, { timeout: 20s }).json() // 轻量端点
rev = meta.revision
IF known != undefined AND known == rev
RETURN { revision: rev, data: null } // 通知调用方"手上就是最新的"
body = fetch(source.contentUrl(rev)) // 仅在变化时下载大文件
RETURN { revision: rev, data: body }
7. 四个必须遵守的保证
保证 1:新鲜度每次都被服务器证实
每调用一次都问源。区别只在于:源回答"还是同一个"(304 / 版本未变)时,返回的是"源刚刚确认过是最新"的内存副本,而不是"一小时前没问就存下来的"旧值。
旧缓存:跳过请求、直接答内存------没问就假装新鲜。 本设计:每次都问,只是让源用 0 字节确认没变。
cached.data 只有在源刚刚确认它是最新时才会被返回。
保证 2:网络失败绝不走复用
304 与网络错误的本质不同:
304= 源可达且确认没变- 超时 / DNS 失败 / 5xx = 源什么都没确认
网络错误走 catch → 记错误 → 试下一个源 / 重试;绝不可能 从 cached 兜底。否则就重建了被否决的"离线快照"------恰好是这套设计要消灭的东西。
保证 3:多源多试回退
- 外层遍历多条源,前一个失败换下一个------一条镜像挂了,进程变慢但不至于拿不到数据。
- 内层每源 2 次------长距离丢包链路的瞬断,一次重试就够;既然调用方永远有"实时 + 无兜底"的期望,瞬断就该被消化掉。
保证 4:验证器绝不跨源
cached.key === source.key 才复用。换区域 / 换源时旧指纹作废,强制一次无条件请求,避免"从未见过的 body 却收到 304"的幽灵命中。
8. 实测收益(原型数据)
| 场景 | 下载量 | 耗时 |
|---|---|---|
| 无条件请求 | ~300 KB | 1.3 s |
| 304 命中 | 0 字节 | 0.5 s |
| 无此机制(每次全量) | ~1 MB / 次 | 9.9 s(逼近 15s 超时) |
代价仅仅是每次多带一个请求头、多一次几十毫秒的往返。
9. 完整时序

10. 适用边界
这套设计不是万能缓存,适合:
- 数据由外部维护、自己只读
- 频率高、体积小(或被压缩后小)
- 过期即错误(漏一条 = 漏一个决策)
不适合:
- 容忍陈旧的数据(用普通 TTL 缓存更简单)
- 数据极大且变化稀疏(应配合真正的断点 / 范围缓存,而非每次校验)
- 写后立即可见强需求(需结合主动失效,而非只靠条件请求)
11. 落地清单
实现时可逐条核对:
- 内存只存"指纹 + 被源确认过的数据",不存"过期可兜底"的副本
- 验证器绑定源 key,换源即作废
- HTTP 路径:一次只发一个验证器,ETag 优先于 Last-Modified
- 304 / 版本未变分支有"无可复用数据"的防御性检查
- 网络错误走重试 / 下一源,绝不返回旧数据
- 每源 2 次重试,多源顺序回退
- 外部输入经
validate()校验后才进入cached - 失败错误含耗时、尝试次数、代理信息
- 通道支持时,优先用版本号作验证器(日志友好、回滚自然失效)
- 超时按真实链路成本设置,而非拍脑袋