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 客户端上挂一个拦截器:
- 请求返回 401 → 说明 token 过期了;
- 拦截住这个错误,别急着抛给页面;
- 拿刷新 token 去换一个新的短 token;
- 换到了,就用新 token 把刚才那个请求重放一遍;
- 用户全程无感知。
思路很清楚,对吧。麻烦在第 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 也过期了、或者被服务端作废了),代码提前return,finally里顶多把_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);}apiClientProvider被invalidate之后,Riverpod 会丢弃当前实例,下次读取时重新构造一份全新的。单例里的_isRefreshing、_refreshQueue连同拦截器本身,统统被扔进垃圾桶,干干净净。
而且因为 Riverpod 的依赖关系是有向的,所有依赖apiClientProvider的 service 也会跟着一起重建,它们各自持有的缓存、队列、标志位也一并清空。这就是用 provider 管理单例的好处,比自己手写一堆reset()方法可靠多了。
两处修复的分工是这样的:修复一是治本,保证拦截器自身在任何分支下都不留死锁;修复二是兜底,保证即使哪天又冒出个没考虑到的分支,一次"清除缓存"也能把状态归零。两个都要有,缺一个都不踏实。
顺手排查一下同类隐患
既然翻到了这儿,建议各位看官顺手把项目里这几类地方也过一遍,它们都是同一个病根——“有个收尾动作漏写了”:
| 隐患点 | 怎么查 | 怎么修 |
|---|---|---|
所有Completer使用点 | 搜Completer(,逐条看 | 确认每个 completer 在所有路径都complete或completeError |
布尔标志位(_isRefreshing/_isLoading) | 看置true后复位在哪 | 复位必须放finally,放try尾巴上一遇异常就废 |
| 长生命周期单例 | 想"退出登录/切账号"时状态该不该清 | 不该跨账号存活的,必须有清理入口invalidate |
| 刷新本身挂起 | 看_refreshToken()有无超时 | 配合理超时,把"永久挂起"降级成"失败但会结束" |
小结
好啦,这个坑就复盘到这儿。
回头看,整件事的因果链其实特别清晰:一条没写finally的提前 return → 队列里的 completer 无人唤醒 → 页面的 await 永久挂起 → 骨架屏永远转圈。再叠加"单例不重建"这一层,让脏状态跨越了整个"清除缓存 → 重新登录"的流程,最终呈现出"杀 App 就好、不杀就卡"这么个让人摸不着头脑的现象。
留给各位看官三条能直接用的经验:
- 401 刷新拦截器里,任何提前 return 的分支都要保证队列被清空,最稳妥的做法就是放进
finally; - 单例里的脏状态跨生命周期存活,是隐形炸弹,清缓存/登出时要连实例一起
invalidate重建; - “杀 App 正常、不杀就卡”,优先怀疑并发队列和单例状态,别在数据和网络层浪费时间。
如果这篇文章对你有点用,希望看官您用发财的小手点个小赞哈,谢谢大家!也欢迎在评论区说说你被哪种"不报错、就是不动"的死锁坑过,大家互相学习。
本文由 FungLeo 主导,Deepseek 优化校阅,转发请注明首发地址,谢谢大家!
相关阅读
- Flutter 带 TTL 的多级缓存设计:内存+磁盘+网络三层实战
- Flutter Riverpod 在 build 期改 provider 导致整页崩溃,踩坑实录
- Flutter Debug 红屏、Release 灰屏:你的 release-only bug,只是异常被藏起来了
- Flutter 接入 Alice 调试浮窗:一个顶层 final 抢跑,把 release 网络整没了
- Flutter 可复用公共组件库设计与落地:AppDialog/BottomSheet 等实战