ARTICLE DETAIL

资讯详情

深耕网站建设、视觉设计与SEO优化的一线实战洞察。

Flutter 401 自动刷新拦截器并发死锁:\_refreshQueue 死锁根治实录

Flutter 401 自动刷新拦截器并发死锁:\_refreshQueue 死锁根治实录

Flutter 401 自动刷新拦截器并发死锁:_refreshQueue 死锁根治实录

作者:FungLeo | 适用:Flutter / Dio(思路适用于任意带拦截器的 HTTP 客户端)
现象:清除缓存后重新登录,列表页永远骨架屏;杀掉 App 重开再登录,一切正常。

前言

各位看官,先说个特别别扭的 bug 现象,看看你有没有遇到过:

"清除缓存 → 重新登录"之后,列表页卡在骨架屏,下拉刷新也没反应。但是把 App 彻底杀掉,重开再登录,一切正常。

我第一次碰到的时候是真的懵。同样的账号、同样的接口、同样的代码路径,就因为中间少了一次"杀进程",结果天差地别。

我当时的第一反应是缓存没清干净。于是我把所有能想到的本地存储挨个翻了一遍,token 清了、用户信息清了、列表缓存也清了,还是卡。第二反应是接口返回有问题,抓包一看更懵了——请求压根就没发出去,服务端那边一片安静。

绕了一大圈才想明白:这不是数据问题,是并发死锁。我那个 401 自动刷新拦截器,在某个分支上把一队请求永久地"关小黑屋"了,它们的 Future 永远不会 resolve,页面await在那儿等到天荒地老。

而"杀 App 就正常"这个现象,恰恰是最关键的线索——它几乎是在明示你:问题出在活在内存里的那点脏状态上。

这篇就把这个坑从现象、根因到两处修复完整讲一遍。

本文要点

  • 401 刷新拦截器里,刷新失败的提前return分支最容易漏清空队列,一个 completer 不被complete就永远 pending
  • 死锁最恶心的地方:不是崩溃,是"什么都没发生"——没报错、没超时、没日志,页面就这么干转
  • "杀 App 正常、不杀就卡"几乎可以锁定:单例里的脏状态跨生命周期存活
  • 治本靠try...finally兜底清空队列;兜底靠清除缓存时ref.invalidate重建 client
  • 所有Completer、布尔标志位、长生命周期单例都要顺着这个思路过一遍

先说说 401 自动刷新拦截器是干嘛的

为了让各位看官都跟得上,先花两句话交代背景。

凡是用短 token + 刷新 token 这套鉴权方案的 App,都要处理一件事:短 token 过期了怎么办?总不能弹个框让用户重新登录吧,那体验太差了。

标准做法是在 HTTP 客户端上挂一个拦截器:

  1. 请求返回 401 → 说明 token 过期了;
  2. 拦截住这个错误,别急着抛给页面;
  3. 拿刷新 token 去换一个新的短 token;
  4. 换到了,就用新 token 把刚才那个请求重放一遍
  5. 用户全程无感知。

思路很清楚,对吧。麻烦在第 3 步和第 4 步之间的并发:如果页面同时发了 5 个请求,5 个一起 401,你总不能刷新 5 次 token。所以就得加一个"正在刷新"的标志位,加一个等待队列——第一个请求负责刷新,其余的进队列排着,等刷新完了统一放行。

死锁,就藏在这个队列里。

表象对照:为什么它这么难查

这个 bug 之所以让人头大,是因为它的表象太"反直觉"——同样的账号、同样的代码,只是操作顺序不同,结果天差地别。我把几种典型场景摆在一起看:

操作顺序正常情况死锁情况
清除缓存 → 重新登录列表正常加载永久骨架屏,下拉刷新无反应
杀掉 App 重开 → 登录正常正常(单例重建,脏状态清零)
抓包看请求有请求正常发出一个包都没有,服务端一片安静

看到没?唯一的分水岭就是"有没有杀进程"。这几乎是在明示:问题不在数据、不在网络,而在活在内存里的那点状态。也正因为"杀 App 就好",很多人会以为是偶发玄学,随手重启了事,结果下次登录又中招。

根因:并发 401 的队列没被清空

典型的(也是有问题的)写法长这样:

classAuthInterceptorextendsQueuedInterceptor{bool _isRefreshing=false;finalList<_PendingRequest>_refreshQueue=[];@overridevoidonError(DioExceptionerr,ErrorInterceptorHandlerhandler)async{if(err.response?.statusCode==401&&!_isRefreshing){_isRefreshing=true;finalnewToken=await_refreshToken();if(newToken!=null){// 刷新成功,重试原始请求handler.resolve(await_retry(err.requestOptions));return;}// ❌ 刷新失败,直接 return 了,队列里排队的请求怎么办?没人管!}handler.next(err);}}

各位看官盯着那个注释看三秒钟。

问题就在refreshToken返回 null 的那条分支。刷新失败了(刷新 token 也过期了、或者被服务端作废了),代码提前returnfinally里顶多把_isRefreshing复位一下,_refreshQueue里那一串排队的请求,谁都没去动它们

于是就形成了这么一条死链:

队列里的请求 → completer.future 永远不 resolve → 页面里的 await / Future.wait 永远不返回 → setState 永远不执行 → 骨架屏永远挂着

一个 completer 只要没人调complete(),它就会安安静静地等一辈子。没有报错,没有超时,没有任何日志——这是这类 bug 最恶心的地方:它不是崩溃,是"什么都没发生"。

我把这条因果链拆开,每一步都对应一个"本该发生却没发生"的动作:

环节本该发生实际发生为什么卡住
刷新 token 失败清空队列、放行等待者提前return队列里的请求没人管
completer 无人complete落一个结果或错误future永久 pending没有超时、没有兜底唤醒
页面await拿到结果后继续永不返回setState永远不执行
单例未重建脏状态随流程清掉残留存活清除缓存没重建实例
为什么"杀 App 就好了"?

因为apiClient通常是个单例。

_isRefreshing_refreshQueue这两个字段,都挂在这个单例实例上。你在应用内点"清除缓存",清的是本地存储里的数据,并没有把这个单例给重建掉。所以:

  • 那个残留的_refreshQueue还在;
  • 更要命的是,如果_isRefreshing卡在了true没复位,那么之后所有401 都会走进!_isRefreshing为 false 的分支,永远没人负责刷新;
  • 这些脏状态就这么跨越了"清除缓存 → 重新登录"这个流程活了下来

而杀掉 App,进程没了,单例自然重建,脏状态一笔勾销。这就完美解释了那个诡异的现象。

一句话记住:“杀 App 正常、不杀就卡”,八成是单例里的脏状态没清。

我是怎么定位到它的

排查过程也值得说说,因为这类"没有任何报错"的 bug,排查思路和普通 bug 不太一样。我把四步走法整理成一张表,方便各位看官直接套用:

步骤动作关键发现
1. 确认请求发没发抓包一个包都没有 → 排除服务端和网络,问题在客户端内部
2. 确认代码走到哪在加载方法前后打日志"开始加载"打了、"加载完成"没打 → 卡在中间await
3. 顺着 await 往下扒一路扒到拦截器看字段看到_refreshQueue,心里咯噔一下
4. 打印队列长度验证清缓存重登后打_refreshQueue.length果然非 0 → 上一轮遗留请求还躺着,真凶确认

说实话,第 2 步是通用技巧:遇到"界面卡住但没报错",别猜,去打日志确认代码执行到了哪一行。能定位到卡在哪个await,问题就解决一半了。

修复一(治本):finally 里兜底清空队列

第一处修复,也是最关键的一处:无论走哪条分支、无论成功失败,队列都得被清空。

Future<void>_refreshAndRetry(...)async{try{// ...刷新 token、替换请求头、重放请求}finally{_isRefreshing=false;// ← 关键:把队列里所有等待者都唤醒,别让任何一个 Future 悬着for(finalpin_refreshQueue){p.completer.complete(null);}_refreshQueue.clear();// 无论成功还是失败,一律清空}}

finally的意义就在这儿:不管你在try里怎么return、怎么抛异常,这段收尾逻辑一定会执行。凡是"必须成对出现"的操作——加锁/解锁、入队/出队、置位/复位——都应该用try...finally保护起来。

这里有个小细节值得多说一句:给等待者complete(null)表示"刷新失败了,你们各自去处理错误",这是让请求以失败告终;如果你希望这些请求走异常路径,也可以用completeError。选哪种取决于上层怎么处理,但绝对不能不选——一个都不唤醒才是最糟的结果。

修复二(对齐"杀 App 正常"):清缓存时重建 client

光修finally还不够。因为在你修复之前,用户设备上可能已经有脏状态了;而且谁也不敢保证以后不会出现新的、别的分支导致的状态残留。

所以第二处修复的思路是:既然"杀 App"能解决问题,那我们就在清除缓存的时候,模拟一次"杀 App"的效果。

Future<void>clearLocalCache(WidgetRefref)async{awaittokenStorage.clearAll();// ...清理其它本地缓存// 重建 apiClient 及其派生的所有 service,一次性丢掉全部脏状态ref.invalidate(apiClientProvider);}

apiClientProviderinvalidate之后,Riverpod 会丢弃当前实例,下次读取时重新构造一份全新的。单例里的_isRefreshing_refreshQueue连同拦截器本身,统统被扔进垃圾桶,干干净净。

而且因为 Riverpod 的依赖关系是有向的,所有依赖apiClientProvider的 service 也会跟着一起重建,它们各自持有的缓存、队列、标志位也一并清空。这就是用 provider 管理单例的好处,比自己手写一堆reset()方法可靠多了。

两处修复的分工是这样的:修复一是治本,保证拦截器自身在任何分支下都不留死锁;修复二是兜底,保证即使哪天又冒出个没考虑到的分支,一次"清除缓存"也能把状态归零。两个都要有,缺一个都不踏实。

顺手排查一下同类隐患

既然翻到了这儿,建议各位看官顺手把项目里这几类地方也过一遍,它们都是同一个病根——“有个收尾动作漏写了”:

隐患点怎么查怎么修
所有Completer使用点Completer(,逐条看确认每个 completer 在所有路径都completecompleteError
布尔标志位(_isRefreshing/_isLoading看置true后复位在哪复位必须放finally,放try尾巴上一遇异常就废
长生命周期单例想"退出登录/切账号"时状态该不该清不该跨账号存活的,必须有清理入口invalidate
刷新本身挂起_refreshToken()有无超时配合理超时,把"永久挂起"降级成"失败但会结束"

小结

好啦,这个坑就复盘到这儿。

回头看,整件事的因果链其实特别清晰:一条没写finally的提前 return → 队列里的 completer 无人唤醒 → 页面的 await 永久挂起 → 骨架屏永远转圈。再叠加"单例不重建"这一层,让脏状态跨越了整个"清除缓存 → 重新登录"的流程,最终呈现出"杀 App 就好、不杀就卡"这么个让人摸不着头脑的现象。

留给各位看官三条能直接用的经验:

  1. 401 刷新拦截器里,任何提前 return 的分支都要保证队列被清空,最稳妥的做法就是放进finally
  2. 单例里的脏状态跨生命周期存活,是隐形炸弹,清缓存/登出时要连实例一起invalidate重建;
  3. “杀 App 正常、不杀就卡”,优先怀疑并发队列和单例状态,别在数据和网络层浪费时间。

如果这篇文章对你有点用,希望看官您用发财的小手点个小赞哈,谢谢大家!也欢迎在评论区说说你被哪种"不报错、就是不动"的死锁坑过,大家互相学习。


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

相关阅读

  • Flutter 带 TTL 的多级缓存设计:内存+磁盘+网络三层实战
  • Flutter Riverpod 在 build 期改 provider 导致整页崩溃,踩坑实录
  • Flutter Debug 红屏、Release 灰屏:你的 release-only bug,只是异常被藏起来了
  • Flutter 接入 Alice 调试浮窗:一个顶层 final 抢跑,把 release 网络整没了
  • Flutter 可复用公共组件库设计与落地:AppDialog/BottomSheet 等实战
返回列表