接口都超时了,下游为什么还在跑:把超时、取消和连接断开放一起看
接口都超时了,下游为什么还在跑:把超时、取消和连接断开放一起看
面试里常有人问:网关已经给了 504,为什么订单服务的日志还在继续,数据库也真的写进去了?
把“超时”和“停止执行”画上等号,基本就答偏了。一个请求链路里至少有三件不同的事:
- 调用方不再等结果
- 某一层发出了取消信号
- 正在跑的代码和下游资源真的停下来
它们可以发生在不同时间点,后两件甚至可能根本不发生。
假设接口总预算是 500 毫秒。第 480 毫秒时,网关放弃等待并返回超时;业务线程可能刚把一条更新提交给数据库。客户端看到的是失败,服务端做的却可能已经生效。这不是“网关没超时”,而是超时只结束了等待关系。
504 到底中断了谁
网关超时通常只说明:网关在自己的等待窗口内没拿到上游响应。
如果网关和应用之间的连接断了,应用也不必然立刻知道。HTTP/1.1 里,对端关闭连接是一次传输层事件。应用可能直到下一次读写,才感知到 EOF、连接重置或写响应失败。HTTP/2 有 RST_STREAM 这类取消流的信号,但收到信号也不等于已经把业务线程、数据库语句和更深一层的 RPC 都撤掉了。
所以排查“超时后还在跑”,第一件事不是盯 504 数量,而是用同一个 traceId 把这些时间点摆出来:
网关开始等待
-> 业务服务开始处理
-> 下游调用开始
-> 网关返回超时
-> 下游完成
-> 业务服务尝试写回响应,发现连接已断
最后一行经常被误解成“接口又执行了一遍”。实际上,它只是同一次执行的响应已经没人收了。
cancel(true) 也不是停止按钮
Java 里更容易踩的坑是:给 Future 调了 cancel(true),就以为任务已经消失。
对普通 Future,true 的意思通常是允许执行器尝试中断运行中的线程。前提有两个:执行器实现支持这件事,任务本身也得配合中断。CPU 循环不检查中断标记,照样会跑到底;某些 I/O 调用也未必能被线程中断立刻打断。
Future<Result> task = pool.submit(() -> {
while (hasMorePages()) {
if (Thread.currentThread().isInterrupted()) {
throw new InterruptedException("request cancelled");
}
loadAndProcessNextPage();
}
return Result.done();
});
try {
return task.get(300, TimeUnit.MILLISECONDS);
} catch (TimeoutException e) {
task.cancel(true);
throw e;
}
这里 cancel(true) 是提出请求,循环里的中断检查才是任务实际退出的地方。
CompletableFuture 更值得单独说。它的 cancel(boolean) 会把这个 Future 标记为取消并让依赖阶段异常完成,但参数 mayInterruptIfRunning 不会把底层执行线程当作控制手段。把真正的耗时操作包在它里面,再取消外层 Future,并不会自动撤销那次数据库更新或远程调用。
超时该传“剩余时间”,不是每跳重新倒计时
链路里每一层都配一个 500 毫秒超时,看起来很整齐,实际会把总时长拉长。
入口应该确定一个绝对 deadline。服务 A 调服务 B 前,只把剩余预算交给 B;B 调 C 时再扣掉自己已经花掉的时间。剩余时间归零就直接失败,不要再发一个注定赶不上的请求。
long deadlineNanos = receivedDeadlineNanos;
long remainingNanos = deadlineNanos - System.nanoTime();
if (remainingNanos <= 0) {
throw new TimeoutException("deadline exceeded before downstream call");
}
Duration childTimeout = Duration.ofNanos(remainingNanos);
return client.call(request, childTimeout);
gRPC 原生有 deadline 和传播机制。HTTP 链路没有统一约定时,可以在内部请求头传绝对 deadline 或剩余毫秒,但服务端、HTTP 客户端、数据库驱动和消息客户端都要吃这套语义。只把数字写进 header,不给具体调用设置子超时,等于没传。
有副作用的步骤,别指望取消替你回滚
库存扣减、发券、扣款、发消息这类操作,经常已经越过“能不能停”的边界。此时更可靠的设计是幂等键、状态机和补偿,而不是等客户端超时后祈祷它没发生。
一个常见的拆法是:先以业务幂等键写入可查询状态,再执行外部动作;重试时先查这把键对应的状态。这样即使调用方在中间超时,也不会因为重试把动作做两次。
日志和指标也该区分这几类状态。至少记录 deadline、哪一层超时、是否已请求取消、任务是否观察到取消、以及副作用走到了哪一步。排障时看到“取消已请求”不能当作“业务未执行”,只有后者才是业务结论。
浙公网安备 33010602011771号