ARTICLE DETAIL

资讯详情

深耕网站视觉设计与运营推广的一线实战洞察。

HttpAsyncClient重试机制:5xx可重试、4xx不可重试的判定与实战

HttpAsyncClient重试机制:5xx可重试、4xx不可重试的判定与实战 先说明一个很多人容易搞混的点HttpAsyncClient里的“可重试异常”和 HTTP 状态码5xx/4xx并没有直接画等号。5xx/4xx是服务端返回的响应状态行只有在服务端已经成功收到请求并给出响应之后才会出现而HttpAsyncClient的重试处理器判断的是“请求是否成功发送并完成响应”这个过程中抛出的IOException。两者一个在网络层一个在业务层很多人把这两件事混在一起写重试逻辑结果要么该重试的没重试要么同一个请求重复提交了好几次。这篇文章我直接用一个生产环境的真实视角把异常分类、重试判定链路、代码落地、异步场景下的特殊坑位全部串一遍尤其会讲清楚为什么 5xx 应该重试、4xx 通常不应该重试以及在异步调用中这个判断到底放在哪一层做才有意义。适合正在用 Apache HttpAsyncClient 4.1.x 做网关、数据采集、服务间调用的同学。1. 为什么“重试”不能拍脑袋两个层面要分开看1.1 异常层面与响应码层面是两回事HttpAsyncClient最核心的特点在于它是 NIO 模型请求发出后不阻塞等待响应而是通过Future或者回调来获取最终结果。在这个模型里一次调用从发起到返回结果中间经历了好几个阶段从连接池获取连接发起 TCP 连接或者从连接池直接拿已建立的连接发送请求头、请求体等待服务端返回响应头读取响应体每一个阶段都可能出问题但问题分两类。第一类是网络层的异常比如连接超时、连接被重置、SSL 握手失败、Socket 超时这类异常以IOException的形式冒出来。第二类是服务端正常返回了 HTTP 响应但是响应码是 400、404、500这属于“请求已经打完一个完整来回”的正常结果。有人可能会问那服务端返回 500 的时候HttpAsyncClient为什么不直接抛异常因为从 HTTP 协议的角度来说只要客户端收到了响应行和响应头这次交互在传输层就是成功的。500 是“本次请求执行完成之后服务端应用逻辑处理失败”的语义不应该由 HTTP 客户端强行翻译成异常。这个设计非常关键——如果你把 5xx 当成异常来捕获那你的代码里处理重试的位置就放错了。1.2 HTTP 状态码语义决定了“不可重试”的边界为什么 4xx 通常不可重试因为 4xx 的语义是“客户端请求本身有问题”。比如400 Bad Request请求参数格式不对重试一百次还是格式不对。401 Unauthorized没有认证或者认证过期重试前需要先刷新 token。403 Forbidden没有权限重试无效。404 Not Found资源路径不对重试不可能把不存在的资源变成存在。409 Conflict资源状态冲突重试大概率继续冲突。这些错误的重试只会带来两个后果一是白白消耗连接池资源二是可能因为请求被重复提交而对服务端造成额外压力比如日志里一堆无意义的错误记录。但是这里有一个非常容易被人忽略的例外429 Too Many Requests。虽然 429 是 4xx但它的语义不是“请求有问题”而是“你被限流了”。这种场景下服务端通常会在响应头里带上Retry-After告诉客户端多少秒后再试。所以严格来说429 属于“可以重试、但要按服务端指定节奏重试”的特殊响应码。我之前在一个对接第三方开放平台的网关服务里就在重试策略里单独对 429 开了白名单并且强制读Retry-After效果比固定间隔重试好得多。反过来看 5xx。500、502、503、504 这些状态码背后往往意味着服务端在某个瞬间出了临时性问题——数据库连接池满了、上游服务超时、节点正在重启、GC 停顿。这类问题有比较高的概率在一两秒内恢复所以重试是合理的。但必须控制重试次数和退避间隔否则一个网关节点抖一下你这边所有调用方同时重试可能直接把服务端打挂这叫“重试风暴”。如果你要一句话总结处理重试逻辑前先想清楚你要处理的这一层到底是“网络交互失败”还是“业务语义失败”。网络交互失败由HttpAsyncClient的重试处理器负责业务语义失败5xx由调用方根据自己的业务重试策略处理。2. HttpAsyncClient 重试处理器的工作机制从源码理解判定链路2.1 HttpAsyncRequestRetryHandler 的触发时机与判定逻辑HttpAsyncClient提供的重试扩展接口是HttpAsyncRequestRetryHandler它长这样public interface HttpAsyncRequestRetryHandler { boolean retryRequest(IOException exception, int executionCount, HttpContext context); }这个方法只有在请求执行过程中抛出IOException时才会被调用。也就是说如果你不对响应状态码做任何检查直接使用默认配置那么 4xx/5xx 都不会触发这里的重试——因为根本没有异常。触发场景是怎么发生的我拆一下。当客户端在发送请求或者等待响应的过程中如果IOReactor检测到连接异常比如对端关闭、读超时、写超时、连接池取不到连接等情况会在DefaultHttpAsyncClientConnectionOperator或者MainClientExec里包一层HttpException/IOException抛出来。抛出后异步执行器会调用retryRequest方法询问“这次失败要不要重新执行整个请求”。如果返回true执行器会重新走一遍连接获取、请求发送、响应读取的完整链路如果返回false这个IOException会被直接原样抛给调用方。默认配置里HttpAsyncClients.custom()不会设置任何自定义重试处理器但底层有一个DefaultHttpRequestRetryHandler默认逻辑是网络异常IOException默认最多重试 3 次请求是幂等的GET、HEAD、PUT、DELETE、OPTIONS、TRACE才重试某些特定的InterruptedIOException、UnknownHostException、ConnectException、SSLException会直接放行不重试。这个默认逻辑对很多场景是够用的。但问题在于它过于“一刀切”它判断的是“这个请求方法是否幂等”而不是“这次失败到底能不能靠重试来恢复”。比如一个 GET 请求因为服务端正常返回 500 而失败默认处理器根本不会感知到因为 500 并不是IOException又比如一个 POST 请求因为连接池超时失败了理论上网络恢复后再试大概率会成功但默认处理器会因为 POST 非幂等直接放弃。所以当你需要精细控制“哪些异常可以重试、哪些不可以”就必须自定义HttpAsyncRequestRetryHandler。2.2 判定链路上的两个隐藏参数executionCount 与 ContextretryRequest方法里有三个参数很多人只盯着exception忽略了另外两个这其实会漏掉很多关键信息。executionCount代表当前是第几次执行。注意这个值不是从 0 开始而是从 1 开始。第一次执行失败后executionCount已经是 2此时你的重试逻辑要判断的是“还能不能再试第 2 次”。所以最常见的写法是if (executionCount 3) { return false; }意思是最多重试 3 次加上第一次执行总共最多发出 4 次请求。context是HttpContext它跟线程安全有关。在异步场景下每次独立的请求会绑定一个独立的HttpClientContext你可以在重试方法里通过context.getAttribute(HttpCoreContext.HTTP_REQUEST)拿到当前这次请求的原始对象比如Object reqObj context.getAttribute(HttpCoreContext.HTTP_REQUEST); if (reqObj instanceof HttpUriRequest) { HttpUriRequest req (HttpUriRequest) reqObj; String method req.getMethod(); // 根据 method 决定是否重试 }这个能力的价值很大。因为不是所有IOException都适合重试也不是所有请求方法都值得重试。举个例子一个 GET 请求因为ConnectTimeoutException失败了重试是合理的但一个上传大文件流的 POST 请求因为SocketTimeoutException失败了重试可能需要重新构造请求实体甚至可能因为流已经被消费完而根本无法重发。通过context拿到原始的请求对象你就能在处理器里做更精细的方法级判断。还有一个隐藏信息context里的HttpAsyncRequestProducer。如果请求体是InputStreamEntity第一次发送失败后流的指针已经走到底了第二次发送时实体根本无法重新读取这种情况下即便你强制重试执行器也会在真正发送阶段抛NonRepeatableRequestException。这个坑我放在第 4 节详说这里先记住一个原则重试之前先确认请求实体是否可以重复生成。3. 实战写一个生产级可重试异常分流器3.1 需要处理的异常类型清单自定义重试处理器之前先把可能遇到的异常类型整理清楚。常见的有这几类异常类型含义是否可以重试理由ConnectTimeoutException连接超时可以服务端可能临时过载稍后连接可能成功ConnectionPoolTimeoutException连接池取连接超时可以连接池可能被其他请求暂时占满释放后即可获取SocketTimeoutException读/写超时可以网络抖动或服务端处理慢重试有概率成功ConnectionClosedException连接被对端关闭可以可能是服务端空闲连接回收导致重连可恢复NoHttpResponseException服务端没有返回响应可以服务器可能在响应前就断开了连接UnknownHostException域名无法解析不建议DNS 配置错误时重试不会解决问题SSLExceptionSSL 握手失败不建议证书问题通常是持续性的重试无用NonRepeatableRequestException请求实体不可重放不可以流已被消费或实体超出缓冲重试会导致异常InterruptedIOExceptionIO 被中断不建议通常是取消或外部中断重试违背调用方意图这里面需要特别注意的是ConnectTimeoutException和ConnectionPoolTimeoutException都继承自InterruptedIOException所以如果你图省事直接对所有InterruptedIOException返回 true会导致连接池排队超时这种“业务压力过大”的失败被无限重试又进一步加大连接池压力。正确的做法是精确判断子类。3.2 5xx 与 4xx 的处理差异再强调一次HttpAsyncRequestRetryHandler里处理不到 5xx/4xx因为这两个是正常响应码。那 5xx 的重试应该放在哪里我习惯的做法是网络层重试交给重试处理器业务层的 5xx 重试放在响应处理阶段。具体来说有两种模式模式一同步阻塞式获取响应如果异步客户端在你的代码里还是通过future.get()来拿结果那你可以直接在拿到HttpResponse后判断状态码HttpResponse response future.get(5, TimeUnit.SECONDS); int code response.getStatusLine().getStatusCode(); if (code 500) { // 走业务重试逻辑 } else if (code 400 code 500) { // 记日志不重试直接抛业务异常 }这种模式简单直接但违背了异步的初衷——future.get()会阻塞线程。建议只在异步化改造初期的过渡阶段使用。模式二异步回调里判断状态码如果在回调里拿到响应就需要自己在回调内部实现“重试 N 次”的状态机。因为重试需要再次发起请求而且需要控制重试间隔这不是retryRequest能给你做的。通常的做法是封装一个retryableAsyncExecute方法内部用递归或者循环计数的方式在回调里判断状态码5xx 且未超过最大重试次数时延迟一段时间后重新发起请求。这里想强调一个很容易踩的坑很多人在回调里拿到 5xx 后选择再调一次同一个HttpAsyncClient的execute然后在外层再挂一个回调。第一次写没什么问题但如果重试次数是 3 次回调就会嵌套 3 层。一旦后续接了熔断或者链路追踪排查问题的时候会非常痛苦。建议提前封装好重试模型不要把重试逻辑散落在业务代码里。3.3 完整代码实现下面是一个我实际在用、比较通用的重试处理器。它把网络层异常和响应码异常做了统一封装public class ApiRetryStrategy implements HttpAsyncRequestRetryHandler { private static final int MAX_RETRY_COUNT 3; Override public boolean retryRequest(IOException exception, int executionCount, HttpContext context) { // 1. 超过最大重试次数不再重试 if (executionCount MAX_RETRY_COUNT) { return false; } // 2. 请求实体不可重放时绝对不能重试 if (exception instanceof NonRepeatableRequestException) { return false; } // 3. 可恢复的网络异常统一重试 if (exception instanceof ConnectTimeoutException || exception instanceof ConnectionPoolTimeoutException || exception instanceof SocketTimeoutException || exception instanceof ConnectionClosedException || exception instanceof NoHttpResponseException) { return true; } // 4. 根据请求方法再做一次幂等性约束 Object reqObj context.getAttribute(HttpCoreContext.HTTP_REQUEST); if (reqObj instanceof HttpUriRequest) { String method ((HttpUriRequest) reqObj).getMethod(); if (GET.equalsIgnoreCase(method) || HEAD.equalsIgnoreCase(method)) { return true; } } return false; } }这里对 GET/HEAD 请求做了一个兜底的重试允许。原因是第 3 步只覆盖了几类明确的网络异常但如果出现未知的IOException对于幂等的 GET 请求来说重试的代价很低风险也可控而对于 POST 这类非幂等请求宁可多报错也不要重复提交。然后在构建客户端时挂上去CloseableHttpAsyncClient client HttpAsyncClients.custom() .setRetryHandler(new ApiRetryStrategy()) .setMaxConnPerRoute(50) .setMaxConnTotal(200) .setConnectionTimeToLive(30, TimeUnit.SECONDS) .setDefaultRequestConfig(RequestConfig.custom() .setConnectTimeout(3000) .setSocketTimeout(5000) .setConnectionRequestTimeout(2000) .build()) .build(); client.start();至于 5xx 的业务重试我封装了一个简单的工具方法思路是传入一个“异步任务”的生成器内部用Future阻塞判断响应状态码但这里的Future不是httpClient.execute返回的那个而是自己定义的一个结果回传容器。你也可以直接使用CountDownLatch来实现更可控的等待这里不展开太多重点是状态码判定逻辑private boolean isRetryableHttpCode(int statusCode) { if (statusCode 429) { return true; // 限流配合 Retry-After 使用 } return statusCode 500 statusCode 600; }4. 异步场景下的重试“坑位”请求体不可重放与重试风暴4.1 NonRepeatableRequestException 与可重放判断前面提到了NonRepeatableRequestException这个异常在同步HttpClient里也有但在异步里更隐蔽。它是一个IOException的子类所以如果你在自定义重试处理器里不对它单独判断按照默认逻辑对IOException一律重试那第二次发送时会再次抛异常而且这次的异常不是原来的原因而是一个“我无法重新读取请求体”的异常非常误导排查方向。什么情况下会触发最常见的是传递了InputStreamEntity或者使用了FileEntity但文件流已经被读过一遍。HttpAsyncClient在发送请求体时是从HttpAsyncRequestProducer里获取内容的如果这个 producer 生成的实体不是RepeatableEntity第一次消费之后内部缓冲区或文件流就已经被读完。重试时执行器会尝试重新创建请求但 producer 无法再生成同样的内容就会直接抛出NonRepeatableRequestException。解决思路有两个尽量使用ByteArrayEntity或StringEntity这类实体直接从内存字节数组读取天然可重放。对于需要上传小文件或不超过几十 MB 的数据这是最省心的方案。如果你的场景确实需要流式发送大文件那就不要在retryRequest里对包含不可重放实体的请求开重试授权。你能在处理器里拿到HttpAsyncRequestProducer可以通过检查实体类型来决定是否允许重试Object producerObj context.getAttribute(HttpCoreContext.HTTP_REQUEST); // 如果 producer 里的实体不可重放请求执行器会在抛出的异常里携带 NonRepeatableRequestException更推荐的做法是在上层发起请求前就判断业务是否需要重试如果请求体来自一次性流且当前网络环境不稳定干脆直接告知调用方“本次请求可能无法重试”把选择权交给上层。4.2 重试风暴控制与退避等待另一个容易被忽略的问题重试风暴。单个请求重试 3 次不算什么但一个服务在高峰期每秒要发出几千个请求如果每个请求在失败后都立即重试瞬时流量会从 3000 QPS 放大到 12000 QPS这对下游服务的冲击是致命的。控制手段大概有这几层限制全局重试次数。不能光看每个请求的重试次数还要看单位时间窗口内的总重试次数。可以在重试处理器里维护一个并发计数器或者令牌桶超过阈值就拒绝重试。要注意多线程并发访问计数器的线程安全最简单的是使用AtomicInteger。退避等待。异步场景下在重试处理器里直接Thread.sleep()是绝对不行的它会阻塞 NIO 的 worker 线程直接影响所有其他请求的处理。正确的做法是在业务层用延迟调度。比如拿到 5xx 后使用ScheduledExecutorService延迟 200ms / 500ms / 1000ms 再提交下一次请求ScheduledExecutorService scheduler Executors.newScheduledThreadPool(2); private void retryWithBackoff(Runnable task, int retryCount) { long delay (long) (Math.pow(2, retryCount) * 100); scheduler.schedule(task, delay, TimeUnit.MILLISECONDS); }这里用了指数退避第一次重试延迟 100ms第二次 200ms第三次 400ms。指数退避的意义在于服务端恢复通常需要一点时间固定间隔重试要么在服务端还未恢复时白白浪费请求要么在服务端已经恢复后仍然傻等。引入随机抖动。如果所有请求都按照 100ms、200ms、400ms 的节奏退避那实际上是“打了时间差的同步重试”依然会在同一秒内集中冲击。随机抖动就是在退避值上加一个随机偏移让重试时间在 80ms-120ms、180ms-220ms 这个区间散开。这个细节在做过大流量的服务里特别重要。熔断兜底。如果连续多次重试仍然失败说明服务端可能处于长时间的不可用状态这时候应该触发熔断而不是无脑重试直到超时。5. 用本地实验验证重试判定结果5.1 构造可控的测试环境纸上谈兵没意思我建议你在本地搭一个最简单的验证环境。不需要复杂的框架直接用一个轻量级的 HTTP 服务根据请求次数返回不同的状态码即可。下面是一个基于 JDK 自带com.sun.net.httpserver.HttpServer的模拟服务方便快速复现public class MockServer { private static int requestCount 0; public static void main(String[] args) throws Exception { HttpServer server HttpServer.create(new InetSocketAddress(8090), 0); server.createContext(/test, exchange - { requestCount; if (requestCount 1) { // 第一次请求返回 500模拟服务端瞬时故障 byte[] body {\error\:\internal\}.getBytes(); exchange.sendResponseHeaders(500, body.length); exchange.getResponseBody().write(body); } else { byte[] body {\ok\:true}.getBytes(); exchange.sendResponseHeaders(200, body.length); exchange.getResponseBody().write(body); } exchange.close(); }); server.start(); System.out.println(Mock server started on 8090); } }这个服务的逻辑很简单第一次请求固定返回 500第二次及以后返回 200。如果你的调用方做了业务层 5xx 重试连着请求两次整个链路应该是成功的如果没做重试第一次 500 就直接失败。再准备一个接口固定返回 400server.createContext(/bad, exchange - { byte[] body {\error\:\bad request\}.getBytes(); exchange.sendResponseHeaders(400, body.length); exchange.getResponseBody().write(body); });这个接口用来验证“4xx 不重试”的预期行为。5.2 观察结果与验收清单用上一节的重试处理器跑一遍重点看几个现象请求/test时第一次返回 500业务重试导致第二次请求成功最终结果是 200。日志里能看到两次请求记录第一次status500第二次status200。请求/bad时只发出去一次请求状态码 400日志里没有第二次请求记录。这符合“4xx 不可重试”的预期。在/test接口第一次请求时人为制造一个SocketTimeoutException比如让服务端 sleep 超过读超时时间观察网络层重试处理器是否介入。预期能看到retryRequest被调用并且第二次请求成功。如果发现retryRequest根本没被调用不要慌先检查触发条件是不是IOException。5xx 响应不会触发它这是设计使然不是 bug。还有一个建议在日志里把每次重试的原因打印出来。自定义处理器里加上日志非常值得这样线上排查时你能一眼看出这次重试到底是网络异常还是业务状态码触发的。否则重试了但不知道重试原因跟盲人摸象没有区别。我个人在实际排查过程中发现很多“重试无效”的反馈最后都指向一个原因请求体不可重放导致的隐式失败而异常又在回调链路上被吞掉了。所以如果你在设计上选择让HttpAsyncClient自动重试强烈建议在重试处理器里打印exception的堆栈和生产者的实体类型如果你选择业务层重试则建议统一封装重试工具不要在每个业务方法里手写for循环包重试那种代码改起来太痛苦了。
返回列表