一次 SSE 断流排查:PrintWriter 吞掉了我的 IOException

#Java #网络编程 #SSE #避坑指南

摘要 :本地测试好好的 SSE 流式接口,上线对账却发现客户端早就断开了,服务端还在对着空气输出,疯狂燃烧 LLM 的 token?本文记录了我在排查流式代理断流时的一场真实"抓虫"经历:从 PrintWriter 默默吞掉 IOException 和乱码的源码陷阱,到 TCP 内核缓冲区带来的网络延迟假象,最终靠换用 ServletOutputStream 并配合严密的幂等结算逻辑彻底封死计费漏洞。不讲空洞理论,带你直击断流案发现场,扒一扒 IO API 选择与防御性编程里的隐蔽大坑。

上个月做了个 SSE 流式代理,从上游 LLM 拉流,转手推给浏览器。自测通过,客户端的字一个个往外蹦,看着挺美。

上线第二天对账就出事了:有些请求 token 消耗对不上,用户明明关了页面,上游还在跑,结算系统里这笔钱没人认。排查大半天,最后落在一行代码上:

java 复制代码
PrintWriter writer = response.getWriter();

getWriter() 返回的这个 PrintWriter,写失败时不抛异常。而且它不只吞异常,下文再说。

一、先还原现场

当时的代码大概长这样:

java 复制代码
PrintWriter writer = response.getWriter();

while ((chunk = upstreamStream.next()) != null) {
    writer.write("data: " + toJson(chunk) + "\n\n");
    writer.flush();
}

writer.write("data: [DONE]\n\n");
writer.flush();

正常路径下没问题。客户端一断开,事情就变了:

  • writer.write() 不报错;
  • writer.flush() 也不报错;
  • 上游的流还在被消费,token 一直在烧;
  • finally 里那段"记录结算",因为没有异常触发,压根没执行。

服务端以为一切正常,实际早就在对着空气说话。

二、PrintWriter 为什么不抛异常

翻 java.io.PrintWriter 的源码,简化一下:

java 复制代码
public void write(String s, int off, int len) {
    try {
        synchronized (lock) {
            ensureOpen();
            out.write(s, off, len);
        }
    } catch (InterruptedIOException x) {
        Thread.currentThread().interrupt();
    } catch (IOException x) {
        trouble = true;
    }
}

public void flush() {
    try {
        synchronized (lock) {
            ensureOpen();
            out.flush();
        }
    } catch (IOException x) {
        trouble = true;
    }
}

public boolean checkError() {
    // 先 flush,再返回 trouble
    return trouble;
}

两个细节。

第一,write 和 flush 的签名上没有 throws IOException。PrintWriter 是 PrintStream 的字符流兄弟,设计目标是"便利的文本输出":往日志写一行、导一份报告,写坏了不至于让程序崩掉。所以它把 IOException 转成内部标志位,想知道出没出事,得自己调 checkError()。

第二,注意 InterruptedIOException 那一支。它不设 trouble,而是恢复线程的中断标志。也就是说它并没有把所有 IO 异常一视同仁地吞,对中断语义有特殊处理。

这套设计放在打日志的场景下没毛病。塞进 HTTP 响应链路,语义就变了:写响应失败不是"日志丢了",而是"用户数据没送到,但你还以为送到了"。

三、它还吞字符

如果你以为 PrintWriter 只吞异常,那还是小看它了。看 JavaDoc 的另一段:

This class always replaces malformed and unmappable character sequences with the charset's default replacement string.

它编码不出来的字符,也不报错,直接换成该字符集的默认替换串 (多数字符集是 ?)。

SSE 场景里这个坑很隐蔽:你返回的是模型生成的文本,中文、日文、emoji、数学符号都可能出现。字符编码没设对时:

java 复制代码
PrintWriter writer = response.getWriter();   // 编码在这一步固化
writer.write("模型回复:你好 🙂");

Servlet 规范里 getWriter() 默认 ISO-8859-1,中文和 emoji 都变 ?。不过别拿这条去对现场:Tomcat、Spring Boot 经常用自己的默认值覆盖它(Spring Boot 的 server.servlet.encoding 默认就是 UTF-8),有人会说"我这儿明明是好的"------那只是运气好。不管默认值是什么,编码显式设。而且 setContentType / setCharacterEncoding 必须在 getWriter() 之前调,之后改对这个 writer 已经不生效了。

顺带说一句,换成 getBytes 也一样静默替换,不会报错。别指望换个 API 就能发现问题,落点还是那句:编码显式设,别赌容器的默认值。

PrintWriter 有两宗静默降级,病根是同一个:

  • IO 异常转成 trouble = true,事后要主动问;
  • 编不出的字符替换成默认替换串,事后基本发现不了,除非对账。

前者丢的是"这条消息没送到",后者丢的是"消息送到了但内容是残的"。

四、getWriter 和 getOutputStream 差在哪

方法 返回类型 写失败时 编码失败时
getWriter() PrintWriter 静默吞掉,设 trouble 静默替换成默认替换串
getOutputStream() ServletOutputStream 抛 IOException 字节由你自己的 getBytes 决定

规范推荐二进制用 getOutputStream(),文本用 getWriter()。但没说出口的是:需要感知写入结果的场景,getWriter() 不够用。这个信息藏在 PrintWriter 的 JavaDoc 里:

Methods in this class never throw I/O exceptions... The client may inquire as to whether any errors have occurred by invoking checkError().

它不会主动告诉你,你得自己去问。

顺带一提,getWriter() 和 getOutputStream() 是互斥的,调了其中一个再调另一个会抛 IllegalStateException。常见坑,不展开。

还有个容易看走眼的地方:PrintWriter 继承自 Writer,而 Writer 的签名上写着 throws IOException。看类型声明你会以为它会抛,看实现才知道全 catch 了。IO 链路上,"看起来会抛、实际不抛"的 API 都是定时炸弹。

五、换成 getOutputStream() 只是第一步

直觉上答案是"换"。换是对的,但换了不等于问题解决了。

坑一:客户端断开的异常,不是同一种

ServletOutputStream 会抛异常,但具体类型跟容器有关:

  • Tomcat 抛 org.apache.catalina.connector.ClientAbortException;
  • Jetty 抛 org.eclipse.jetty.io.EofException;
  • Undertow 抛一个 message 是 UT000128: Remote peer closed connection... 的 IOException。

catch (IOException e) 能兜住,但"这是客户端断开还是别的 IO 问题"各家写法不同。如果结算逻辑挂在 catch 上,至少得把这几家认一遍:

java 复制代码
private boolean isClientDisconnect(Throwable e) {
    Throwable t = e;
    while (t != null) {
        String className = t.getClass().getName();
        if (className.equals("org.apache.catalina.connector.ClientAbortException")
                || className.equals("org.eclipse.jetty.io.EofException")) {
            return true;
        }
        String msg = t.getMessage();
        if (msg != null && msg.contains("UT000128")) {
            return true;
        }
        t = t.getCause();
    }
    return false;
}

得说清楚,这是权宜之计,不是标准答案 。按类名字符串比对、在消息里搜 UT000128,换个 Undertow 版本改个错误码就碎了。更稳的判据是组合信号:response.isCommitted()、已写入字节数、上游 idle 超时。异常分类只用来决定日志和结算 reason 的标签,别拿它做主控流程判断。

坑二:checkError() 靠不住

继续用 PrintWriter、写完调 checkError(),这条路也不好走。先摆平一个流传很广的说法:它内部确实会先 flush 一次,但 SSE 本来每条消息 flush 一次,性能不是问题。

问题在错误的传递方式。异常是发生时立刻打断控制流,你在写的地方就能接住;trouble 是个事后查询的布尔值,你得记得在每个可能失败的点之后问一遍。流式转发是一长串 write/flush,漏问一次,那次失败就永远留在标志位里。而且它只告诉你"出过事",不告诉你是哪一次写的、写到哪了。等你想关上游、补结算,线索已经没了。

说白了,这是把异常驱动的控制流,降级成靠自觉的轮询。IO 边界上不该这么干。

六、客户端断了,你不会立刻知道

这一点我当初也没想到,是排查时踩到的。

flush() 返回成功不等于客户端收到了。TCP 层的内核缓冲区会先接纳数据:客户端进程被 kill、NAT 超时、网线拔掉,服务端可能继续写好几秒都无感。真正触发 Broken pipe 或 Connection reset,要等 RST 包到达,或者缓冲区写满之后的某次 flush。对 SSE 这种每条消息都不大的场景,延迟可能是几秒甚至几十秒。

指望 catch (IOException) 做结算触发点不可靠,生产上还得叠一层:

  • 上游 idle 超时:多久没新 token 就判定结束;
  • 定期心跳注释 (: keep-alive\n\n):逼着 TCP 把异常暴露出来,空闲期也不会被中间设备掐断;
  • X-Accel-Buffering: no :让 nginx 这类反代别缓冲,否则异常暴露得更晚。这头是 nginx 专有的,Caddy、Envoy 各有各的开关,必要时直接关 proxy_buffering;
  • 写入侧总时长上限:防止僵尸连接长期占着线程。

谁先失败谁负责终止,异常分类只补一个原因标签。

七、正确的姿势

SSE、文件下载、流式转发,都用 getOutputStream()。不是规范要求你这么写,是结算逻辑需要在 flush() 失败的那一刻拿到信号,好去关上游、好去记账。

java 复制代码
response.setContentType("text/event-stream;charset=UTF-8");
response.setCharacterEncoding("UTF-8");
response.setHeader("Cache-Control", "no-cache");
response.setHeader("X-Accel-Buffering", "no");
// 不写 Connection: keep-alive,hop-by-hop 头,HTTP/2 下设了也没用

ServletOutputStream out = response.getOutputStream();

String reason = "aborted";
try {
    while ((chunk = upstream.next()) != null) {
        out.write(("data: " + toJson(chunk) + "\n\n").getBytes(StandardCharsets.UTF_8));
        out.flush();
    }
    out.write("data: [DONE]\n\n".getBytes(StandardCharsets.UTF_8));
    out.flush();
    reason = "finished";
} catch (IOException e) {
    boolean clientGone = isClientDisconnect(e);
    reason = clientGone ? "client_disconnected" : "io_error";
    if (!clientGone) {
        // 客户端还活着才补 error 事件,断了还写只会再抛一次,把原始异常顶掉
        try {
            out.write("event: error\ndata: {}\n\n".getBytes(StandardCharsets.UTF_8));
            out.flush();
        } catch (IOException ignore) {
        }
    }
    throw e;
} finally {
    try {
        upstream.close();
    } catch (Exception closeEx) {
        log.warn("close upstream failed", closeEx);
    } finally {
        settleOnce(requestId, reason);
    }
}

几个点说一下。

真正决定字节的是 getBytes(StandardCharsets.UTF_8),跟上面 setCharacterEncoding 没关系。用 getOutputStream() 时响应的 charset 只是写进 Content-Type 的元数据,容器不替你转码,设上是为了客户端解析不跑偏。

结算只在 finally 里调一次,reason 用前置变量定下来。catch 里写一遍、finally 里再兜一遍,看着稳妥,实际是把重复结算的风险引进门。真实事故里最贵的不是"没记账",是记了两笔。settleOnce 自己也要幂等:超时回调、容器重试、别的线程的 cancel 都可能跟这条路径撞车,谁先到谁生效,后来的空操作。

那个嵌套 try/finally 别嫌啰嗦。upstream.close() 自己会抛异常,把 settleOnce 直接写在它后面,close 一炸结算又没了------那就是本文开头那个 bug 的缩小版。嵌套保证无论 close 成不成,结算一定执行。

代码里没写 response.isCommitted() 那套分支:SSE 场景它几乎永远是 true,消息早就 flush 过头了,真按它分流能走的也只有"补 error 事件"那条,已经在上面了。

try-with-resources 在这儿不顺手。先纠正个误解:close 异常不会丢,try 块正常结束时它直接抛出来,try 块已抛异常时挂在主异常的 getSuppressed() 上。真正的问题是控制流------你分不清"业务写挂了"还是"只是 close 挂了",也没法在 close 之后、异常外抛之前做结算。流式转发建议显式写 try/catch/finally。

用 Spring 的话,SseEmitter 的 onError / onTimeout / onCompletion 三个回调,正好对应你需要感知失败的三个点,挂上去,别只写 send。底层那些坑还在,只是你不直接摸到。另外阻塞式 Servlet 跑长连接会一直占着容器线程池,并发几百路就能把 Tomcat 工作线程吃干,量大时考虑异步 Servlet 或 WebFlux。

八、什么时候 PrintWriter 也无所谓

PrintWriter 本身不是坏东西。往控制台打调试日志、写一份"尽力而为"的报告文件、任何"写不上就算了,不影响主流程"的地方,用它都合适:

java 复制代码
PrintWriter log = new PrintWriter(new FileWriter("debug.log"));
log.println("step 1 completed");
log.flush();
// 不需要 catch,也不需要 checkError

要警惕的是"写入失败会改变主流程决策"的场景:流式代理、网关转发、计费相关的写操作。判断标准一句话:如果这次写入失败会导致上游资源泄漏、状态不一致、或者该记的账没记上,那你就必须能感知失败。

写在最后

PrintWriter 吞两样东西:IO 异常,和编码不出来的字符。它本是为"便利的文本输出"设计的,不是为 HTTP 响应设计的,IO 边界上优先选会抛异常的 API。还有,别以为换了 getOutputStream() 就万事大吉,TCP 有自己的节奏,心跳和超时一样不能省。

最后交代那笔账。根因定位后,我们拿 nginx 的 request_time 和上游调用记录做了对账,把"客户端已断开但上游还在跑"的那批请求统一补记成 client_disconnected,人工认领掉。数额不大,但教训很清楚:那笔钱不是被人贪了,是没人知道它该归谁------因为本该报信的那个 API,把消息咽回去了。


参考

相关推荐
1m555555551 小时前
RAG 工程实战(四):明明召回了正确知识,为什么最终 Top-K 还是漏了?
后端
我是人✓1 小时前
Java核心语法:作用域、循环与大数值处理
java·开发语言·后端
newerp1 小时前
GC Pacer 调优算法:GOGC、GOMEMLIMIT 与容器 OOM 防御
后端·程序员·go
邱杰4701 小时前
数智人事系统(二):一条命令起齐四个中间件
后端
newerp1 小时前
逃逸分析、栈分配与 sync.Pool 对象池
后端·程序员·go
upala1 小时前
【仓颉语言入门 · 第二篇 第 3 课】
后端
松就是我902982 小时前
3.7 万 Star 的「第二大脑」Khoj,为什么关掉了云服务
后端
夜之眷属2 小时前
PriorityQueue:for-each 遍历和 poll 取出,顺序一样吗?
java·服务器·数据结构·后端·算法