接口都超时了,下游为什么还在跑:把超时、取消和连接断开放一起看

接口都超时了,下游为什么还在跑:把超时、取消和连接断开放一起看

请求超时、取消信号与业务完成的时间线

面试里常有人问:网关已经给了 504,为什么订单服务的日志还在继续,数据库也真的写进去了?

把“超时”和“停止执行”画上等号,基本就答偏了。一个请求链路里至少有三件不同的事:

  • 调用方不再等结果
  • 某一层发出了取消信号
  • 正在跑的代码和下游资源真的停下来

它们可以发生在不同时间点,后两件甚至可能根本不发生。

假设接口总预算是 500 毫秒。第 480 毫秒时,网关放弃等待并返回超时;业务线程可能刚把一条更新提交给数据库。客户端看到的是失败,服务端做的却可能已经生效。这不是“网关没超时”,而是超时只结束了等待关系。

504 到底中断了谁

网关超时通常只说明:网关在自己的等待窗口内没拿到上游响应。

如果网关和应用之间的连接断了,应用也不必然立刻知道。HTTP/1.1 里,对端关闭连接是一次传输层事件。应用可能直到下一次读写,才感知到 EOF、连接重置或写响应失败。HTTP/2 有 RST_STREAM 这类取消流的信号,但收到信号也不等于已经把业务线程、数据库语句和更深一层的 RPC 都撤掉了。

所以排查“超时后还在跑”,第一件事不是盯 504 数量,而是用同一个 traceId 把这些时间点摆出来:

网关开始等待
  -> 业务服务开始处理
  -> 下游调用开始
  -> 网关返回超时
  -> 下游完成
  -> 业务服务尝试写回响应,发现连接已断

最后一行经常被误解成“接口又执行了一遍”。实际上,它只是同一次执行的响应已经没人收了。

cancel(true) 也不是停止按钮

Java 里更容易踩的坑是:给 Future 调了 cancel(true),就以为任务已经消失。

对普通 Futuretrue 的意思通常是允许执行器尝试中断运行中的线程。前提有两个:执行器实现支持这件事,任务本身也得配合中断。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、哪一层超时、是否已请求取消、任务是否观察到取消、以及副作用走到了哪一步。排障时看到“取消已请求”不能当作“业务未执行”,只有后者才是业务结论。

posted on 2026-08-02 16:57  Karuha_m  阅读(2)  评论(0)    收藏  举报