线程中断引发的Tomcat连接异常:一次JSON响应截断的深度排查

202 阅读11分钟

线程中断引发的Tomcat连接异常:一次JSON响应截断的深度排查

前言

在一次需求的联调阶段,日志显示正常给前端返回JSON格式的数据,但是前端收到的JSON被截断了,仅收到部分数据。

本文记录了完整的思路和排查过程,深入Tomcat源码,解释线程中断对Tomcat的影响。

最后总结出一些实践启示和经验教训,为类似问题提供分析参考。

问题描述

调用方(前端 / Apifox 等工具)在接收接口响应时,仅获取到部分 JSON 数据,响应体出现JSON截断现象,后续数据结构未能完整返回。起初怀疑是接口性能优化中引入多线程后,几经修改,直至通过代码回滚至优化前版本,接口响应才恢复正常。由此展开根因排查。

代码排查过程

日志验证
  • 首先在代码的最外层以及内部添加了日志。经过观察发现,代码执行情况正常,controller 能够正常返回对象数据,而且从日志打印内容中也能看到正常的 JSON 。这表明在服务端业务代码层面,数据的处理和返回是没有问题的。
调用工具验证
  • 由于异常是前端报出来的,使用 Apifox 调用时也出现异常,为了进一步排查问题,使用AI生成了一份curl代码用于测试,代码详见附录。
  • 通过 curl 测试发现,总共要接收 24568 个字节的数据,但实际只收到了 8069 字节,还有 16499 字节数据待接收时,http 连接就断开了,这就导致只收到了部分 JSON 串:

Tomcat 配置排查
  • 上述结果定位了问题发生在 “服务端数据传输阶段”,考虑到 spring 是内嵌 tomcat 的,而并没有修改 tomcat 或者其他 http 相关的配置,都是一些业务逻辑方面的修改。
问题代码定位
  • 仔细检查代码后,发现了如下几行代码:
try {
    future.get();
} catch (InterruptedException | ExecutionException e) {
    Thread.currentThread().interrupt();
}
  • 其中的 Thread.currentThread().interrupt(); 调用该方法会将当前线程的中断状态设置为 true,并且执行 tomcat 逻辑和执行业务逻辑是在同一个线程内完成的。这就可能导致 tomcat 在传输数据过程中因为线程中断状态而提前断开连接。
问题解决
  • 把代码里 Thread.currentThread().interrupt(); 这一行去掉后,再次调用接口,数据能够完整传输,问题得到解决。

现象引发的技术疑问

  • 线程中断标记与 HTTP 连接关闭的关联机制

在 Tomcat 处理请求的线程中设置中断标记,为何会触发 HTTP 连接提前关闭?这涉及到 Tomcat 线程模型中中断信号的传递路径,是否存在某个关键组件将中断状态解释为终止连接的指令?

  • 异常处理与数据传输的矛盾现象

设置中断标记是为了向上层传递异常信息,既然都已经识别到异常了,Tomcat为什么还会传输部分数据?是输出采用类似do {} while ()的循环结构导致的吗?

  • 固定截断字节数(8069)的成因分析

每次请求均稳定在 8069 字节处截断,这个数值是否对应 Tomcat 或底层网络对象的特定缓冲区大小、数据帧限制或分块传输阈值?例如: 是否为 Tomcat 默认输出缓冲区大小?

源码排查过程

首先把问题的答案给出,有一个大致轮廓上的印象,然后我们再一起看看是以什么样的思路。

问题 1:定位触发 HTTP 连接关闭的关键方法
  • 为了确定是哪个方法导致 HTTP 连接关闭,我们需要追踪 Tomcat 中 Socket 连接的释放逻辑。根据AI的回答找到NioSocketWrapper类的父类org.apache.tomcat.util.net.SocketWrapperBase#close,由close方法的代码推测是在NioEndpoint.NioSocketWrapper#doClose方法里关闭连接。
  • 从doClose()方法出发,通过断点逐步回溯调用链,发现连接关闭的直接原因是NioEndpoint.SocketProcessor#doRun方法中的SocketState = CLOSED状态。doRun方法是处理请求的核心逻辑,握手成功后,调用 getHandler().process() 处理请求,并根据处理结果(SocketState)决定是否关闭连接。

  • 在确认 Controller 正常执行且返回数据后,需重点分析 Tomcat 如何处理响应数据传输。通过在 Controller 入口设置断点并跟踪调用链,定位到以下关键处理环节: 请求处理主循环:org.apache.coyote.AbstractProcessorLight#process ;该方法作为连接事件驱动的核心枢纽,通过状态机模式处理 Socket 生命周期:

在首次循环中,status 为 OPEN_READ,触发以下关键操作:从 Socket 读取 HTTP 请求头和体,调用匹配的 Controller 方法,Controller 返回数据被封装到HttpServletResponse对象。在业务逻辑执行完成后,进入第二次循环,触发dispatch方法。

  • 定位到 org.apache.coyote.AbstractProcessor#dispatch

该方法作为网络层分发器,负责识别并处理各类 Socket 事件,同时协调连接状态的转换。

通过断点调试发现,当 asyncDispatch 方法返回 false 时,dispatch 方法会直接返回 SocketState.CLOSED,这将触发后续的连接关闭流程。因此,需要重点分析 asyncDispatch 的执行逻辑。

  • 进入org.apache.catalina.connector.CoyoteAdapter#asyncDispatch 方法。

在asyncDispatch方法返回false的地方打上断点,发现是执行res.action(ActionCode.IS_ERROR, error)方法时导致

这里面判断了org.apache.coyote.AbstractProcessor#errorState这个属性值的状态是不是错误的,errorState是个私有变量,于是找到这个变量的set方法org.apache.coyote.AbstractProcessor#setErrorState,将setErrorState这个方法打上断点,就可以知道在什么时候设置的错误状态了。

再根据setErrorState方法的调用链,找到了doWrite方法,发现因为在往客户端写入数据的时候发生了异常。

通过堆栈定位到根因:org.apache.tomcat.util.net.NioChannel#checkInterruptStatus,里面的Thread.interrupted()方法会检测线程中断状态。

小结:当 Tomcat 通过 NIO 通道向客户端传输响应时,会调用 checkInterruptStatus() 方法检查线程状态并抛出异常,该异常被上层AbstractProcessor捕获并设置SocketState,最终回到NioEndpoint.SocketProcessor#doRun方法,导致连接关闭。

问题 2:传输部分数据的机制解析
  • 既然在检测中断状态时,已经抛出了异常,为什么还会传递部分数据呢?根据堆栈找到调用checkInterruptStatus的方法:org.apache.tomcat.util.net.NioChannel#write

该方法是Tomcat中处理NIO写入操作的核心方法,在NioChannel类里面封装了Java nio的SocketChannel sc对象,用于将数据从 ByteBuffer 写入底层网络通道(SocketChannel),nio的基础知识就不过多赘述了,下图中的Buffer对应ByteBuffer,Channel对应SocketChannel,Thread可以简单理解为Tomcat线程,程序就是客户端,Selector即nio的多路复用组件。

  • 既然客户端可以收到部分数据,那么write方法肯定还是被执行了,于是在write方法打上断点,重新执行发现:第一次执行write方法会因为中断发生异常,第二次执行时成功执行结束。

根据第二次执行的堆栈,发现调用发生在适配器CoyoteAdapter#asyncDispatch 方法里面,这个方法在执行第一次write后捕获了异常,在下面这段代码里执行了异常处理逻辑。

  • 异常处理会调用flush方法清空缓冲区,于是出现了第二次执行了write方法。而第一次执行时,Thread.interrupted()会返回true,并清除中断标志,所以第二次调用时Thread.interrupted()会返回false,不再触发异常,客户端收到部分数据。
问题 3:响应数据字节数差异
  • 在NioChannel#write方法中观察到入参ByteBuffer的初始状态为pos=0, lim=8192,表明 Tomcat 默认使用8KB(8192 字节)的输出缓冲区进行数据传输。

尽管缓冲区大小为 8192 字节,但客户端仅收到8069 字节。

  • 联想到HTTP作为一种应用层协议,客户端一定是先读取协议部分内容,才能知道这次请求要读取多少字节等信息。而HTTP响应的完整结构包含: 状态行(如HTTP/1.1 200 OK)、响应头(如Content-Type: application/json)、响应体(业务数据 JSON)

继续修改已有curl代码,打印完整响应头(添加-w参数),发现:

响应头的总字节数为123 字节(示例值,实际因配置而异),所以响应体首次写入字节数为:8192 - 123 = 8069字节,这一计算完美匹配客户端接收的 8069 字节。

  • 这时,细心的朋友们就会发现一个问题,你不是说HTTP响应分为三部分吗,上面截图里只展示了:Headers和Body的大小加一起等于8192字节,那状态行呢,在什么时候传输的?

继续把客户端代码和问题描述好抛给AI,被告知状态行可能在代码认为的响应头里面,于是再次修改客户端代码,打印出响应头全部内容

可以看到客户端率先读取第一行,就可以得到响应行了,然后再逐行得到响应头,直到遇到空行表示响应头结束,后面该读取响应体了。

总结与建议

问题的根因

这个问题的根因是业务代码中异常处理逻辑的设计缺陷,在捕获中断异常时,仅设置中断标记(Thread.interrupt()),但未对中断状态进行响应。

并且在捕获其他异常(ExecutionException)时也执行了设置中断的操作,将中断异常(InterruptedException)与其他异常的处理混淆。

应使用Fail-Fast(快速失败)或者Fail-Safe(安全容错)方式处理,比如:

// 明确处理中断状态,避免传递给容器线程
try {
    future.get();
} catch (InterruptedException e) {
    // 恢复中断状态
    Thread.currentThread().interrupt(); 
    // 抛出业务异常
    throw new RuntimeException("任务被中断", e);
} catch (ExecutionException e) {
    throw new RuntimeException("任务执行失败", e.getCause());
}

Tomcat的代码逻辑本身也是有问题的,发现线程中断后,不应再把ByteBuffer缓冲区里面的数据发送给客户端,所以我把spring-boot-starter-parent版本从2.1.11.RELEASE升级到3.3.4后,再执行得到:

服务端日志
客户端日志

可以看到DefaultHandlerExceptionResolver 这个Spring MVC 框架中默认的异常处理解析器打印了一条日志,而客户端得到一个空的响应,可见在高版本的spring代码里修复了这个 JSON 可能被截断的问题

建议

(1)加强异常处理设计:在编写代码时,严格遵循异常处理机制的设计原则,采用合理的异常处理策略,避免类似问题的再次发生。

(2)充分利用 AI 辅助工具:在技术排查和开发过程中,充分发挥 AI 辅助工具的作用,如生成代码、源码方法用途等,提高问题定位和解决的效率。

(3)强化代码设计规范:注重代码的命名规范和结构设计,提高代码的可读性和可维护性,降低后续排查和维护的难度。

附录

  1. 参考文章
  1. 客户端使用curl调用代码
import java.io.BufferedReader;
import java.io.IOException;
import java.io.InputStreamReader;

public class Client {
    public static void main(String[] args) {
        try {
            ProcessBuilder processBuilder = new ProcessBuilder(
                    "curl",
                    "--location",
                    "--request", "GET",
                    "127.0.0.1:8080/query",
                    "--connect-timeout", "30",
                    "--max-time", "90",
                    "-i",                   // 添加 -i 参数以包含响应头
                    "-w",
                    "\nResponse Headers Size: %{size_header} bytes\nResponse Body Size: %{size_download} bytes\n"
            );

            Process process = processBuilder.start();

            // 读取标准输出(包含响应头和响应体)
            BufferedReader reader = new BufferedReader(new InputStreamReader(process.getInputStream()));
            StringBuilder response = new StringBuilder();
            String line;

            // 标记是否已读取完响应头(遇到空行)
            boolean isHeader = true;
            StringBuilder headers = new StringBuilder();
            StringBuilder body = new StringBuilder();

            while ((line = reader.readLine()) != null) {
                response.append(line).append("\n");

                if (isHeader) {
                    headers.append(line).append("\n");
                    // 空行表示响应头结束
                    if (line.isEmpty()) {
                        isHeader = false;
                    }
                } else {
                    body.append(line).append("\n");
                }
            }

            // 获取错误输出流
            BufferedReader errorReader = new BufferedReader(new InputStreamReader(process.getErrorStream()));
            StringBuilder errorResponse = new StringBuilder();
            while ((line = errorReader.readLine()) != null) {
                errorResponse.append(line).append("\n");
            }

            int exitCode = process.waitFor();

            System.out.println("Response Headers:");
            System.out.println(headers.toString());
            System.out.println("Response Body:");
            System.out.println(body.toString());
            System.out.println("Error Response:");
            System.out.println(errorResponse.toString());
            System.out.println("Exit Code: " + exitCode);

        } catch (IOException | InterruptedException e) {
            e.printStackTrace();
        }
    }
}

3. 服务端模拟代码

@RestController
public class Server {

    @GetMapping(value = "/query")
    String query() {
        Thread.currentThread().interrupt();
        if (Thread.currentThread().isInterrupted()) {
            System.out.println("主线程的中断状态被设置为 true");
        }

        // 这里s的长度加上响应头的长度只要大于8192字节就可以模拟
        return "1".repeat(10000);
    }
}

4. 流程关键节点的断点方法路径

描述方法
tomcat主入口org.apache.tomcat.util.net.NioEndpoint.SocketProcessor#doRun
Tomcat处理网络连接和请求的核心方法org.apache.coyote.AbstractProtocol.ConnectionHandler#process
里面包含读时调用service方法,写时调用dispatch方法org.apache.coyote.AbstractProcessorLight#process
本篇文章的核心方法,里面有onWritePossible作为写回给客户端的代码入口,还有异常后置处理代码入口,返回false会导致连接关闭org.apache.catalina.connector.CoyoteAdapter#asyncDispatch
写缓冲区数据到通道的方法org.apache.tomcat.util.net.NioChannel#write(java.nio.ByteBuffer)
校验中断状态方法org.apache.tomcat.util.net.NioChannel#checkInterruptStatus
设置异常状态方法org.apache.coyote.AbstractProcessor#setErrorState
关闭http连接入口方法org.apache.tomcat.util.net.SocketWrapperBase#close
关闭http连接方法org.apache.tomcat.util.net.NioEndpoint.NioSocketWrapper#doClose
业务逻辑controller方法自定义