网络与数据篇(3/6):缓存策略:内存、磁盘、失效机制

10 阅读4分钟

缓存策略:内存、磁盘、失效机制

系列:网络与数据篇(4/6)

Flutter Dart 缓存 网络 移动端架构


1. 问题背景

业务里常见:同一接口在多个页面、Tab、二级详情里反复请求;弱网下用户反复进入同一页,白屏与转圈重复出现;或为了「快」在内存里随手塞 Map登出/切账号后仍闪现旧数据
现象总结为三类:流量与耗电浪费、首屏与回流体验差、一致性不可预期(过期、脏读、多端覆盖)。


2. 原因分析

  • 没有统一缓存对象与生命周期:Widget、Provider、Repository 各写一份「记住上次结果」,失效策略互相打架。
  • 混淆「快取」与「数据源」:把缓存当真理来源,网络失败时不知道展示的是缓存还是业务上的「空」。
  • 失效维度缺失:只有 TTL,没有用户维度、版本维度、写操作后的主动失效;列表改了,详情仍读旧缓存。
  • 磁盘与内存未分工:全走内存,进程杀死后从零拉取;全走磁盘,解码与 I/O 把首帧拖慢。

3. 解决方案

分层与职责

  • 数据源(Remote / Local)Repository 内组合「先读哪、后写哪」;UI 不直面缓存实现。
  • 内存缓存:热点、小体积、与当前会话强相关(例如当前用户首页摘要);生命周期绑定 Provider / 应用级容器,登出统一 invalidate
  • 磁盘缓存:大列表、可离线看的详情、配置类数据;需序列化、版本号、可选压缩或加密敏感字段。
  • 失效策略(组合使用优于单一 TTL)
    • TTL:适用于配置、Banner 等对时效有容忍度的数据。
    • 事件失效:创建/更新/删除后,按资源 id 或列表 key 标记脏;下次读取可先读旧缓存再后台刷新,或直接跳过缓存走网络。
    • 版本/ETag:与服务端约定时,可减少无效传输(本篇侧重思路,实现可接 HTTP 缓存头或业务版本字段)。
    • 用户与租户隔离:缓存 key 必须包含 userId / tenantId,避免串号。

读写策略(常见三种,按场景选型)

  • Cache-Aside:读时未命中再拉远端,适合大多数业务 API。
  • Stale-While-Revalidate:先展示稍旧数据,后台刷新,适合列表与详情首屏。
  • Write-through / 写后更新:写接口成功后更新本地缓存与内存快照,减少二次 GET。

并发与单次飞行(避免缓存风暴)

  • 同一 key 在飞行中的请求只发一次,其余等待同一 Future,防止列表快速滚动或重复 watch 触发 N 次相同请求。

4. 关键代码(极简约定,不展开示例段)

  • Repository 方法表达策略:getX({bool forceRefresh})invalidateX(id),避免在 Widget 里判断「要不要走缓存」。
  • 内存层:Map<String, _Entry<T>> + expiresAt + inFlight;或使用成熟包时也要保证 key 规范 与失效出口一致。
  • 磁盘层:key = userScope + endpoint + 规范化 query;写入前带 schemaVersion,升级时整仓清理或按前缀删。
  • Riverpod / 状态层:ref.keepAlive 仅用于明确的共享读模型;登出监听里 ref.invalidate 一批 Provider 或清全局缓存单例。

5. 效果验证

  • 指标:重复进入同一路径的接口 QPS、首屏 P50/P90、离线二次进入是否仍有内容、崩溃/杀进程后是否可恢复关键页。
  • 一致性:写操作后详情与列表是否在可接受延迟内对齐(含事件失效或 refetch)。
  • 回归:切账号、切环境(预发/正式)后是否出现他人数据或旧域名缓存。

6. 可复用结论

  • 缓存是产品行为:先定义「可以接受多旧、写后多快必须一致」,再选 TTL 与 SWR。
  • key 与用户作用域同等重要:无作用域的缓存等于埋雷。
  • 失效要同时考虑时间与事件:只靠 TTL 会在「刚改完仍看旧数据」上反复踩坑。
  • Repository 收口:内存、磁盘、网络只在数据层交织,UI 只处理展示态(加载 / 成功 / 空 / 错误 + 是否来自缓存的弱提示)。

下期:列表分页与并发请求优化(4/6)