Flutter 带 TTL 的多级缓存设计:内存+磁盘+网络三层实战

Flutter 带 TTL 的多级缓存设计:内存+磁盘+网络三层实战

作者:FungLeo | 适用:Flutter / Dart(思路可迁移到任意端)
目标:让"几乎不变的字典数据"和"列表首屏"少发一半以上的请求,同时保证数据还能更新。

前言

先说个我自己都觉得有点丢人的观察。

前段时间我拿抓包工具看了一下自己项目的请求,发现一个很尴尬的事实:用户每切一次页面,我就要重新拉一遍下拉选项。分类、状态、归属人这些字典数据,一天到晚也不见得变一次,我却老老实实每次进页面都去问服务端一遍"这些选项是啥"。

页面进得越勤,请求发得越欢。用户看到的就是每次进列表页都要先转个圈,体验很一般。

一开始我想的很简单:那我加个变量存起来不就完了?结果很快被现实打脸——内存缓存重开 App 就没了,冷启动第一次进页面照样转圈;后来又加了本地持久化,结果走向另一个极端:数据永远不刷新,服务端改了一个选项名,客户端半个月还显示老的。

来回折腾了几轮,最后沉淀出一套还算好用的模式:内存 + 磁盘 + TTL 三层结构。这篇就把它完整写出来,各位看官可以直接抄到自己项目里。

本文要点

  • 一个能长期跑生产的缓存,必须同时答上四问:命中怎么办、没命中怎么办、过期怎么办、强制更新怎么办
  • 三层结构:内存扛高频访问、磁盘扛冷启动、TTL 扛数据陈旧、force 参数把最终决定权还给用户
  • 磁盘持久化一定要连时间戳一起存,否则 TTL 形同虚设(我在这翻过最狠的车)
  • 列表服务用 peekCache 实现 stale-while-revalidate:先拿旧数据填满界面,后台再悄悄更新
  • 缓存 key 必须带用户/租户维度,否则切账号会串数据——那是事故不是体验问题

先想清楚:缓存到底要解决几个问题

在贴代码之前,我想先把需求拆清楚。说实话,缓存这东西写起来不难,难的是想清楚边界。一个能用的缓存方案,至少要同时回答四个问题:

  1. 命中怎么办 —— 有数据就直接用,一个包都别发;
  2. 没命中怎么办 —— 降级到下一层,最后才走网络;
  3. 过期怎么办 —— 得有个时间概念,不能永远吃老本;
  4. 要强制更新怎么办 —— 用户下拉刷新的时候,缓存得给我让路。

只解决第 1 个问题的,那叫"变量存了一下",算不上缓存方案。四个问题都答上了,才是能长期跑在生产里的东西。

好啦,思路理清楚了,开干。

三层各自是什么角色

很多人一上来就写代码,但先把三层的定位摆清楚,后面才不会写乱:

层级介质速度存活期适用场景何时失效
内存进程内变量最快(ns 级)活不过进程重启高频读、几乎不变的字典App 被杀 / 重启
磁盘本地持久化(sp 或 db)慢一点(ms 级)扛冷启动字典、首屏首拉超过 TTL
网络服务端最慢(数百 ms+)永远最新以上全 miss 时

一句话记牢:内存最快但活不过进程;磁盘慢一点但能扛冷启动;网络最慢但数据最新。按这个顺序降级,才能做到"绝大多数情况零请求,冷启动一次请求,过期一次请求"。

在这里插入图片描述

模式一:Options 缓存(内存 + 磁盘 + TTL)

这个模式适合那些变化频率极低、但到处都要用的字典数据。

class OptionsCacheService {
  // 第一层:内存缓存,进程内最快
  List<OptionItem>? _categories;
  List<OptionItem>? _users;
  DateTime? _lastFetchTime;

  // TTL:这里给 10 小时(36000 秒),按数据变化频率自己定
  static const int ttlSeconds = 36000;

  bool get _isValid =>
      _lastFetchTime != null &&
      DateTime.now().difference(_lastFetchTime!).inSeconds < ttlSeconds;

  /// 对外暴露:按 id 取名称
  /// 命中内存或磁盘就直接返回,过期了才会真的发请求
  Future<String> getCategoryName(String id) async {
    await _ensureLoaded();
    // 注意 orElse:查不到就回退显示 id,别让它抛异常
    final hit = _categories?.firstWhere(
      (e) => e.id == id,
      orElse: () => OptionItem(id: id, name: id),
    );
    return hit?.name ?? id;
  }

  /// 三层降级的核心逻辑
  Future<void> _ensureLoaded() async {
    if (_isValid && _categories != null) return;  // 1. 内存命中,直接走人
    if (await _loadFromLocal()) return;           // 2. 磁盘命中(含时间戳校验)
    await _refreshFromApi();                      // 3. 都没有,才走网络
    await _saveToLocal();                         // 4. 回写磁盘,下次冷启动能用
    _lastFetchTime = DateTime.now();
  }

  /// 手动强制刷新:绕过所有缓存
  Future<void> refresh() async {
    await _refreshFromApi();
    await _saveToLocal();
    _lastFetchTime = DateTime.now();
  }
}
这里面有几个点特别容易写错

第一,磁盘持久化一定要连时间戳一起存。

这是我当初翻车最狠的地方。我最早只把数据本身写进了本地存储,时间戳留在内存里。结果 App 一重启,内存里的 _lastFetchTime 归零,磁盘里的数据却还在——于是 _loadFromLocal() 每次都命中,TTL 形同虚设,数据永远不刷新。

在这里插入图片描述

所以磁盘里存的应该是这么个结构:

{
  "fetchedAt": 1717029000000,
  "categories": [
    { "id": "1", "name": "选项 A" },
    { "id": "2", "name": "选项 B" }
  ]
}

_loadFromLocal() 读出来之后,得先拿 fetchedAt 和 TTL 比一比,过期了就当没读到,老老实实返回 false 去走网络。

第二,三层的顺序不能乱:内存 → 磁盘 → 网络。

上文那张表就是这个顺序的来历。顺序乱了,要么冷启动照样转圈,要么过期了还走旧数据。

第三,firstWhere 记得带 orElse

Dart 的 firstWhere 查不到会直接抛 StateError。字典数据这种东西,服务端删掉一个选项、而客户端还缓存着旧数据引用的场景太常见了。带上 orElse 回退成显示 id,页面顶多丑一点,总比整个列表崩了强,对吧。

模式二:服务层首屏缓存 + peekCache

字典数据搞定了,那列表本身呢?

列表数据当然不能像字典那样缓存 10 小时,但"用户刚从详情页返回列表"这种场景,重新请求一遍确实没必要。所以我给列表服务也加了一层短 TTL 的首屏缓存。

class SomeService {
  List<ItemModel>? _cache;
  String? _cacheKey;
  DateTime? _cacheTime;

  static const int ttl = 300; // 列表数据变化快,5 分钟足够了

  bool _isCacheValid(String key) =>
      _cacheKey == key &&
      _cacheTime != null &&
      DateTime.now().difference(_cacheTime!).inSeconds < ttl;

  /// 引用安全的 peek:命中就返回数据,不命中返回 null
  /// 关键是它绝不触发请求,纯查询
  List<ItemModel>? peekCache(String key) =>
      _isCacheValid(key) ? _cache : null;

  Future<PageResult> fetch({
    required int page,
    required String key,     // 缓存维度,见下面的说明
    bool force = false,      // 下拉刷新时传 true,绕过缓存
  }) async {
    // 只缓存第一页;force 时直接跳过缓存判定
    if (page == 1 && !force && _isCacheValid(key)) {
      return PageResult(items: _cache!, total: _cache!.length);
    }

    final r = await _api(page);

    if (page == 1) {
      _cache = r.items;
      _cacheKey = key;               // ← 存的是本次请求的 key,别写成 _cacheKey = _cacheKey
      _cacheTime = DateTime.now();
    }
    return r;
  }
}
几个设计上的讲究

缓存判定要放在"请求守卫"前面。

很多人的列表服务里都有个 _isLoading 之类的守卫变量,防止重复请求。如果你把缓存判定写在守卫后面,就会出现"明明有缓存,却因为守卫拦截而什么都没返回"的尴尬情况。正确的顺序是:先查缓存 → 命中就返回 → 没命中再进守卫逻辑 → 最后才发请求。

peekCache 为什么要单独存在?

因为它把"要不要发请求"的决定权交还给了调用方。页面可以这么用:

// 进页面先 peek 一把,有缓存就先把界面画出来,用户不用看骨架屏
final cached = service.peekCache(currentKey);
if (cached != null) {
  setState(() => items = cached);
}
// 然后再决定要不要静默拉一次新数据

这就是所谓的 stale-while-revalidate:先拿旧数据把界面填满,后台再悄悄更新。用户感知到的就是"秒开"。如果 peekCache 自己会触发请求,这个模式就玩不起来了。

缓存 key 一定要带维度。

这条是血的教训。_cacheKey 里除了筛选条件,还应该带上用户标识、租户标识这类维度(团队内部的隔离规范文档里也专门强调过)。否则 A 账号退出、B 账号登录,一进列表页看到的还是 A 的数据——这已经不是体验问题了,是事故。

另外提醒一句,退出登录的时候记得把这些缓存清干净,包括内存里的和磁盘里的。缓存服务最好统一提供一个 clear() 方法,登出流程里挨个调一遍。

TTL 该设多长?我的经验值

这个没有标准答案,我一般按"数据能容忍多久不更新"来倒推:

数据类型建议 TTL说明
字典 / 枚举 / 分类选项数小时 ~ 一天基本不变,配合手动 refresh 兜底
用户信息 / 权限配置十几分钟 ~ 半小时变了要相对及时地反映出来
列表首屏几分钟主要是解决"页面来回切"的重复请求
实时性数据(余额、状态)不缓存缓存的收益远小于显示错数据的代价

一般而言,够用就好,没必要太折腾。TTL 设得再精妙,也不如给用户留一个下拉刷新的口子来得实在。

小结

好啦,两个模式都讲完了。

回头看,这套东西的核心其实就一句话:用内存扛住高频访问,用磁盘扛住冷启动,用 TTL 扛住数据陈旧,用 force 参数把最终决定权还给用户。 四层加起来,才是一个跑得住的缓存方案。

再把几个容易翻车的点用一张表收个口:

现象根因修法
磁盘只存数据不存时间戳数据永远不刷新时间戳留内存,重启归零磁盘结构带 fetchedAt,读取先比 TTL
三层顺序乱冷启动转圈 / 过期走旧降级顺序错严格 内存→磁盘→网络
firstWhere 没带 orElse服务端删选项,列表崩StateError 抛异常带 orElse 回退显示 id
缓存判定写在守卫后有缓存却返回空守卫先拦截先查缓存→命中返回→再进守卫
缓存 key 无维度切账号串数据key 只含筛选条件key 带 用户/租户 标识
登出不清缓存切账号残留内存/磁盘未清统一 clear() 在登出调用

那么各位看官,您在项目里是怎么做缓存的呢?有没有更省事的封装方式?欢迎在评论区交流一下。如果这篇文章对你有点用,希望看官您用发财的小手点个小赞哈,谢谢大家!


本文由 FungLeo 主导,Deepseek 优化校阅,转发请注明首发地址,谢谢大家!

相关阅读

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

FungLeo

您的鼓励,是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值