14、Token管理与自动刷新:拦截器自动附加Token、Token过期自动刷新、并发请求的Token同步问题

Token管理这件事,说简单也简单,说复杂也复杂。

我刚入行那会儿,觉得Token不就是往请求头里塞个字符串嘛,有什么好讲的?直到有一次线上事故——用户同时发起多个请求,Token恰好过期,结果所有请求都去刷新Token,服务器直接返回429限流,用户集体掉线。嗯,从那以后,我再也不敢小看Token管理了。

14.1 拦截器自动附加Token

先解决最基础的问题:怎么让每个请求都带上Token?

我个人习惯用OkHttp的Interceptor来做这件事。你想想看,如果每个API调用都手动传Token,那代码得多难看?而且万一哪天Token的存储位置变了,你得改多少个地方?

class AuthInterceptor(
    private val tokenProvider: TokenProvider
) : Interceptor {
    override fun intercept(chain: Interceptor.Chain): Response {
        val originalRequest = chain.request()
        
        // 跳过不需要Token的请求(比如登录、注册)
        if (shouldSkipAuth(originalRequest)) {
            return chain.proceed(originalRequest)
        }
        
        val token = tokenProvider.getAccessToken()
        val authRequest = originalRequest.newBuilder()
            .header("Authorization", "Bearer $token")
            .build()
            
        return chain.proceed(authRequest)
    }
    
    private fun shouldSkipAuth(request: Request): Boolean {
        val path = request.url.encodedPath
        return path.contains("/login") || path.contains("/register")
    }
}
小技巧:我建议把TokenProvider设计成接口,这样方便做单元测试。你总不能在测试环境里真的去调登录接口吧?

14.2 Token过期自动刷新

Token总会过期的,这是设计使然。问题在于:过期之后怎么办?

最粗暴的做法是:每次请求前检查Token是否过期,过期就刷新。但这样有个问题——你想想看,如果Token还有5分钟才过期,但用户正好在刷一个很长的列表,刷到一半Token过期了,那体验多差?

更好的做法是:让服务器告诉你Token过期了,然后你再去刷新。说白了就是利用HTTP 401状态码。

class TokenRefreshInterceptor(
    private val tokenProvider: TokenProvider,
    private val tokenRefresher: TokenRefresher
) : Interceptor {
    override fun intercept(chain: Interceptor.Chain): Response {
        val request = chain.request()
        val response = chain.proceed(request)
        
        // 只有401才触发刷新
        if (response.code == 401 && !isRefreshRequest(request)) {
            response.close()
            
            // 同步刷新Token
            val newToken = tokenRefresher.refreshToken()
            if (newToken != null) {
                // 用新Token重试
                val retryRequest = request.newBuilder()
                    .header("Authorization", "Bearer $newToken")
                    .build()
                return chain.proceed(retryRequest)
            }
        }
        
        return response
    }
    
    private fun isRefreshRequest(request: Request): Boolean {
        return request.url.encodedPath.contains("/refresh")
    }
}
注意:千万不要在刷新Token的请求里再次触发刷新,否则会死循环。我见过有人犯这个错,结果日志里全是401,服务器都快被刷爆了。

14.3 并发请求的Token同步问题

这才是真正的难点。

假设用户同时发起5个请求,每个请求都带着过期的Token。服务器返回5个401,然后5个请求同时去刷新Token。结果呢?

  • 要么服务器限流,刷新失败
  • 要么刷新成功,但生成了5个新Token,只有最后一个有效
  • 要么并发导致数据竞争,Token状态混乱

怎么解决?核心思路就一句话:同一时间只允许一个刷新请求执行,其他请求排队等待

class TokenManager(
    private val tokenApi: TokenApi,
    private val tokenStore: TokenStore
) {
    private val mutex = Mutex()
    private var refreshInProgress = false
    private var newToken: String? = null
    
    suspend fun getValidToken(): String {
        val currentToken = tokenStore.getAccessToken()
        if (!isTokenExpired(currentToken)) {
            return currentToken
        }
        
        // 加锁,确保只有一个协程执行刷新
        mutex.withLock {
            if (refreshInProgress) {
                // 已经有其他协程在刷新,等待结果
                return newToken ?: throw TokenRefreshException()
            }
            
            refreshInProgress = true
            try {
                val response = tokenApi.refreshToken(
                    tokenStore.getRefreshToken()
                )
                newToken = response.accessToken
                tokenStore.saveAccessToken(newToken!!)
                return newToken!!
            } finally {
                refreshInProgress = false
            }
        }
    }
}
核心要点:
  • 使用Mutex(互斥锁)保证同一时间只有一个刷新操作
  • 用refreshInProgress标志位避免重复刷新
  • 其他协程等待第一个刷新完成后直接复用结果

14.4 完整的拦截器链

把上面这些整合到一起,就形成了一个完整的Token管理方案。我画了一张图,你可以看看整个流程:

Token管理完整流程 发起请求 检查Token是否过期 直接附加Token发送 触发Token刷新 Mutex加锁 刷新Token并保存 重试原始请求 并发请求场景 请求A、请求B、请求C 同时到达

这张图里最关键的就是那个Mutex加锁的环节。我再说得直白一点:不管有多少个并发请求,刷新Token的操作只会执行一次。其他请求要么等,要么复用刷新结果。

14.5 避坑指南

我在项目中踩过不少坑,这里列几个最常见的:

  • 刷新Token本身也会过期——Refresh Token也有有效期,一般比Access Token长。如果Refresh Token也过期了,那就只能让用户重新登录了。
  • 不要用全局变量存Token——App切到后台再切回来,Token可能已经被其他进程刷新了。我建议用SharedPreferences或者DataStore,配合加密存储。
  • 注意协程作用域——Token刷新操作应该在ViewModelScope或者ApplicationScope里执行,不要在某个Activity的Scope里做,否则Activity销毁了刷新就中断了。
  • 处理刷新失败的情况——如果刷新接口返回了非200状态码,比如网络超时或者服务器错误,不要无限重试。设置一个最大重试次数,超过就提示用户重新登录。
我的习惯:我会在TokenManager里加一个回调接口,当Refresh Token也过期时,通过EventBus或者SharedFlow通知UI层跳转到登录页。这样用户无感知,体验最好。

14.6 总结

Token管理说白了就三件事:

  1. 用拦截器自动附加Token,别让业务代码操心这事
  2. 用401状态码触发刷新,别自己瞎猜过期时间
  3. 用Mutex解决并发问题,别让多个请求同时去刷新

做到这三点,你的Token管理基本就稳了。剩下的就是一些细节打磨,比如Token存储加密、刷新失败的重试策略、以及用户登录态的生命周期管理。这些我会在后面的章节里继续聊。