用 HTTP 条件请求把“强一致“和“低成本

主题:一种通用的"每次都问源、但几乎不重传"的远程数据访问设计 适用场景:需要从远程拉取一份会频繁更新的清单 / 配置 / 数据集,且过期即错误的场合

1. 一句话概括

一个 loadResource() 函数每次被调用都会真实请求 远程数据源,但会带上上次的"内容指纹"(ETag / Last-Modified / 版本号),让源用最小的代价回答"内容没变"(304 Not Modified)。只有源亲自确认过没变,内存中的数据才被允许返回;网络失败时绝不复用旧数据。

这把"强一致(每次都是最新)"和"低成本(几乎不重传)"两件事同时拿到了。

2. 问题定义:我们要解决什么

假设进程需要周期性地(或按需)读取一份托管在远程的数据:

  • 一份 feature flag 配置
  • 一份可下载组件清单
  • 一份定价表 / 汇率表
  • 一份地址库 / 黑名单

这份数据有三个特征:

  1. 由别人维护,自己只读------不能本地造,得问源
  2. 会频繁更新------今天新加的条目,明天就必须能看到
  3. 过期就是错误,不是降级------缺失的条目会被当成"不存在",从而做出错误决策(例如拒绝一个本该合法的项)

于是产生张力:

  • 想省流量 / 省延迟 → 倾向于缓存、少请求
  • 想正确 → 倾向于每次都拿最新的

朴素的两类方案都会翻车,见下一节。

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
  • 失败错误含耗时、尝试次数、代理信息
  • 通道支持时,优先用版本号作验证器(日志友好、回滚自然失效)
  • 超时按真实链路成本设置,而非拍脑袋
相关推荐
Horn Still Sounds19 分钟前
Linux进程间通信:信号、消息队列、共享内存完整笔记
linux·网络·笔记
2601_9621229721 分钟前
Spring Cloud——路由网关Zuul
后端·spring·spring cloud
卷无止境22 分钟前
从一杯咖啡的订单说起,数据库设计到底是怎么长出来的
后端·python
demon755200330 分钟前
IDEA-http-client-指南
http·intellij-idea
再吃一根胡萝卜31 分钟前
Rust 桌面宠物拖拽踩坑实录:重影、不跟手、置顶失效
后端
再吃一根胡萝卜32 分钟前
Rust 桌面宠物进化记:从静态图标到会动的"QQ 宠物"
后端
路弥行至37 分钟前
ROS2 通信避坑实录:从 topic/msg 底层原理到一次内存错位复盘
网络·经验分享·笔记·其他·内存·topic·ros2
Alan CGH38 分钟前
一次非寻常的压测优化经历
java·网络·压力测试
Yang96111 小时前
一人一机一手机:鼎讯G-4000A让光缆查找告别双人配合
网络