#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,把消息咽回去了。
参考