团队在对接一个受 OAuth2 保护的数据服务时,第一版用的是同步 HttpClient,认证逻辑写得很直接:先调认证接口拿 token,然后每次请求手动往 Header 里塞 Authorization。后来为了吞吐量切到 HttpAsyncClient,这套逻辑马上就乱了——业务代码里到处都是手动取 token、塞 Header 的碎片,异步回调里既拿不到 ThreadLocal 里的上下文,又不敢在回调里做阻塞式刷新,一旦 token 过期,全线请求 401,排查半天都定位不到问题到底出在哪一层。这篇文章就是把踩完坑之后沉淀下来的方案展开讲:怎么在不破坏 HttpAsyncClient 异步模型的前提下,用请求拦截器加 TokenProvider 的方式,把 OAuth2 和 JWT 的 token 获取、自动注入、过期刷新统一收敛起来。适合正在做异步微服务对接、网关调用,或者准备把同步客户端替换成异步客户端的开发同学参考。
1. 需求拆解:为什么不能靠手工塞 Header
1.1 同步转异步后,认证代码为什么最先崩
我见过太多项目在同步模型下一切正常,一换异步客户端就开始出诡异问题。根因基本都出在线程模型的变化上。同步模型里一个线程从头到尾处理一个请求,用户信息、token 状态放在 ThreadLocal 里完全没问题。异步 NIO 模型下,IO 线程数量少且共享,一个线程同时服务成千上万个请求,回调可能落在任意一个 IO 线程上,过去赖以生存的 ThreadLocal 直接失效。
token 的手动管理在异步环境里会放大成三个具体问题。第一,上下文传递困难。你没法在发起请求时把 token 塞进某个线程局部变量,然后在回调里再取出来。第二,并发刷新风暴。多个请求同时发现 token 快过期了,每个都去调认证服务刷新,本来一次刷新能解决的事,突然变成几十次并发请求,认证服务很容易被限流甚至打挂。第三,业务侵入太深。每个需要认证的调用点都要写一遍“检查 token 是否存在、是否过期、需不需要刷新”的逻辑,散落在各处,改一处漏一处。
所以问题的本质不是“谁帮我把 token 加进 Header”,而是“谁能把 token 的整个生命周期管理起来,并且对业务代码透明”。自动注入只是表象,生命周期管理才是核心。
1.2 三个候选扩展点,选哪个更合适
扩展 HttpAsyncClient 实现自动注入,有几个层次可选。我整理成一张对比表,方便对照着选。
| 扩展方式 | 能做的事 | 复杂度 | 适合场景 |
|---|---|---|---|
| HttpRequestInterceptor | 请求发出前改 Header、改请求行,拿不到响应 | 低 | token 由外部刷新或定时刷新,客户端只负责读取注入 |
| 包装 CloseableHttpAsyncClient | 同时拿到请求和响应,可以在回调层做逻辑,但需要复制大量方法签名 | 中 | 老版本 4.x 下做回调级重试,没有更合适的官方扩展点时 |
| AsyncExecChainHandler | 官方执行链扩展点,可修改请求、观察响应码、决定是否重放 | 高 | HttpClient 5 下做 401 自动刷新和请求重放 |
主体方案选 HttpRequestInterceptor,理由很直接:它是官方支持的轻量扩展点,实现一个方法就能在请求发出前统一改 Header,不侵入执行链,也不影响异步模型的调度。代价是拦截器感知不到响应,但生产环境我们本来就不应该把刷新动作交给“收到 401 之后再做”,这个后文会细说。至于 401 自动重放,属于进阶需求,优先考虑 AsyncExecChainHandler。
1.3 先定三条硬性设计约束
动手写代码之前,我先给自己定了三条约束,后面所有设计都围绕这三条来。
第一条:拦截器里绝不发起网络请求。HttpAsyncClient 的 IO 线程是公共资源,任何阻塞调用都会拖垮所有请求的吞吐。token 获取和刷新必须在启动阶段或后台定时任务里完成,拦截器只读内存缓存。
第二条:并发环境下,一次只能有一个刷新动作在跑。多个线程同时刷新 token,既浪费又危险。用 AtomicReference 加状态标记来保证单飞,刷新中其它线程读旧 token 即可。
第三条:刷新失败时,旧 token 必须继续保留。遇到认证服务抖动,刷新失败后如果把缓存清空,等于让所有请求瞬间失去凭证,那是雪崩。缓存里留着旧 token,虽然可能马上过期,但至少请求能发出去,配合重试成本远低于全部 401。
2. 核心实现:Token 抽象、请求拦截器与注册顺序
2.1 TokenProvider:把 token 生命周期收拢到一个接口
先定义一个 TokenProvider 接口,所有 token 来源都实现它。接口只暴露两个方法,一个是同步读当前 token,一个是异步刷新。
public interface TokenProvider { String getToken(); CompletableFuture<String> refreshTokenAsync(); }两个方法的语义必须严格区分:getToken 是纯内存读取,延迟要控制在微秒级,实现时绝不能在里面发起阻塞 HTTP 请求;refreshTokenAsync 返回 CompletableFuture,内部可以走异步刷新链路,调用方通过 thenAccept、thenCompose 编排后续逻辑。这样设计,拦截器拿 token 时是轻量无阻塞的,而刷新动作天然异步化。
看一个 OAuth2 Password 模式的具体实现。核心是用 AtomicReference 包一个 TokenBox,TokenBox 里存 accessToken 和过期时间点。getToken 读出后判断是否在过期宽限期附近,判断逻辑只访问内存。
public class OAuth2PasswordTokenProvider implements TokenProvider { private static final long EXPIRE_SKEW_MS = 30_000L; private final AtomicReference<TokenBox> cache = new AtomicReference<>(TokenBox.EMPTY); private final CloseableHttpClient tokenClient; private final String tokenEndpoint; private final String clientId; private final String clientSecret; private final String username; private final String password; public OAuth2PasswordTokenProvider( CloseableHttpClient tokenClient, String tokenEndpoint, String clientId, String clientSecret, String username, String password) { this.tokenClient = tokenClient; this.tokenEndpoint = tokenEndpoint; this.clientId = clientId; this.clientSecret = clientSecret; this.username = username; this.password = password; } @Override public String getToken() { TokenBox box = cache.get(); if (box == TokenBox.EMPTY || System.currentTimeMillis() > box.expireAt - EXPIRE_SKEW_MS) { throw new TokenNotReadyException("token not ready, start refresh first"); } return box.accessToken; } @Override public CompletableFuture<String> refreshTokenAsync() { return CompletableFuture.supplyAsync(() -> { HttpPost post = new HttpPost(tokenEndpoint); // 构造 form 参数:grant_type=password、client_id、client_secret、username、password // 解析响应里的 access_token 与 expires_in TokenBox newBox = new TokenBox(accessToken, System.currentTimeMillis() + expiresIn * 1000L); cache.set(newBox); return newBox.accessToken; }); } private static final class TokenBox { private static final TokenBox EMPTY = new TokenBox("", 0L); final String accessToken; final long expireAt; TokenBox(String accessToken, long expireAt) { this.accessToken = accessToken; this.expireAt = expireAt; } } }这里有个细节要说清楚:CompletableFuture.supplyAsync 默认用的是公共 ForkJoinPool,它的线程数按 CPU 核数分配,不是给网络 IO 等待准备的。生产环境一定要自己传一个带固定线程数的 Executor,防止刷新请求把池子占满。比如 newFixedThreadPool(2),配置专用线程名,方便排查。
2.2 BearerTokenInjectInterceptor:五行的自动注入逻辑
TokenProvider 准备好了,拦截器的实现简单到超乎预期。实现 HttpRequestInterceptor 接口,在 process 方法里把 token 写进 Authorization Header。
public class BearerTokenInjectInterceptor implements HttpRequestInterceptor { private final TokenProvider tokenProvider; public BearerTokenInjectInterceptor(TokenProvider tokenProvider) { this.tokenProvider = tokenProvider; } @Override public void process(HttpRequest request, EntityDetails entity, HttpContext context) { String token = tokenProvider.getToken(); request.setHeader(HttpHeaders.AUTHORIZATION, "Bearer " + token); } }注意这里用的是 setHeader 而不是 addHeader。addHeader 会在已有同名 Header 后面再追加一条,服务端解析时拿到重复的 Authorization 大概率直接 400;setHeader 会覆盖原有值,保证只有一条认证头。
还要强调一个原则:process 方法里不做任何刷新动作。前面设计约束说过,这里只需要读内存、写 Header,整个过程不能出现锁竞争、不能出现网络 IO。实测下来,这一段耗时稳定在几十微秒以内,对异步吞吐可以忽略不计。
2.3 注册顺序:拦截器加到哪个位置决定成败
拦截器写好了,接下来是注册到 HttpAsyncClientBuilder 上。
CloseableHttpAsyncClient client = HttpAsyncClients.custom() .addRequestInterceptorLast(new BearerTokenInjectInterceptor(tokenProvider)) .build(); client.start();builder 提供 addRequestInterceptorFirst 和 addRequestInterceptorLast 两个方法。First 的拦截器在请求处理链的最前端执行,Last 的拦截器在请求即将发出前执行。如果你的客户端还挂了其它拦截器,比如链路追踪、签名计算、日志打印,顺序就会有影响。
我个人的习惯是把认证注入放在 Last,理由是它最接近真实发送点,前面的拦截器无论怎么改请求头,最终认证头都是最新覆盖的状态。如果放在 First,后面的拦截器可能会覆盖掉 Authorization,或者抛异常导致请求直接失败。这个坑我踩过,当时排查了很久才发现是某个日志拦截器把 Header 里的敏感字段替换成了脱敏值。
注册完成后,所有通过这个客户端发出去的请求都会自动带上 Bearer Token,业务代码不用再关心认证。这是一个非常干净的扩展点。
2.4 OAuth2 与 JWT 的 Provider 落地写法
前面 2.1 的 OAuth2 实现已经展示了核心骨架,这里把启动阶段的首次填充补上。建议在应用启动时同步等待一次刷新成功,保证第一个请求进来时 token 一定可用。
tokenProvider.refreshTokenAsync().get(5, TimeUnit.SECONDS);虽然这里用了阻塞等待,但只发生在启动阶段,可接受。如果启动时认证服务故障,直接启动失败,比运行五分钟后再炸要容易排查得多。
JWT 场景和 OAuth2 不太一样。JWT 的签发往往是本地计算的,不涉及网络请求,刷新成本极低,因此 Provider 的写法可以更轻。用 AtomicReference 存当前 token,getToken 发现快过期时直接生成一个新的。
public class JwtTokenProvider implements TokenProvider { private final AtomicReference<String> cache = new AtomicReference<>(); private final Key signingKey; private final Duration validity; public JwtTokenProvider(Key signingKey, Duration validity) { this.signingKey = signingKey; this.validity = validity; } @Override public String getToken() { return cache.updateAndGet(prev -> (prev != null && !isExpired(prev)) ? prev : createToken()); } private String createToken() { // 使用 jjwt 或 java-jwt 生成 token // 声明里至少要有 sub、iss、iat、exp、jti } @Override public CompletableFuture<String> refreshTokenAsync() { return CompletableFuture.completedFuture(getToken()); } }JWT Provider 的 getToken 里虽然做了生成操作,但因为是本地计算,耗时通常不到 1 毫秒,放在同步路径里完全可接受。需要注意的是签名密钥的管理和时钟偏移问题,如果服务器时钟有较大抖动,客户端和服务端的 exp 判断可能不一致,建议在服务端验签时设置一个 30 秒左右的时钟偏移容忍窗口。
3. 实操升级:401 自动刷新与请求重放
3.1 生产环境优先做主动预刷新,别等 401 风暴
很多人在实现“自动注入”时第一反应是:收到 401 再去刷新 token,然后重放请求。这个思路听起来天衣无缝,实际生产环境里我强烈建议不要把它作为默认策略,原因有三。
第一,并发放大效应。假设一个 token 在时刻 T 过期,你有 50 个线程同时拿着旧 token 发请求,这 50 个请求几乎同时收到 401,然后几乎同时触发刷新。本来一次刷新就能解决的问题,变成 50 次并发刷新,认证服务不被打限流才怪。第二,重放安全风险。不是所有接口都幂等,一个创建订单的请求如果因为 401 被重放两次,订单可能被创建两次。第三,延迟不可控。每次 401 都要走一次网络刷新再重放,对请求时延的打击是致命的。
正确姿势是主动预刷新。token 的过期时间在响应里能拿到,我们就在过期前提前刷新。用后台定时任务每 30 秒检查一次,提前 30 秒刷新掉。
ScheduledExecutorService scheduler = Executors.newSingleThreadScheduledExecutor(r -> { Thread t = new Thread(r, "token-refresher"); t.setDaemon(true); return t; }); scheduler.scheduleAtFixedRate(() -> { try { tokenProvider.refreshTokenAsync().join(); } catch (Exception ignored) { // 刷新失败保留旧 token,下次定时间隔继续尝试 } }, 0, 30, TimeUnit.SECONDS);定时刷新固定间隔 30 秒,是一种简单可靠的做法。哪怕 token 有效期只有 5 分钟,30 秒刷新一次的频率也完全可控。如果以后想更精细,可以在 TokenProvider 里加一个 isExpiringSoon 方法,让定时任务只在接近过期时才刷新。
这套方案的好处是所有请求永远不会经历“过期空窗期”,业务侧无感,认证服务侧的刷新压力也均匀分散。我在实际项目中用这个方案配合拦截器,跑了大半年没有出现过一次认证相关的超时。
3.2 401 后自动重放:用 AsyncExecChainHandler 接管执行链
主动预刷新能解决大部分问题,但总有一些边界情况绕不开,比如网络抖动导致 token 在服务端提前失效,或者多节点部署时某个节点的缓存时间不准。这时候 401 重放就有存在价值了。
HttpClient 5 提供了 AsyncExecChainHandler,这是执行链上的官方扩展点,可以在请求被执行前、响应返回后介入,甚至决定是否重新执行整条链。实现思路是:发起请求前注入 token,收到 401 响应后,异步刷新 token,然后用新 token 重放请求。
public class OAuth2RetryExecChainHandler implements AsyncExecChainHandler { private final AsyncExecChainHandler delegate; private final TokenProvider tokenProvider; private final int maxRetries; public OAuth2RetryExecChainHandler( AsyncExecChainHandler delegate, TokenProvider tokenProvider, int maxRetries) { this.delegate = delegate; this.tokenProvider = tokenProvider; this.maxRetries = maxRetries; } @Override public void execute(HttpRequest request, EntityDetails entityDetails, AsyncExecChain chain, AsyncExecChain.Scope scope, AsyncExecCallback asyncExecCallback) throws HttpException, IOException { executeInternal(request, entityDetails, chain, scope, asyncExecCallback, 0); } private void executeInternal(HttpRequest request, EntityDetails entityDetails, AsyncExecChain chain, AsyncExecChain.Scope scope, AsyncExecCallback asyncExecCallback, int retryCount) { BearerTokenInjector.inject(request, tokenProvider.getToken()); chain.proceed(request, entityDetails, scope, new AsyncExecCallback() { @Override public CompletableFuture<Void> completed(HttpResponse response) { if (response.getCode() == HttpStatus.SC_UNAUTHORIZED && retryCount < maxRetries) { return tokenProvider.refreshTokenAsync() .thenCompose(newToken -> { BearerTokenInjector.inject(request, newToken); return executeInternal(request, entityDetails, chain, scope, asyncExecCallback, retryCount + 1); }); } return asyncExecCallback.completed(response); } @Override public CompletableFuture<Void> failed(HttpException cause) { return asyncExecCallback.failed(cause); } @Override public CompletableFuture<Void> cancelled() { return asyncExecCallback.cancelled(); } }); } }这段代码的核心在于:重放动作并不在 IO 线程里同步执行,而是通过返回 CompletableFuture,把刷新和再次执行编排成异步事件。整个过程中没有任何阻塞调用,符合异步非阻塞的线程模型。实际落地时,不同小版本的 HttpClient 5 对 AsyncExecCallback 的方法签名有细微差别,这份代码需要按你当前版本来适配,但编排思路是通用的。
3.3 异步重放最容易踩的坑
能走到重放这一步,说明对 HttpAsyncClient 的机制已经有了一定理解,但有几个坑几乎人人都会踩一遍。
第一个是请求体不可重复读取。chain.proceed 第一次执行时,如果请求体是 InputStreamEntity 这种一次性流,第一次发送就把流消费完了,重放时请求体已经没了。解决方案有两种:要么在进入执行链前把请求体缓存成 byte 数组,要么在重放时重新构造一个完整请求。
第二个是重放放大效应。如果一个 token 确实在服务端提前失效,那么一瞬间可能有几十个请求同时 401,每个请求都触发一次刷新和重放。结果就是认证服务被刷新请求打爆,业务请求被延迟拖垮。应对办法是给刷新动作加一个全局互斥标记,刷新中只允许一个请求去刷新,其它请求等待同一个 CompletableFuture,拿到新 token 后再各走各的重放逻辑。
第三个是重放次数必须设上限。我见过把 maxRetries 配成无限循环的配置,token 一直刷新失败,请求就一直在链上打转,线程虽然不阻塞,但连接池连接被持续占用,最终把连接池打满。生产环境我建议 maxRetries 设 1 到 2 次,最多不要超过 3 次。
4. 常见问题、版本差异与排查清单
4.1 高频问题:拦截器不生效、Header 重复、刷新失效
先说拦截器不生效。最常见的原因是注册错了客户端。HttpAsyncClient 的 builder 在 build 之后返回的是新的客户端实例,如果你用的是一个没经过 builder 改造的默认实例,拦截器自然不会触发。排查时先确认发出请求的客户端对象就是从 builder 出来的那一个。
然后是 Header 重复。前面说过使用 setHeader,但要注意如果你在拦截器里用的是 addHeader,或者在别的地方已经手动设置过 Authorization,最终发出时可能带着两条。抓包看一眼就明白了。
刷新失效也是高频问题。很多人发现定时任务明明在跑,token 却一直不更新。这时候优先检查刷新逻辑的异常处理——如果 refreshTokenAsync 内部把异常吞了,外层定时任务的 catch 一无所获,缓存永远停在旧值。我的习惯是刷新失败时打 WARN 日志并带上异常堆栈,保留旧 token 是策略,但一定得让人知道策略被触发过。
4.2 版本差异:HttpAsyncClient 4.x 与 HttpClient 5 的扩展点
这个坑一定要单独讲,因为我们经常在搜索资料时看到混杂的代码。Apache HttpAsyncClient 4.x 和 HttpClient 5 虽然名字像,但扩展点差异很大。
| 维度 | HttpAsyncClient 4.x | HttpClient 5.x |
|---|---|---|
| 核心构建类 | org.apache.http.impl.nio.client.HttpAsyncClientBuilder | org.apache.hc.client5.http.impl.async.HttpAsyncClients |
| 请求拦截器 | HttpRequestInterceptor 可用 | HttpRequestInterceptor 保留 |
| 执行链扩展 | 没有 AsyncExecChainHandler | 提供 AsyncExecChainHandler |
| 异步回调 | FutureCallback | AsyncExecCallback,返回 CompletableFuture |
如果你的项目还在 4.x 上,建议优先用 HttpRequestInterceptor 做注入,401 重放只能通过包装 FutureCallback 的方式实现,比较别扭。有条件的话尽早升级到 5.x,执行链设计明显更合理。
4.3 快速定位:一张排查清单表
最后整理一张排查清单,遇到问题时对照着看,能省不少时间。
| 症状 | 可能原因 | 处理方式 |
|---|---|---|
| 请求没有 Authorization Header | 拦截器未注册或注册到错误的客户端 | 检查 builder 链上是否调用了 addRequestInterceptorLast |
| 出现多条 Authorization Header | 拦截器用了 addHeader | 改成 setHeader |
| token 一直不变 | 刷新线程异常被吞掉 | 检查 refresh 方法异常日志,确认定时任务确实在执行 |
| 请求全部 401 | 启动阶段没有完成首次刷新 | 初始化时同步调用 refreshTokenAsync 并等待完成 |
| 某个线程刷新后所有请求冲垮认证服务 | 缺少刷新互斥 | 给刷新动作加全局锁,同一时刻只允许一个刷新在飞 |
| 重放后请求体为空 | 一次性 InputStreamEntity 被消费 | 重放前把请求体缓存为可重复读的字节数组 |
最后再分享一个实操体会
这套扩展真正稳定下来,靠的不是堆代码,而是把 token 生命周期当成第一公民来设计。在对接某内部数据服务的实践中,我用定时预刷新加请求拦截器的组合,把原来到处手动塞 token 的代码收敛成了几十行,生产环境运行几个月都没有出现过认证导致的超时或 401 风暴。如果后续还要对接多个不同的 OAuth2 服务,建议把 TokenProvider 做成一个带路由 key 的实例集合,请求进来时按目标服务选择对应的 Provider。这个方向继续走下去,你会发现所有认证逻辑可以做到对业务代码完全透明,而拦截器、Provider、重试策略的分层,也让后续排查问题变得极其清晰。