------并发只刷新一次 + 原请求自动重试 + 刷新失败全局登出
适用:Flutter + Dio;页面并发请求多(首页/列表/消息)且需要登录态。
目标:当 accessToken 过期导致 401 时,自动 refresh,再把失败请求重试一次;并发场景只 refresh 一次,避免刷新风暴。
1. 真实场景:为什么会出现"一堆 401"?
首页常见并发请求:
Dart
await Future.wait([
api.profile(),
api.orders(),
api.unread(),
]);
当 token 过期时,A/B/C 三个请求都带着旧 token 发出去 → 各自返回 401。
多个 401 是正常现象 ,真正要解决的是:不能多个 401 触发多个 refresh。
2. 方案总览(分层边界)
推荐网络层分层:
Dart
UI/State → Repository → DioClient(唯一出口)
├─ AuthInterceptor(请求注入 token)
├─ RefreshInterceptor(401:刷新 + 重试)
└─ (可选) Error/Log 拦截器
职责一句话:
- AuthInterceptor:正常请求都带上 token
- RefreshInterceptor:只在 401 时介入,完成 refresh + retry
- UI/Repository:不写任何 refresh 逻辑,只处理"登录过期"事件即可
3. 核心原理:共享 Future = 异步并发锁
刷新 token 这件事是全局临界区,必须"只执行一次"。
做法:用一个共享 Future(句柄)保存"正在刷新"的动作。
- 第一个 401:创建 refresh Future 并 await
- 后续 401:复用同一个 Future 并 await
- refresh 成功:大家拿到同一个新 token,各自重试一次原请求
4. 最小可用实现(可直接拷项目)
4.1 TokenStore(存取 access/refresh)
Dart
class TokenPair {
final String accessToken;
final String refreshToken;
TokenPair(this.accessToken, this.refreshToken);
}
class TokenStore {
static TokenPair? _pair;
static TokenPair? get() => _pair;
static Future<void> set(TokenPair pair) async {
_pair = pair;
// TODO: persist to secure storage
}
static Future<void> clear() async {
_pair = null;
}
}
4.2 RefreshManager(共享 Future:只刷新一次)
Dart
class RefreshManager {
Future<String?>? _refreshing; // new accessToken
Future<String?> getOrCreate(Future<String?> Function() job) {
_refreshing ??= job().whenComplete(() {
_refreshing = null;
});
return _refreshing!;
}
}
4.3 AuthInterceptor(每次请求自动带 token)
Dart
import 'package:dio/dio.dart';
class AuthInterceptor extends Interceptor {
@override
void onRequest(RequestOptions options, RequestInterceptorHandler handler) {
final token = TokenStore.get()?.accessToken;
if (token != null && token.isNotEmpty) {
options.headers["Authorization"] = "Bearer $token";
}
handler.next(options);
}
}
4.4 RefreshInterceptor(401 → refresh → retry)
两条保险丝必须有:
1)同一请求只重试一次(防死循环)
2)refresh 用干净 Dio(防递归)
Dart
import 'package:dio/dio.dart';
class RefreshInterceptor extends Interceptor {
final Dio dio; // 主 Dio:用于重试原请求
final Dio cleanDio; // 干净 Dio:只用于 refresh
final RefreshManager mgr;
final void Function() onAuthExpired; // 刷新失败回调(全局登出)
RefreshInterceptor({
required this.dio,
required this.cleanDio,
required this.mgr,
required this.onAuthExpired,
});
@override
void onError(DioException err, ErrorInterceptorHandler handler) async {
if (err.response?.statusCode != 401) {
return handler.next(err);
}
final req = err.requestOptions;
// 保险丝1:同一请求只重试一次
if (req.extra["retried"] == true) {
return handler.next(err);
}
final pair = TokenStore.get();
if (pair == null || pair.refreshToken.isEmpty) {
onAuthExpired();
return handler.next(err);
}
// 核心:共享 Future,保证只刷新一次
final newAccessToken = await mgr.getOrCreate(() async {
try {
// TODO: 改成你们的 refresh 接口 & 字段
final res = await cleanDio.post(
"/auth/refresh",
data: {"refreshToken": pair.refreshToken},
);
final data = res.data as Map<String, dynamic>;
final access = data["accessToken"] as String;
final refresh = data["refreshToken"] as String;
await TokenStore.set(TokenPair(access, refresh));
return access;
} catch (_) {
await TokenStore.clear();
return null;
}
});
if (newAccessToken == null) {
onAuthExpired();
return handler.next(err);
}
// 刷新成功:重试原请求一次(带新 token + retried 标记)
try {
final retryResp = await dio.request(
req.path,
data: req.data,
queryParameters: req.queryParameters,
options: Options(
method: req.method,
headers: Map<String, dynamic>.from(req.headers)
..["Authorization"] = "Bearer $newAccessToken",
extra: Map<String, dynamic>.from(req.extra)..["retried"] = true,
),
cancelToken: req.cancelToken,
onSendProgress: req.onSendProgress,
onReceiveProgress: req.onReceiveProgress,
);
return handler.resolve(retryResp);
} catch (_) {
return handler.next(err);
}
}
}
4.5 DioClient(主 dio + cleanDio + 注册顺序)
顺序建议:AuthInterceptor 在前,RefreshInterceptor 在后(401 错误回来时先被 RefreshInterceptor 处理)
Dart
import 'package:dio/dio.dart';
class DioClient {
DioClient._();
static final DioClient instance = DioClient._();
late final Dio dio;
late final Dio cleanDio;
final RefreshManager mgr = RefreshManager();
void init({required void Function() onAuthExpired}) {
dio = Dio(BaseOptions(baseUrl: "https://api.example.com"));
cleanDio = Dio(BaseOptions(baseUrl: "https://api.example.com"));
dio.interceptors.addAll([
AuthInterceptor(),
RefreshInterceptor(
dio: dio,
cleanDio: cleanDio,
mgr: mgr,
onAuthExpired: onAuthExpired,
),
]);
}
}
5. 首页 A/B/C 并发:发生了什么?
当 token 过期,A/B/C 都会 401,但:
- 第一个进入 RefreshInterceptor 的 401:发 refresh(真正网络请求)
- 其他两个 401:await 同一个 refresh Future(不再 refresh)
- refresh 成功后:A/B/C 各自重试一次原请求 → 页面成功刷新
- refresh 失败:触发
onAuthExpired(),统一登出/跳登录
一句话:401 可以多次出现,但 refresh 只能一次。
6. 刷新失败"只处理一次"的建议
onAuthExpired 建议做成"全局事件",只跳一次登录,避免多页面重复跳转。
全局 onAuthExpired:刷新失败时为什么一定要"统一登出/跳登录"?
很多教程只写到:401 → refresh → retry。
但真实项目里还有一个不可忽略的分支:
-
refreshToken 也过期/失效
-
refresh 接口失败(401/403/400/网络错误)
-
账号被踢、被禁用、权限变更导致持续 401
这时,继续 retry 没意义,你必须把系统切到"未登录态"。
为什么不能在每个接口里各自处理登出?
因为首页很常见是并发 A/B/C 多个请求:
-
token 过期 → A/B/C 全部 401
-
refresh 失败 → A/B/C 都会走到失败分支
-
如果每个请求都执行 "logout + 跳登录",就会出现:
-
重复弹 toast
-
重复 push/login
-
路由栈混乱
-
状态重复清理
-
所以需要一个全局入口:onAuthExpired 只触发一次,把登出流程收敛到一个地方。
推荐做法:网络层只"发信号",App 层只"处理一次"
1)AuthEventBus:全局事件(最通用,和你用什么状态管理无关)
Dart
import 'dart:async';
enum AuthEvent { expired }
class AuthEventBus {
AuthEventBus._();
static final AuthEventBus I = AuthEventBus._();
final _c = StreamController<AuthEvent>.broadcast();
Stream<AuthEvent> get stream => _c.stream;
void emit(AuthEvent e) => _c.add(e);
}
2)AuthExpiredGate:防止并发重复触发(只 fire 一次)
Dart
class AuthExpiredGate {
bool _fired = false;
void fireOnce(void Function() action) {
if (_fired) return;
_fired = true;
action();
}
void reset() => _fired = false; // 登录成功后可 reset
}
3)在 DioClient.init() 注入 onAuthExpired(给 RefreshInterceptor 用)
Dart
final _gate = AuthExpiredGate();
void onAuthExpired() {
_gate.fireOnce(() {
AuthEventBus.I.emit(AuthEvent.expired);
});
}
class DioClient {
DioClient._();
static final DioClient instance = DioClient._();
late final Dio dio;
late final Dio cleanDio;
final RefreshManager mgr = RefreshManager();
void init() {
dio = Dio(BaseOptions(baseUrl: "https://api.example.com"));
cleanDio = Dio(BaseOptions(baseUrl: "https://api.example.com"));
dio.interceptors.addAll([
AuthInterceptor(),
RefreshInterceptor(
dio: dio,
cleanDio: cleanDio,
mgr: mgr,
onAuthExpired: onAuthExpired,
),
]);
}
}
这样当 refresh 失败时,拦截器里调用 onAuthExpired(),全局只会发一次 "expired"。
App 层统一登出/跳登录(NavigatorKey 方案,最稳)
1)定义全局 navigatorKey
final navigatorKey = GlobalKey<NavigatorState>();
MaterialApp:
MaterialApp(
navigatorKey: navigatorKey,
// routes...
)
2)应用启动时订阅 AuthEventBus(只订阅一次)
Dart
late final StreamSubscription _authSub;
void setupAuthListener() {
_authSub = AuthEventBus.I.stream.listen((e) async {
if (e == AuthEvent.expired) {
// 1) 清 token(保险)
await TokenStore.clear();
// 2) 清全局用户态(你用 Riverpod/Bloc/Provider 都在这里统一处理)
// userStore.logout();
// 3) 跳登录(只做一次)
navigatorKey.currentState
?.pushNamedAndRemoveUntil('/login', (route) => false);
// 4) 可选:toast 一次
// showToast("登录已过期,请重新登录");
}
});
}
void disposeAuthListener() {
_authSub.cancel();
}
结果:并发 N 个请求同时失败,也只会登出/跳转一次,UI 不乱。
结束。
下一篇:
401 刷新 Token 的队列版(请求挂起排队 + 刷新后统一重放/统一失败)
======================================================
增强版本(可以不看)
队列版不是"必须",而是当项目复杂到一定程度,共享 Future 版会暴露一些工程痛点;队列版是为了解决这些痛点,让行为更可控、更像"中间件"。
下面我把"为什么要搞队列版"讲透,你就知道什么时候该用、什么时候不需要。
1)共享 Future 版已经能解决什么?
共享 Future 版核心是:
-
多个 401 → 只刷新一次
-
其他 401 等刷新结果 → 各自重试一次
它最大的优点:简单、好写、足够稳 。
所以小中型 App 直接用它就行。
2)那队列版解决的是哪些"更工程化"的问题?
A. "刷新期间请求怎么处理"更可控
共享 Future 版是:
每个 401 都自己在 onError 里 await → 然后自己 retry
队列版是:
401 先"挂起入队",等刷新结束后统一重放/统一失败
好处:
-
你能统一控制重放顺序(按进入顺序)
-
你能做限流重放(一次重放 N 个,避免刷新后瞬时洪峰)
-
你能做合并/去重(同一个接口短时间重复,只保留一个)
如果你的首页一刷新会触发几十个接口,这个控制会很有价值。
B. 刷新失败时"一锅端"更干净
共享 Future 版里,刷新失败后每个请求都会走自己的错误链,容易出现:
-
多个请求同时触发 UI 提示(toast 多次)
-
多个地方同时触发登出逻辑(重复跳转)
队列版天然适合:
-
refresh 失败 → 队列里所有等待请求统一 reject
-
同时只发一次
AuthExpired事件
体验上更"整洁"。
C. 更容易"挂起而不是立刻失败"
有些项目希望:
刷新期间不要让业务层立刻收到错误(比如页面下拉刷新时不想闪红错误),而是:
-
token 刷新成功 → 无感恢复请求
-
token 刷新失败 → 再统一报错/跳登录
队列版天生就是"挂起等待"的模式。
D. 更方便做"统一重试策略"
队列版可以统一做很多策略,比如:
-
每个请求最多重试 1 次(你现在已有)
-
某些接口 不允许重试(比如支付/下单等非幂等请求,避免重复提交)
-
对 GET 允许重试,对 POST/PUT 做白名单
共享 Future 也能做,但分散在每个请求的 retry 分支里,队列版更集中。
3)队列版的代价是什么?(你也要知道)
队列版不是白给的,它会带来:
-
实现复杂度更高(要保存 handler、要 drain 队列)
-
需要更谨慎处理:
-
取消请求(CancelToken)
-
队列过大(内存)
-
重放时的并发/顺序策略
-
所以它适合"接口多、并发多、状态敏感"的项目。
4)什么时候你应该用队列版?
满足任意一条就建议上队列版:
-
首页/核心页一次会并发几十个请求
-
刷新成功后重试洪峰明显(服务器/弱网下抖动)
-
你想做重试限流、合并去重、顺序控制
-
你需要"刷新期间请求挂起,不要立刻报错"
-
你们后端 refreshToken 一次性、并发失败概率高,希望统一收敛处理
5)什么时候共享 Future 版就足够?
-
接口数量不大(并发 3~10 个)
-
只需要"只刷新一次 + 自动重试"这两个核心能力
-
你还在搭项目早期,想先用简单可靠的方案
结论(帮你直接选型)
-
90% App:共享 Future 版(足够、简单、稳定)
-
接口量大/并发大/体验要求高:队列版(更工程化)