客户端缓存策略模式

Client-Side Caching Strategies — 5 种经典模式对比与 Celoria 应用实践

1. 为什么需要了解缓存策略?

客户端(移动 App、PWA、桌面应用)在访问远程数据时,都面临一个核心矛盾:

🚀 速度(用户体验)

用户不想等待网络请求,希望数据瞬间出现

🔄 新鲜度(数据准确性)

数据需要是最新的,过期数据可能导致错误决策

不同的缓存策略就是在这两个维度上做不同的权衡取舍。没有"最好的"策略,只有"最适合场景的"策略。

📚 来源:这些模式最早被 Google 在 Service Worker / PWA 文档中系统化定义,后来被 Workbox(Google 的 Service Worker 工具库)广泛传播。但这些概念适用于所有客户端,不限于 Web。

2. 五种策略速览

策略 英文名 速度 新鲜度 离线可用 典型场景
① 缓存优先 Cache-First 最快 可能过期 ✅ 离线 字体、图标、静态资源
② 网络优先 Network-First 最新 ⚠️ 有缓存时 新闻、社交动态
③ 过期可用,后台刷新 Stale-While-Revalidate 最终一致 ✅ 离线 Logo、配置、头像
④ 仅网络 Network-Only 最新 ❌ 不可用 支付、验证码
⑤ 仅缓存 Cache-Only 最快 打包时的 ✅ 离线 APP 内置资源

3. 各策略详解

① Cache-First(缓存优先)速度最快

核心思想:先查本地缓存,命中就直接返回,不走网络。只有缓存 miss 才请求网络,请求成功后写入缓存供下次使用。

flowchart LR
    A[客户端请求] --> B{缓存中有?}
    B -->|命中| C[返回缓存数据]
    B -->|未命中| D[请求网络]
    D --> E[写入缓存]
    E --> F[返回网络数据]
    style C fill:#2ecc71,color:#000
    style F fill:#3498db,color:#000
    

适用场景

优缺点

✅ 优点

  • 速度极快,命中后零网络延迟
  • 节省带宽和流量
  • 完全离线可用(缓存命中时)

❌ 缺点

  • 缓存没有自动更新机制
  • 如果不设 TTL 或版本号,用户可能永远看到旧数据
  • 首次请求(冷启动)仍然慢
⚠️ 关键问题:如何让缓存过期?常见方案:URL 带 hash(/logo-v2a3f.png)、设置 TTL 过期时间、或版本号比对。

② Network-First(网络优先)延迟较高

核心思想:每次都先请求网络获取最新数据。网络失败时,才降级到缓存兜底。

flowchart LR
    A[客户端请求] --> B[请求网络]
    B -->|成功| C[更新缓存]
    C --> D[返回最新数据]
    B -->|失败/超时| E{缓存中有?}
    E -->|有| F[返回缓存数据]
    E -->|没有| G[报错]
    style D fill:#3498db,color:#000
    style F fill:#f39c12,color:#000
    style G fill:#e74c3c,color:#000
    

适用场景

优缺点

✅ 优点

  • 数据新鲜度最好
  • 离线时有缓存兜底
  • 缓存自动被最新响应覆盖

❌ 缺点

  • 每次请求都走网络,在弱网环境下慢
  • 首次离线且无缓存时,完全不可用
  • 网络超时阈值需要仔细调优
💡 超时策略:实践中通常设 3-5 秒超时,超时后立即返回缓存。这样在弱网环境下用户不会等太久。TanStack Query(React Query)的 staleTime + networkMode 就是这个思路。

③ Stale-While-Revalidate(过期可用,后台刷新)速度快 推荐

核心思想:先立刻返回缓存数据(即使可能过期),同时在后台发起网络请求更新缓存。下次请求时就能拿到最新数据。

flowchart LR
    A[客户端请求] --> B{缓存中有?}
    B -->|命中| C[立刻返回缓存数据]
    B -->|未命中| F[请求网络]
    C --> D[后台请求网络]
    D -->|成功| E[静默更新缓存]
    E --> N[下次请求即最新]
    F --> G[写入缓存]
    G --> H[返回网络数据]
    style C fill:#2ecc71,color:#000
    style E fill:#9b59b6,color:#fff
    style H fill:#3498db,color:#000
    

名字的来源

来自 HTTP 响应头 Cache-Control: max-age=3600, stale-while-revalidate=86400,意思是:

适用场景

优缺点

✅ 优点

  • 用户几乎不感知加载延迟
  • 离线完全可用
  • 最终一致性 — 数据会自动更新
  • 兼顾速度和新鲜度的最佳平衡

❌ 缺点

  • 用户当前看到的可能是旧数据
  • 更新是"最终一致"的(eventual consistency),不是实时的
  • 需要处理"数据突然变了"的 UI 跳动问题
✅ Celoria POS Logo 方案:租户品牌 Logo 正是用这个策略。Logo 更换频率极低(可能几个月甚至几年),但必须支持更新。设备启动时先显示缓存的 Logo,连接成功后后台静默检查更新。

④ Network-Only(仅网络)无缓存

核心思想:完全不使用缓存,每次都走网络。失败就报错,没有降级。

flowchart LR
    A[客户端请求] --> B[请求网络]
    B -->|成功| C[返回数据]
    B -->|失败| D[报错]
    style C fill:#3498db,color:#000
    style D fill:#e74c3c,color:#000
    

适用场景

⚠️ 在 Celoria 中:CodePay 支付请求(POST /api/entry/bindpay)和 Query Order(POST /api/entry/orderquery)必须是 Network-Only。使用缓存的支付状态可能导致重复扣款或漏单。

⑤ Cache-Only(仅缓存)最快

核心思想:完全不走网络,只使用本地预加载/内置的数据。

flowchart LR
    A[客户端请求] --> B{缓存中有?}
    B -->|有| C[返回缓存数据]
    B -->|没有| D[报错/显示空]
    style C fill:#2ecc71,color:#000
    style D fill:#e74c3c,color:#000
    

适用场景

💡:在 Celoria 中,POS APK 内置的默认 Celoria Logo 就是 Cache-Only。设备首次开机、未配对任何租户时,显示这个内置 Logo。

4. 如何选择?决策流程图

flowchart TD
    A[这个数据需要缓存吗?] -->|不需要,实时性第一| B[Network-Only]
    A -->|需要缓存| C{数据变化频率?}
    C -->|永远不变| D[Cache-Only]
    C -->|几乎不变,偶尔更新| E[Stale-While-Revalidate]
    C -->|经常变化| F{离线可用重要吗?}
    F -->|重要| G[Network-First]
    F -->|不重要| H{首屏速度重要吗?}
    H -->|是| E
    H -->|否| G

    style B fill:#e74c3c,color:#fff
    style D fill:#9b59b6,color:#fff
    style E fill:#00d4ff,color:#000
    style G fill:#f39c12,color:#000
  

5. 进阶组合与变种

5.1 Cache-then-Network(缓存然后网络)

和 Stale-While-Revalidate 类似,但区别是:网络数据返回后立刻更新 UI(不是等下次请求)。

sequenceDiagram
    participant U as UI
    participant C as 缓存
    participant N as 网络

    U->>C: 读缓存
    C-->>U: 返回旧数据 (立刻渲染)
    U->>N: 同时请求网络
    N-->>C: 写入新数据
    N-->>U: 返回新数据 (替换渲染)

    Note over U: UI 闪烁一下变成新数据
  
💡 和 SWR 的区别 SWR 库(useSWR)和 TanStack Query(React Query)默认用的其实更接近 Cache-then-Network。

5.2 Race(竞速)

同时请求缓存和网络,谁先返回用谁。高可用场景使用,但实现复杂度高。

5.3 分层缓存(Tiered Cache)

多级缓存:内存缓存 → 磁盘缓存 → 网络。每一层作为下一层的加速层。

Memory Cache (50ms) → Disk Cache (200ms) → Network (1000ms+)
  

Flutter 的 cached_network_image 就是这个模式:内存 LRU → 文件缓存 → 网络下载。

6. Celoria 平台中的缓存策略分布

🖥️ POS 终端(CodePay P5)

数据策略理由
默认 Celoria Logo Cache-Only APK 内置,出厂自带
租户品牌 Logo Stale-While-Revalidate 配对时下载,后台静默更新
支付请求 Network-Only 支付结果绝不能缓存
设备配对状态 Stale-While-Revalidate 开机读本地配对信息,后台验证是否还有效

📱 iPad Kiosk App

数据策略理由
品牌配置(Logo/主题色) Stale-While-Revalidate 客人看到的品牌必须秒加载
服务列表与价格 Network-First 价格变动需要尽快反映,但离线时需要兜底
员工/技师列表 Network-First 当天排班可能变化,但离线时需要能选择
签到提交 Network-Only 签到是写操作,不能缓存

🌐 Web 管理后台(Next.js)

数据策略理由
React Query 查询 Stale-While-Revalidate TanStack Query 默认行为就是 SWR
门店列表、权限配置 Stale-While-Revalidate staleTime 设 5 分钟,窗口聚焦时自动 revalidate
预约看板实时数据 Network-First WebSocket 推送 + 定期 refetch,需要实时
支付/结账操作 Network-Only 写操作,mutation 不缓存

7. 技术实现参考

7.1 Flutter(POS / Kiosk App)

// Stale-While-Revalidate 伪代码(Dart)
class CachedBrandingService {
  final SharedPreferences _prefs;
  final ApiClient _api;

  /// 获取租户 Logo —— SWR 模式
  Future<String> getTenantLogo(String tenantId) async {
    // 1. 先读缓存(立刻返回)
    final cached = _prefs.getString('logo_$tenantId');

    // 2. 后台静默刷新
    _refreshInBackground(tenantId);

    // 3. 有缓存返回缓存,没有则等网络
    if (cached != null) return cached;
    return await _fetchAndCache(tenantId);
  }

  void _refreshInBackground(String tenantId) async {
    try {
      final hash = _prefs.getString('logo_hash_$tenantId');
      final remote = await _api.getBranding(tenantId);
      if (remote.logoHash != hash) {
        // Logo 变了,下载新的
        final bytes = await _api.downloadImage(remote.logoUrl);
        await _saveToLocal(tenantId, bytes, remote.logoHash);
      }
    } catch (_) {
      // 后台刷新失败不影响当前显示
    }
  }
}
  

7.2 React / Next.js(Web 管理后台)

// TanStack Query 天然支持 SWR 模式
const { data: stores } = useQuery({
  queryKey: ['stores'],
  queryFn: () => api.get('/stores'),
  staleTime: 5 * 60 * 1000,     // 5 分钟内认为是"新鲜"的
  gcTime: 30 * 60 * 1000,        // 缓存保留 30 分钟
  refetchOnWindowFocus: true,    // 窗口聚焦时自动 revalidate
});

// Network-Only: 支付 mutation
const payMutation = useMutation({
  mutationFn: (data) => api.post('/payment/checkout', data),
  // mutation 天然是 Network-Only,不走缓存
});
  

7.3 Service Worker(PWA)

// Workbox 的 StaleWhileRevalidate 策略
import { StaleWhileRevalidate } from 'workbox-strategies';
import { registerRoute } from 'workbox-routing';

// 对 /api/branding/* 使用 SWR 策略
registerRoute(
  ({url}) => url.pathname.startsWith('/api/branding'),
  new StaleWhileRevalidate({
    cacheName: 'branding-cache',
    plugins: [
      new ExpirationPlugin({
        maxEntries: 50,
        maxAgeSeconds: 7 * 24 * 60 * 60, // 7 天
      }),
    ],
  })
);
  

8. 常见陷阱

🪤 陷阱 1:对写操作用了缓存策略

POST/PUT/DELETE 等写操作永远是 Network-Only。缓存策略只适用于 GET 读操作。对写操作返回缓存结果会导致"以为成功了实际没有"的严重 bug。

🪤 陷阱 2:Cache-First 没有过期机制

使用 Cache-First 时必须配合 TTL、版本号或 URL hash。否则用户可能永远看到旧数据。在 Celoria 的场景中,这就是为什么选 SWR 而不是纯 Cache-First — SWR 有后台更新机制。

🪤 陷阱 3:SWR 的 UI 跳动

如果使用 Cache-then-Network 变种(新数据返回后立即更新 UI),Logo 可能出现"先显示旧的 → 突然闪一下变成新的"。对于 Logo 这种品牌元素,建议下次启动再显示新 Logo,而不是当场替换。

🪤 陷阱 4:多设备缓存一致性

同一租户的多台 POS/Kiosk 设备,Logo 更新后各设备的缓存刷新时间不同。可接受 — 品牌 Logo 不是实时数据,各设备下次联网时会自动同步。

9. 一句话记忆法

策略一句话
Cache-First有存货就不去超市(但存货可能过期)
Network-First每次都去超市,关门了才吃家里的
Stale-While-Revalidate先吃家里的,同时让人去超市补货
Network-Only必须去超市现买,不接受隔夜的
Cache-Only只吃家里的,从不出门

最后更新:2026-02-20 | 关联:POS 品牌 Logo 缓存方案