成为全栈·基础补充·缓存的层次与失效:浏览器、CDN、服务端、数据库

成为全栈·基础补充·缓存的层次与失效:浏览器、CDN、服务端、数据库

做移动端那批文章时,我被一个现象困惑了很久:

App 明明显示"已下架"的文章,点进去还能读到完整正文。

第一反应是接口有 bug------服务端返回了不该返回的内容。而真相更平淡:服务端返回了,但客户端没重新去拿。 它从本地缓存里读的。

而更让我在意的是:这个现象在开发环境根本不会出现。 因为开发环境几乎不缓存。

这篇讲缓存的四个层次,以及一个我在这个项目里学到的判断------缓存不是"加速",是"用一致性换速度",而换多少由你决定,但要说得清楚。

同一个 URL,四层可能各存着一份

先建立这个概念,因为它解释了后面所有的困惑。

当你访问 GET /articles/123,下面四层可能各有一份数据:

层 存什么 谁控制 失效由谁决定
浏览器 / 客户端 HTTP 缓存、App 本地缓存 Cache-Control、客户端策略 服务端头 / 客户端逻辑
CDN / 反向代理 静态资源、偶尔 API 响应 边缘节点配置 通常靠 TTL 或 Purge
应用服务器 内存缓存、磁盘缓存 你的代码 你的失效逻辑
数据库 查询计划缓存、连接池 数据库自己 数据库自己

这四层的失效逻辑完全独立。 而这个项目最有意思的一点是:

我在应用层写了一套完整的缓存机制,而 HTTP 层的缓存我几乎没管。

这不是疏漏,是刻意的。理由后面讲。

客户端缓存:最容易制造困惑的一层

先说客户端,因为它最容易被忽略,也最容易出问题。

Cache-Control 决定浏览器/客户端怎么缓存:

http 复制代码
Cache-Control: max-age=300

意思是"5 分钟内可以不发请求"。

而这个项目在网络层做了这样的事:

ts 复制代码
Future<CacheReply> fetchCacheReply(ApiClient api, String path, {...}) async {
  String control = '';
  Duration age = Duration.zero;
  final data = await api.request(
    path,
    query: query,
    anonymous: anonymous,
    onHeaders: (headers) {
      control = headers.value('cache-control') ?? '';
      age = Duration(seconds: int.tryParse(headers.value('age') ?? '') ?? 0);
    },
  );
  return CacheReply(data, control: control, age: age);
}

它把 cache-control 和 age 收集起来,交给应用层去判断。

而应用层的处理是:

ts 复制代码
// 缓存策略只允许收紧服务端限制;Age 扣减保存时间,不重置存活期。
var fresh = policy.fresh, max = policy.maxAge;
final seconds = int.tryParse(
  RegExp(r'(?:^|,)\s*max-age\s*=\s*"?(\d+)').firstMatch(control)?.group(1) ?? '',
);
if (seconds != null) {
  final age = Duration(seconds: seconds);
  if (age < fresh) fresh = age;
  if (age < max) max = age;
}

注意那个 if (age < fresh) fresh = age; ------只能在服务端要求更短的时候才生效。

如果服务端说"缓存 1 小时",而我的策略是"2 分钟",不会变成 1 小时。

这是一个主动的收紧设计。 理由是:服务端的 Cache-Control 可能是针对中间代理设的,而客户端没有理由因为服务端给得宽松就放宽自己的策略------那意味着用户可能看到一小时前的通知,而 App 本该一分钟就更新。

顺带说 Age 头那个处理:

ts 复制代码
entry: CacheEntry(response.value, now().subtract(response.age), fresh, max, tags),

Age 是响应在 CDN 或代理上停留的时间。一个响应在路上花了 40 秒,那它其实已经 40 秒老了。now().subtract(response.age) 让它"看起来"老了 40 秒,这样 fresh/maxAge 的判断会自动把这段路途算进去。

而这一条如果忽略,症状是"每份缓存数据都比配置值年轻"------不报错,只是新鲜度实际比设定低。

应用层缓存:三态是这个项目的核心

现在说这个项目真正下了功夫的地方。

先看一个容易误解的地方:"缓存"不是"有/没有",而是三态。

状态 条件 行为
fresh 未超过 fresh 直接返回,不发请求
stale 超过 fresh 但未超过 maxAge 立刻返回旧值,同时后台悄悄更新
expired 超过 maxAge 删掉,必须等网络

看真实实现:

ts 复制代码
if (!force &&
    entry != null &&
    now().difference(entry.saved) < entry.maxAge) {
  _put(key, entry);                                  // 续期
  if (now().difference(entry.saved) < entry.fresh) {
    count('freshHit');
    return entry.value;                              // fresh:直接返回
  }
  count('staleHit');
  unawaited(_fetch(key, policy, fetch, tags, forbidden)
    .catchError((Object _) => null));                // 后台更新
  return entry.value;                                // stale:先给旧的
}
if (entry != null && now().difference(entry.saved) >= entry.maxAge) {
  _entries.remove(key);                              // expired:删掉
}
count('miss');

stale 这一档是这个设计里最重要的部分。

用户在地铁里,网络很差,App 打开了首页。这时候:

  • 如果强制要网络 → 白屏或者转圈
  • 如果只用 fresh → 网络不好时拿不到任何内容
  • stale 的做法:立刻显示可能有点旧的缓存,同时后台更新

用户感知到的是"打开很快",而不是"等很久"。

而那个 .catchError((Object _) => null) 也很关键:后台更新失败不能影响用户。 用户已经看到内容了,后台刷新失败对他不可见。

吞掉异常和报告失败是两件事:前者防止后台任务崩溃,后者让界面有机会提示。而这个项目的做法是------

dart 复制代码
if (error != null)
  Text('更新失败,当前显示上次内容', style: context.text.bodySmall),

在 M4 那批文章里,我把这条翻译成了界面上的三行字。 数据层的"保留旧值"最终变成用户可见的一句话。

失效:比"什么时候删"更难的是"删哪些"

这是这个项目花时间最多的地方。

先看一张表,这是 M4-07 讲过的写后失效映射:

ts 复制代码
Set<String> mutationTags(String path, Object? data) => switch (family(path)) {
  ResourceFamily.reaction => {
    'reactions:${Endpoints.articleId(path)}',
    Endpoints.meLikes,
    'overview',
  },
  ResourceFamily.article || ResourceFamily.articleList => {
    CacheTags.articleBodies,
    'articleLists',
    Endpoints.meArticles,
    'overview',
    Endpoints.tags,
    Endpoints.categoriesStats,
  },
  ...
};

一次点赞要失效三个键:文章自己的反应状态、我的点赞列表、会员中心的统计数字。

而发一篇文章要失效六个 ------其中 tags 和 categoriesStats 是最容易漏的:新文章会让标签页和分类计数变化,而这两个页面和发帖操作看起来毫无关系。

这就是失效映射的核心难点:不是"删掉什么",而是"想清楚还有什么会变"。

而这套表能成立的前提是 M4-07 讲的另一个设计------family(path) 只判一次,读策略、响应校验、写后失效三处共用。否则同一个路径在不同代码里可能被归成不同的类,失效就漏了。

fence 和 invalidate 必须是两个动作

现在讲这个项目里最微妙的一个实现,也是"缓存和写操作正确协作"的关键。

问题场景:用户点收藏 → 缓存失效 → 同时还有一个之前发出的列表请求在路上 → 那个请求带着旧数据回来 → 把旧数据写进缓存。

解法分两步:

ts 复制代码
/// Fence reads at mutation start; preserve visible data until its outcome.
void fence(Set<String> tags) {
  for (final key in _keyTags.keys.toList()) {
    if (_keyTags[key]!.intersection(tags).isNotEmpty) {
      _versions[key] = Object();      // 换一个新对象当哨兵
      _flights.remove(key);           // 取消同键请求复用
    }
  }
}

// 依赖标签同时清理内存和磁盘;旧网络响应已被 fence 隔断。
void invalidate(Set<String> tags) {
  fence(tags);
  for (final key in _keyTags.keys.toList()) {
    if (_keyTags[key]!.intersection(tags).isNotEmpty) {
      _entries.remove(key);
      emit(CacheEvent(key, 'invalidated'));
    }
  }
  unawaited(disk.removeWhere((_, m) => (m['tags'] as List? ?? []).any(tags.contains)));
}

为什么必须拆成两个动作?

因为写操作可能失败 。如果 invalidate 一上来就把数据删了,然后写操作失败了------用户正在看的内容没了,而他刚才的操作根本没成功。

所以顺序是:

dart 复制代码
if (started) {
  cache.fence(affected);     // 写开始:只围栏,不删
  return;
}
if (error is SessionChanged) return;
cache.invalidate(affected);  // 写结束:真的删

"围栏"和"拆墙"是两件事:围栏隔断在途请求,拆墙才真的删除数据。而拆墙要等到确认写成功了。

而 _versions[key] = Object() 这个哨兵实现很巧:在途请求回来时检查"我那个哨兵还在不在",不在了就丢弃结果。

用 Object() 而不是自增整数,是因为 Dart 的 identical 是 O(1) 的,而且不会溢出。

数据库层:最后一道,也是最不该动的

前面四层都是缓存"查询结果"。数据库自己还有一层缓存,但它性质不同。

层 缓存什么 能不能自己失效
应用层 HTTP 响应 能,你写代码
数据库 查询计划、执行计划 不能 ,要靠 ANALYZE

数据库的查询计划缓存意思是:数据库把"这条 SQL 怎么执行"的决定缓存起来,避免每次重新分析。而它失效的依据是数据分布的变化------你插入了一百万行之后,原来的计划可能不再最优。

ANALYZE 就是告诉数据库"数据分布是这样的,重新算计划"。

而这个项目用 D1(Cloudflare 的 SQLite),每次请求的数据量都小,没有手动调 ANALYZE 的必要。 这是个明确的取舍,不是遗漏。

它的逻辑是:这一层的优化收益最小、成本最高(需要理解执行计划),所以最后做。

一条我一开始想省掉的事

Age 头那个处理,第一版我完全没做。

那时候我以为 saved = now() 就够了。问题出现在加 CDN 之后:一个响应在 CDN 上待了 40 秒,到客户端时它已经 40 秒老了,但我记成 0 秒。

后果是每份缓存数据都"年轻"了它在路上花掉的时间。对 2 分钟 fresh 的通知列表来说,那是 33% 的新鲜度被白送了。

而这类 bug 极难发现,因为它不报错,只是让缓存的实际新鲜度低于配置值。

我发现它是因为顺手把两个时间戳打了日志对比,发现 saved 和响应头的 Age 差着 40 秒。

教训是:实现 HTTP 缓存时,Age 不是可选的头。 任何中间层(CDN、反向代理)都会引入它,而忽略它的后果是静默的。

顺带说一个我承认没解决的:读缓存会更新 saved 时间戳,导致通知这类高频读取的数据永远不进 stale 状态。

正确的解法是拆成两个字段(saved 给 maxAge 用、fetchedAt 给 fresh 用),但我没做------因为它需要改缓存层核心数据结构,而收益只在通知这类场景上。

小结

缓存这四层,本项目沉淀下来的判断是:

  1. 客户端 / CDN / 应用 / 数据库四层独立,而这个项目只在应用层下了功夫------因为那是唯一能精确控制的一层。
  2. 服务端的 Cache-Control 只能让策略更严格,不能让它更宽松。
  3. fresh / stale / expired 三态,"先给旧的再更新"是弱网体验的关键。
  4. 失效映射的核心不是"删什么",是"还有什么会变"------发一篇文章要失效六个键,其中两个最容易漏。
  5. fence(隔断)和 invalidate(删除)必须分开,因为写操作可能失败。
  6. Age 不处理会让新鲜度静默偏低。

第 5 条我觉得是这批文章里最值得记住的,因为它体现了一个通用模式:

"停止一件坏事"和"做一件坏事"要分开。 前者可以立即做(隔断在途请求),后者必须确认条件(删数据)。

这个模式和 B-18 讲的迁移回滚是同构的------迁移时"先加列"可以立刻做,"删数据"必须确认新代码就绪。

下一篇讲 Web 安全。我会拿这个项目的真实防护来对照讲------SQL 注入是彻底防住的(白名单),但 XSS 在这个项目里靠的是"不用 innerHTML"这种隐式防护。

延伸阅读


如果这篇文章对你有帮助,欢迎订阅我的 CSDN 专栏 「成为全栈」:

🔗 专栏地址:https://blog.csdn.net/fungleo/category_13204651.html

📦 本系列配套代码仓库:https://github.com/fengcms/become-a-full-stack-developer

相关推荐
code2cat6 小时前
【随笔】KV缓存量化:省下的显存,要付出多少数值误差
人工智能·缓存·大语言模型
csdn_aspnet6 小时前
Redis 入门:Windows、Mac安装指南
数据库·windows·redis·缓存·mac
FungLeo8 小时前
成为全栈·基础补充·Node.js 运行时:事件循环与异步
javascript·node.js·事件循环·后端开发·异步编程·成为全栈
Pniubi8 小时前
缓存击穿的解决方案
缓存
FungLeo1 天前
成为全栈·Go 后端篇·三轮代码审阅之后:把可读性和证据做成工程规则
golang·go·ast·静态检查·成为全栈·代码审阅
code2cat1 天前
【随笔】KV缓存卸载:逐层预取能藏住多少传输时间
人工智能·缓存·大语言模型
FungLeo1 天前
成为全栈·基础补充·域名、DNS 与 HTTPS 证书
https·网络基础·网站部署·tls证书·成为全栈·域名 dns
FungLeo1 天前
成为全栈·基础补充·Web 安全基础:XSS、CSRF 与 SQL 注入
web安全·网络安全·xss·csrf·sql注入·成为全栈
AI技术新视界2 天前
LLM 提示词缓存(Prompt Caching)的秘密
人工智能·缓存·llm·prompt