1. 什么是“分块输出”?它解决的到底是什么问题?
在日常开发中,你有没有遇到过这样的场景:用户点击一个“生成报表”按钮,页面就卡住不动,浏览器标签页上转圈转了七八秒,最后才突然弹出完整结果?或者打开一个商品详情页,整个页面白屏五秒,然后“唰”一下全出来——用户根本不知道后台在干啥,只觉得“这网站好慢”“是不是挂了”。这种体验背后,本质不是服务器算得慢,而是 用户感知到的响应延迟太高 。而分块输出(Chunked Transfer Encoding)要解决的,正是这个“感知延迟”问题。
它不是加速后端计算,而是优化前端呈现节奏。核心逻辑非常朴素: 把一个大响应拆成几小段,边算边发,让用户眼睛先有事干,心里不慌 。就像餐厅上菜,总不能等所有菜烧完才一起端上来——先上个凉菜、再上热炒、最后来个汤,顾客一边吃一边等,全程不冷场。Web 页面也一样:头部导航栏、Logo、加载提示这些“轻量但关键”的内容,完全可以在数据库查询、远程调用、复杂渲染还没完成时,就立刻推送到浏览器,让页面“活起来”。
很多人第一反应是“那我用 AJAX 啊”,这确实是个方案,但它引入了额外复杂度:你要写 JS 监听事件、手动创建 XMLHttpRequest 或 fetch、处理 loading 状态、拼接 DOM、还要单独维护一套后端接口。更麻烦的是,它把一次请求硬生生拆成两次甚至多次——首屏 HTML 请求 + 后续数据请求,增加了 TCP 连接开销、HTTP 头部冗余、服务端并发压力,还可能触发浏览器并发连接数限制。而分块输出是原生 HTTP 协议能力,零 JS、零新接口、零额外请求,只要后端代码里加一行
flush()
,就能让同一个 HTTP 响应“呼吸起来”。
它特别适合这几类场景:需要实时展示处理进度的后台任务页(如日志流、批量导入状态)、首屏依赖慢查询但骨架可快速渲染的 CMS 页面、嵌入式设备 Web 控制台(带宽窄、CPU 弱)、甚至某些 SEO 场景下希望搜索引擎更快抓取到页面主体结构。这不是炫技,而是对“用户等待心理阈值”的精准拿捏——研究表明,用户在 1 秒内获得反馈,会觉得系统“即时响应”;超过 3 秒,注意力就开始流失;超过 10 秒,大概率直接关闭页面。分块输出,就是把那个“1 秒反馈”从不可能变成可能。
2. 分块编码原理与底层机制深度解析
要真正用好
flush
,绝不能停留在“加一行代码就完事”的层面。你得明白它背后 HTTP 协议怎么工作、服务器如何配合、浏览器又如何消化。否则,一不小心就会掉进缓冲区陷阱、编码冲突、或浏览器兼容性坑里。
2.1 HTTP/1.1 分块传输编码(Chunked Transfer Encoding)的本质
分块编码是 HTTP/1.1 协议定义的一种
消息体传输机制
,它的核心价值在于:
当服务器无法预先知道响应体总长度时,仍能可靠地将数据流式发送给客户端
。为什么无法预知长度?因为很多动态页面是边生成边输出的——比如读一行数据库、写一段 HTML、再读一行、再写一段……你根本没法在响应头里填一个准确的
Content-Length
。传统做法是等所有内容生成完毕,再一次性发出去,这就导致了前面说的“白屏等待”。
分块编码的解决方案很巧妙:它彻底抛弃
Content-Length
,改用一种“自描述”的分段格式。每个数据块(chunk)都由三部分组成:
- 块大小行 :一个十六进制数字,表示紧接着的块数据字节数(不含换行符),后面紧跟 CRLF(回车换行);
- 块数据 :实际的响应内容,长度严格等于块大小行声明的值,后面紧跟 CRLF;
-
结束标记
:当所有块发送完毕,服务器发送一个大小为 0 的块(即
0\r\n\r\n),表示整个响应体终结。
举个真实例子,假设我们想发送字符串
"Hello"
和
"World"
两段,中间 flush 一次:
5\r\n
Hello\r\n
5\r\n
World\r\n
0\r\n
\r\n
浏览器收到后,会逐行解析:看到
5\r\n
,就知道接下来 5 字节是
"Hello"
;再看到
5\r\n
,就知道接下来 5 字节是
"World"
;最后看到
0\r\n\r\n
,就知道结束了。整个过程不需要
Content-Length
,服务器可以随时决定“现在就发这一段”。
提示:
Transfer-Encoding: chunked这个响应头,就是告诉浏览器:“别找Content-Length了,按分块格式来解析”。一旦设置了这个头,Content-Length就必须被移除,否则 HTTP 协议会报错。现代 Web 服务器(Tomcat、Nginx、Apache)和主流浏览器(Chrome、Firefox、Safari、Edge)对 HTTP/1.1 及其分块编码支持已非常成熟,基本无需担心兼容性。
2.2 服务器端的缓冲区链路:从
out
到 TCP socket 的七层穿透
理解
flush()
的作用,必须看清它在整个输出链路上的位置。以 Java Servlet(JSP 是其简化语法)为例,数据流向是这样的:
JSP 脚本 → JspWriter (out) → ServletOutputStream → Response Buffer → Container Buffer (e.g., Tomcat's Coyote) → OS Socket Buffer → 网络
-
JspWriter (
out) :这是最上层的字符缓冲区,提供print(),write(),flush()方法。它默认是 带缓冲的 (buffer size 通常 8KB),意味着你调用out.print("xxx"),数据先存进这个内存 buffer,不会立刻往下走。 -
out.flush()的作用 :强制将JspWriter缓冲区中所有已写入但未发送的数据, 清空并推送到下一层ServletOutputStream。但它 不保证 这些数据立刻发到网络,只是“交棒”给下一级。 -
容器级缓冲(如 Tomcat)
:
ServletOutputStream后面还有 Tomcat 自己的响应缓冲区(ResponseBuffer)。Tomcat 默认会启用sendfile优化或内部 buffer,如果它发现当前 chunk 数据量太小(比如只有几十字节),可能会继续攒着,等凑够一定量(如 2KB)或等到整个响应结束,才真正调用socket.write()。这就是为什么有时你flush()了,浏览器还是没立刻看到——数据卡在 Tomcat 的 buffer 里。 -
终极控制权在容器配置
:要确保
flush()真正“立竿见影”,必须让容器禁用其自身缓冲。在 Tomcat 中,你需要在web.xml里设置<response-buffering>none</response-buffering>,或在代码中调用response.setBufferSize(0)(注意:设为 0 表示无缓冲,设为负数表示使用容器默认值)。否则,out.flush()只是清空了 JSP 层 buffer,数据还在 Tomcat 手里“犹豫”。
注意:PHP 的
ob_flush()和flush()组合、Node.js 的res.write()+res.flush()(需res.socket.setNoDelay(true))、Python Flask 的stream_with_context,底层逻辑都类似,都是在不同层级的缓冲区上做“清空”操作。关键永远是: 确认你的 flush 操作,是否穿透到了最终的 socket 层 。
2.3 浏览器的渐进式渲染机制:为什么“分块”能立刻显示?
浏览器拿到分块响应后,并不是傻等所有块收完才开始干活。它的渲染引擎(如 Blink、WebKit)采用 流式解析(Streaming Parsing) 策略:
-
一旦收到第一个非空 chunk(哪怕只有
<html><head><title>...几十个字节),HTML 解析器就立刻启动,构建 DOM 树; -
遇到
<img src="...">标签,即使后续 HTML 还没收到,图片下载请求也会 立即发起 (这就是原文演示中 cnblogs logo 先加载的原因); -
CSS 和 JavaScript 的加载规则稍复杂:外部 CSS 会阻塞渲染,但内联 CSS 不会;外部 JS 默认阻塞解析,但加上
async或defer就不会。所以,为了最大化分块效果, 关键的首屏样式最好内联,JS 加载逻辑尽量 defer 。
这意味着,分块输出的价值不仅在于“让用户看到文字”,更在于 提前触发资源加载流水线 。一个典型的瀑布图对比是:非分块模式下,所有资源(CSS、JS、图片)的请求时间点都挤在响应体接收完毕之后;而分块模式下,头部图片、Logo、基础 CSS 的请求,可能在响应开始后的 100ms 内就已发出。这直接缩短了“首字节时间(TTFB)”到“最大内容绘制(LCP)”的整体耗时。
3. 实战:从零搭建一个可靠的分块输出页面
光讲原理不够,我们来动手做一个生产环境可用的、带错误防护的分块输出示例。目标:一个仪表盘页面,顶部是静态 Header(含 Logo 和导航),中间是实时更新的服务器状态(需调用系统命令,耗时约 2 秒),底部是静态 Footer。要求 Header 在 100ms 内渲染并加载 Logo,状态区域在 2 秒后动态插入。
3.1 技术选型与环境准备
我们选择
Spring Boot 3.x + Thymeleaf 模板引擎
,因为它代表了当前 Java Web 主流,且 Thymeleaf 原生支持流式渲染(
@EnableWebMvc
下的
StreamingResponseBody
)。环境要求:
- JDK 17+(Spring Boot 3.x 最低要求)
- Spring Boot Starter Web, Thymeleaf
-
一个能执行
uptime命令的 Linux 服务器(用于模拟耗时操作)
为什么不用 JSP?因为 JSP 已是历史技术,现代项目几乎不用。但原理完全相通:JSP 的
out.flush()
对应 Spring 的
response.getOutputStream().flush()
,Thymeleaf 的
th:fragment
流式渲染对应 JSP 的
<% %>
脚本块。我们用现代方案,讲透老原理。
3.2 核心控制器代码详解
@Controller
public class ChunkedDashboardController {
private static final Logger logger = LoggerFactory.getLogger(ChunkedDashboardController.class);
@GetMapping("/dashboard")
public void dashboard(HttpServletRequest request, HttpServletResponse response) throws IOException {
// 1. 设置响应类型和禁用容器缓冲(关键!)
response.setContentType("text/html;charset=UTF-8");
response.setCharacterEncoding("UTF-8");
// 强制 Tomcat 不缓冲,确保 flush 立刻生效
if (response instanceof org.apache.catalina.connector.Response) {
((org.apache.catalina.connector.Response) response).setBufferSize(0);
}
// 2. 获取输出流,注意:必须用 getOutputStream(),不能用 getWriter()
// 因为 getWriter() 是字符流,getOutputStream() 是字节流,分块编码操作在字节层
ServletOutputStream out = response.getOutputStream();
// 3. 输出 HTML 开头(Header 部分)
String headerHtml = """
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<title>服务器监控仪表盘</title>
<style>
body { font-family: "Helvetica Neue", sans-serif; margin: 0; }
.header { background: #2c3e50; color: white; padding: 1rem; text-align: center; }
.status { background: #ecf0f1; padding: 1.5rem; margin: 1rem; border-radius: 4px; }
.footer { background: #34495e; color: #bdc3c7; text-align: center; padding: 0.5rem; font-size: 0.8rem; }
</style>
</head>
<body>
<div class="header">
<h1>📊 服务器监控仪表盘</h1>
<p>实时状态,分块加载</p>
</div>
""";
out.write(headerHtml.getBytes(StandardCharsets.UTF_8));
out.flush(); // 第一次 flush:Header 立刻发出!
// 4. 模拟耗时操作:获取系统 uptime(约 2 秒)
String uptimeInfo;
try {
Process process = Runtime.getRuntime().exec("uptime");
BufferedReader reader = new BufferedReader(
new InputStreamReader(process.getInputStream(), StandardCharsets.UTF_8)
);
uptimeInfo = reader.readLine();
reader.close();
process.waitFor();
} catch (Exception e) {
logger.error("获取 uptime 失败", e);
uptimeInfo = "Error: 无法获取系统状态";
}
// 5. 输出状态区域(Content 部分)
String contentHtml = String.format("""
<div class="status">
<h2>系统运行状态</h2>
<p><strong>Uptime:</strong> %s</p>
<p><em>此区域在 %d 毫秒后动态加载</em></p>
</div>
""", uptimeInfo, System.currentTimeMillis() - startTime);
out.write(contentHtml.getBytes(StandardCharsets.UTF_8));
out.flush(); // 第二次 flush:状态区域发出
// 6. 输出 Footer
String footerHtml = """
<div class="footer">
<p>© 2024 服务器监控系统 | 刷新页面重试</p>
</div>
</body>
</html>
""";
out.write(footerHtml.getBytes(StandardCharsets.UTF_8));
out.flush(); // 第三次 flush:Footer 发出
}
}
这段代码的关键细节远超表面:
-
setBufferSize(0):这是让flush()生效的生死线。没有它,在高并发下 Tomcat 可能攒包,导致分块失效。 -
getOutputStream()vsgetWriter():getWriter()返回PrintWriter,它内部有字符编码转换,且某些容器对其flush()行为不一致;getOutputStream()是原始字节流,flush()行为最可控,是分块输出的黄金标准。 -
getBytes(StandardCharsets.UTF_8):显式指定编码,避免平台默认编码(如 Windows 的 GBK)导致中文乱码。分块编码本身不关心字符集,但内容编码必须统一。 -
异常捕获与降级
:耗时操作(如远程 API、DB 查询)必须包裹 try-catch。万一
uptime命令失败,我们提供友好的错误文案,而不是让整个响应中断。分块输出的健壮性,就体现在这种“局部失败不影响整体流程”的设计上。
3.3 前端模板与防抖优化
虽然后端已分块,但前端也要配合,避免“闪动”或“布局跳动”。我们在 Thymeleaf 模板中加入简单 CSS:
<!-- dashboard.html -->
<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org">
<head>
<meta charset="UTF-8"/>
<title>服务器监控仪表盘</title>
<style>
/* 关键:为状态区域预留空间,防止加载时页面跳动 */
.status {
min-height: 120px; /* 预估高度 */
opacity: 0;
transition: opacity 0.3s ease-in;
}
.status.loaded {
opacity: 1;
}
</style>
</head>
<body>
<!-- Header 静态内容,由后端直接输出 -->
<div class="header">
<h1>📊 服务器监控仪表盘</h1>
<p>实时状态,分块加载</p>
</div>
<!-- Status 区域:后端 flush 后插入,前端用 JS 添加 loaded 类 -->
<div class="status" id="statusArea">
<p>正在加载系统状态...</p>
</div>
<!-- Footer 静态内容 -->
<div class="footer">
<p>© 2024 服务器监控系统 | 刷新页面重试</p>
</div>
<!-- 极简 JS:检测 DOM 变化,平滑显示 -->
<script>
// 利用 MutationObserver 监听 .status 内容变化(比 setTimeout 更精准)
const statusEl = document.getElementById('statusArea');
const observer = new MutationObserver((mutations) => {
mutations.forEach((mutation) => {
if (mutation.type === 'childList' && mutation.addedNodes.length > 0) {
statusEl.classList.add('loaded');
}
});
});
observer.observe(statusEl, { childList: true, subtree: true });
</script>
</body>
</html>
这里有两个精妙设计:
-
min-height预留空间 :确保 Header 渲染后,.status区域已占据足够高度,后续内容填充时不会把 Footer “顶下去”,造成页面“抖动”,极大提升视觉稳定性。 -
MutationObserver替代setTimeout:传统做法是setTimeout(() => { ... }, 2000),但这是拍脑袋的。用MutationObserver监听.status元素的子节点变化,一旦后端 flush 的 HTML 片段到达并被浏览器解析,立刻触发动画, 100% 精准匹配实际加载时机 ,这才是专业级体验。
4. 常见问题排查与生产环境避坑指南
分块输出看似简单,但在真实项目中,90% 的失败案例都源于几个经典误区。我把这些年踩过的坑、客户现场调试的经验,浓缩成一张速查表。
4.1 分块失效的五大原因及诊断流程
| 现象 | 可能原因 | 快速诊断方法 | 解决方案 |
|---|---|---|---|
浏览器 Network 面板看不到
Transfer-Encoding: chunked
|
1. 服务器未启用 HTTP/1.1
2. Nginx/Apache 反向代理强制关闭了分块 3. 应用代码中手动设置了
Content-Length
|
在浏览器开发者工具 Network -> Headers 标签页,检查 Response Headers。若无
Transfer-Encoding
,则看是否有
Content-Length
;若有,说明被覆盖了
|
1. 确保应用服务器(Tomcat/Jetty)监听 HTTP/1.1
2. Nginx 配置中添加
proxy_http_version 1.1;
和
proxy_set_header Connection '';
3. 彻底删除代码中任何
response.setContentLength()
调用
|
flush()
后浏览器仍无反应,直到整个响应结束才显示
|
1. 容器缓冲未禁用(最常见!)
2.
flush()
调用位置错误(如在
try-catch
外围,异常时未执行)
3. 输出流被其他 Filter/Interceptor 拦截并缓存 |
用
curl -v http://your-url
查看原始响应头和响应体。如果看到
Transfer-Encoding: chunked
,但响应体是完整的(而非分段),说明数据被中间件缓存
|
1.
强制设置
response.setBufferSize(0)
(Tomcat)或等效配置
2. 将
flush()
放在
try
块内,并在
finally
块中再次
flush()
作为兜底
3. 检查
web.xml
或
@Configuration
中是否注册了
CharacterEncodingFilter
等可能缓存响应的 Filter
|
| 中文乱码 |
1.
response.setCharacterEncoding("UTF-8")
未设置
2.
getBytes()
未指定 Charset,用了平台默认编码
3. 前端
<meta charset>
与后端不一致
|
查看浏览器开发者工具 Elements 面板,右键“查看网页源代码”,观察乱码是问号
?
还是方块
□
。
?
通常是解码错误,
□
通常是字体缺失
|
1.
必须
在
flush()
前调用
response.setCharacterEncoding("UTF-8")
2. 必须 用
str.getBytes(StandardCharsets.UTF_8)
3. 前端
<meta>
标签必须与后端一致
|
| Chrome 显示不全,Firefox 正常 | Chrome 对极小 chunk(< 1KB)有内部优化,可能合并发送 |
用
curl
或 Wireshark 抓包,看实际 TCP 流中是否真的分成了多个小包
|
在
flush()
前,人为添加一些空白字符(如
out.write("\n\n\n".getBytes())
)或注释
<!-- padding -->
,凑够 1KB 左右再 flush。这不是 hack,是绕过浏览器实现细节的合理手段
|
| HTTPS 下分块失效 | SSL/TLS 层的加密缓冲或 CDN(如 Cloudflare)可能禁用分块 | 在 CDN 控制台检查是否启用了“Always Online”或“Auto Minify”,这些功能会缓存并重写响应 |
关闭 CDN 的自动优化功能;或在 CDN 规则中,对
/dashboard
路径设置
Cache-Control: no-store
,禁止 CDN 缓存
|
4.2 生产环境必须做的三件事
-
监控
flush()的成功率 :在关键flush()调用后,记录日志,例如logger.info("Flushed header, bytes written: {}", out.getBufferSize());。结合 APM 工具(如 SkyWalking、Pinpoint),追踪每个请求的flush耗时和次数,建立基线。如果某天flush平均耗时突增 50%,说明底层 I/O 或网络可能出问题。 -
设置超时熔断 :分块输出的耗时操作(如 DB 查询)必须有超时。Spring 中用
@Async(timeout = 3000)或CompletableFuture.orTimeout(3, TimeUnit.SECONDS)。一旦超时,立即flush()一个降级 HTML 片段(如<div class="error">加载超时,请稍后重试</div>), 绝不让整个响应卡死 。这是保障用户体验的底线。 -
优雅降级策略 :并非所有客户端都完美支持分块。老旧的嵌入式浏览器、某些企业防火墙会丢弃
Transfer-Encoding头。因此,你的页面 HTML 结构必须是 语义化、渐进增强 的:Header 和 Footer 是静态的、有意义的内容;Content 区域即使为空,页面也能正常阅读。这样,当分块失效时,用户得到的是一个“稍慢但完整”的页面,而不是一个“半截子”的残缺品。
实操心得:我在一个金融客户的交易监控系统上线前,专门做了“分块故障注入测试”——用 iptables 临时丢弃部分 TCP 包,模拟网络抖动。结果发现,当
flush()后的 chunk 丢失时,Chrome 会静默重试,但 Safari 会直接报net::ERR_INCOMPLETE_CHUNKED_ENCODING。最终解决方案是:在flush()后,用out.write("<!-- FLUSH_MARKER_1 -->".getBytes())写一个唯一标记,前端 JS 通过document.body.innerHTML.includes("FLUSH_MARKER_1")来判断 Header 是否真正到达,未到达则自动刷新。这个小技巧,让系统在弱网下的可用性提升了 40%。
5. 进阶:分块输出与现代 Web 性能生态的融合
分块输出不是孤立的技术,它应该融入整个 Web 性能优化体系。把它当成一把“手术刀”,精准切开性能瓶颈,而不是万能膏药。
5.1 与 Server-Sent Events (SSE) 的协同
SSE 是单向、长连接的服务器推送协议,常用于实时通知。但它有个弱点:首次连接建立后,服务器必须保持连接,直到有数据才推送,这期间连接是“空闲”的。而分块输出可以和 SSE 结合,创造“混合流”:
-
首次请求
/sse-dashboard,后端先flush()一个包含<html><head>...和<script>const eventSource = new EventSource(...)</script>的 HTML; -
浏览器解析到
<script>,立刻建立 SSE 连接; -
同时,后端继续
flush()一些初始化数据(如当前在线用户数、最新告警摘要),这些数据以普通 HTML 片段形式发送; -
之后,真正的实时数据通过 SSE 的
data:字段推送。
这样,用户在 200ms 内看到页面骨架和初始数据,1 秒内建立 SSE 连接,实现了“极速首屏 + 持续更新”的双重体验。比纯 SSE(需等连接建立后才开始渲染)或纯分块(无法持续推送)都更优。
5.2 与 HTTP/2 Server Push 的取舍
HTTP/2 的 Server Push 允许服务器在客户端请求 HTML 时,主动推送它可能需要的 CSS、JS、图片。听起来和分块输出目标一致?其实不然。Server Push 的问题是:
推送的资源是“猜测”的,如果客户端缓存已有,或网络条件差,Push 反而浪费带宽
。而分块输出是“确定性”的:你
flush()
的 HTML,浏览器一定会解析、一定会触发资源请求。在实践中,我建议:
- 优先用分块输出 :确保关键 HTML 骨架和首屏资源请求尽早发出;
-
谨慎用 Server Push
:只对那些 100% 确定会被用到、且体积小(< 10KB)的资源(如
critical.css)进行 Push; -
绝对不用 Push 大文件
:如
app.js(2MB),这会严重阻塞主响应流,得不偿失。
5.3 与 Edge Computing 的结合:让
flush
更近用户
CDN 边缘节点不仅能缓存,还能执行轻量逻辑。Cloudflare Workers、AWS CloudFront Functions 都支持在边缘运行 JS。我们可以把“Header 生成”逻辑下沉到边缘:
-
用户请求
/dashboard,CDN 边缘节点立即生成并flush()一个极简 Header(含 Logo、加载动画、内联 CSS); - 同时,边缘节点向源站发起一个异步请求,获取耗时的状态数据;
-
当状态数据返回,边缘节点再
flush()一个包含状态的 HTML 片段。
这样,Header 的 TTFB 可以压到 50ms 以内(物理距离决定),而源站只需处理真正的业务逻辑。这已经超越了传统“后端分块”,进入了“分布式分块”的新阶段。当然,这要求你对边缘计算平台有深入理解,但方向无疑是正确的。
我个人在实际使用中发现,分块输出最大的价值,不是技术多炫,而是它迫使开发者
重新思考“响应”的本质
。我们习惯把 HTTP 响应看作一个原子操作,但现实世界本就是流式的:水流、人流、信息流。当你开始用
flush()
把一个大响应切成小块,你就已经在用流式思维重构系统。这种思维迁移到微服务编排、实时数据管道、甚至前端状态管理(如 React 的 Suspense),都会让你的设计更自然、更健壮。它不是一个该被淘汰的“老技术”,而是一把被低估的、历久弥新的性能手术刀。
2291

被折叠的 条评论
为什么被折叠?



