移动端登录态安全设计(6):多个接口同时 401,为什么刷新 Token 必须加锁? 📅 2026/7/1 2:54:14 上一篇我们讲了OkHttp 登录态闭环Interceptor、Authenticator、401 自动刷新。里面有一个非常关键的点多个接口同时 401 时刷新 Token 必须加锁。这个问题在真实项目里非常常见。比如首页一打开同时请求/user/info /message/count /order/list /banner/list /config如果这时候 accessToken 刚好过期这几个接口可能会同时返回 401。如果没有控制好客户端可能会同时发起多个 refreshToken 请求。这就是登录态设计里非常典型的并发问题。这篇文章专门讲清楚1. 为什么会出现多个接口同时 401 2. 不加锁会发生什么问题 3. refreshToken 轮换为什么会放大这个问题 4. 移动端应该怎么加锁 5. OkHttp Authenticator 里怎么正确处理 6. 刷新失败后怎么统一退出登录一句话先说结论多个请求同时 401 时只允许一个请求去刷新 Token其他请求应该等待刷新结果然后复用新的 accessToken 重试原请求。一、为什么会出现多个接口同时 401移动端页面通常不是一个接口一个接口串行请求。比如首页、我的页面、工作台页面经常会同时请求多个接口首页进入 ↓ 请求用户信息 请求未读消息数 请求任务列表 请求 Banner 请求系统配置这些请求几乎同时发出。如果 accessToken 还有效没问题。但如果 accessToken 刚好过期那么这些请求带的都是同一个旧 Token。于是后端会同时返回HTTP/1.1 401 Unauthorized也就是请求 A401 请求 B401 请求 C401 请求 D401 请求 E401这时 OkHttp 的 Authenticator 可能会被多次触发。如果每次触发都去刷新 Token就出问题了。二、不加锁会发生什么假设现在有 5 个接口同时返回 401。如果没有加锁流程可能是这样请求 A 返回 401 → 发起 refreshToken 请求 B 返回 401 → 发起 refreshToken 请求 C 返回 401 → 发起 refreshToken 请求 D 返回 401 → 发起 refreshToken 请求 E 返回 401 → 发起 refreshToken也就是说同一时间会出现 5 个刷新请求。这会带来几个问题1. 重复刷新浪费网络请求。 2. 多个刷新请求同时修改本地 Token容易造成状态覆盖。 3. 如果后端做 refreshToken 轮换后面的刷新请求可能失败。 4. 某个刷新失败可能误清登录态。 5. 用户明明可以无感刷新却被踢回登录页。所以多个 401 并发刷新不是小问题。它会直接影响登录态稳定性。三、refreshToken 轮换会放大问题企业级登录体系里refreshToken 通常建议做轮换。所谓 refreshToken 轮换就是旧 refreshToken 请求刷新 ↓ 后端返回新的 accessToken 新的 refreshToken ↓ 旧 refreshToken 立即失效这样做的好处是降低 refreshToken 泄漏后的长期风险。但它也带来一个并发问题。假设当前本地 Token 是accessToken A_old refreshToken R_old首页 5 个接口同时 401。如果没有锁5 个请求都拿着R_old去刷新。可能发生请求 A 使用 R_old 刷新成功 ↓ 后端返回 A_new R_new ↓ R_old 失效 请求 B 继续使用 R_old 刷新 ↓ 后端发现 R_old 已失效 ↓ 刷新失败 请求 C / D / E 同理也可能刷新失败这时客户端如果处理不好就可能出现请求 A 刷新成功刚保存了新 Token。 请求 B 刷新失败又把 Token 清空了。 最后用户被误判为登录失效。这就是不加锁最危险的地方。不是重复请求那么简单而是会造成登录态错乱。四、正确思路同一时间只能有一个刷新请求正确流程应该是多个接口同时 401 ↓ 第一个进入刷新逻辑的请求负责真正刷新 Token ↓ 其他 401 请求等待 ↓ 刷新成功后其他请求直接使用新的 accessToken 重试 ↓ 刷新失败后统一清 Token 并通知登录失效也就是请求 A负责刷新 请求 B等待 A 刷新结果 请求 C等待 A 刷新结果 请求 D等待 A 刷新结果 请求 E等待 A 刷新结果刷新成功后请求 A 用新 accessToken 重试 请求 B 用新 accessToken 重试 请求 C 用新 accessToken 重试 请求 D 用新 accessToken 重试 请求 E 用新 accessToken 重试这才是稳定的登录态刷新逻辑。核心原则是refreshToken 是登录态续命入口必须串行刷新。五、如何判断别人已经刷新成功了在 OkHttp Authenticator 里有一个很关键的判断当前失败请求用的 accessToken是否还是内存里的最新 accessToken假设请求 A、B、C 同时发出时带的都是旧 TokenAuthorization: Bearer A_old请求 A 先进入刷新逻辑刷新成功后TokenManager 里的内存 Token 变成A_new这时请求 B 也进入 Authenticator。请求 B 原始请求里带的是A_old但 TokenManager 当前内存里已经是A_new这说明什么说明已经有别的请求刷新成功了。那么请求 B 不需要再刷新。它只需要用新的 accessToken 重新构造请求即可Authorization: Bearer A_new所以关键判断是val requestToken response.request.header(Authorization) ?.removePrefix(Bearer ) ?.trim() val currentToken tokenManager.getAccessTokenFromMemory() if (!currentToken.isNullOrBlank() currentToken ! requestToken) { return response.request.newBuilder() .header(Authorization, Bearer $currentToken) .build() }这段逻辑非常重要。它的含义是如果当前内存 Token 已经不同于失败请求携带的 Token 说明其他请求已经刷新成功。 当前请求不需要再次刷新直接用新 Token 重试。六、为什么要在锁里面判断很多人会问这个判断放锁外面不行吗不推荐。因为多个线程同时进来时锁外判断可能看到的状态不是最终状态。正确结构是进入 Authenticator ↓ 先判断是否需要跳过 ↓ 进入 synchronized 锁 ↓ 在锁内重新检查当前 Token 是否已经变化 ↓ 如果已经变化说明别人刷新成功直接重试 ↓ 如果没变化当前线程负责刷新也就是锁内二次检查这和很多并发场景里的“双重检查”思想类似。因为真正决定是否刷新要基于进入锁之后的最新状态。七、synchronized 版本实现OkHttp 的 Authenticator 是同步接口override fun authenticate(route: Route?, response: Response): Request?所以可以用synchronized做刷新锁。示例class TokenAuthenticator( private val tokenManager: TokenManager, private val authApi: AuthApi, private val loginStateManager: LoginStateManager ) : Authenticator { private val refreshLock Any() override fun authenticate(route: Route?, response: Response): Request? { if (shouldSkipAuth(response.request)) { return null } if (responseCount(response) 2) { return null } synchronized(refreshLock) { val requestToken response.request.header(Authorization) ?.removePrefix(Bearer ) ?.trim() val currentToken tokenManager.getAccessTokenFromMemory() if (!currentToken.isNullOrBlank() currentToken ! requestToken) { return response.request.newBuilder() .header(Authorization, Bearer $currentToken) .build() } val newToken runBlocking { refreshTokenInternal() } ?: return null return response.request.newBuilder() .header(Authorization, Bearer ${newToken.accessToken}) .build() } } private suspend fun refreshTokenInternal(): TokenEntity? { val oldToken tokenManager.getToken() if (oldToken?.refreshToken.isNullOrBlank()) { tokenManager.clearToken() loginStateManager.notifyLoginExpired() return null } return runCatching { val result authApi.refreshToken( RefreshTokenRequest(oldToken!!.refreshToken) ) val newToken TokenEntity( accessToken result.accessToken, refreshToken result.refreshToken, expiresAt result.expiresAt ) tokenManager.saveToken(newToken) newToken }.getOrElse { tokenManager.clearToken() loginStateManager.notifyLoginExpired() null } } private fun shouldSkipAuth(request: Request): Boolean { val path request.url.encodedPath return path.contains(/auth/login) || path.contains(/auth/refresh) || path.contains(/auth/logout) || path.contains(/auth/register) } private fun responseCount(response: Response): Int { var count 1 var priorResponse response.priorResponse while (priorResponse ! null) { count priorResponse priorResponse.priorResponse } return count } }这段代码里有几个关键点1. shouldSkipAuth避免登录、刷新、退出接口触发刷新逻辑。 2. responseCount避免刷新失败后无限重试。 3. synchronized(refreshLock)保证同一时间只有一个线程刷新 Token。 4. currentToken ! requestToken判断是否已有其他请求刷新成功。 5. refreshTokenInternal真正刷新 Token。 6. 刷新失败后 clearToken notifyLoginExpired。八、为什么要跳过 refreshToken 接口refreshToken 接口不能再次触发刷新。否则可能出现死循环。比如业务接口返回 401 ↓ Authenticator 调用 refreshToken 接口 ↓ refreshToken 接口也返回 401 ↓ Authenticator 又被触发 ↓ 再次调用 refreshToken ↓ 无限循环所以这些接口应该跳过认证刷新/auth/login /auth/refresh /auth/logout /auth/register可以通过 path 判断也可以通过 Request tag 标记。比如private fun shouldSkipAuth(request: Request): Boolean { val path request.url.encodedPath return path.contains(/auth/login) || path.contains(/auth/refresh) || path.contains(/auth/logout) || path.contains(/auth/register) }更工程化的做法是给请求加一个标记data class SkipAuthRefresh(val skip: Boolean true)构建请求时request.newBuilder() .tag(SkipAuthRefresh::class.java, SkipAuthRefresh()) .build()Authenticator 判断val skip request.tag(SkipAuthRefresh::class.java) ! null if (skip) { return null }这样比写死 path 更灵活。九、为什么要限制 responseCount如果刷新成功后重试原请求结果还是 401Authenticator 可能再次被调用。如果不限制就可能出现请求接口 ↓ 401 ↓ 刷新 Token ↓ 重试原请求 ↓ 还是 401 ↓ 继续刷新 ↓ 继续重试这会造成无限循环。所以要判断if (responseCount(response) 2) { return null }意思是这个请求已经因为认证问题重试过了不要继续刷新了。返回null后OkHttp 会把 401 返回给上层。上层再统一处理登录失效。十、刷新成功后必须保存新的 refreshToken如果后端做 refreshToken 轮换刷新成功后会返回{ accessToken: A_new, refreshToken: R_new, expiresAt: 1780000000000 }客户端必须同时保存new accessToken new refreshToken不能只保存 accessToken。错误做法tokenManager.saveAccessToken(result.accessToken)正确做法val newToken TokenEntity( accessToken result.accessToken, refreshToken result.refreshToken, expiresAt result.expiresAt ) tokenManager.saveToken(newToken)因为下一次刷新时需要使用新的 refreshToken。如果还拿旧 refreshToken会导致刷新失败。十一、刷新失败后为什么要统一清 Token刷新失败通常意味着refreshToken 过期 refreshToken 已被服务端删除 refreshToken 被轮换后旧值失效 用户在其他设备改了密码 后台踢下线 账号被冻结 Token 版本失效这种情况下客户端继续保留本地 Token 已经没有意义。应该统一处理1. 清空内存 Token 2. 清空本地加密 Token 3. 通知登录态失效 4. 跳转登录页 5. 避免重复弹窗示例private suspend fun handleRefreshFailed() { tokenManager.clearToken() loginStateManager.notifyLoginExpired() }注意刷新失败不要只让当前请求失败。 登录态已经不可用了应该统一退出登录态。十二、如何避免多个登录失效弹窗多个接口同时 401 时如果刷新失败可能多个请求都通知登录失效。如果上层每收到一次事件就弹窗就会出现登录已过期 登录已过期 登录已过期 登录已过期所以 LoginStateManager 也要做防重复通知。示例class LoginStateManager { Volatile private var hasNotifiedExpired false private val _loginExpiredEvent MutableSharedFlowUnit( replay 0, extraBufferCapacity 1 ) val loginExpiredEvent _loginExpiredEvent.asSharedFlow() fun notifyLoginExpired() { if (hasNotifiedExpired) { return } hasNotifiedExpired true _loginExpiredEvent.tryEmit(Unit) } fun reset() { hasNotifiedExpired false } }登录成功后重置loginStateManager.reset()这样可以保证一次登录态失效只通知一次。十三、Mutex 版本可以吗如果你的刷新逻辑完全在协程体系里也可以用 Mutex。比如class TokenRefresher( private val tokenManager: TokenManager, private val authApi: AuthApi, private val loginStateManager: LoginStateManager ) { private val mutex Mutex() suspend fun refreshIfNeeded(requestToken: String?): TokenEntity? { return mutex.withLock { val currentToken tokenManager.getAccessTokenFromMemory() if (!currentToken.isNullOrBlank() currentToken ! requestToken) { returnwithLock tokenManager.getToken() } val oldToken tokenManager.getToken() if (oldToken?.refreshToken.isNullOrBlank()) { tokenManager.clearToken() loginStateManager.notifyLoginExpired() returnwithLock null } runCatching { val result authApi.refreshToken( RefreshTokenRequest(oldToken!!.refreshToken) ) val newToken TokenEntity( accessToken result.accessToken, refreshToken result.refreshToken, expiresAt result.expiresAt ) tokenManager.saveToken(newToken) newToken }.getOrElse { tokenManager.clearToken() loginStateManager.notifyLoginExpired() null } } } }然后 Authenticator 里val newToken runBlocking { tokenRefresher.refreshIfNeeded(requestToken) } ?: return null这种方式结构更清楚TokenAuthenticator 负责 OkHttp 的 authenticate 入口。 TokenRefresher 负责加锁刷新 Token。 TokenManager 负责 Token 保存和读取。如果你想让代码更干净可以把刷新锁从 Authenticator 里抽出来。十四、为什么不建议每个 Repository 自己处理 401有些项目会在每个 Repository 里写val result api.getUserInfo() if (result.code 401) { refreshToken() api.getUserInfo() }这很不推荐。问题是1. 代码重复。 2. 每个接口都要写刷新逻辑。 3. 并发 401 更难控制。 4. 登录失效通知容易重复。 5. 刷新失败处理不统一。 6. 后面改逻辑会很痛苦。401 刷新属于网络基础设施能力。它应该放在网络层统一处理而不是散落到业务层。更合理的是Repository 只关心业务请求。 OkHttp Interceptor 统一加 Token。 OkHttp Authenticator 统一处理 401。 TokenManager 统一管理 Token。十五、后端接口语义也要清楚客户端刷新加锁做得再好也需要后端配合。后端最好明确区分accessToken 过期 返回 HTTP 401允许客户端刷新。 refreshToken 过期 刷新接口返回 401 或明确错误码客户端退出登录。 refreshToken 被轮换 旧 refreshToken 不能继续使用。 用户改密 所有旧 refreshToken 失效。 后台踢下线 refreshToken 失效客户端提示被踢下线。 权限不足 返回 403而不是 401。不要把所有认证、权限、业务失败都混成一个 code。否则客户端很难判断这是 accessToken 过期 这是 refreshToken 失效 这是没权限 这是账号异常如果要做企业级登录态闭环前后端必须约定好语义。十六、完整流程图最终多个接口同时 401 的正确流程如下多个业务请求同时发出 ↓ 都携带旧 accessToken ↓ 后端同时返回 401 ↓ 多个请求进入 Authenticator ↓ 第一个请求进入 refreshLock ↓ 检查当前 Token 没变化 ↓ 使用 refreshToken 刷新 ↓ 刷新成功保存新 accessToken 新 refreshToken ↓ 第一个请求用新 accessToken 重试 ↓ 第二个请求进入 refreshLock ↓ 发现内存 accessToken 已经变了 ↓ 不再刷新直接用新 accessToken 重试 ↓ 其他请求同理如果刷新失败第一个请求进入 refreshLock ↓ refreshToken 失败 ↓ 清空内存 Token ↓ 清空本地密文 Token ↓ 通知登录态失效 ↓ 其他请求不再重复弹窗 ↓ 统一跳登录页这就是加锁的意义。十七、常见错误总结错误 1每个 401 都发起 refreshToken错误。多个接口同时 401 时会重复刷新甚至造成登录态错乱。错误 2没有判断当前 Token 是否已经更新错误。其他请求刷新成功后当前请求应该复用新 Token不应该再次刷新。错误 3refreshToken 轮换后只保存 accessToken错误。必须同时保存新的 refreshToken。错误 4refreshToken 接口也触发 Authenticator容易死循环。refresh 接口要跳过刷新逻辑或使用独立 OkHttpClient。错误 5刷新失败不清本地 Token错误。刷新失败说明登录态不可用应该清空内存和本地密文 Token。错误 6多个请求同时通知登录失效不推荐。应该统一通知一次避免多个弹窗、多个跳转。错误 7业务层每个接口自己处理 401不推荐。401 刷新属于网络层基础能力应该统一处理。十八、最终落地清单这一篇可以落成一个清单1. 多个接口同时 401 是真实项目里的常见情况。 2. 401 自动刷新不能每个请求各刷一次。 3. refreshToken 刷新必须加锁。 4. 同一时间只允许一个 refreshToken 请求执行。 5. 其他 401 请求等待刷新结果。 6. 进入锁后要重新判断当前 accessToken 是否已经变化。 7. 如果 Token 已更新直接用新 accessToken 重试原请求。 8. 如果 Token 没更新当前请求负责刷新。 9. refreshToken 轮换时刷新成功后必须保存新的 refreshToken。 10. refreshToken 接口要跳过 Authenticator避免死循环。 11. responseCount 要限制重试次数避免无限重试。 12. 刷新失败要清空内存 Token 和本地密文 Token。 13. 登录失效事件要统一通知避免重复弹窗。 14. 后端要明确区分 401、403、refreshToken 失效、踢下线等语义。十九、总结多个接口同时 401为什么刷新 Token 必须加锁因为 accessToken 过期时多个并发请求可能同时触发刷新。如果不加锁就会出现重复刷新 refreshToken 轮换冲突 Token 被覆盖 刷新成功后又被失败请求清空 登录态错乱 用户被误踢回登录页正确做法是同一时间只允许一个请求刷新 Token。 其他请求等待刷新结果。 刷新成功后复用新 accessToken 重试。 刷新失败后统一清 Token 并通知登录失效。一句话总结401 自动刷新不是简单地“失败后再请求一次”而是一个并发控制问题refreshToken 是登录态续命入口必须串行刷新。再放到完整登录态体系里AuthInterceptor 请求前加 accessToken。 TokenAuthenticator 401 后触发刷新。 RefreshLock / Mutex 保证同一时间只刷新一次。 TokenManager 统一保存、读取、清空 Token。 SecureTokenStore AES-GCM Keystore 加密保存 Token。 LoginStateManager 统一通知登录失效。这套结构做好了移动端登录态才不会在并发场景下乱掉。