前言接口返回为空是所有 PM 描述里最模糊、也最容易让排查跑偏的一句话。它实际上至少包含五种完全不同的故障而每一种的根因和解法都不一样HTTP 200 但 body 长度是 0、body 只有null、body 是[]、body 被截断成半截 JSON、以及 body 其实有内容但多了 BOM 导致前端JSON.parse失败。如果不先区分这五种就会在数据库查询是不是写错了和网关是不是有缓存之间来回打转。PHP 侧的病灶集中在两个地方。第一是json_encode()失败——它失败时不抛异常只返回false而echo false输出的是空字符串于是接口平静地返回了一个空 body状态码还是 200。第二是输出生命周期被打断——致命错误发生在输出缓冲区刷新之后、exit在响应写入之前、输出缓冲没有被 flush都会产生看似成功、实则空的响应。本文给出分形态的定位方法、一段能直接替换掉库里echo json_encode(...)的响应封包代码基于 PHP 8.3用到该版本新增的json_validate()以及一份高频坑点清单。一、先分形态空的空不一样第一步永远是拿到原始响应而不是看前端控制台。用curl把响应头和字节数都看清楚# -i 显示响应头-s 静默进度最后用 wc -c 看 body 的准确字节数 curl -i -s https://api.example.com/v1/orders?page1 -H Accept: application/json | tee /tmp/resp.txt # body 到底几个字节0 字节和 null 是两回事 tail -n $(($(grep -n ^$ /tmp/resp.txt | head -1 | cut -d: -f1) 1)) /tmp/resp.txt | wc -c # 十六进制看一眼有没有 BOMEF BB BF或不可见字符 head -c 32 /tmp/resp.txt | xxd响应头里有三个字段特别能说明问题观察到的现象含义常见根因Content-Length: 0状态 200脚本正常结束但什么都没输出json_encode()返回false分支里提前exit无Content-LengthTransfer-Encoding: chunkedbody 很短输出被截断中途致命错误、超时被 kill状态 502 / 504body 是网关的错误页请求根本没走完PHP-FPM 超时、进程崩溃、OOMbody 以EF BB BF开头有 BOM 头某个被 include 的文件存成了 UTF-8 with BOMbody 是{data:[]}且Content-Length正常链路完全正常数据侧确实没查到记录第三行和第四行尤其重要502/504 不是 PHP 返回的空响应那是网关在 PHP 进程失联之后自己生成的。看到 502 却去翻业务代码方向就全错了。一个最小的复现json_encode()是怎么静默失败的?php // repro-empty.php —— 需要 PHP 8.0 // 用法php repro-empty.php // 场景一非法 UTF-8数据来自 GBK 库、被截断的多字节字符等 $data [name 张三\xC3]; // \xC3 是不完整的多字节序列 $json json_encode($data); var_dump($json); // bool(false) var_dump(json_last_error()); // int(5) var_dump(json_last_error_msg()); // Malformed UTF-8 characters, ... // 用 echo 输出 false等价于输出空字符串HTTP 200 空 body echo body-start|; echo $json; echo |body-end, PHP_EOL; // 输出body-start||body-end —— 中间什么都没有 // 场景二INF / NANfdiv() 是 PHP 8.0 引入的除零返回 INF 而不抛异常 $json2 json_encode([ratio fdiv(1, 0)]); var_dump($json2); // bool(false) var_dump(json_last_error_msg()); // Inf and NaN cannot be JSON encoded这就是接口返回空最经典的一条链路数据里有一个坏字节 →json_encode()返回false→echo false输出空 → 前端收到 200 加空 body。报错信息一直躺在json_last_error_msg()里只是从来没人调用它。二、用 PHP 8.3 写一个绝不静默的响应出口要把这类问题一次性堵住做法只有一个让所有 JSON 输出都经过同一个函数并且这个函数永远不会输出空。PHP 8.3 新增的json_validate()在这里正好能派上用场——它用来校验一段已经是字符串的 JSON 是否合法比如从 Redis 里读出来的缓存比先json_decode()再判null更直接也不会有null既是合法 JSON 又是解码失败标志的歧义。?php // api-responder.php —— 需要 PHP 8.3 // 用法php api-responder.php declare(strict_types1); /** * 递归归一化把不能进 JSON 的值换成可序列化的形态。 * - INF / -INF / NAN - null * - 非法 UTF-8 字符串 - 用替换字符重新编码避免整个响应失败 */ function normalizeForJson(mixed $value): mixed { if (is_float($value) !is_finite($value)) { return null; } if (is_string($value)) { if (!mb_check_encoding($value, UTF-8)) { // 常见于从 GBK/latin1 数据源读出来的旧数据 return mb_convert_encoding($value, UTF-8, UTF-8); } return $value; } if (is_array($value)) { return array_map(normalizeForJson, $value); } if ($value instanceof JsonSerializable) { return normalizeForJson($value-jsonSerialize()); } if ($value instanceof BackedEnum) { // 枚举是 PHP 8.1 的 return $value-value; } return $value; } /** * 把任意数据结构编码成 JSON 字符串失败时抛异常绝不返回空串。 */ function encodeOrFail(mixed $payload): string { $safe normalizeForJson($payload); try { return json_encode( $safe, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES | JSON_THROW_ON_ERROR ); } catch (JsonException $e) { // 记日志时把错误码也带上便于区分类别 error_log(sprintf( JSON 编码失败: %s (code%d), $e-getMessage(), $e-getCode() )); throw $e; } } /** 统一出口保证缓冲区干净、内容类型正确、body 一定非空 */ function jsonResponse(mixed $payload, int $status 200): never { // 丢掉此前所有输出避免致命错误信息混进 JSON 里 while (ob_get_level() 0) { ob_end_clean(); } try { $body encodeOrFail($payload); } catch (JsonException $e) { $status 500; // 兜底即便业务数据有问题也要给出一个结构完整的错误体 $body json_encode( [error SERIALIZE_FAILED, message $e-getMessage()], JSON_UNESCAPED_UNICODE ) ?: {error:SERIALIZE_FAILED,message:unknown}; } http_response_code($status); header(Content-Type: application/json; charsetutf-8); header(Content-Length: . strlen($body)); echo $body; exit; } /** 读缓存时用 json_validate() 先验一遍PHP 8.3 新增 */ function readCachedJson(Redis $redis, string $key): ?array { $raw $redis-get($key); if (!is_string($raw) || $raw ) { return null; // 缓存未命中 } if (!json_validate($raw)) { // PHP 8.3直接判断是不是合法 JSON // 缓存被写坏或写入方序列化方式不一致直接丢弃并记一条日志 error_log(缓存内容不是合法 JSON: {$key}); return null; } $data json_decode($raw, true); return is_array($data) ? $data : null; } // —————————— 演示 —————————— // 坏数据非法 UTF-8 无穷大经过归一化之后依然能正常输出 $dirty [ name 张三\xC3, ratio 1e400, // 溢出成 INF list [1, 2, 3], ]; echo encodeOrFail($dirty), PHP_EOL; // {name:张三?,ratio:null,list:[1,2,3]}非法字节被替换为占位符 // 对比不做归一化时直接编码会失败 var_dump(json_encode($dirty, JSON_THROW_ON_ERROR) ! false); // 抛 JsonException这段代码解决的是不该静默这个核心问题编码失败的三种可能非法 UTF-8、INF/NAN、对象jsonSerialize()返回了非法值全部被显式处理要么归一化后成功输出要么抛出带上下文的异常绝不会出现200 空 body。顺手把致命错误也兜住如果空响应是由中途致命错误造成的比如调用了一个不存在的对象方法、内存耗尽只在输出层做文章是不够的。用register_shutdown_function()配合error_get_last()可以在脚本收尾时捕获到致命错误——这一步必须在业务代码之前注册而且要注意如果响应已经输出去了就只能记日志了。?php // fatal-guard.php —— 需要 PHP 7.4 declare(strict_types1); // 必须先注册且早于任何业务逻辑 register_shutdown_function(static function (): void { $error error_get_last(); if ($error null) { return; } $fatal [E_ERROR, E_PARSE, E_CORE_ERROR, E_COMPILE_ERROR, E_USER_ERROR]; if (!in_array($error[type], $fatal, true)) { return; } error_log(sprintf( FATAL %s in %s:%d | 已输出字节数%d, $error[message], $error[file], $error[line], ob_get_length() false ? -1 : ob_get_length() )); // 如果还没有任何输出就给一个结构化的 500 响应 if (!headers_sent()) { while (ob_get_level() 0) { ob_end_clean(); } http_response_code(500); header(Content-Type: application/json; charsetutf-8); echo {error:INTERNAL_ERROR}; } });日志里的已输出字节数是排查这类问题的关键信息它大于 0 就说明响应已经被部分写出去了前端看到的截断 JSON 就是这么来的等于 0 则说明致命错误发生在一开始此时用上面的兜底还能救回来。三、分场景排查清单定位到形态之后按下面这张表逐个排除。命令都可以直接执行。形态检查动作命令示例空 body200找json_encode的返回值有没有被判断grep -rn json_encode app/ \空 body200看是否在某分支里exit了grep -rn exit\截断 JSON看 PHP-FPM 与 nginx 错误日志tail -n 100 /var/log/php-fpm/error.log502 / 504看超时与内存配置php -i \前端解析失败看 body 开头是否有 BOM 或空白curl -s URL \数据为空[]打印 SQL 与参数确认确实没查到var_dump($sql, $params)或用慢查询日志偶发为空查缓存写入与读取的序列化方式是否一致对比写缓存与读缓存的代码路径几个补充要点max_execution_time对 I/O 等待不计时。它只统计 CPU 时间在类 Unix 系统上所以一个卡在数据库连接上的请求不会因为这个配置被终止——真正杀掉它的是 PHP-FPM 的request_terminate_timeout或 nginx 的fastcgi_read_timeout症状就是 504 空 body。这三个超时要一起看。ob_start()之后的exit不一定触发 flush。输出缓冲在脚本正常结束时会被自动 flush但有些框架在中间件里调用ob_end_clean()清理缓冲或者缓冲区超过了output_buffering的限制被提前送出都会让最后统一输出的设计失效。空数组和空对象在 JSON 里不是一个东西。json_encode([])得到[]json_encode(new stdClass())和json_encode([], JSON_FORCE_OBJECT)得到{}。前端如果写了if (res.data.xxx)这种取值方式拿到[]就会静默失败——数据其实没丢是形态不对。常见坑点1. 直接echo json_encode($data)不检查返回值// ❌ 编码失败时输出空字符串响应 200 空 body问题被吞掉 echo json_encode($data);// ✅ 让失败变成异常再由统一出口兜底 echo json_encode($data, JSON_THROW_ON_ERROR);2. 数据里有非法 UTF-8整包一起失败// ❌ 一个坏字节废掉整个响应 $out [total 10, rows $rows]; // 某个 $row 里混了 GBK 字节// ✅ 在响应层统一归一化或从数据源就统一按 UTF-8 读 $out normalizeForJson([total 10, rows $rows]);3. 把INF/NAN放进响应// ❌ 命中 Inf and NaN cannot be JSON encoded整个接口返回空 return json_encode([rate $total 0 ? INF : $hit / $total]);// ✅ 先判有限性用 null 表达无法计算 $rate $total 0 ? null : $hit / $total; return json_encode([rate is_finite((float) $rate) ? $rate : null], JSON_THROW_ON_ERROR);4. 某处的exit让响应没写出去// ❌ 中间件里为了提前拦截直接 exit结果什么都没输出 if (!$user) { exit; }// ✅ 任何终止路径都必须先输出结构化响应 if (!$user) { jsonResponse([error UNAUTHORIZED], 401); }5. 空数组被前端当成空对象// ❌ 前端期望对象拿到 [] 后 res.data.id 静默失败 echo json_encode([data []]);// ✅ 明确用对象语义或者干脆约定空结果返回 null echo json_encode([data (object) []], JSON_THROW_ON_ERROR); // {data:{}}6. 文件存成 UTF-8 with BOMbody 前面多三个字节// ❌ 某个被 require 的类文件带了 BOM输出到响应最前面 // 前端 JSON.parse 报 Unexpected token# ✅ 排查时全局找一遍 BOM并把编辑器统一设为 UTF-8 无 BOM grep -rlP ^\xEF\xBB\xBF app/7. 缓存里存的和读的序列化方式不一致// ❌ 写入方存的是 serialize() 结果读取方却按 JSON 解析得到空数组 $cache-set($key, serialize($data)); $data json_decode((string) $cache-get($key), true); // null// ✅ 读缓存前先用 json_validate() 验一遍PHP 8.3不一致就丢弃并记日志 if (json_validate((string) $raw)) { $data json_decode((string) $raw, true); }8. 只看日志不看响应头把网关超时当成业务空数据# ❌ 日志里没有任何错误就以为业务逻辑没问题 tail -n 50 /var/log/php-fpm/error.log# ✅ 先确认状态码与 body 关系504 是网关生成的不是 PHP 返回的空 curl -o /dev/null -s -w status%{http_code} size%{size_download} time%{time_total}\n https://api.example.com/v1/orders总结步骤动作目的1curl -i看状态码、Content-Length、body 字节数区分真空截断网关错误2xxd看 body 头几个字节排除 BOM 与不可见字符3统一响应出口 JSON_THROW_ON_ERROR让编码失败不再静默4normalizeForJson()归一化处理非法 UTF-8 与INF/NAN5register_shutdown_function兜底致命错误拿到已输出字节数判断是否截断6对比三处超时max_execution_time、FPM、nginx定位 502/504结论排查接口返回为空先分形态再找环节不要一上来就怀疑 SQL。绝大多数200 空 body的根因就是json_encode()返回了false而没人检查——错误信息一直都在json_last_error_msg()里。把响应出口收敛到一个函数上让它在失败时给出结构化的错误响应、在成功时保证Content-Length与实际字节数一致这类问题就会从偶发的玄学故障变成日志里一行明确的报错。