三亩地 三亩地SAN MU DI · CODE DIARY
ARTICLE DETAIL

日记详情

真实记录编程学习的某一天,欢迎挑你感兴趣的翻一翻。

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

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)

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

classOptionsCacheService{// 第一层:内存缓存,进程内最快List<OptionItem>?_categories;List<OptionItem>?_users;DateTime?_lastFetchTime;// TTL:这里给 10 小时(36000 秒),按数据变化频率自己定staticconstint ttlSeconds=36000;boolget_isValid=>_lastFetchTime!=null&&DateTime.now().difference(_lastFetchTime!).inSeconds<ttlSeconds;/// 对外暴露:按 id 取名称/// 命中内存或磁盘就直接返回,过期了才会真的发请求Future<String>getCategoryName(Stringid)async{await_ensureLoaded();// 注意 orElse:查不到就回退显示 id,别让它抛异常finalhit=_categories?.firstWhere((e)=>e.id==id,orElse:()=>OptionItem(id:id,name:id),);returnhit?.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 的首屏缓存。

classSomeService{List<ItemModel>?_cache;String?_cacheKey;DateTime?_cacheTime;staticconstint ttl=300;// 列表数据变化快,5 分钟足够了bool_isCacheValid(Stringkey)=>_cacheKey==key&&_cacheTime!=null&&DateTime.now().difference(_cacheTime!).inSeconds<ttl;/// 引用安全的 peek:命中就返回数据,不命中返回 null/// 关键是它绝不触发请求,纯查询List<ItemModel>?peekCache(Stringkey)=>_isCacheValid(key)?_cache:null;Future<PageResult>fetch({required int page,requiredStringkey,// 缓存维度,见下面的说明bool force=false,// 下拉刷新时传 true,绕过缓存})async{// 只缓存第一页;force 时直接跳过缓存判定if(page==1&&!force&&_isCacheValid(key)){returnPageResult(items:_cache!,total:_cache!.length);}finalr=await_api(page);if(page==1){_cache=r.items;_cacheKey=key;// ← 存的是本次请求的 key,别写成 _cacheKey = _cacheKey_cacheTime=DateTime.now();}returnr;}}
几个设计上的讲究

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

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

peekCache为什么要单独存在?

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

// 进页面先 peek 一把,有缓存就先把界面画出来,用户不用看骨架屏finalcached=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 优化校阅,转发请注明首发地址,谢谢大家!

相关阅读

  • Flutter Riverpod 在 build 期改 provider 导致整页崩溃,踩坑实录
  • Flutter Debug 红屏、Release 灰屏:你的 release-only bug,只是异常被藏起来了
  • Flutter 可复用公共组件库设计与落地:AppDialog/BottomSheet 等实战
  • Flutter 接入 Alice 调试浮窗:一个顶层 final 抢跑,把 release 网络整没了
  • Flutter Material 3 从 0 搭品牌主题系统,四件套实战全记录
← 返回列表