Java 接口超时后,重试为什么会多建一条任务?

提交导出任务,接口超时了。再点一次,页面恢复正常,却出现了两条任务。

先看这个本机 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枫斗者·枫哥

相关推荐
xcl09255 小时前
北京24小时自助健身房系统软件开发实战:从架构到部署全流程指南
java·spring boot·架构
一 乐7 小时前
动漫书销售商城|基于springboot + vue动漫书销售商城(源码+数据库+文档)
java·数据库·vue.js·spring boot·毕业设计
卓怡学长7 小时前
w214基于jsp知道特产网
java·intellij-idea
步行cgn7 小时前
Spring 注解使用详解
java·spring
一条小小yu8 小时前
Spring IoC的理解
java·后端·spring
落魄实习生8 小时前
Agent Scope Java 2.x 系列【7】工具使用
java·开发语言·ai
旺仔学长 哈哈8 小时前
springboot钓鱼爱好者交流平台APP设计与实现
java·spring boot·mysql·充电桩管理系统
自强的小白10 小时前
核心功能(Service接口)
java·mybatis
乌暮11 小时前
深入理解 Java 泛型:把「万能盒子」用对、用稳
java·开发语言·后端·学习