ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

Java 17 HttpClient对接文件转写API:超时与失败重试实战

Java 17 HttpClient对接文件转写API:超时与失败重试实战

做音视频转写服务对接时,文件转写API那套超时逻辑一开始把我坑得不轻。用 Java 17 内置的 HttpClient 去提交一个 200MB 的音频文件,我照着普通 REST 接口的习惯,连接超时设了 10 秒、请求超时设了 30 秒,结果线上出现最多的失败不是连不上,而是文件还没传完就被客户端单方面掐断了。更麻烦的是,超时之后的简单重试会在转写服务端生成重复任务,费用直接翻倍。后来我把超时拆成连接、请求、任务三个层次分别配置,再把失败回退做成“分类错误码 + 指数退避 + 幂等键”的机制,才算把这套链路稳定下来。这篇文章就是把这段实战经验完整拆开讲一遍:文件转写API 的任务模型决定了超时边界在哪里,Java 17 HttpClient 的哪些参数对应哪一层,以及失败之后怎么设计一套不会把自己打挂的回退策略。适合用 Java 写服务、正打算接类似长耗时转写 API 的同事参考。

1. 文件转写API的任务模型:先搞清你在等什么

转写API(语音转文字、音视频内容提取)和普通查询接口有一个本质区别:你在等的东西,服务器也没干完。普通接口是“你给一个请求,服务器算完立刻返回”;转写API往往是“你给一个文件,服务器返回一个任务号,过几分钟你再来问结果”。这意味着,超时的边界不是一堵墙,而是好几段路。

1.1 两种接口模型:一步到位与异步轮询

先看模型。文件转写API通常分为两种:

  • 同步模型:上传文件,服务器在几秒内完成转写并直接返回结果文本。这种模型只支持很短音频/小文件,一般用于实时听写、短语音片段。
  • 异步任务模型:上传文件后,服务器立刻或稍后返回一个任务ID(taskId),客户端定期轮询任务状态,直到SUCCESS/FAILED。大文件和长音频基本都走这种模型。

我接的这个场景属于第二种。一次完整的转写,客户端和服务端要发生多次HTTP交互:提交任务、查状态、拿结果。其中只有“查状态”是真正轻量的请求,而“提交任务”要把几十上百MB的文件完整传上去,“拿结果”可能要拉几百KB甚至几MB的转写文本。

这种模型决定了超时设置不能想当然。你在“提交任务”阶段设置请求超时,覆盖的是“把文件传完 + 服务端受理并返回taskId”这段时间;而“拿到taskId之后转写要花多久”,完全不在HTTP请求超时的管辖范围内,属于应用层的任务超时。如果混为一谈,要么上传动不动超时误杀,要么为了等转写把HTTP超时调到十几分钟,网络抖动时客户端挂死一片。

1.2 三个时间层次:建连、传文件、等任务

把一次完整调用拆开,其实有三个独立的时间预算:

时间层次对应Java 17 HttpClient配置覆盖范围典型值
TCP连接建立HttpClient.Builder.connectTimeout从发起连接到TCP握手完成3~10秒
HTTP请求全程HttpRequest.Builder.timeout从发出请求到收到完整响应头/体按文件大小动态估算
转写任务处理应用层轮询逻辑服务端处理音频、队列排队数分钟到数小时

看清楚这张表,很多问题就豁然开朗了。connectTimeout只解决“目标能不能连上”;如果文件传了一半,带宽不够,请求超时会以HttpTimeoutException形式抛出来;而如果上传成功、任务ID都拿到了,后面轮询卡住则是另一套处理逻辑。

你可能会问,为什么需要单独把“连接超时”拿出来?因为文件转写API的服务端地址通常是一个网段下的集群,偶尔会出现某个节点半死不活的情况:TCP握手能通,但就是不给HTTP响应。如果没有connectTimeout,默认情况下Java的Socket连接会等操作系统级别很久的超时(对TCP连接来说可能几十秒到几分钟不等),在高并发场景下这些半开连接会占满线程池。设一个5~10秒的connectTimeout,能让快速失败,把路由切到备用节点。

生活化类比:这就像给一个繁忙的办事处打电话。connectTimeout对应“拨号后最多等多久接通”;requestTimeout对应“接通后最多聊多久”;任务轮询对应“挂了电话后,人家多久把公文办出来送给你”。三段等待,各自有各自的计时钟。

1.3 最容易被坑的一点:客户端超时不代表服务端停手

这是我在这个项目里亏了一笔之后才彻底想明白的。HttpRequest.timeout一旦触发,客户端会取消这次请求,也就是不再等待响应,并且尝试关闭对应的HTTP流。但问题在于,如果你的请求体已经完整送到了服务端,甚至服务端已经开始创建转写任务,此时客户端单方面超时,服务端的任务并不会因为客户端走了就被取消。

结果就是:任务其实建成功了,而客户端以为失败了。这时如果代码里写了“重试N次”,每次重试又都创建一个新任务,那账单上出现的任务数就是重试次数。音频转写按分钟计费,重复提交一次就多一份成本。

所以要有一个基本认知:文件转写API的提交请求,超时后的动作顺序应该是“先确认,再重试”,而不是“直接重试”。至于确认手段,要么利用API提供的幂等键/请求ID(Idempotency-Key),要么在重试前调用“查询任务列表”接口,按文件名、大小、时间戳等维度判断相同任务是否已经存在。这个设计在后面“失败回退”部分会展开讲。

同时,这也解释了为什么不能把requestTimeout设成几十秒就完事。上传阶段的时间预算要按文件大小和实际带宽去估算,而不是拍脑袋。我在第2节里会给出一个可以落地的动态估算方法。

2. Java 17 HttpClient超时配置:参数别设错层级

Java 17自带的java.net.http.HttpClient用起来比早期Java的URLConnection顺手太多,但它的超时配置分散在两个地方:HttpClient的connectTimeout和HttpRequest的timeout。理解错层级,配置就等于白设。

2.1 构建HttpClient实例:线程池、重定向、协议版本

先看我最终采用的客户端构建方式:

import java.net.URI; import java.net.http.HttpClient; import java.net.http.HttpRequest; import java.net.http.HttpResponse; import java.net.http.HttpTimeoutException; import java.nio.charset.StandardCharsets; import java.nio.file.Files; import java.nio.file.Path; import java.time.Duration; import java.util.concurrent.*; public class TranscriptionClient { private static final String SUBMIT_URL = "https://api.example-transcribe.com/tasks"; private static final String POLL_URL_TEMPLATE = "https://api.example-transcribe.com/tasks/%s"; private final HttpClient httpClient; // 可配置参数,建议从配置中心读取 private final int maxRetries = 3; private final long baseRetryDelayMillis = 1000; private final long estimatedUploadBytesPerSecond = 1024 * 1024; // 1MB/s经验值 public TranscriptionClient() { this.httpClient = HttpClient.newBuilder() .connectTimeout(Duration.ofSeconds(10)) .version(HttpClient.Version.HTTP_2) .executor(Executors.newFixedThreadPool(8)) .followRedirects(HttpClient.Redirect.NEVER) .build(); } }

这里有几个细节值得说道说道。

第一,executor一定要设。默认情况下sendAsync的CompletableFuture回调会跑在公共的ForkJoinPool.commonPool上。如果你的服务是Spring WebMvc这类同步模型,可能感觉不明显;但如果是WebFlux或者自己用了大量并行流,公共池被占满会殃及池鱼。给HttpClient一个专属线程池,隔离IO回调。

第二,followRedirects设置成NEVER。上传文件的POST请求如果中间被某个网关返回302,重定向时方法可能被改写(比如303会把POST变成GET),请求体直接丢失,而且很难排查。与其依赖重定向,不如让客户端直接暴露一个3xx,在上层决定是否构造新的请求重新提交。文件转写API一般是内部集群,直接访问正确的地址即可。

第三,Version.HTTP_2。Java 17的HttpClient在HTTPS下通过ALPN协商,服务端如果不支持HTTP/2会自动降级到HTTP/1.1,不会报错,所以可以放心设置。HTTP/2的流复用对频繁的小请求(轮询状态)提升明显,只是要小心5.2节里说的连接重置问题。

2.2 请求级超时:动态估算而不是固定死值

HttpRequest.timeout是最容易设置又最容易出错的地方。我给提交文件这个请求设置超时的思路是:固定缓冲 + 按文件大小和预估带宽计算。

private Duration estimateUploadTimeout(Path file) { try { long sizeBytes = Files.size(file); long estimatedSeconds = 5 + sizeBytes / estimatedUploadBytesPerSecond; return Duration.ofSeconds(estimatedSeconds); } catch (IOException e) { return Duration.ofSeconds(30); } }

这个公式的意思是:先给5秒的固定缓冲应付服务端受理时间,再加上“按1MB/s估算的上传耗时”。1MB/s是我针对当时公网到转写服务端的实测带宽给的偏保守取值,如果你们的内网带宽更大,可以把这个值调成5MB/s甚至10MB/s,或者从配置中心读取。

为什么不能固定30秒?一个几十KB的小音频和几百MB的长视频,上传时间差两个数量级。拿固定超时去套,要么小文件遇到瞬时抖动就误杀,要么大文件永远在超时边缘试探,失败率陡增。动态估算的好处是,把超时的时间预算和实际传输成本绑定,让“网络确实有问题”和“本来就该传这么久”这两种情况有机会被区分开。

轮询状态和获取结果这两个请求就简单多了,因为请求体很小,直接用固定超时即可:

private HttpResponse<String> sendWithFixedTimeout(HttpRequest request) throws IOException, InterruptedException { return httpClient.send(request, HttpResponse.BodyHandlers.ofString(StandardCharsets.UTF_8)); }

轮询请求我一般设10秒,拿结果请求设15秒。小请求没必要复杂的动态估算。

2.3 请求体和响应体的处理:别把大文件整进内存

上传大文件时,RequestBodyPublishers.ofFile是标准答案。它按需从文件流式读取,不会把整个文件加载进堆内存。一个500MB的文件如果用ofByteArray读完整个byte[],内存直接爆炸,这在生产环境是不可接受的。

响应体同理。转写结果如果是JSON,通常不大,用BodyHandlers.ofString没问题。但要注意显式指定字符集:

HttpResponse<String> response = httpClient.send(request, HttpResponse.BodyHandlers.ofString(StandardCharsets.UTF_8));

不指定的话,Java 17会根据响应头Content-Type里的charset去解码,而很多转写API返回的Content-Type可能是application/json不带charset,此时默认才用UTF-8。显式指定就是消除这种不确定性。

如果你的转写API支持“直接下载转写文本文件”(比如几十MB的会议纪要全文),就不要用ofString了,改成BodyHandlers.ofFile,把响应直接落到磁盘:

HttpResponse<Path> fileResponse = httpClient.send(request, HttpResponse.BodyHandlers.ofFile(Paths.get("/tmp/transcript.txt")));

这样内存占用是恒定的,不管转写文本多大都不会撑爆堆。

2.4 异步发送的CompletableFuture异常形态

既然Java 17的HttpClient主打异步,这里把异步路径的异常形态也说清楚。调用sendAsync会得到CompletableFuture<HttpResponse >,请求异常完成时,同步send直接抛HttpTimeoutException,异步路径则通常是CompletionException,真正的异常类型包在cause里。

一个常见的错误写法是直接用join()拿结果,然后发现抛出的异常是CompletionException,里面包着真正的HttpTimeoutException,导致catch的时候类型没对上,错误处理逻辑失效。所以做统一异常处理时,要递归解包:

private Throwable unwrap(Throwable t) { while (t instanceof CompletionException) { t = t.getCause(); } return t; }

另外,对异步Future调orTimeout或者get(timeout),和HttpRequest自身timeout是两套独立机制。HttpRequest.timeout是让HttpClient的网络栈去中断请求;Future.get(timeout)只是当前线程不再等待。真正要做的还是设置HttpRequest.timeout,让它从底层把请求取消,否则就算调用方线程不等了,请求仍然在后台跑,白白占用连接和线程池。

3. 失败回退设计:从无脑重试到分类处理

超时只是问题的一半,另一半是超时之后怎么办。很多项目里的“失败重试”就是套一个for循环重发三次,这在文件转写API这种场景下非常危险。危险的来源有两个:一是重复提交产生重复任务,二是重试风暴把自己和第三方API都打挂。

3.1 错误分类:不是所有异常都值得重试

先把失败分成两类:瞬时失败(transient)和确定性失败(non-transient)。瞬时失败是网络抖动、服务端临时过载、限流导致,重试还可能成功;确定性失败是参数错误、无权限、文件格式不支持,重试一万次还是失败,只会徒增压力。

我在封装时按错误类型做了一个决策表:

错误/异常是否重试理由
HttpConnectTimeoutException(连接超时)是网络或节点临时不可达
HttpTimeoutException(请求超时)谨慎请求可能已达服务端,需先做幂等确认
IOException(连接被重置等)是链路抖动,但要注意流是否已发送完毕
400/401/403/404否客户端错误,重试无意义
429(限流)是等待Retry-After,按对方要求退避
500/502/503是服务端临时故障,概率恢复
业务错误码(文件损坏、格式不支持)否转写任务失败,属业务问题,应走人工或换格式

这里最重要的是“HttpTimeoutException要谨慎”这一行。正如1.3节所说,超时发生时,服务端完全可能已经成功创建任务。如果对这个异常直接无脑重试,就会制造重复任务。所以针对这类异常的正确动作是:

  1. 先不重试;
  2. 用幂等键/请求ID去查询这次提交是否已经被服务端受理;
  3. 如果已经受理,拿taskId进入轮询流程,整个过程对调用方表现为“虽然发生了超时但任务正常进行”;
  4. 如果没受理,才真正重试提交。

3.2 重试间隔:指数退避加抖动,而不是等差数列

对需要重试的瞬时失败,重试间隔我采用指数退避 + 随机抖动。固定间隔重试最大的问题是同步性:如果20个客户端同时遇到超时,大家都会在1秒后同时重试,形成一个人为的流量尖峰,把原本已经过载的服务端打得更惨。

指数退避的公式很成熟:

private long nextRetryDelay(int retryCount) { long exponential = baseRetryDelayMillis * (1L << retryCount); // 1s, 2s, 4s, ... long jitter = ThreadLocalRandom.current().nextLong(0, 500); return exponential + jitter; }
  • 重试第0次失败后等1秒;
  • 第1次失败后等2秒;
  • 第2次失败后等4秒;
  • 封顶,比如最大30秒。

加抖动的意义在于打破“同步重试”。如果不加,假设100个请求同时超时,它们在1、2、4秒的时间点会同时爆炸;加了0~500ms的随机量,每个请求的重试时刻就被错开了。这是从线上事故里学到的:我见过一个项目里把重试逻辑写成Sleep(1000 * retryCount),结果高峰期超时批量出现后,重试流量像踩点一样齐刷刷地打过去,直接把服务端打到限流。

还有一点,如果服务端返回429,通常会带Retry-After响应头。这个头指示客户端“多久以后再来”,它应该优先于本地计算的退避时间。我在实现里会先读这个头,能读到一个合法的秒数就用它当delay,读不到才回退到本地算法。

3.3 幂等设计:防重复任务的最后防线

这个环节是整个失败回退设计的灵魂。我提交任务时,会给每个请求生成一个UUID作为幂等键,放在Idempotency-Key请求头里:

private HttpRequest buildSubmitRequest(Path file, String requestId) { return HttpRequest.newBuilder() .uri(URI.create(SUBMIT_URL)) .timeout(estimateUploadTimeout(file)) .header("Idempotency-Key", requestId) .header("Content-Type", "application/octet-stream") .POST(HttpRequest.BodyPublishers.ofFile(file)) .build(); }

服务端如果支持幂等键,看到相同key的第二次请求时,不会重复创建任务,而是直接返回第一次创建的taskId。这样即使客户端因为超时而重试,账单上还是一次任务。

如果转写服务商不支持这个头,就要靠业务层的查询来兜底。提交前,我可以调用“查询最近任务”接口,按文件名的SHA-256和文件大小判断是否已有相同内容的任务在跑。这里有一个非常实用的做法:在文件名里嵌入内容哈希,比如meeting_20240101_abc123.wav,然后查询时拿同样的哈希去匹配。虽然别扭,但确实是很多没有幂等键的第三方API下的可行方案。

无论哪种方案,原则都一样:重试动作本身必须是幂等的,也就是说“重发一次”不会造成“多做一次事”。

3.4 降级通道:重试耗尽之后的路

三次重试都失败,不代表这个文件就能被丢弃。文件转写这种数据往往是用户上传的会议记录、质检录音,丢了就是事故。我在重试耗尽后采用了两级降级:

第一级,切备用通道。如果主转写服务商的API连续不可用,代码里维护了一个备用服务商配置(另一个转写API端点),重试耗尽后自动切换,用同一份文件和新的幂等键提交。成本可能略有上升,但至少任务不会卡死。

第二级,本地延迟队列待重投。如果备用通道也不可用,就把文件路径、请求元数据、当前重试次数封装成一个重投事件,塞进本地持久化的延迟队列。后台线程每隔一段时间拉一次队列,对积压任务做有限次数的重试。这样就算两个通道同时挂掉,数据也不会丢,恢复后任务还能自动继续。

断路器在这里也有价值。我在一个通道上如果连续失败(比如10次),短期内就不再发起请求,直接走备用通道,避免对一个明显故障的通道反复试探。恢复探测可以简单实现:每30秒放一个探测请求过去,成功一次就把断路器闭合,恢复正常流量。文件转写本身不算高频调用,断路器规则不需要很复杂,但要有。

4. 完整封装:一个可落地的转写客户端

讲完原理,直接上可以抄作业的完整代码。这个客户端涵盖了动态超时、分类重试、幂等键、异步轮询、降级入口,结构上也做了职责分离。

4.1 核心提交方法:动态超时 + 分类重试

public String submitWithRetry(Path file) throws Exception { String requestId = UUID.randomUUID().toString(); int retryCount = 0; while (true) { try { return doSubmit(file, requestId); } catch (HttpTimeoutException e) { // 超时可能已受理,先查询确认,再决定是否重试 String alreadyTaskId = queryExistingTaskId(requestId); if (alreadyTaskId != null) { return alreadyTaskId; } if (!needRetry(e, retryCount)) { throw e; } sleepQuietly(nextRetryDelay(retryCount++)); } catch (TransientException e) { if (!needRetry(e, retryCount)) { throw e; } sleepQuietly(nextRetryDelay(retryCount++)); } } } private String doSubmit(Path file, String requestId) throws IOException, InterruptedException { HttpRequest request = HttpRequest.newBuilder() .uri(URI.create(SUBMIT_URL)) .timeout(estimateUploadTimeout(file)) .header("Idempotency-Key", requestId) .header("Content-Type", "application/octet-stream") .POST(HttpRequest.BodyPublishers.ofFile(file)) .build(); HttpResponse<String> response = httpClient.send(request, HttpResponse.BodyHandlers.ofString(StandardCharsets.UTF_8)); int code = response.statusCode(); if (code == 200) { return parseTaskId(response.body()); } if (code == 429) { long retryAfter = parseRetryAfter(response.headers()); if (retryAfter > 0) { throw new RateLimitedException(retryAfter); } throw new TransientException("rate limited"); } if (code >= 500) { throw new TransientException("server error: " + code); } throw new NonTransientException("client error: " + code); }

这个方法里有几个点要解释一下。

为什么HttpTimeoutException单独catch?因为它对应的情况最微妙——请求发出去但响应没按时回来,服务端可能已经受理了。此时直接重试是最危险的,所以先调用queryExistingTaskId去查一下。如果查到了taskId,说明第一次提交其实成功了,那就直接返回这个taskId,整个重试逻辑不用启动,自然也不会重复计费。

queryExistingTaskId在实现上就是调用“查询最近任务列表”接口,传入requestId(或者嵌入内容哈希的文件名),匹配到一个未完成任务就返回任务ID。这个查询本身也可能超时,所以查询请求也要设超时,但这个查询超时相对没那么危险,因为查询是只读操作,重试查询不会产生副作用。

TransientException是我把429、5xx等可重试错误统一包装的异常类型。RateLimitedException是TransientException的子类,额外携带Retry-After时间,重试层读取它来覆盖默认退避时间。由于这里用了同步send,sleepQuietly会在当前线程等待;如果生产环境对吞吐有更高要求,可以换成sendAsync + CompletableFuture,但核心逻辑一致。

4.2 轮询逻辑:动态间隔和总时间预算

拿到taskId之后,转写任务还需要一段时间才能完成。轮询是我用ScheduledExecutorService实现的,间隔指数增长,并设置了总时间预算。

private static final ScheduledExecutorService POLL_SCHEDULER = Executors.newScheduledThreadPool(2); private static final long MAX_POLL_DURATION_MILLIS = 15 * 60 * 1000; // 15分钟 public CompletableFuture<TranscriptionResult> pollUntilFinished(String taskId) { CompletableFuture<TranscriptionResult> future = new CompletableFuture<>(); long start = System.currentTimeMillis(); schedulePoll(taskId, 3, start, future); return future; } private void schedulePoll(String taskId, long delaySeconds, long startMillis, CompletableFuture<TranscriptionResult> future) { POLL_SCHEDULER.schedule(() -> { try { if (System.currentTimeMillis() - startMillis > MAX_POLL_DURATION_MILLIS) { future.completeExceptionally(new TimeoutException("poll timeout for task " + taskId)); return; } HttpRequest pollRequest = HttpRequest.newBuilder() .uri(URI.create(String.format(POLL_URL_TEMPLATE, taskId))) .timeout(Duration.ofSeconds(10)) .GET() .build(); HttpResponse<String> resp = httpClient.send(pollRequest, HttpResponse.BodyHandlers.ofString(StandardCharsets.UTF_8)); TaskStatus status = parseStatus(resp.body()); if (status == TaskStatus.COMPLETED) { future.complete(parseResult(resp.body())); } else if (status == TaskStatus.FAILED) { future.completeExceptionally(new TaskFailedException("task " + taskId + " failed")); } else { long nextDelay = Math.min(delaySeconds * 2, 60) + ThreadLocalRandom.current().nextLong(0, 3); schedulePoll(taskId, nextDelay, startMillis, future); } } catch (Exception e) { // 轮询本身出现瞬时错误:如果时间预算未耗尽,小退一下继续 if (System.currentTimeMillis() - startMillis < MAX_POLL_DURATION_MILLIS) { schedulePoll(taskId, 5, startMillis, future); } else { future.completeExceptionally(e); } } }, delaySeconds, TimeUnit.SECONDS); }

轮询间隔从3秒开始,每次翻倍,封顶60秒,再加0~3秒抖动,避免所有任务在同一秒内扎堆查询。为什么最小间隔设为3秒而不是1秒?因为转写任务最短耗时也是几十秒,前几秒的频繁查询基本都返回“处理中”,白白消耗连接。等3秒、翻倍到60秒,是经验上足够平滑且服务端友好的节奏。

4.3 配置项清单

所有关键参数我没写死在类里,而是从配置系统加载。这样线上出了带宽变化、服务商限流策略调整,改配置就行,不用重新发版。建议的配置项如下:

配置项类型默认值说明
transcribe.connect.timeoutDuration10sTCP建连超时
transcribe.upload.base.timeoutDuration5s上传请求固定缓冲部分
transcribe.upload.ratelong1048576预估上传带宽(字节/秒)
transcribe.poll.initial.delayDuration3s轮询初始间隔
transcribe.poll.max.delayDuration60s轮询最大间隔
transcribe.poll.max.durationDuration15m轮询总预算
transcribe.retry.maxint3最大重试次数
transcribe.retry.base.delayDuration1s重试退避基准值

我在实际项目中把这些参数挂到了配置中心,每次调优都能灰度观察,比改代码重新部署高效得多。

5. 线上踩到的坑和最后的经验

代码能跑通和线上稳定运行之间,还隔着一堆看起来不起眼的坑。这里挑几个我实际踩过的,按严重程度排个序。

5.1 一次误配requestTimeout造成的重复账单

第一次上线时,我把提交任务的requestTimeout统一设成了10秒,心想这总够了吧。结果线上接大文件时,一个200MB的视频按当时不到1MB/s的上传带宽,光传文件就要200秒,10秒的超时等于刚发了个头就被掐了。客户端抛HttpTimeoutException,我的重试逻辑又只做了“直接重试”,于是每个大文件都被提交了三四遍,服务端生成了多个转写任务。一周后对账时发现转写量是预期的四倍,这个教训值不少钱。

后来我做了两个修正:一是把超时改成按文件大小动态估算;二是把HttpTimeoutException的重试路径里加上了“先查询是否已受理”的幂等确认逻辑。上线后提交阶段超时率大幅下降,即使偶发超时,也不会再产生重复任务。这个组合拳建议所有接文件类API的人都配上。

5.2 HTTP/2连接关闭与请求体重放的问题

Java 17的HttpClient默认用HTTP/2。传输大文件时,如果服务端因为负载或者配置问题发送了GOAWAY帧,当前HTTP/2连接就会关闭。HttpClient对还没有成功的幂等请求有自动重试机制,但要重试就必须重新发送请求体。

这里就凸显publisher类型的重要性。BodyPublishers.ofFile是“可重新订阅”的,文件在磁盘上,可以重新打开、重新读,所以HTTP/2连接异常时自动重试可行。但如果你图方便,用InputStream包装文件自定义publisher,第一次发送后流已经读到底了,自动重试拿不到数据,就容易抛出类似“request body already sent”的异常。我看到不少朋友在这一步栽跟头,资料上只说“发送请求体失败会重试”,但没说明还需要可重放body。

不过自动重试有另一个隐患:如果第一次请求的body只有前半段到达了服务端,服务端可能已经创建了一个“损坏的任务”。自动重试在第二个连接上重新传,如果服务端不去重,还是会生成两个任务。所以哪怕用了ofFile,Idempotency-Key依然是必需品。我还额外建议在任务完成或失败后,用服务端返回的文件摘要哈希与本地文件哈希做比对,确认转的是同一份文件。多数正经的转写API会返回这类校验信息,能做就做上。

5.3 轮询也能轮出风暴

轮询设计初期,我让所有任务用固定1秒间隔轮询。某天同时提交了30个大文件,结果每分钟产生1800次请求,被服务端当成滥用限流了,然后一堆本应成功的任务都报了连接错误。排查时看网关日志,整个请求量像一个整齐的方波,每1秒跳动一次,非常显眼。

改法就是4.2节那套:指数间隔加抖动,初始3秒封顶60秒,再加0~3秒的随机扰动。改完30个任务的轮询QPS从每分钟1800降到每分钟几十次,服务端状态码的429基本绝迹。轮询这件事,永远记住一个原则:转写任务完成时间的量级是分钟,轮询间隔的量级也应该是秒到十秒,而不是亚秒级。

5.4 线程池泄漏与Future提前结束

用CompletableFuture.orTimeout让轮询Future提前结束,只是让调用方线程不再等,底层定时任务和HTTP连接并不会自动清理。如果每次调用都创建新的ScheduledExecutorService并且不关闭,线程池会堆积,迟早OutOfMemoryError。

我的建议是:进程级只维护一个静态的ScheduledExecutorService,轮询和重试定时任务都通过它调度。另外,异步调用的线程池也用静态字段持有,不要在一个方法里new一个出来。写完代码自查一下,凡是Executor、ScheduledExecutorService,都问一句“这个线程池会在什么时候关闭”,答不上来就是隐患。

5.5 可观测性:失败重试必须留痕

最后一条经验,把每一次超时、重试、降级都记成结构化日志和指标。日志至少包含:requestId、taskId(如果有)、文件名、文件大小、失败阶段(submit或poll)、异常类型、重试次数、耗时。指标至少包含:提交成功率、轮询成功率、超时率、重试率、降级次数。

我后来就是靠这些日志定位了很多偶发问题,比如某个时间段的超时全部集中在同一个服务端节点,或者某个备用通道的延迟明显高于主通道。没有这些数据,超时和重试就只是黑盒里的概率事件。把这条基本盘打好,整个转写链路的稳定性才真正可控。

如果后续要给这个客户端加功能,我大概率会先做“转写任务持久化”,把文件路径、requestId、taskId、状态存到数据库,重启服务后还能恢复轮询。再往下就是多通道的智能路由,根据历史延迟动态选服务商。不过这些都是后话了,先把超时和回退这层地基打扎实,文件转写API的接入就不会再出什么大乱子。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进