Client-Side Caching Strategies — 5 种经典模式对比与 Celoria 应用实践
客户端(移动 App、PWA、桌面应用)在访问远程数据时,都面临一个核心矛盾:
用户不想等待网络请求,希望数据瞬间出现
数据需要是最新的,过期数据可能导致错误决策
不同的缓存策略就是在这两个维度上做不同的权衡取舍。没有"最好的"策略,只有"最适合场景的"策略。
| 策略 | 英文名 | 速度 | 新鲜度 | 离线可用 | 典型场景 |
|---|---|---|---|---|---|
| ① 缓存优先 | Cache-First | 最快 | 可能过期 | ✅ 离线 | 字体、图标、静态资源 |
| ② 网络优先 | Network-First | 慢 | 最新 | ⚠️ 有缓存时 | 新闻、社交动态 |
| ③ 过期可用,后台刷新 | Stale-While-Revalidate | 快 | 最终一致 | ✅ 离线 | Logo、配置、头像 |
| ④ 仅网络 | Network-Only | 慢 | 最新 | ❌ 不可用 | 支付、验证码 |
| ⑤ 仅缓存 | Cache-Only | 最快 | 打包时的 | ✅ 离线 | APP 内置资源 |
核心思想:先查本地缓存,命中就直接返回,不走网络。只有缓存 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
/logo-v2a3f.png)、设置 TTL 过期时间、或版本号比对。
核心思想:每次都先请求网络获取最新数据。网络失败时,才降级到缓存兜底。
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
staleTime + networkMode 就是这个思路。
核心思想:先立刻返回缓存数据(即使可能过期),同时在后台发起网络请求更新缓存。下次请求时就能拿到最新数据。
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,意思是:
max-age=3600:1 小时内缓存是"新鲜"的,直接使用stale-while-revalidate=86400:过了 1 小时但在 24 小时内,缓存"过期但可用"(stale),先返回旧数据,后台去重新验证(revalidate)核心思想:完全不使用缓存,每次都走网络。失败就报错,没有降级。
flowchart LR
A[客户端请求] --> B[请求网络]
B -->|成功| C[返回数据]
B -->|失败| D[报错]
style C fill:#3498db,color:#000
style D fill:#e74c3c,color:#000
POST /api/entry/bindpay)和 Query Order(POST /api/entry/orderquery)必须是 Network-Only。使用缓存的支付状态可能导致重复扣款或漏单。
核心思想:完全不走网络,只使用本地预加载/内置的数据。
flowchart LR
A[客户端请求] --> B{缓存中有?}
B -->|有| C[返回缓存数据]
B -->|没有| D[报错/显示空]
style C fill:#2ecc71,color:#000
style D fill:#e74c3c,color:#000
precache 的 App Shell
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
和 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 闪烁一下变成新数据
useSWR)和 TanStack Query(React Query)默认用的其实更接近 Cache-then-Network。
同时请求缓存和网络,谁先返回用谁。高可用场景使用,但实现复杂度高。
多级缓存:内存缓存 → 磁盘缓存 → 网络。每一层作为下一层的加速层。
Memory Cache (50ms) → Disk Cache (200ms) → Network (1000ms+)
Flutter 的 cached_network_image 就是这个模式:内存 LRU → 文件缓存 → 网络下载。
| 数据 | 策略 | 理由 |
|---|---|---|
| 默认 Celoria Logo | Cache-Only | APK 内置,出厂自带 |
| 租户品牌 Logo | Stale-While-Revalidate | 配对时下载,后台静默更新 |
| 支付请求 | Network-Only | 支付结果绝不能缓存 |
| 设备配对状态 | Stale-While-Revalidate | 开机读本地配对信息,后台验证是否还有效 |
| 数据 | 策略 | 理由 |
|---|---|---|
| 品牌配置(Logo/主题色) | Stale-While-Revalidate | 客人看到的品牌必须秒加载 |
| 服务列表与价格 | Network-First | 价格变动需要尽快反映,但离线时需要兜底 |
| 员工/技师列表 | Network-First | 当天排班可能变化,但离线时需要能选择 |
| 签到提交 | Network-Only | 签到是写操作,不能缓存 |
| 数据 | 策略 | 理由 |
|---|---|---|
| React Query 查询 | Stale-While-Revalidate | TanStack Query 默认行为就是 SWR |
| 门店列表、权限配置 | Stale-While-Revalidate | staleTime 设 5 分钟,窗口聚焦时自动 revalidate |
| 预约看板实时数据 | Network-First | WebSocket 推送 + 定期 refetch,需要实时 |
| 支付/结账操作 | Network-Only | 写操作,mutation 不缓存 |
// 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 (_) {
// 后台刷新失败不影响当前显示
}
}
}
// 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,不走缓存
});
// 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 天
}),
],
})
);
POST/PUT/DELETE 等写操作永远是 Network-Only。缓存策略只适用于 GET 读操作。对写操作返回缓存结果会导致"以为成功了实际没有"的严重 bug。
使用 Cache-First 时必须配合 TTL、版本号或 URL hash。否则用户可能永远看到旧数据。在 Celoria 的场景中,这就是为什么选 SWR 而不是纯 Cache-First — SWR 有后台更新机制。
如果使用 Cache-then-Network 变种(新数据返回后立即更新 UI),Logo 可能出现"先显示旧的 → 突然闪一下变成新的"。对于 Logo 这种品牌元素,建议下次启动再显示新 Logo,而不是当场替换。
同一租户的多台 POS/Kiosk 设备,Logo 更新后各设备的缓存刷新时间不同。可接受 — 品牌 Logo 不是实时数据,各设备下次联网时会自动同步。
| 策略 | 一句话 |
|---|---|
| Cache-First | 有存货就不去超市(但存货可能过期) |
| Network-First | 每次都去超市,关门了才吃家里的 |
| Stale-While-Revalidate | 先吃家里的,同时让人去超市补货 |
| Network-Only | 必须去超市现买,不接受隔夜的 |
| Cache-Only | 只吃家里的,从不出门 |
最后更新:2026-02-20 | 关联:POS 品牌 Logo 缓存方案