PAYATHON 2026

支付处理器 API 调用超过 60 秒时如何中断 HTTP 请求

支付老李

结论

不要用 TimerTask 修改局部变量,也不要让处理 UI 请求的线程同步等待支付处理器返回。

可以改成下面的处理方式:

  1. 将面向 UI 的 JAX-RS 请求改为异步响应。
  2. 异步调用支付处理器。
  3. 使用独立的超时任务控制 UI 响应时间。
  4. UI 超时后不要取消支付处理器请求。
  5. 支付结果晚到时继续处理,并根据最终状态发送通知。

需要注意,60 秒超时只表示“支付结果暂时未知”,不能据此认定支付失败。支付处理器仍有可能在第 70 秒扣款成功。因此,向用户返回“处理中”或“结果待确认”,通常比直接返回“支付失败”更安全。

为什么原实现不可行

builder.post(...) 是同步调用。支付处理器返回之前,当前线程会一直阻塞。即使 TimerTask 已经执行,也无法从另一个线程直接结束当前 HTTP 请求。

原实现还有以下问题:

  • TimerTask.run() 没有可返回给 HTTP 客户端的返回值。
  • Java 局部变量被内部类捕获时,必须是 final 或 effectively final。
  • 即使使用 AtomicBoolean 解决变量可见性,也没有解决如何异步结束 HTTP 响应的问题。
  • Timer 不适合管理 Web 应用中的并发任务和生命周期。
  • 应用重启后,保存在内存中的异步任务和延迟支付结果都会丢失。

使用 JAX-RS 异步响应

下面的示例使用 @Suspended AsyncResponse,通过 InvocationCallback 异步接收支付处理器的响应。

Client 和 ScheduledExecutorService 应由应用统一管理,不要为每个请求创建新实例。

import javax.inject.Inject;
import javax.ws.rs.*;
import javax.ws.rs.client.*;
import javax.ws.rs.container.AsyncResponse;
import javax.ws.rs.container.Suspended;
import javax.ws.rs.core.*;
import java.util.concurrent.*;
import java.util.concurrent.atomic.AtomicBoolean;

@Path("/payments")
@Consumes(MediaType.APPLICATION_JSON)
@Produces(MediaType.APPLICATION_JSON)
public class PaymentResource {

    private final Client paymentClient;
    private final ScheduledExecutorService scheduler;
    private final AdminNotificationService notificationService;

    @Inject
    public PaymentResource(
            Client paymentClient,
            ScheduledExecutorService scheduler,
            AdminNotificationService notificationService) {
        this.paymentClient = paymentClient;
        this.scheduler = scheduler;
        this.notificationService = notificationService;
    }

    @POST
    public void pay(PaymentRequest request,
                    @Suspended AsyncResponse uiResponse) {

        AtomicBoolean uiResponseCompleted = new AtomicBoolean(false);

        ScheduledFuture<?> timeoutTask = scheduler.schedule(() -> {
            if (uiResponseCompleted.compareAndSet(false, true)) {
                uiResponse.resume(
                    Response.status(Response.Status.ACCEPTED)
                        .entity(new PaymentResult(
                            request.getPaymentId(),
                            "PENDING",
                            "支付结果仍在确认中,请勿重复支付"
                        ))
                        .build()
                );
            }
        }, 60, TimeUnit.SECONDS);

        WebTarget target = paymentClient.target(
            "https://processor.example.com/payments"
        );

        target.request(MediaType.APPLICATION_JSON_TYPE)
            .header("Idempotency-Key", request.getPaymentId())
            .async()
            .post(
                Entity.entity(request, MediaType.APPLICATION_JSON_TYPE),
                new InvocationCallback<Response>() {
                    @Override
                    public void completed(Response processorResponse) {
                        try {
                            int status = processorResponse.getStatus();
                            String body = processorResponse.hasEntity()
                                ? processorResponse.readEntity(String.class)
                                : "";

                            boolean successful =
                                status >= 200 && status < 300;

                            if (uiResponseCompleted.compareAndSet(false, true)) {
                                timeoutTask.cancel(false);

                                uiResponse.resume(
                                    Response.status(status)
                                        .entity(body)
                                        .type(MediaType.APPLICATION_JSON_TYPE)
                                        .build()
                                );
                            } else if (successful) {
                                notificationService.notifyLateSuccess(
                                    request.getPaymentId(),
                                    body
                                );
                            }
                        } finally {
                            processorResponse.close();
                        }
                    }

                    @Override
                    public void failed(Throwable error) {
                        if (uiResponseCompleted.compareAndSet(false, true)) {
                            timeoutTask.cancel(false);

                            uiResponse.resume(
                                Response.status(Response.Status.BAD_GATEWAY)
                                    .entity(new PaymentResult(
                                        request.getPaymentId(),
                                        "UNKNOWN",
                                        "暂时无法确认支付结果"
                                    ))
                                    .build()
                            );
                        } else {
                            notificationService.notifyProcessorError(
                                request.getPaymentId(),
                                error
                            );
                        }
                    }
                }
            );
    }
}

示例中的 DTO 可以这样定义:

public class PaymentResult {

    private final String paymentId;
    private final String status;
    private final String message;

    public PaymentResult(String paymentId, String status, String message) {
        this.paymentId = paymentId;
        this.status = status;
        this.message = message;
    }

    public String getPaymentId() {
        return paymentId;
    }

    public String getStatus() {
        return status;
    }

    public String getMessage() {
        return message;
    }
}

这里的 AtomicBoolean 不是用来保存“是否超过阈值”的,而是用来处理两个异步事件之间的竞争:

  • 如果支付处理器先返回,就由回调完成 UI 响应。
  • 如果 60 秒超时先发生,就由超时任务完成 UI 响应。
  • 只有第一个成功执行 compareAndSet(false, true) 的线程可以完成 UI 响应。
  • 如果支付结果晚到,支付处理器回调仍会运行,但不会再次写入已经结束的 HTTP 响应。

不要把超时直接表示为支付失败

支付请求一旦被支付处理器接收,调用方通常无法通过关闭 HTTP 连接来撤销请求。因此,60 秒后更准确的状态是:

{
  "paymentId": "pay-123",
  "status": "PENDING",
  "message": "支付结果仍在确认中,请勿重复支付"
}

HTTP 状态可以使用 202 Accepted。如果现有接口必须返回超时错误,也可以使用 504 Gateway Timeout,但响应体仍应明确指出支付状态是 UNKNOWN,而不是 FAILED。

前端应使用 paymentId 查询最终状态,避免用户看到“失败”后立即再次支付。

更可靠的生产方案

只依赖一条持续 70 秒的出站 HTTP 连接并不可靠。应用重启、容器回收、反向代理超时或网络中断,都可能导致延迟回调丢失。生产环境通常采用以下流程:

  1. 生成唯一的支付 ID 和幂等键。
  2. 在数据库中创建 PENDING 支付记录。
  3. 向支付处理器提交请求。
  4. 如果在 60 秒内收到结果,直接更新记录并返回响应。
  5. 如果超过 60 秒,向 UI 返回 PENDING。
  6. 通过支付处理器的 webhook 接收最终结果。
  7. 如果支付处理器没有提供 webhook,则由后台任务根据支付 ID 查询支付状态。
  8. 将最终状态从 PENDING 更新为 SUCCEEDED 或 FAILED。
  9. 对延迟成功的支付发送管理员通知,同时让前端能够查询到成功结果。

即使保留异步 HTTP 回调,也应使用 webhook 或状态查询来完成最终确认。

其他注意事项

  • 必须使用支付处理器支持的幂等键,避免用户重试导致重复扣款。
  • 出站 HTTP 客户端的读取超时必须大于 60 秒,否则客户端可能在 UI 超时后同时取消支付处理器请求。
  • 负载均衡器、API Gateway 和 Servlet 容器也可能有各自的超时配置。
  • 不要在 HTTP 回调线程中同步发送邮件,应将发送任务提交给邮件队列或独立执行器。
  • Client 通常应该复用,并在应用关闭时统一关闭。
  • 必须关闭 JAX-RS Response,否则可能造成连接泄漏。
  • 支付处理器返回 2xx 是否代表“支付成功”,取决于其协议。有些 API 的 2xx 只表示请求已受理,实际支付状态仍需从响应字段中判断。
  • 即使用户关闭浏览器或 UI 连接中断,后台支付确认也应继续,并将结果写入持久化存储。

备注:内容仅供参考。