做Web开发这么多年,我遇到过很多次这样的情况:后端算得辛苦,浏览器却是一片白屏,用户盯着转圈的小图标干瞪眼。直到最后所有数据都生成完毕,页面才“啪”地一下整块渲染出来。这种体验不能说致命,但绝对影响口碑。其实解决思路早就存在,那就是利用flush()让页面分块、逐步呈现——一边算一边往浏览器吐内容,用户看到的不再是“全有或全无”,而是像下载进度条一样,一块一块往前推进。
flush()这个函数,老一代PHP开发都很熟,但绝大多数人只把它当成“输出缓冲清理”的工具,很少真正把它当成绩效优化手段。这篇文章就把flush()相关的原理、实战和坑一次讲透:为什么它能提升感知性能、怎么用才能真的生效、有哪些环节会悄悄把你的分块输出变成一次性输出,以及在现代Web架构下,要不要继续用flush()。
适合谁看?如果你还在用PHP做传统MVC接口或页面渲染,或者任何语言的开发者想搞懂HTTP流式输出、浏览器渲染时机,这篇都能给你实质帮助。
1. flush()到底解决了什么问题?
1.1 用户的耐心和“首字节时间”之间的鸿沟
评价一个Web页面的性能,通常看两大指标:首字节时间(Time To First Byte,TTFB)和完全加载时间(Load Time)。早期大家只盯着后者,觉得页面只要整体加载够快就没问题。但真实用户不会一直盯着进度条计算总时长,他们只关心一件事——我是不是已经看到了内容?
如果一个页面需要5秒才能完全生成,但用户在第0.5秒就看到了一部分页面结构,那他的主观等待时间可能只有0.5秒,甚至会感觉页面“很流畅”。反过来,如果后端闷头算满5秒,过程中什么也不往外吐,哪怕最终总耗时也是5秒,用户也会觉得“卡死了”。这在很多报表、数据导出、批量处理任务上尤其明显:计算耗时集中在服务端,前端几乎没有渲染压力,拖垮体验的恰恰是那段“无声的等待”。
flush()的价值就在于:把服务端一次性产出的内容,拆成若干小块,每当产出一个小块就立即推送给浏览器渲染,把“等待总时长”变成“渐进式完成感”。注意,总计算量一点没少,完整页面依然要5秒才能全部呈现,但用户感受到的压力小得多,这在产品上叫“感知性能优化”。
1.2 传统输出流程:所有内容攒齐了一次性发
要理解flush()的价值,先要理解没有flush时页面是怎么输出的。以PHP为例,一次典型的请求流程是这样的:PHP脚本执行,echo输出内容存进PHP的输出缓冲(output buffer),然后脚本结束后,PHP把缓冲区的所有内容打包交给Web服务器(Apache或nginx+PHP-FPM),Web服务器再通过socket把这包数据发给浏览器。
这个过程里出现了多个“攒批”的环节:
- PHP输出缓冲:PHP默认开着output buffering,echo不会立刻进入网络。
- Web服务器缓冲:Apache有mod_buffering之类的机制,nginx有proxy_buffering。
- 压缩缓冲:如果开了gzip,内容还会被压缩模块攒一段时间。
- 浏览器接收缓冲:浏览器对响应内容也有一定的缓冲,积累到某个阈值才开渲染。
四个缓冲环节叠加,最终的结果就是:用户只能等到所有内容都处理完、整包到达浏览器,才看到一次完整的渲染。flush()的职责,就是在这些缓冲环节中逐层“打通”,把输出立刻往下游推。
这里有个非常重要的认知:flush()并不能一键清除所有缓冲。它只能把当前代码层面能控制的缓冲刷出去,后面的Web服务器缓冲、压缩缓冲能不能扛得住,还得配合服务器配置。这一点后面专门讲。
1.3 什么场景真正需要“分块呈现”?
我梳理了一下,实际项目里最需要flush()的场景大概有这么几类:
第一类是长耗时页面。比如生成用户报表、批量发邮件、处理大数据导入,脚本要跑几十秒甚至几分钟。这些任务通常不需要等待全部算完才知道结果,前几步的结果完全可以先告诉用户。
第二类是进度型页面。典型的例子是“正在初始化... 正在压缩文件... 下载中...”这种带步骤提示的页面。传统做法是用Ajax轮询接口拿进度,但用flush()直接在同一个HTTP连接里输出进度文案,实现成本低得多,而且不需要额外的轮询接口。
第三类是简单的实时通知。比如站内信即时到达、内容订阅刷新等轻度场景。如果用WebSocket、SSE来实现,架构复杂度高,杀鸡用牛刀;用flush()在普通HTTP连接上做短小的流式输出,也能凑合。
第四类是大型页面分段加载。如果一个页面本身包含多个相对独立的区域,比如顶部统计、中部列表、底部推荐,可以在每个区域渲染完后flush()一次,让用户先看到统计和列表,底部推荐慢慢补上。
这几类场景有一个共同特点:前面的输出不依赖后面的计算结果。如果前后强依赖,那flush()也救不了,只能多分几段或者用异步处理。
1.4 经典误区:把flush()当“性能开关”的人
这里必须先泼一盆冷水。经常有人把flush()当成Web优化大杀器,觉得只要把所有echo后面挂个flush(),页面性能就能起飞。实际不是。flush()优化的是“用户能感知到的等待分布”,而不是“总耗时”。总耗时的瓶颈在业务逻辑本身——如果你的脚本里有全表扫描、N+1查询、低效的正则匹配,加100遍flush()也补不回来。
正确的定位是:flush()是沟通层优化,不是业务层优化。先确保业务逻辑本身速度快,再考虑用flush()把剩余等待时间“拆碎”呈现给用户。两件事别搞反了,否则容易白费功夫。
2. 为什么页面能“一块一块”出现:分块传输的底层逻辑
2.1 HTTP实体传输:Content-Length vs. Chunked
要真正懂flush(),得先了解HTTP协议层面的分块传输机制。HTTP响应发送实体内容时,有两种编码方式:Content-Length和Transfer-Encoding: chunked。
第一种,如果服务器在响应头里带上Content-Length,就表示“我知道这次响应总共有多少字节”,浏览器会按照这个长度去接收,接收完再渲染。这种模式下,服务器必须把全部内容生成完、知道了总字节数,才能设定这个头——这就是为什么传统页面总是“憋大招”式输出。
第二种,如果不设置Content-Length,服务器就可以用Transfer-Encoding: chunked来发送响应。这种模式不要求服务器提前知道总长度,而是把内容拆成若干块,每块由“十六进制字节长度 + CRLF + 数据 + CRLF”组成,最后以长度为0的块结束。浏览器收到这样的响应后,可以边接收边渲染,不需要等待完整内容。
PHP正是在第二种模式下发挥作用的:当脚本未关闭缓冲且开始向客户端输出时,底层会以chunked方式发送,而flush()就是“通知底层,现在就把当前缓冲区里的数据当作一个chunk发出去”的指令。如果这时有人提前设了Content-Length,那flush()要么变成无意义的空操作,要么直接触发报错。
2.2 浏览器什么时候开始渲染?
就算服务器发的是chunked响应,浏览器也不是收到一个字节就立刻画一个像素。浏览器的渲染引擎有自己的节奏和缓冲策略。很多老文章都会提“浏览器需要收到至少256/1024字节才开始渲染”——这个说法在现代浏览器里已经不再严格成立,但“浏览器不会每收到一个字节就立刻渲染”这件事依然成立。
通常浏览器会在以下几种节点触发渲染:文档足够大(比如超过某个阈值字节数)、网络空闲了一小段时间(比如有约几十到几百毫秒没有新数据到来)、脚本执行进入空闲点、用户主动改变了视口大小。所以,如果你的flush()输出的是特别碎的小片段,而且间隔时间特别短,浏览器可能还是会攒上几块再渲染,效果反而不明显。
我实际测试中一个比较实用的经验是:每个输出块至少保证1024字节以上,或者让每两次flush()之间有明显的停顿(比如100毫秒以上),这样浏览器更容易“察觉到”新数据从而触发渲染。如果你输出的是纯文本进度,可以适当补一些空格达到字节数要求,同时不影响视觉。
2.3 你还需要理解浏览器对响应头部和初始内容的处理
有一个常被忽略的点:浏览器在收到响应头、解析出HTTP状态码(如200、302)和Content-Type后,才会正式进入文档解析流程。所以如果要在同一个响应里分块输出,最好把Content-Type、响应状态等关键头信息放在第一块里输出。
另外,如果第一块输出时没有设好Content-Type,有些浏览器可能一直无法决定用哪种文档模式去渲染,导致后续输出始终不能正确解析。我遇到过一个真实案例:前端代码在PHP脚本开始几秒后输出了一段HTML片段,但header()设的Content-Type在前面的数据之后才生效,结果是浏览器把前几KB当纯文本处理,页面崩了。这个坑提醒我们:分块输出前,必须把所有header操作集中放在最前面,header()一旦输出过内容就不能再设置了。
2.4 flush()在Web服务器层被“中转”了一手
上面讲的还是PHP层面的原理。真实生产环境里PHP很少直连浏览器,通常由Apache或nginx做中转。这个中转过程中,flush()的效果可能被吞掉。
最典型的是nginx作为反向代理前的fastcgi应用:nginx默认会打开proxy_buffering,也就是说它会把来自后端(PHP-FPM)的响应先整个收进自己的缓冲,等缓冲满了或上游响应结束,再发给浏览器。这时候你在PHP里拼命flush(),数据到了nginx仍然会被扣住。
解决方法是关闭代理缓冲,具体配置形如:
location / { proxy_buffering off; fastcgi_buffering off; }其中fastcgi_buffering off是有前提的,需要nginx版本支持(1.5.6+)。Apache下则是关闭mod_deflate对响应的缓冲,并且在HTTPS+HTTP/2下也要注意流控窗口的影响。
这块细节太多,我在第4节会专门列一份“排查缓冲名单”,照着逐层清理即可。
3. 实战:用flush()打造一个多阶段任务进度条
3.1 场景设计:批量导入用户数据的例子
我先拿一个最常见的例子来说:批量导入用户数据。假设你有一个后台功能,管理员上传几千条用户记录,后端要逐条校验、清洗、入库、发短信通知。整个过程可能需要十几秒,传统做法是:点击导入按钮后,页面一直转圈,直到全部完成才刷新并显示“已导入8000条”。
现在我们希望:页面打开后,实时显示当前进行到的阶段,比如“正在解析文件...”、“正在校验第123/8000条...”、“正在写入数据库...”、“正在发送短信通知...”。这个界面看起来很像一个分步进度条。
实现这份效果,最简单、成本最低的方案不是WebSocket,不是SSE,而是同一个普通HTTP请求里配合flush()分块输出进度文本。这样后台逻辑不用拆分成多个接口,也不需要额外的前端轮询逻辑,一个连接从头拉到尾,简单直接。
3.2 前置准备:先关闭一切缓冲
进入代码前,先把PHP侧的“坑”填平。我们需要在脚本开头设置:
// 关闭PHP输出缓冲 ini_set('output_buffering', 'off'); // 打开隐式flush ob_implicit_flush(true); // 长任务避免执行超时 set_time_limit(0); // 释放session锁,防止进度请求被阻塞 session_write_close(); // 设置响应类型和编码 header('Content-Type: text/html; charset=utf-8');注意:set_time_limit(0)意味着脚本不再受执行时间限制,但在生产环境里,如果脚本本身存在死循环或无限等待,会拖垮进程。更务实的做法是设一个足够大的时间上限,比如600秒,并在循环里加入终止条件,而不是无脑0。我见过一个同事写了个遍历所有文件的递归函数,配合set_time_limit(0),线上直接拖垮了进程,教训深刻。
另外,如果你用的是PHP-FPM,传统的flush()在部分环境下(特别是FPM版本问题和代理配置问题)不一定立刻生效,这时可以考虑使用更底层的措施:让FPM以阻塞方式输出,或者在结束部分用fastcgi_finish_request()。不过一般情况下,在FPM+nginx下做分块输出还是可行的,前提是第4节讲的服务器缓冲配置到位。
3.3 完整示例代码:一个带flush的进度输出页面
下面是一个可直接运行的示例,模拟批量导入的三个阶段。由于不同环境对缓冲的设置不同,我会把必要的配置和代码放在一起:
<?php // 关闭全部PHP输出缓冲 while (ob_get_level() > 0) { ob_end_flush(); } // 打开隐式flush ob_implicit_flush(true); // 防止超时 set_time_limit(300); // 释放session写锁,避免进度请求被锁住 session_write_close(); // Content-Type必须最先设置 header('Content-Type: text/html; charset=utf-8'); // 禁止页面被缓存,保证实时性 header('Cache-Control: no-cache'); // 打印页面骨架 echo '<!DOCTYPE html><html><head><title>导入进度</title>'; echo '<style>body{font-family:monospace;padding:20px}.done{color:green}.pending{color:#999}</style>'; echo '</head><body>'; flush(); function stage($msg) { echo "<div class=\"msg\">$msg</div>"; flush(); // 模拟耗时操作 usleep(500000); } stage('第一步:解析上传文件...'); stage('第二步:校验数据格式...'); stage('第三步:写入数据库...'); stage('第四步:发送通知短信...'); echo '<div class="done">完成!共处理8000条记录</div>'; echo '</body></html>'; ?>这段代码跑起来,浏览器里应该能看到:每一个stage()函数输出一行文字,然后停0.5秒,下一行再出现。而不是四行一起蹦出来。这就是flush()分块呈现的基本效果。
3.4 进阶:输出进度条,不只是文字
文本进度看起来已经有了“渐进感”,但不够直观。真实项目里更常见的是输出一个进度条。传统方案是在前端用Ajax配合进度接口动态改页面,但用flush()也可以在服务端直接输出进度条的状态。
思路是:每处理完一个批次,就输出一行带style宽度百分比的内联HTML,配合flush()逐步显示。例如:
$total = 8000; $perBatch = 1000; for ($offset = 0; $offset < $total; $offset += $perBatch) { // 模拟处理1000条数据 processBatch($offset, $perBatch); $percent = round(($offset + $perBatch) / $total * 100); echo "<div style=\"background:#eee;width:100%;height:20px;margin:4px 0;\">"; echo "<div style=\"background:#4caf50;width:{$percent}%;height:20px;\"></div>"; echo "</div>"; echo "<div>{$percent}% 已完成</div>"; flush(); }如果担心浏览器因为输出过快而攒批,可以在循环结尾加一点usleep(20000)(20毫秒)人为制造间隔。但注意,加了延迟会增加总耗时,应根据业务容忍度权衡。
这里还要提一个细节:如果使用了模板引擎或框架的响应处理,很容易把flush()“架空”。比如某些框架会在请求结束时统一渲染模板并输出,那脚本里手动的echo和flush()可能被框架的缓冲机制拦截。解决办法有两个:一是把该请求排除在框架的主渲染机制之外,二是用框架提供的流式响应API(比如有的框架有response->stream方法)。写代码前先搞清你用的框架到底允不允许中途输出,否则折腾半天发现全被吞了。
3.5 别忘了前端如何接收:长连接与Content-Type的考量
如果页面是通过浏览器直接点击链接打开的,那上面的实现没问题。但如果前端是Ajax请求,就要注意Content-Type和响应处理方式。使用fetch调进度页面时,浏览器会等整个响应结束才把完整responseText给你,这并不符合分块输出的预期。
要让前端实时读响应内容,需要借助fetch的ReadableStream接口:
fetch('/import-progress.php') .then(response => response.body.getReader()) .then(reader => { const decoder = new TextDecoder(); function read() { reader.read().then(({done, value}) => { if (done) return; console.log(decoder.decode(value)); read(); }); } read(); });这种方案常用于把服务端分块输出接入现代前端逻辑。不过坦白讲,一旦前端需要实时读取流,你已经走到了“流式输出”的领域,后面第5节介绍的SSE可能就是更标准的选择。
4. 绕不开的“缓冲关”:为什么很多人的flush()不生效
4.1 输出缓冲层级全景图
用flush()做分块输出,最让人抓狂的不是用法,而是“明明代码里flush()了,浏览器里就是看不到渐进效果”。我总结过一个“缓冲层级图”,排除问题时按这个顺序逐层排查,基本能覆盖绝大多数情况。
第一层:PHP的output_buffering。这个可以在配置里全局设,也可以在代码里用ob_start()自行开启。如果开了,echo的内容先进入缓冲区,flush()前要确保ob_end_flush()或ob_flush()被调用。最粗暴的写法是循环里先用ob_end_flush()关掉所有缓冲,再用ob_implicit_flush(true)让每次echo自动flush。
第二层:PHP-FPM的缓冲。PHP-FPM以fastcgi协议与nginx通信,fastcgi模块自身也可能缓冲数据。在nginx里配置fastcgi_buffering off;可以关闭。
第三层:nginx反向代理缓冲。如果是nginx作为前端代理转发给后端应用,proxy_buffering off;可以关掉。同理,如果后端前还有一层负载均衡,那它也可能缓冲,需要一并检查。
第四层:Web服务器压缩模块。Apache的mod_deflate、nginx的gzip模块,它们会在压缩前把内容攒起来,导致块状输出被合并。测试时直接禁用压缩,或者为这个响应关闭压缩即可。
第五层:浏览器缓冲。现代浏览器对chunked响应并非逐字节渲染,所以前面强调过,要适当增加输出块大小或输出间隔。
4.2 nginx下打通flush之路:配置清单
如果你用的是nginx+PHP-FPM,而且希望flush()生效,最小可复现的配置是:
location /progress { proxy_buffering off; fastcgi_buffering off; gzip off; proxy_cache off; add_header Cache-Control "no-cache"; }同时注意fastcgi_pass的目标地址,如果是unix socket,确保路径正确。另外,如果你在nginx里额外配置了limit_rate,可能会限制传输速率,间接影响体验,但这种一般不导致flush失效。
还有一个坑:HTTP/1.1下的chunked在HTTP/2中会被重新封装。HTTP/2有流控机制,如果开启了流量控制窗口,即使关闭了所有缓冲,接收端也可能因为窗口太小而延迟接收。好在现代浏览器对HTTP/2流控做得比较宽松,问题不大,但你要知道这个机制存在。
4.3 Apache的mod_gzip与mod_deflate缓冲
Apache下更常见的问题是mod_gzip和mod_deflate。Apache的mod_deflate默认会用固定大小的缓冲区压缩内容,在压缩完成前不会把数据推给客户端。如果你的页面开启了gzip,PHP里再多的flush()都会被压缩层扣住。
解决思路有两个: 一是对该路径关闭mod_deflate,通常用mod_setenvif实现:
SetEnvIf Request_URI "^/progress" no-gzip二是配置mod_deflate的flush模式。比如mod_deflate有一个FlushMaxTime参数,在Apache 2.4.17之后可用,可以设置为无缓冲模式。但总体来说,关闭压缩是最稳妥的方案——反正进度内容是纯文本,压缩收益也不大。
4.4 压缩、TLS与HTTP/2的二次缓冲
即使服务器不压缩,某些部署环境还有TLS加密模块,加密层可能自己维护一个小缓冲。HTTPS下的流式输出,需要OpenSSL层的flushBuffer支持,部分老版本的OpenSSL实现对小块数据的频繁flush性能很差,不过这属于边缘场景,一般不会遇到。
重点还是HTTP/2。HTTP/2之后,响应不再严格落实chunked编码,而是以帧(frame)为单位传输,每个帧就是一个数据片段。浏览器的渲染机制对HTTP/2帧的处理会更宽松一些,但帧调度仍可能受流控影响。根据我的实测,HTTP/2下只要服务器端配置好了,flush()分块依然有效,只是输出如果不带明显的标识(比如换行、空行),浏览器默认不会每帧都去渲染。
所以建议在flush()前输出一个分隔标记,比如换行符或HTML标签,帮助解析器识别边界。纯文本环境中,一个“\n\n”比连续不分行的内容更容易触发渲染。
4.5 别忽略浏览器端“太小不配渲染”的机制
前面提到的“输出块至少1024字节”,这一条在真实环境里经常是最后一步压垮骆驼的稻草。理想情况下,每个chunk应包含超过一个完整可见实体(比如一个完整的HTML元素),并且如果可能,加上一段注释。注释对用户不可见,但能增加字节数。
我经常写这样的辅助函数:
function flushWithPadding($content) { echo $content; // 补充注释块,确保超过1024字节 $padLen = 1024 - strlen($content) % 1024; if ($padLen > 0) { echo str_repeat('<!-- padding -->', intdiv($padLen, 15) + 1); } flush(); }注意这个函数只适合输出HTML时使用,如果是JSON或纯文本接口,注释会影响数据有效性,就不要这么干。另一个办法是输出一定数量的空格,但在HTML中空格会被折叠,效果不如注释;在纯文本中空格也有意义,需谨慎。
5. 进阶:当我们谈流式输出时,其实有更多选择
5.1 从flush()到SSE:面向实时内容的原生方案
如果流式输出的需求已经不仅是“分块显示一个页面的内容”,而是持续不断地推送实时消息(比如行情变动、通知流、日志流),那flush()就有点力不从心了。
SSE(Server-Sent Events)是更正规的方案。它本质上仍然是HTTP流式响应,但专门设计为“持续推送”。SSE要求服务器把Content-Type设置为text/event-stream,每一条消息以“data: 内容\n\n”的格式输出。浏览器端的EventSource对象会自动解析这些消息,并触发onmessage事件,不用自己去读流。
SSE相比手动flush()的优势:
- 标准协议,前端处理简单,天然支持断线重连。
- 也是长连接,但和WebSocket相比,它只需要单向推送,网络开销小。
- 服务器端实现,仍然可以用flush()或专用流响应API输出。
如果我在做一个需要持续推送进度的系统,第一选择就是SSE,而不是原始flush()。但如果只是一个短小的后台页面,用flush()就够了,不需要引入额外协议。
5.2 Node.js的流式响应:背压机制和pipe
在Node.js里,实现分块输出比PHP直观得多。res是一个Writable Stream,可以直接写块并不断推给浏览器,参考代码:
const http = require('http'); http.createServer((req, res) => { res.writeHead(200, {'Content-Type': 'text/html; charset=utf-8'}); // 直接写第一块 res.write('<div>开始处理...</div>'); let count = 0; const timer = setInterval(() => { count++; res.write(`<div>第${count}步完成</div>`); if (count >= 5) { clearInterval(timer); res.end(); } }, 500); }).listen(8080);Node的流式处理天然友好,还能借助Transform Stream做各种转换。但要注意背压问题:如果消费者(浏览器)处理不过来,像write()返回false时你就不应该继续写,而是等drain事件再继续。背压机制是流式编程的核心,新手很容易因为不了解背压直接抛内存暴涨。
另外,在NestJS或Express这类框架中,也可以用res.flushHeaders()之类的API来实现,但框架层有时会拦截直接res.write的行为,需要小心处理。
5.3 WebSocket:真正的双向实时通道
如果后端不仅需要推送,还需要接收前端的实时指令(比如聊天室、多人协作、实时存稿),那就必须用WebSocket了。WebSocket是独立的协议,不走HTTP的响应/请求形态,支持服务端和客户端双向发送消息。和flush()、SSE这种单向HTTP流式响应完全不是一类东西。
我通常建议按需求强度做选择:
- 只要逐步渲染页面结果,用flush()。
- 需要服务端主动持续推送,且主要是单向,用SSE。
- 需要双向、低时延、高频率交互,用WebSocket。
这个选型不是说“越先进越好”,而是“越简单越好”。为了一次性的页面进度就上WebSocket,你会陷入前端断线重连、消息队列、鉴权等多重坑里难以自拔。
5.4 PHP框架时代,flush()还有位置吗?
现在的PHP项目很少是纯脚本输出,基本都是基于各类主流框架。框架里要执行flush(),比裸写PHP更麻烦。
在多数框架里,可以这样写出一个流式响应:
$response = response()->stream(function () { while (true) { echo 'data: 进度信息\n\n'; ob_flush(); flush(); sleep(1); } });但要注意,框架的中间件、路由、Session等机制都可能开启输出缓冲。关掉这些缓冲后,框架提供的CSRF、认证逻辑可能受影响,所以通常只在独立的、不需要全套框架机制的路径上使用流式响应。
实际生产里看到更多的做法是:独立脚本控制器返回一个空响应,通过后台队列协作,前端用轮询或者SSE获取进度。flush()在有框架的场景逐渐边缘化,但我仍然坚持认为,在传统的、简单的、需要逐步渲染的服务端渲染页面里,flush()依然是一个高效到让人惊叹的工具。
6. 常见问题与排查技巧实录
6.1 “flush()无效”的终极排查清单
很多人问过我:“为什么我按网上的教程写了flush(),浏览器里什么反应都没有?”这里我给一个排查思路,按顺序走:
- 确认PHP输出缓冲是否关闭:用var_dump(ob_get_level())看看级别,如果是0说明PHP侧已关闭。
- 确认Content-Type是否正确并已发送:如果输出内容前有任何一个空格或BOM,header()就会失败,响应头混乱。
- 确认Web服务器配置:nginx关fastcgi_buffering、proxy_buffering;Apache关mod_deflate。
- 确认浏览器端是否收到分块:在开发者工具里看响应头中是否有Transfer-Encoding: chunked,如果有,说明服务器端确实在分块,问题可能在浏览器渲染时机。
- 确认输出大小与间隔:是否每个块太小(小于1024字节)或输出太快(几十毫秒内连续输出多块)。
- 确认中间是否有CDN或负载均衡层缓冲:如果有CDN,几乎必然缓冲,这种场景不要用flush(),直接换WebSocket/SSE/Ajax轮询。
这个清单可以单独打印出来贴在工位上,比翻文档快。
6.2 实例:为什么gzip开启后进度被“憋”住了?
有一次我做了一个实时日志查看器,就是在页面上滚动输出服务端日志。代码里flush()写得很标准,nginx缓冲也关了,但页面始终还是几十行日志攒成一批跳出来。
后来逐个开关测试,发现元凶是gzip。nginx配置里有gzip on;,而且对text/html默认生效。由于日志内容是纯文本,压缩模块会先把内容攒起来压缩,攒到一定量才发一个压缩包,浏览器解开压缩包后才能看到一批日志。最终我把该location下的gzip关掉,日志立刻变流畅了。
这也印证了一个原则:分块输出和内容压缩天然相克。压缩会牺牲实时性换取带宽降低,实时性要求高的场景应主动关闭压缩,反过来带宽敏感的大文件场景则应该保持压缩,两者是一个取舍问题。
6.3 实例:Session锁把进度页面卡死
另一个经典悲剧是这样的:用户点击进度页,脚本里开启Session(PHP默认会给session加锁),然后在循环里flush()输出进度。同时浏览器还发了一个Ajax请求,请求同一个session(比如获取用户资料),此时Ajax请求会阻塞,直到第一个脚本释放session锁。极端情况下,两个请求互相等待,页面看起来就像死掉了一样。
解决办法是在脚本一开始就调用session_write_close(),告诉PHP“我要开始长任务了,不再需要写session了”。如果还需要读session数据,可以在调用前复制到变量中使用。这个细节很多教程不会提,但实际项目里几乎必踩。
6.4 数据库查询与网页实体输出交织的隐蔽问题
还有一个更隐蔽的问题:长耗时任务里面如果你在循环里执行数据库查询,而查询结果集太大,数据库客户端库可能默默把数据全部缓存到内存,导致你本来想“处理一批输出一批”,结果数据库一查就是全部,进度条却等了很久。解决办法是用缓冲查询(比如mysqli使用use_result()),让查询结果也能流式读取。
这个问题说明:flush()只是“输出层”的流式,如果你想实现真正的“全链路流式”,数据源本身也要支持流式。入口流式、处理流式、出口流式,三者缺一个,都可能让前端分块效果打折。我在做日志流的时候深有体会,数据库端用了游标,处理端用了生成器,输出端用了flush,整个链路才真正顺畅。
6.5 常见问题速查表
| 现象 | 首要怀疑 | 深挖方向 |
|---|---|---|
| flush()无任何效果 | PHP输出缓冲未关闭 | ob_get_level()检查 |
| 内容一块出现,但总耗时不变 | 服务器缓冲层未配置 | nginx fastcgi_buffering、proxy_buffering |
| 内容一批出现,尤其几十行 | gzip或deflate开启 | 临时关闭压缩并重试 |
| 浏览器等待很久才开始显示 | 浏览器接收缓冲/输出块太小 | 增大输出块,加入注释padding |
| 页面总在最后整体完成 | 框架或模板引擎缓冲 | 改用框架流式API或独立脚本 |
| 进度页面有其他请求被阻塞 | Session锁 | session_write_close() |
| 数据库查询卡了很久 | 数据库结果集未流式读取 | 改为use_result或游标 |
7. 我在实际项目中的几点体会
写到这里,我把这几年用过flush()的心得做个总结。
第一,flush()不是银弹。它最适合的场景就是“一批本来就应该逐步展示的内容”,比如步骤型任务、长报表、日志流。硬要把不相关的、必须整体渲染的页面拆成小块输出,反而会破坏页面结构,让用户看到丑陋的半成品。
第二,做分块输出之前,先确认服务器的缓冲配置是有能力关掉的。如果生产环境前面还有CDN或者其他反代层,整体架构不允许关闭缓冲,那就别硬上flush(),老老实实上Ajax轮询或SSE,否则做出来的效果不稳定,线上出问题排查成本极高。
第三,注意输出块的质量。我见过不少实现,flush()一顿操作,页面却输出了一堆散乱的文本片段,进度没有结构化,用户反而觉得很低级。好的分块输出应该是有语义的:每块都是一个完整的模块、一句话或者一个可辨识的状态变更,让用户看到的是“动态推进”,而不是“乱码跳跃”。
第四,别忘了反向优化:flush()虽然让用户感知变快,但它会显著增加网络交互频次,每个小块都是一个数据包,如果内容本身极小,网络开销占比反而升高。所以我一般建议输出间隔不低于100毫秒,或输出块不低于几百字节,否则在弱网环境下会加剧TCP小包问题,体验反而退化。
最后再分享一个细节:在开发环境调试flush()时,建议先用命令行方式跑PHP脚本,观察stdout是否按预期分块输出,这样很容易排查出问题出在PHP侧还是Web服务器侧。命令大致是:
php -d output_buffering=0 -d implicit_flush=1 script.php命令行确认无误后,再回到浏览器验证,整个排查路径会清晰很多。
关于flush()的分享就到这里。如果你在项目里试过类似的流式输出方案,或者踩过什么不一样的坑,欢迎交流。我自己在很长一段时间里都觉得flush()是过时技术,直到真正用它解决了一个报表页面的体验问题,才意识到工具不分新旧,关键是场景匹配不匹配。希望这篇内容也能帮你在合适的场景里想起这个“老函数”的价值。