支付处理器 API 调用超过 60 秒时如何中断 HTTP 请求
结论
不要用 TimerTask 修改局部变量,也不要让处理 UI 请求的线程同步等待支付处理器返回。
可以改成下面的处理方式:
- 将面向 UI 的 JAX-RS 请求改为异步响应。
- 异步调用支付处理器。
- 使用独立的超时任务控制 UI 响应时间。
- UI 超时后不要取消支付处理器请求。
- 支付结果晚到时继续处理,并根据最终状态发送通知。
需要注意,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 连接并不可靠。应用重启、容器回收、反向代理超时或网络中断,都可能导致延迟回调丢失。生产环境通常采用以下流程:
- 生成唯一的支付 ID 和幂等键。
- 在数据库中创建
PENDING支付记录。 - 向支付处理器提交请求。
- 如果在 60 秒内收到结果,直接更新记录并返回响应。
- 如果超过 60 秒,向 UI 返回
PENDING。 - 通过支付处理器的 webhook 接收最终结果。
- 如果支付处理器没有提供 webhook,则由后台任务根据支付 ID 查询支付状态。
- 将最终状态从
PENDING更新为SUCCEEDED或FAILED。 - 对延迟成功的支付发送管理员通知,同时让前端能够查询到成功结果。
即使保留异步 HTTP 回调,也应使用 webhook 或状态查询来完成最终确认。
其他注意事项
- 必须使用支付处理器支持的幂等键,避免用户重试导致重复扣款。
- 出站 HTTP 客户端的读取超时必须大于 60 秒,否则客户端可能在 UI 超时后同时取消支付处理器请求。
- 负载均衡器、API Gateway 和 Servlet 容器也可能有各自的超时配置。
- 不要在 HTTP 回调线程中同步发送邮件,应将发送任务提交给邮件队列或独立执行器。
Client通常应该复用,并在应用关闭时统一关闭。- 必须关闭 JAX-RS
Response,否则可能造成连接泄漏。 - 支付处理器返回
2xx是否代表“支付成功”,取决于其协议。有些 API 的2xx只表示请求已受理,实际支付状态仍需从响应字段中判断。 - 即使用户关闭浏览器或 UI 连接中断,后台支付确认也应继续,并将结果写入持久化存储。
备注:内容仅供参考。
