公司动态
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 客户端上挂一个拦截器请求返回 401 → 说明 token 过期了拦截住这个错误别急着抛给页面拿刷新 token 去换一个新的短 token换到了就用新 token 把刚才那个请求重放一遍用户全程无感知。思路很清楚对吧。麻烦在第 3 步和第 4 步之间的并发如果页面同时发了 5 个请求5 个一起 401你总不能刷新 5 次 token。所以就得加一个正在刷新的标志位加一个等待队列——第一个请求负责刷新其余的进队列排着等刷新完了统一放行。死锁就藏在这个队列里。表象对照为什么它这么难查这个 bug 之所以让人头大是因为它的表象太反直觉——同样的账号、同样的代码只是操作顺序不同结果天差地别。我把几种典型场景摆在一起看操作顺序正常情况死锁情况清除缓存 → 重新登录列表正常加载永久骨架屏下拉刷新无反应杀掉 App 重开 → 登录正常正常单例重建脏状态清零抓包看请求有请求正常发出一个包都没有服务端一片安静看到没唯一的分水岭就是有没有杀进程。这几乎是在明示问题不在数据、不在网络而在活在内存里的那点状态。也正因为杀 App 就好很多人会以为是偶发玄学随手重启了事结果下次登录又中招。根因并发 401 的队列没被清空典型的也是有问题的写法长这样classAuthInterceptorextendsQueuedInterceptor{bool _isRefreshingfalse;finalList_PendingRequest_refreshQueue[];overridevoidonError(DioExceptionerr,ErrorInterceptorHandlerhandler)async{if(err.response?.statusCode401!_isRefreshing){_isRefreshingtrue;finalnewTokenawait_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. 确认代码走到哪在加载方法前后打日志开始加载打了、加载完成没打 → 卡在中间await3. 顺着 await 往下扒一路扒到拦截器看字段看到_refreshQueue心里咯噔一下4. 打印队列长度验证清缓存重登后打_refreshQueue.length果然非 0 → 上一轮遗留请求还躺着真凶确认说实话第 2 步是通用技巧遇到界面卡住但没报错别猜去打日志确认代码执行到了哪一行。能定位到卡在哪个await问题就解决一半了。修复一治本finally 里兜底清空队列第一处修复也是最关键的一处无论走哪条分支、无论成功失败队列都得被清空。Futurevoid_refreshAndRetry(...)async{try{// ...刷新 token、替换请求头、重放请求}finally{_isRefreshingfalse;// ← 关键把队列里所有等待者都唤醒别让任何一个 Future 悬着for(finalpin_refreshQueue){p.completer.complete(null);}_refreshQueue.clear();// 无论成功还是失败一律清空}}finally的意义就在这儿不管你在try里怎么return、怎么抛异常这段收尾逻辑一定会执行。凡是必须成对出现的操作——加锁/解锁、入队/出队、置位/复位——都应该用try...finally保护起来。这里有个小细节值得多说一句给等待者complete(null)表示刷新失败了你们各自去处理错误这是让请求以失败告终如果你希望这些请求走异常路径也可以用completeError。选哪种取决于上层怎么处理但绝对不能不选——一个都不唤醒才是最糟的结果。修复二对齐杀 App 正常清缓存时重建 client光修finally还不够。因为在你修复之前用户设备上可能已经有脏状态了而且谁也不敢保证以后不会出现新的、别的分支导致的状态残留。所以第二处修复的思路是既然杀 App能解决问题那我们就在清除缓存的时候模拟一次杀 App的效果。FuturevoidclearLocalCache(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 等实战