提交导出任务,接口超时了。再点一次,页面恢复正常,却出现了两条任务。
先看这个本机 Java 实验的输出:
text
first=HttpTimeoutException; tasks=1
retry=T2; tasks=2
第一行已经说明了问题:客户端拿到的是超时异常,服务端的创建计数却已经是 1。第二次请求不能再按"第一次肯定没做成"来处理。
下面用 JDK 自带的 HTTP 客户端和服务端跑三个对照。不是在 catch 里手动抛个超时,也不需要启动 Spring 或数据库。完整源码和测试都在文内。
先让超时发生在创建之后
客户端请求超时设为 500ms。服务端收到请求后先创建任务,再用一个测试等待点卡住响应,不把任务号发回去。
关键代码如下:
java
Task task = create(key, params);
if ("yes".equals(
ex.getRequestHeaders()
.getFirst("X-Lab-Hold"))) {
createdAt = System.nanoTime();
// 任务已在服务端内存中创建。
stored.countDown();
// 卡住响应,不撤销任务。
release.await(10, TimeUnit.SECONDS);
}
reply(ex, 200, task.id());
这里等待的是"什么时候返回响应",不是"什么时候创建任务"。客户端捕获 HttpTimeoutException 后,测试程序放行服务端,再用相同参数补发请求。

如果把等待放在 create 之前,就可能变成"请求还没处理,客户端先超时"。两种情况都能看到超时异常,但对重试的判断完全不同。
代码还检查了创建信号、任务计数和时间记录。若不能确认客户端捕获异常时任务已经创建,就直接失败,不把这轮算作复现成功。timedOutAt 记录的是捕获异常的时刻,不是网络断开的精确时间,也不是数据库提交时间。
图里最后一步是开启去重的同号补发;先不要把它当成所有接口都会有的行为。
只改去重开关和请求号,会得到什么
三组实验的首次请求都是 REQ-42,正文都是 orders:2026-09-01:2026-09-07。每组重新启动一个本机服务,任务计数从零开始。
| 对照 | 服务端去重 | 补发请求号 | 补发返回 | 最终创建计数 |
|---|---|---|---|---|
| no-dedup | 关闭 | REQ-42 | T2 | 2 |
| same-key | 开启 | REQ-42 | T1 | 1 |
| new-key | 开启 | REQ-43 | T2 | 2 |
第一组两次都叫 REQ-42,仍然建了两次。请求头只是携带一个值,服务端不识别、不保存、不复用,它不会自己产生去重能力。
第二组查到了 REQ-42 对应的 T1,返回原任务,没有再创建。
第三组也开了去重,但补发时换成 REQ-43。这个实验把新号当成新操作,因此又建了 T2。若你用拦截器给每次网络发送都生成一个 UUID,重试也会带上新号,就容易走到这里。
请求号应当跟随一次业务操作:例如同一次导出操作首次发送和补发保持一致;用户明确发起另一次导出时再用新号。不要把用于追踪单次网络请求的 traceId,不加区分地当成业务去重号。
返回旧任务前,还要核对参数
本例保存的不是一个孤立 taskId,而是任务号和业务参数。创建方法如下:
java
synchronized Task create(
String key, String params) {
Task old = dedup
? results.get(key) : null;
if (old != null) {
if (!old.params().equals(params)) {
throw new IllegalStateException(
"same key, different params");
}
return old;
}
Task task = new Task(
"T" + (++created), params);
if (dedup) results.put(key, task);
return task;
}
REQ-42 第一次要求导出 9 月 1 日至 7 日。第二次仍用这个号,却改成另一段日期,直接返回旧 T1 会让调用方误以为拿到了新任务。
所以这个实验约定:同号同参复用;同号异参拒绝。HTTP 层把这里的参数冲突异常映射成 409,并验证没有新增任务,原参数仍能取回 T1。409 是本例的接口约定,不是所有系统唯一可用的状态码。
源码按原始字符串比较参数,方便观察行为。真实 JSON 接口应先定义哪些字段影响业务含义,再处理字段顺序、默认值和规范化,不能直接照搬字符串比较。
这段 synchronized 只保护同一个 Java 对象内的查找与创建。它能让本例的内存状态保持一致,不会自动协调两个服务实例,也不会让内存记录在进程重启后留下来。
复制到本机怎么跑
把文末两段完整代码分别保存为 HttpRetryLab.java 和 HttpRetryLabTest.java,放在同一个目录,在该目录执行:
shell
javac -encoding UTF-8 --release 17 --add-modules jdk.httpserver HttpRetryLab.java HttpRetryLabTest.java
java --add-modules jdk.httpserver HttpRetryLab
java --add-modules jdk.httpserver HttpRetryLabTest
实际复核环境是 Windows、JDK 21.0.11,编译目标为 Java 17;没有另用 JDK 17 运行时验证。需要完整 JDK,只有 JRE 不够。程序只监听 127.0.0.1 的随机空闲端口,不向局域网开放。
演示程序本轮输出:
text
CASE no-dedup
first=HttpTimeoutException; tasks=1
retry=T2; tasks=2
created-before-timeout=true
CASE same-key
first=HttpTimeoutException; tasks=1
retry=T1; tasks=1
created-before-timeout=true
CASE new-key
first=HttpTimeoutException; tasks=1
retry=T2; tasks=2
created-before-timeout=true
测试另外检查了连续重放、同号异参、空号、空正文和超长正文。本轮连续跑三遍,末行均为 PASS 31 checks,31 是断言数量,不是 31 种业务场景。错误对照返回 T2 是反例按预期出现,不代表它满足"不重复创建"的业务要求。
如果报 cannot prove creation before timeout,先保留完整日志,检查 JDK、机器负载和测试控制是否被改过。这表示本轮没有确认目标顺序,不要删掉检查来凑一个通过。
500ms 是本实验制造超时的设置,不是生产推荐值;补发请求也设了等待上限。生产还需要结合接口耗时、重试预算和服务负载确定策略,但延长超时、减少次数或退避都不能替代去重语义。
回到自己的接口,先找三个位置
先找请求号的生成位置。确认失败补发时保留的是原业务号,还是又生成了新号。
再找真正产生副作用的位置:插入任务记录、发送消息、调用下游。不要只看 Controller 有没有返回 200。
最后找请求号与结果的保存位置。如果任务已提交,请求记录却在另一个缓存系统里还没写完,进程此时退出,下一次仍可能查空。这里不能靠本例的一把对象锁处理,要结合持久化、事务和去重范围设计。
本实验里的 tasks 是内存创建计数,没有真的导出文件,也没有数据库、认证、跨实例、进程重启或消息测试。它验证的是一个具体事实:客户端等待超时,并不能据此认定服务端没有创建任务。生产接口的保证范围,需要在自己的架构下另做验证。
下面附全部实现,关键等待点和检查保留中文注释。可以先运行,再把 main 中 new-key 的补发号改回 REQ-42,预测返回值与任务数后重跑;也可以运行测试中的同号异参用例,看看冲突后原任务还在不在。
完整实现:HttpRetryLab.java
java
import com.sun.net.httpserver.HttpExchange;
import com.sun.net.httpserver.HttpServer;
import java.io.IOException;
import java.net.InetSocketAddress;
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.time.Duration;
import java.util.HashMap;
import java.util.Map;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.TimeUnit;
/** 本机 HTTP 教学实验:真实请求超时,业务状态仅存内存。不是生产接口。 */
public class HttpRetryLab implements AutoCloseable {
static final String PARAMS = "orders:2026-09-01:2026-09-07";
static final HttpClient CLIENT = HttpClient.newBuilder()
.version(HttpClient.Version.HTTP_1_1)
.connectTimeout(Duration.ofSeconds(3)).build();
record Task(String id, String params) {}
record Result(String taskId, int count, long createdAt, long timedOutAt) {}
private final boolean dedup;
private final Map<String, Task> results = new HashMap<>();
private int created;
final CountDownLatch stored = new CountDownLatch(1);
final CountDownLatch release = new CountDownLatch(1);
volatile long createdAt;
private final ExecutorService workers = Executors.newFixedThreadPool(4);
private final HttpServer server;
final URI endpoint;
public HttpRetryLab(boolean dedup) throws IOException {
this.dedup = dedup;
// 随机空闲端口,只监听本机,不暴露到局域网。
server = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0);
server.setExecutor(workers);
server.createContext("/tasks", this::handle);
server.createContext("/ping", ex -> reply(ex, 200, "ready"));
server.start();
endpoint = URI.create("http://127.0.0.1:" + server.getAddress().getPort() + "/tasks");
}
// BEGIN STORE
synchronized Task create(
String key, String params) {
Task old = dedup
? results.get(key) : null;
if (old != null) {
if (!old.params().equals(params)) {
throw new IllegalStateException(
"same key, different params");
}
return old;
}
Task task = new Task(
"T" + (++created), params);
if (dedup) results.put(key, task);
return task;
}
// END STORE
synchronized int count() { return created; }
private void handle(HttpExchange ex) throws IOException {
if (!ex.getRequestURI().getPath().equals("/tasks")) {
reply(ex, 404, "not found");
return;
}
if (!ex.getRequestMethod().equals("POST")) {
reply(ex, 405, "POST required");
return;
}
String key = ex.getRequestHeaders().getFirst("Idempotency-Key");
byte[] bytes = ex.getRequestBody().readNBytes(1025);
if (key == null || key.isBlank() || key.length() > 80
|| bytes.length == 0 || bytes.length > 1024) {
reply(ex, 400, "key and bounded body required");
return;
}
String params = new String(bytes, StandardCharsets.UTF_8);
try {
// BEGIN DELAY
Task task = create(key, params);
if ("yes".equals(
ex.getRequestHeaders()
.getFirst("X-Lab-Hold"))) {
createdAt = System.nanoTime();
// 任务已在服务端内存中创建。
stored.countDown();
// 卡住响应,不撤销任务。
release.await(10, TimeUnit.SECONDS);
}
reply(ex, 200, task.id());
// END DELAY
} catch (IllegalStateException conflict) {
reply(ex, 409, "same key, different params");
} catch (InterruptedException interrupted) {
Thread.currentThread().interrupt();
ex.close();
} catch (IOException disconnected) {
// 客户端超时后可能已断开,写回失败不代表 create 被撤销。
ex.close();
}
}
private static void reply(HttpExchange ex, int code, String body) throws IOException {
try (ex) {
byte[] bytes = body.getBytes(StandardCharsets.UTF_8);
ex.getResponseHeaders().set("Content-Type", "text/plain; charset=utf-8");
ex.sendResponseHeaders(code, bytes.length);
ex.getResponseBody().write(bytes);
}
}
HttpResponse<String> post(String key, String params, boolean hold, long timeoutMs)
throws IOException, InterruptedException {
// BEGIN REQUEST
HttpRequest request =
HttpRequest.newBuilder(endpoint)
.timeout(
Duration.ofMillis(timeoutMs))
.header("Idempotency-Key", key)
.header("X-Lab-Hold",
hold ? "yes" : "no")
.POST(HttpRequest.BodyPublishers
.ofString(params))
.build();
return CLIENT.send(request,
HttpResponse.BodyHandlers
.ofString());
// END REQUEST
}
/** 三组实验各自新建服务端;先预热连接,失败时不把连接超时冒充业务超时。 */
static Result scenario(String label, boolean dedup, String retryKey) throws Exception {
try (HttpRetryLab lab = new HttpRetryLab(dedup)) {
CLIENT.send(HttpRequest.newBuilder(lab.endpoint.resolve("/ping"))
.timeout(Duration.ofSeconds(3)).GET().build(), HttpResponse.BodyHandlers.ofString());
System.out.println("CASE " + label);
long timedOutAt;
try {
lab.post("REQ-42", PARAMS, true, 500);
throw new AssertionError("first request must really time out");
} catch (HttpTimeoutException timeout) {
timedOutAt = System.nanoTime();
// 若请求还未到服务端,或创建晚于超时,实验无效,直接失败。
if (lab.stored.getCount() != 0 || lab.createdAt > timedOutAt || lab.count() != 1) {
throw new AssertionError("cannot prove creation before timeout");
}
System.out.println("first=HttpTimeoutException; tasks=" + lab.count());
} finally {
lab.release.countDown();
}
HttpResponse<String> replay = lab.post(retryKey, PARAMS, false, 3000);
if (replay.statusCode() != 200) throw new AssertionError("retry failed");
System.out.println("retry=" + replay.body() + "; tasks=" + lab.count());
System.out.println("created-before-timeout=" + (lab.createdAt < timedOutAt));
return new Result(replay.body(), lab.count(), lab.createdAt, timedOutAt);
}
}
public static void main(String[] args) throws Exception {
scenario("no-dedup", false, "REQ-42");
scenario("same-key", true, "REQ-42");
scenario("new-key", true, "REQ-43");
}
@Override public void close() {
release.countDown();
server.stop(0);
workers.shutdownNow();
}
}
完整测试:HttpRetryLabTest.java
java
import java.net.http.HttpResponse;
/** 每项检查具有业务断言;不用"没有抛异常"代替结果正确。 */
public class HttpRetryLabTest {
static int checks;
static void eq(Object expected, Object actual, String label) {
checks++;
if (!expected.equals(actual)) {
throw new AssertionError(label + ": expected=" + expected + ", actual=" + actual);
}
}
public static void main(String[] args) throws Exception {
checkScenario("bad", false, "REQ-42", "T2", 2);
checkScenario("safe-replay", true, "REQ-42", "T1", 1);
checkScenario("new-operation", true, "REQ-43", "T2", 2);
try (HttpRetryLab lab = new HttpRetryLab(true)) {
HttpResponse<String> first = lab.post("REQ-42", HttpRetryLab.PARAMS, false, 3000);
eq(200, first.statusCode(), "first status");
eq("T1", first.body(), "first id");
for (int i = 0; i < 3; i++) {
HttpResponse<String> replay = lab.post("REQ-42", HttpRetryLab.PARAMS, false, 3000);
eq(200, replay.statusCode(), "replay status");
eq("T1", replay.body(), "replay id");
eq(1, lab.count(), "replay count");
}
HttpResponse<String> conflict = lab.post("REQ-42", "orders:other-date", false, 3000);
eq(409, conflict.statusCode(), "changed params conflict");
eq(1, lab.count(), "conflict adds nothing");
eq("T1", lab.post("REQ-42", HttpRetryLab.PARAMS, false, 3000).body(), "original still readable");
eq(400, lab.post("", HttpRetryLab.PARAMS, false, 3000).statusCode(), "blank key rejected");
eq(1, lab.count(), "blank key adds nothing");
eq(400, lab.post("REQ-44", "", false, 3000).statusCode(), "empty body rejected");
eq(1, lab.count(), "empty body adds nothing");
eq(400, lab.post("REQ-44", "x".repeat(1025), false, 3000).statusCode(), "large body rejected");
eq(1, lab.count(), "large body adds nothing");
eq("T2", lab.post("REQ-43", HttpRetryLab.PARAMS, false, 3000).body(), "new key creates");
eq(2, lab.count(), "new key count");
}
System.out.println("PASS " + checks + " checks");
}
static void checkScenario(String label, boolean dedup, String key, String id, int count)
throws Exception {
HttpRetryLab.Result result = HttpRetryLab.scenario(label, dedup, key);
eq(id, result.taskId(), label + " id");
eq(count, result.count(), label + " count");
eq(true, result.createdAt() < result.timedOutAt(), label + " created before timeout");
}
}
对照官方说明
- JDK 21:HttpRequest.Builder.timeout:说明请求等待超时和 HttpTimeoutException 的关系,没有把它定义为服务端业务回滚。
- RFC 9110,9.2.2:幂等方法:非幂等请求不能仅因通信失败就当成安全的自动重试;需要知道其语义幂等,或能确认原请求未被应用。本篇用服务端约定讨论 POST 的重复处理,不是给 POST 方法本身改语义。
- AWS:Making retries safe with idempotent APIs:讨论请求标识、相同标识而意图不同,以及记录标识和业务修改的原子性。本文没有实现其中的分布式方案。
------ IT枫斗者·枫哥