CatchAdmin PHP 后台管理框架 Logo CatchAdmin

Content-Length 不匹配,PHP 响应挂起与截断详解

用户报告某个 API 端点一直挂起。浏览器无限期显示加载动画,最终超时。开发人员复现了该问题并检查 PHP 应用程序——代码运行正常,响应已生成,echo $body 正常执行。服务器日志显示请求在 40ms 内完成。但服务器与客户端之间存在问题:浏览器在等待,curl 超时,移动应用永远停在加载状态。响应正文真的到达了吗?最终到达?有时?看情况?调查最终追溯到中间件中由两行代码引起的 bug:header('Content-Length: ' . mb_strlen($body))。该中间件使用 mb_strlen(字符数)而不是 strlen(字节数)来计算长度。对于纯 ASCII 响应,两个数字相等,一切正常。对于含多字节字符的响应(印尼语文本、中文、表情符号、任何 UTF-8 非 ASCII 字符),字符数小于字节数。声明的 Content-Length 过低,实际正文更长。取决于客户端的 HTTP 实现,这要么导致响应被截断(某些客户端),要么挂起等待永远不会到来的字节(另一些客户端)。

Content-Length 不匹配是那种只在特定条件下才会显现的 bug。开发环境流量(小型 ASCII 负载、localhost)很少触发它。生产环境流量(真实用户内容、多字节字符、启用 keep-alive 的代理)则定期触发。这种不匹配可能以“API 有时很慢”或“移动应用偶尔存在连接问题”的形式影响约 5% 的响应——表面症状不会指向底层的头部不匹配。当有人最终进行抓包、发现字节数与头部不符时,bug 便一目了然。在此之前,它一直是个谜。

本文将梳理 Content-Length 究竟是什么、它出错时(过高与过低两种情况)经过验证的客户端行为、产生不匹配的具体 PHP 模式(mb_strlen、BOM、输出缓冲交互、修改响应的中间件),以及能够避免这一类 bug 的更安全模式。全文基于 PHP 8.3.6 并结合真实 HTTP 客户端行为进行验证。

速览

Content-Length 精确告知客户端应从响应正文中读取多少字节。HTTP 客户端据此判断响应何时结束。如果 Content-Length 声明为 100 字节,客户端便恰好读取 100 字节并将其视为响应完成。这个数字一旦出错,就会导致客户端侧挂起(等待永远不会到来的字节)或截断(在实际正文结束前停止)。

Content-Length 不匹配时经过验证的客户端行为:(1) Content-Length 过高,连接保持 keep-alive → 客户端挂起等待更多字节;已验证:curl 在 3 秒后超时,“with 13 out of 100 bytes received”。(2) Content-Length 过高,连接关闭 → 客户端报错:“transfer closed with 87 bytes remaining to read”。(3) Content-Length 过低 → 客户端截断响应;已验证:当 Content-Length 设为 5 时,客户端得到的是 “Hello”(5 字节),而不是 “Hello, world!”(13 字节)。

最常见的 PHP 成因:用 mb_strlen() 代替了 strlen()。mb_strlen 返回字符数,strlen 返回字节数。对 ASCII 文本两者相等;对含多字节字符的 UTF-8 文本,strlen 更大。Content-Length 必须是字节数;使用 mb_strlen 会得到过小的值,从而造成截断。已验证:strlen("Halo, dunia! 你好 café") = 25 字节,mb_strlen() = 20 个字符。若使用 mb_strlen,会发生 5 字节的截断。

BOM(字节顺序标记)可能悄然混入。Windows 编辑器保存的带 UTF-8 BOM 的 PHP 文件,其开头会有 \xEF\xBB\xBF(3 字节)。当文件被 include/require 时,这 3 个字节会在任何 echo 之前被输出。如果 Content-Length 基于预期正文计算(通过 ob_start 或手动计算),就会漏掉 BOM 字节。已验证:带 BOM 前缀的内容比预期正文长 3 字节。

输出缓冲的交互很微妙。正确的模式是:ob_start(),生成全部输出,用 ob_get_contents() 捕获,对捕获的字符串用 strlen() 计算长度,设置 header,再输出。缺少任何一步(在所有输出生成前就计算长度、使用错误的长度函数、完全不使用输出缓冲)都会产生不匹配。

最好的修复通常是不手动设置 Content-Length。当整个响应被缓冲时,PHP/PHP-FPM/nginx 可以自动计算它。手动 Content-Length 只在特定场景下才需要(无法放入内存的流式响应、与要求手动设置 Content-Length 的客户端集成)。对于典型响应,交由服务器处理更为安全。

当确实需要手动 Content-Length 时,正确的模式是:将整个正文捕获到变量中,计算 strlen($body)(绝不用 mb_strlen),设置 header,然后输出。任何偏离此模式的做法都容易产生 bug。

你将学到什么

  • Content-Length 是什么,HTTP 客户端如何使用它
  • 过高与过低不匹配的已验证故障模式
  • 产生不匹配的具体 PHP 模式(附修复方案)
  • 何时手动设置 Content-Length,何时交由服务器处理
  • Content-Length、分块编码与流式传输之间的关系

Content-Length 是什么

HTTP 响应有两种方式来表示正文的大小:

  • Content-Length: N:正文恰好为 N 字节;收到 N 字节即表示响应完成。
  • Transfer-Encoding: chunked:正文以分块形式发送,每块带各自的长度前缀;零长度块表示结束。

对于 HTTP/1.1 keep-alive 连接(客户端需要在不断开连接的情况下获知响应何时结束),二者必须存在其一。对于 HTTP/2 和 HTTP/3,分帧机制不同,但 Content-Length 在概念上仍然适用。

客户端对 Content-Length 的行为:

  • 从连接中读取字节
  • 对其进行计数
  • 收到 Content-Length 声明的字节数后停止
  • 将这些字节视为响应正文

如果服务器发送的字节数超过 Content-Length 所承诺的,客户端会在声明的长度处停止读取;多余的字节可能(在流水线连接中)与下一个响应交错,导致后续所有响应被破坏。如果服务器发送的字节数少于承诺值,客户端会等待剩余部分——要么直到连接关闭(报错),要么直到超时发生(挂起)。

Content-Length 必须是正文的精确字节数,以字节为单位。不是字符数,不是 UTF-8 码点数,而是将要在线路上传输的字节。

已验证:过高 → 挂起

测试设置:一个 TCP 服务器发送带 Content-Length: 100 但正文只有 13 字节的响应,然后保持连接打开(不显式关闭)。

php
$response = "HTTP/1.1 200 OK\r\n"
          . "Content-Type: text/plain\r\n"
          . "Content-Length: 100\r\n"
          . "\r\n"
          . "Hello, world!";  // 13 bytes

客户端行为(curl 设置 3 秒超时):

text
Elapsed: 3.003s
Response: 0 bytes
Error: Operation timed out after 3002 milliseconds with 13 out of 100 bytes received

已验证:客户端等待所承诺的 100 字节,只收到 13 字节,随后无限期等待剩余部分。如果没有 curl 的超时,等待时间会长得多(服务器端 keep-alive 超时,通常为 15–75 秒,视服务器配置而定)。

对真实用户而言,这表现为浏览器无限期显示加载动画、移动应用卡在加载状态、监控工具报告“响应非常慢”却不指明慢在哪里。测试中的 3 秒超时相当宽裕;浏览器在放弃之前会等待更久。

已验证:过高 + 连接关闭 → 报错

同样的正文,但加上 Connection: close:

php
$response = "HTTP/1.1 200 OK\r\n"
          . "Content-Type: text/plain\r\n"
          . "Content-Length: 100\r\n"
          . "Connection: close\r\n"
          . "\r\n"
          . "Hello, world!";

客户端行为:

text
Response: '' (empty)
Error: transfer closed with 87 bytes remaining to read

服务器在发送 13 字节后关闭连接。客户端检测到关闭,发现只收到承诺的 100 字节中的 13 字节,于是报错。这比挂起要好——至少客户端得到了“出了问题”的明确信号——但响应依然不可用。

不同客户端的处理方式不同。有的(如示例中的 curl)返回错误。有的(浏览器,视版本而定)可能返回部分内容并给出“加载完成”的提示。有的代理可能直接拒绝整个响应。HTTP 规范并未定义这种行为(规范只是说“不要发送错误的 Content-Length”);实际实现千差万别。

已验证:过低 → 截断

Content-Length 声明为 5,正文却有 13 字节:

php
$response = "HTTP/1.1 200 OK\r\n"
          . "Content-Type: text/plain\r\n"
          . "Content-Length: 5\r\n"
          . "Connection: close\r\n"
          . "\r\n"
          . "Hello, world!";

客户端行为:

text
Response: 'Hello' (5 bytes)

客户端恰好读取 5 字节并将其视为响应完成。“Hello, world!” 变成了 “Hello”。剩余的 “, world!” 要么丢失,要么(在流水线连接中)被当作下一个响应的开头,从而破坏后续响应。

对 JSON API 而言,这是灾难性的——被截断的 JSON 是无效的 JSON。客户端尝试 json_decode() 被截断的正文,得到解析错误,通常会显示一条笼统的“来自服务器的响应无效”消息。与此同时,服务器日志显示 200 响应;没有任何迹象表明发生了截断。

mb_strlen 与 strlen 的陷阱

Content-Length 不匹配最常见的 PHP 成因。已验证:

php
$str = "Halo, dunia! 你好 café";
strlen($str);      // 25 (bytes)
mb_strlen($str);   // 20 (characters - 5 fewer)

该字符串有 20 个字符(空格也计为字符),却有 25 字节,因为在 UTF-8 中“你”占 3 字节、“好”占 3 字节、“é”占 2 字节。每个多字节字符贡献的字节数都多于字符数。

用 mb_strlen 计算 Content-Length 的代码:

php
$body = json_encode($data);  // may contain UTF-8 characters
header('Content-Length: ' . mb_strlen($body));  // WRONG
echo $body;

对于纯 ASCII 数据,mb_strlen 与 strlen 相等——没有 bug。对于含任何 UTF-8 字符的数据(名叫 “Café” 的用户、中文产品名、评论中的表情符号),mb_strlen 更小。头部声明的长度短于实际正文。客户端会截断响应。

修复方法很简单:改用 strlen。

php
$body = json_encode($data);
header('Content-Length: ' . strlen($body));  // correct
echo $body;

PHP 中的 strlen 无论字符串内容如何,始终返回字节数。对于 Content-Length 这类面向字节的操作,它才是正确的函数。

开发者的困惑往往源于“为 Unicode 安全的操作使用 mb_* 函数”这样的字符串处理建议。这条建议对面向字符的操作是正确的(统计单词数、按字符位置提取子串、跨语言不区分大小写比较),但对面向字节的操作则是错误的。Content-Length 按字节计;请使用 strlen。

BOM 陷阱

字节顺序标记(BOM)是一些编辑器在 UTF-8 文件开头添加的特殊序列(\xEF\xBB\xBF),其本意是标示“此文件为 UTF-8 编码”。大多数现代工具会忽略或正确处理它。PHP 则不然——带 BOM 的 PHP 文件会在任何 echo 或 print 语句之前先输出这 3 个字节。

已验证:

php
$intended = json_encode(['user' => 'Alice']);  // 24 bytes
$actual = "\xEF\xBB\xBF" . $intended;           // 27 bytes if BOM present
// If Content-Length is calculated on the intended body:
// header('Content-Length: ' . strlen($intended));  // says 24
// But the actual output is 27 bytes (BOM + JSON)
// Client reads 24 bytes, gets BOM + 21 bytes of JSON, tries to parse as JSON, fails.

BOM 通常通过以下途径进入 PHP 文件:

  • Windows 编辑器(某些旧版记事本、某些带特定设置的 IDE)将文件保存为“带 BOM 的 UTF-8”
  • 文件在会添加 BOM 的系统上被编辑后保存回去
  • 构建工具拼接 PHP 文件,而源文件带有 BOM

检测方法:Linux/macOS 上的 file 命令会报告是否存在 UTF-8 BOM。VS Code 等编辑器会在状态栏中显示。若存在 BOM,head -c 3 file.php | xxd 会显示前 3 个字节为 ef bb bf。

修复方法:从文件中移除 BOM。在编辑器中保存为不带 BOM 的格式。添加 CI 检查:若任何 PHP 文件带有 BOM 则构建失败。部分编辑器提供“转换为不带 BOM 的 UTF-8”选项;在整个代码库中运行一次即可。

BOM 问题会以微妙的方式与 Content-Length 交互。如果在任何输出(包括 BOM 输出)之后调用 header(),PHP 会发出 “headers already sent” 警告。如果启用了输出缓冲,BOM 会被捕获进缓冲区并计入 strlen($body)——于是 Content-Length 是正确的,但响应以多余的 BOM 字节开头(可能破坏 JSON 解析器或 CSV 导入器)。

输出缓冲:正确的模式

当需要手动设置 Content-Length 时(参见下文“何时手动设置”),输出缓冲是正确的机制:

php
// Step 1: Start output buffering
ob_start();
// Step 2: Generate all output (this goes into the buffer, not to the client)
echo "First line\n";
echo "Second line\n";
// Any code that produces output; may span function calls, templates, etc.
// Step 3: Capture the buffered content
$body = ob_get_contents();
ob_end_clean();  // Discard the buffer (we captured it)
// Step 4: Set the header with correct byte count
header('Content-Type: text/plain; charset=UTF-8');
header('Content-Length: ' . strlen($body));
// Step 5: Output the body
echo $body;

要点:

  • ob_get_contents() 将缓冲区以字符串形式返回;对该字符串调用 strlen() 得到字节数。
  • ob_end_clean() 清空缓冲区而不发送内容(之后会显式 echo)。
  • 先 header() 后 echo——头部必须在正文之前发送。
  • 使用 strlen(),而非 mb_strlen()。

框架抽象层通常会处理这一切。通过框架 API 构造响应时,Laravel 的响应系统、Symfony 的 Response 类等都能正确处理 Content-Length。手动 header() + echo 会绕过这些保护机制;应尽可能使用框架的响应对象。

何时手动设置 Content-Length

默认答案:不要设置。让 PHP/PHP-FPM/nginx 自动计算。

当 PHP 缓冲整个响应时(php.ini 中启用 output_buffering 时的默认行为),服务器可以在发送头部时根据缓冲区大小计算 Content-Length。开发人员无需为此操心。

手动 Content-Length 有意义的特定场景:

流式响应。如果响应是增量生成的(边计算边写入块,用于大文件或长时间运行的数据流),总长度可能无法预先得知。此时,要么:

  • 若在流式传输开始前已知总长度,则基于该总量设置 Content-Length
  • 改用 Transfer-Encoding: chunked(不设置 Content-Length;每个分块自带长度前缀) 对大多数 PHP 应用而言,流式传输并不常见——响应都能放入内存,缓冲即可奏效。流式传输只在大型文件下载、导出或特定的实时数据场景中才相关。

HEAD 请求。HTTP HEAD 返回与 GET 相同的头部但不含正文。Content-Length 仍应设置为 GET 会返回的值,以便客户端规划资源分配。PHP 框架通常会正确处理这一点:在内部生成完整响应,提取头部,然后仅为 HEAD 返回头部。

显式二进制响应。通过 readfile() 或直接的 fread/fwrite 循环输出二进制数据(图片、PDF、ZIP 文件)时,Content-Length 必须根据文件大小(filesize($path))设置,因为 PHP 不会缓冲这些内容。

修改正文的中间件。如果中间件更改了响应正文(添加页脚、应用转换、注入分析脚本),Content-Length 必须在修改后重新计算。更改正文却不更新 Content-Length 的中间件是常见的 bug 来源。

对于所有其他情况(典型 API 响应、HTML 页面、JSON 负载),让服务器处理 Content-Length。

中间件的陷阱

修改响应的中间件是 Content-Length bug 的常见来源。示例场景:

压缩中间件。对响应正文应用 gzip/deflate。如果中间件根据压缩后的正文设置 Content-Length,而框架已经根据未压缩的正文设置过(或反之),该值就是错误的。正确处理:压缩中间件应在压缩后计算并设置 Content-Length,或者(更好的做法)对压缩响应使用 Transfer-Encoding: chunked。

分析脚本注入。向 HTML 响应中插入跟踪脚本标签的中间件。如果它在不更新 Content-Length 的情况下修改了正文,头部现在就是错误的。

响应转换。转换 JSON 响应的中间件(添加元数据、字段脱敏)。同样的问题——正文变了,头部没变。

替换正文的错误处理器。如果错误处理器用错误消息替换了响应正文,原响应中的 Content-Length 对新的正文来说就是错误的。

中间件的通用模式:如果中间件修改了响应正文,要么在修改后重新计算 Content-Length,要么清空 Content-Length 头部让服务器重新计算(部分框架会自动处理)。

对于 Laravel 和 Symfony 等框架,响应对象的 setContent() 方法通常会触发 Content-Length 的自动重新计算。手动操作 $response->headers 或直接调用 header() 会绕过这一机制。

检测与调试

提示 Content-Length 问题的症状:

  • 生产环境中“响应永远无法完成”的间歇性问题。永远不会消失的加载动画、特定端点的超时、卡在加载状态的移动应用。

  • 与特定内容相关。Bug 报告中提到“当用户名含表情符号时”或“产品描述为中文时”——这些是多字节内容的迹象。

  • 随机截断。客户端出现 JSON 解析错误,而服务器日志却显示这些响应是成功的。响应正文在单词或 JSON 结构的中间戛然而止。

  • 本地正常,生产环境失败。本地开发往往使用更简单的 ASCII 内容,或者绕过了 bug 所在的中间件。 诊断工具:

  • 抓包。服务器上的 tcpdump 或客户端上的 Wireshark 能显示线路上的实际字节。将 Content-Length 头部与实际正文大小进行比较即可发现不匹配。

  • 带详细输出的 curl。curl -v 显示响应头部。将 Content-Length 与接收到的正文长度进行对比是一种快速检查。

  • 浏览器开发者工具。Network 标签页显示 Content-Length 头部,能够揭示不匹配(尽管现代浏览器常将其隐藏在更高级的视图后面)。

  • 响应大小日志。添加同时记录 Content-Length 头部值和实际正文长度(在写入响应时)的日志,可以从源头捕获不匹配。 对于自动化检测,可以添加响应验证中间件:捕获实际正文长度,与 Content-Length 头部值比较,记录差异。这会为每个响应带来额外开销,但能在 bug 进入生产环境之前在预发布环境中捕获它们。

需要避免的陷阱

用 mb_strlen 计算 Content-Length。已验证:对于含多字节字符的 UTF-8,字符数与字节数不同。始终使用 strlen()。

在所有输出生成之前计算 Content-Length。如果先设置了头部,随后又产生了更多输出,头部就错了。应使用输出缓冲捕获全部内容,然后设置头部,最后输出。

在框架会处理的情况下手动设置 Content-Length。对大多数情况只会徒增复杂度而无益。让框架的响应对象处理 Content-Length;仅在特定的流式或二进制场景下手动计算。

忽视修改响应的中间件。更改正文的中间件必须更新或清除 Content-Length。假设头部在修改后仍然有效是错误的。

忘记检查 PHP 文件中的 BOM。BOM 会在任何 echo 之前为输出增加 3 个字节。如果 Content-Length 的计算基于预期正文,就会漏掉 BOM。应从所有 PHP 文件中移除 BOM;并添加 CI 检查。

在 Transfer-Encoding: chunked 的同时发送 Content-Length。根据 HTTP 规范,二者互斥。如果同时存在,行为未定义;有些客户端拒绝响应,另一些忽略其中一个头部。每个响应只选择一种分帧方式。

以为 HTTP/2 会让这个问题无关紧要。HTTP/2 有自己的分帧机制(流具有各自的长度语义),但 Content-Length 仍然有意义。在 HTTP/2 中发送错误的 Content-Length 会有不同的故障模式,但依然是错误的。

只用 ASCII 内容测试。当内容是 ASCII 时,bug 会被掩盖。应刻意使用多字节字符(中文、阿拉伯文、表情符号)进行测试,以暴露编码相关问题。

迷你问答

是否应该总是手动设置 Content-Length?

不。对于典型的 PHP 响应,让框架或服务器计算才是正确且更安全的做法。手动 Content-Length 仅用于流式传输(长度可能预先已知)、HEAD 请求或通过 readfile() 输出的特定二进制响应。

Content-Length 与 Transfer-Encoding: chunked 有什么区别?

两者都标示响应正文的结束,但机制不同。Content-Length 是发送开始前已知的单一数字。分块则是一系列各自带长度的块,以零长度块结束。Content-Length 要求预先知道总大小;分块则不需要。有完整正文时使用 Content-Length;流式传输时使用分块。

为什么 mb_strlen 返回字符数而不是字节数?

因为那正是大多数字符串处理代码所需要的。“这个字符串有多长”通常指“有多少个字符”(用于显示、字数统计、子串提取)。字节数是与网络协议和文件大小相关的技术细节。PHP 的 strlen 返回字节数,mb_strlen 返回字符数。各按其用途使用。

可以用 Content-Length: 0 表示空正文吗?

可以,这是空响应的正确模式。Content-Length: 0 明确表示响应没有正文。常见于 HTTP 204(No Content)和某些 304(Not Modified)响应。

框架如何处理 Content-Length?

大多数框架(Laravel、Symfony、Slim 等)在发送前于内存中构建完整响应。发送响应时,它们会根据实际正文长度自动计算 Content-Length。手动调用 header('Content-Length: ...') 通常会覆盖这一行为;在框架上下文中直接 echo 也可能绕过框架的处理。

PHP-FPM 会自动处理 Content-Length 吗?

会,只要工作正常。PHP-FPM 缓冲 PHP 的输出并通过 FastCGI 传递给 nginx;nginx 根据实际正文计算 Content-Length。之所以需要缓冲,是因为 PHP-FPM 在将完整响应发送给 nginx 之前必须知道它的大小。PHP 中手动调用 header('Content-Length: ...') 会覆盖自动计算。

gzip 压缩怎么办?

压缩发生在响应正文生成之后。如果 PHP 将 Content-Length 设置为未压缩正文的大小,而 nginx 在发送前压缩了响应,头部就是错误的。标准做法:让 nginx 处理压缩(通过 gzip on),并让它在压缩后重新计算 Content-Length。不要在 PHP 中压缩,除非能为压缩后的数据正确设置头部。

如何检查服务器实际发送了什么?

curl -v https://example.com/endpoint 会显示响应头部和正文。tcpdump -A -i any port 80 显示原始字节(用于 HTTP;HTTPS 需要通过 SSLKEYLOGFILE 和 Wireshark 进行 TLS 解密)。浏览器开发者工具的 Network 标签页显示头部,并能揭示 Content-Length。响应验证中间件可以记录实际字节长度并与头部值比较。

总结

Content-Length 是那种默认情况下大多能正确工作、一旦出错就产生神秘 bug 的 HTTP 细节之一。这种 bug 模式(错误的 Content-Length)易于描述,却很难从表面症状(挂起、截断、间歇性失败)中诊断。预防方法很直接:使用 strlen 而非 mb_strlen,配合框架响应对象使用输出缓冲,除非必要否则避免手动操作头部。

对 PHP 应用而言,务实的姿态是:典型响应使用框架的响应对象;让 PHP-FPM 和 nginx 自动处理 Content-Length。只在确实需要的特定场景(流式传输、二进制文件、模拟 HEAD)才手动设置 Content-Length。当需要手动计算时,严格遵守输出缓冲模式:缓冲一切,用 ob_get_contents 捕获,用 strlen 计算,设置头部,输出正文。

对于排查间歇性“永远加载中”或“响应被截断” bug 的团队,审计路径是:检查是否有手动设置 Content-Length 头部的代码;检查触及 HTTP 响应的代码中是否使用了 mb_strlen;检查 PHP 文件中是否有 BOM;检查修改响应正文的中间件。每一项都是可以搜索的具体代码模式。bug 一旦被发现往往显而易见;难点在于知道该去哪里找。

更深的启示:HTTP 有许多小细节,大多数时候无关紧要,直到它们真正造成影响。Content-Length 是其中之一,字符编码是另一个,头部大小写敏感是第三个。当代码遵循惯例时,这些细节能正常工作;不寻常的模式(手动头部、自定义框架、修改字节的中间件)会暴露出开发者的假设与 HTTP 要求之间的差距。培养“何时该去看线路上的原始字节”的运维直觉是值得的——工具(curl -v、tcpdump、浏览器开发者工具)成本很低;而高效使用它们的能力会在众多类型的 bug 中持续带来回报。

收尾与闭环

曾经遭遇用户反馈响应挂起的团队,最终把 bug 追溯到用 mb_strlen 设置的 Content-Length 头部。该中间件是六个月前在一次国际化推进中引入的——这个初衷良好的改动认为多字节字符串函数应当被一致地使用,包括用于面向字节的操作。修复只有一行:把 mb_strlen 改成 strlen。完整的诊断过程(抓包、与实际正文大小比对、定位中间件、追溯引入时间)花了两天;修复只花了两分钟。

事件发生后,团队改进了测试:在测试数据中加入多字节内容(中文产品名、含表情符号的用户名、阿拉伯文本);在集成测试中检查 HTTP 响应的 Content-Length 一致性(捕获实际正文字节数,断言它与头部匹配);添加 CI 检查,在触及 HTTP 头部的文件中查找 mb_strlen。多层次的检查能在类似问题进入生产环境之前将其捕获。

六个月后,一名新开发人员在一个无关的功能中又为 Content-Length 引入了 mb_strlen。CI 检查在 PR 阶段就发现了它;评审者解释了字节与字符的区别;代码被更新为使用 strlen。“发现一类 bug、添加自动化检测、防止复发”的模式让团队免去了一次为期两天的排查。

一年后,团队的编码规范文档中写入了这样一段话:“HTTP 响应是字节流。任何触及 Content-Length、偏移计算或二进制数据的操作都应使用面向字节的函数(strlen、substr、strpos);面向字符的操作(显示、面向用户的字符串处理)则应使用多字节函数(mb_strlen、mb_substr)。这一区别至关重要,否则就会产生 bug。”这种针对具体场景的规则,避免了“为求安全,处处使用 mb_*”这一笼统建议被错误套用。新团队成员学会了二者的区别;同样的 bug 不再复发。

“人们还问”

  1. HTTP Content-Length 是什么,为什么它很重要?Content-Length 是规定响应正文精确字节数的 HTTP 头部。HTTP 客户端利用它来判断响应何时完成——读取那么多字节,响应便结束。Content-Length 出错会导致客户端侧挂起(等待并不存在的字节)或截断(在实际正文结束前停止)。对于持久化 HTTP/1.1 连接和 HTTP/2,Content-Length 或 Transfer-Encoding: chunked 是界定响应所必需的。

  2. 为什么 PHP 响应在浏览器中一直挂起?很可能是 Content-Length 不匹配。如果服务器告诉客户端“预期 100 字节”却只发送了 13 字节,客户端就会无限期等待剩余的 87 字节。已验证:在启用 keep-alive 且 Content-Length 过高的情况下,curl 在 3 秒后超时,“with 13 out of 100 bytes received”。客户端行为各不相同(有的挂起,有的报错,有的显示部分内容),但“响应永远不完成”是 Content-Length 过高的典型症状。检查 PHP 代码中是否有手动设置的 header('Content-Length: ...');确保使用的是 strlen()(而非 mb_strlen());检查 PHP 文件中是否有 BOM。

  3. strlen() 和 mb_strlen() 有什么区别?strlen() 返回字符串的字节数,与内容无关。mb_strlen() 针对支持多字节的字符串编码(通常是 UTF-8)返回字符数。对 ASCII 字符串,两者返回相同的值。对含多字节字符的 UTF-8 字符串,strlen() 更大(每个多字节字符贡献 2-4 字节,但只算 1 个字符)。对于 Content-Length 或任何面向字节的操作,使用 strlen()。对于面向字符的操作(字数统计、显示对齐),使用 mb_strlen()。

  4. 在 PHP 中应该手动设置 Content-Length 吗?通常不需要。带输出缓冲的 PHP(默认行为)、PHP-FPM 和 nginx 都可以根据实际输出自动计算 Content-Length。手动设置 header('Content-Length: ...') 会覆盖这一行为并引入不匹配的风险。手动 Content-Length 仅在特定场景下有必要:长度已知但正文增量写入的流式响应、只返回头部的 HEAD 请求,或通过 readfile() 直接输出二进制内容。

  5. PHP 文件中的 UTF-8 BOM 是什么引起的,如何修复?BOM(字节顺序标记)是一些编辑器添加到 UTF-8 文件开头的序列 \xEF\xBB\xBF。常见原因:Windows 编辑器(旧版记事本、某些带特定设置的 IDE)、从使用 BOM 的系统导入的文件。当 PHP 文件带 BOM 时,这 3 个字节会在任何 echo 或 print 语句之前被输出,导致 “headers already sent” 错误和 Content-Length 不匹配。修复方法:在编辑器中保存为“不带 BOM 的 UTF-8”;转换现有文件(sed -i '1s/^\xEF\xBB\xBF//' file.php 可移除 BOM);添加 CI 检查,对带 BOM 的文件判定失败。

  6. 中间件的修改如何影响 Content-Length?更改响应正文(压缩、转换、注入)的中间件会使先前设置的任何 Content-Length 失效。正确处理:修改正文的中间件要么在修改后重新计算 Content-Length,要么完全移除该头部,让框架/服务器重新计算。否则就会产生头部声明一个长度、实际正文却是另一个长度的响应——典型的不匹配。只要一致使用,框架响应对象通常能正确处理这一问题;直接 echo 或手动调用 header() 则可能绕过这些保护机制。

  7. 可以同时使用 Content-Length 和 Transfer-Encoding: chunked 吗?不可以,根据 HTTP 规范二者互斥。一个响应只能使用其中一种分帧方式。如果同时存在,行为未定义——有些客户端拒绝响应,有些忽略其中一个头部。流式传输(总大小未知)时使用分块;有完整正文时使用 Content-Length。如果框架自动设置 Content-Length,就不要添加分块编码;如果流式传输需要分块编码,请确保没有设置 Content-Length。

  8. 如何在生产环境中调试 Content-Length 不匹配?这是一个多步骤过程。(1) 检查服务器日志中异常的响应模式——与报告问题无关的大小、特定端点的错误率。(2) 在类生产环境中对出问题的端点使用 curl -v;将 Content-Length 头部与实际正文长度进行比较。(3) 在预发布环境或少量生产流量中添加响应验证日志:捕获实际正文字节数和 Content-Length,记录不匹配。(4) 对于持续存在的问题,在服务器上运行 tcpdump 或 Wireshark 捕获原始字节;将头部中的 Content-Length 与实际字节数进行比较,可以精确揭示不匹配。(5) 确定端点后,审计代码路径中的 mb_strlen、手动头部设置以及修改响应的中间件。

注:本文中所有 Content-Length 行为和不匹配场景均在 PHP 8.3.6 上结合真实 HTTP 客户端行为(curl 8.5)验证。具体的超时值反映的是 curl 的默认超时行为;浏览器和移动应用的具体超时阈值可能不同,但底层机制(等待所承诺的字节)在所有 HTTP/1.1 客户端中是一致的。HTTP/2 分帧不同,但语义要求类似——Content-Length 必须与实际正文匹配。框架特定行为(Laravel、Symfony、Slim)反映的是其文档记载的响应处理方式;具体版本可能存在细微差异。对于生产环境调试,抓包和响应验证中间件是最可靠的工具;理论理解有助于缩小搜索范围。

本作品采用《CC 协议》,转载必须注明作者和本文链接