前言
"接口返回为空"是所有 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 把响应头和字节数都看清楚:
bash
# -i 显示响应头,-s 静默进度,最后用 wc -c 看 body 的准确字节数
curl -i -s 'https://api.example.com/v1/orders?page=1' -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
# 十六进制看一眼有没有 BOM(EF BB BF)或不可见字符
head -c 32 /tmp/resp.txt | xxd
响应头里有三个字段特别能说明问题:
| 观察到的现象 | 含义 | 常见根因 |
|---|---|---|
Content-Length: 0,状态 200 |
脚本正常结束,但什么都没输出 | json_encode() 返回 false;分支里提前 exit |
无 Content-Length,Transfer-Encoding: chunked,body 很短 |
输出被截断 | 中途致命错误、超时被 kill |
| 状态 502 / 504,body 是网关的错误页 | 请求根本没走完 | PHP-FPM 超时、进程崩溃、OOM |
body 以 EF BB BF 开头 |
有 BOM 头 | 某个被 include 的文件存成了 UTF-8 with BOM |
body 是 {"data":[]} 且 Content-Length 正常 |
链路完全正常 | 数据侧确实没查到记录 |
第三行和第四行尤其重要:502/504 不是 PHP 返回的空响应,那是网关在 PHP 进程失联之后自己生成的。看到 502 却去翻业务代码,方向就全错了。
一个最小的复现:json_encode() 是怎么静默失败的
php
<?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 / NAN(fdiv() 是 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
<?php
// api-responder.php ------ 需要 PHP 8.3+
// 用法:php api-responder.php
declare(strict_types=1);
/**
* 递归归一化:把不能进 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; charset=utf-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
<?php
// fatal-guard.php ------ 需要 PHP 7.4+
declare(strict_types=1);
// 必须先注册,且早于任何业务逻辑
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; charset=utf-8');
echo '{"error":"INTERNAL_ERROR"}';
}
});
日志里的"已输出字节数"是排查这类问题的关键信息:它大于 0 就说明响应已经被部分写出去了,前端看到的截断 JSON 就是这么来的;等于 0 则说明致命错误发生在一开始,此时用上面的兜底还能救回来。
三、分场景排查清单
定位到形态之后,按下面这张表逐个排除。命令都可以直接执行。
| 形态 | 检查动作 | 命令示例 |
|---|---|---|
| 空 body,200 | 找 json_encode 的返回值有没有被判断 |
`grep -rn 'json_encode' app/ \ |
| 空 body,200 | 看是否在某分支里 exit 了 |
`grep -rn 'exit\ |
| 截断 JSON | 看 PHP-FPM 与 nginx 错误日志 | tail -n 100 /var/log/php-fpm/error.log |
| 502 / 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),不检查返回值
php
// ❌ 编码失败时输出空字符串,响应 200 + 空 body,问题被吞掉
echo json_encode($data);
php
// ✅ 让失败变成异常,再由统一出口兜底
echo json_encode($data, JSON_THROW_ON_ERROR);
2. 数据里有非法 UTF-8,整包一起失败
php
// ❌ 一个坏字节废掉整个响应
$out = ['total' => 10, 'rows' => $rows]; // 某个 $row 里混了 GBK 字节
php
// ✅ 在响应层统一归一化,或从数据源就统一按 UTF-8 读
$out = normalizeForJson(['total' => 10, 'rows' => $rows]);
3. 把 INF / NAN 放进响应
php
// ❌ 命中 "Inf and NaN cannot be JSON encoded",整个接口返回空
return json_encode(['rate' => $total === 0 ? INF : $hit / $total]);
php
// ✅ 先判有限性,用 null 表达"无法计算"
$rate = $total === 0 ? null : $hit / $total;
return json_encode(['rate' => is_finite((float) $rate) ? $rate : null], JSON_THROW_ON_ERROR);
4. 某处的 exit 让响应没写出去
php
// ❌ 中间件里为了"提前拦截"直接 exit,结果什么都没输出
if (!$user) {
exit;
}
php
// ✅ 任何终止路径都必须先输出结构化响应
if (!$user) {
jsonResponse(['error' => 'UNAUTHORIZED'], 401);
}
5. 空数组被前端当成空对象
php
// ❌ 前端期望对象,拿到 [] 后 res.data.id 静默失败
echo json_encode(['data' => []]);
php
// ✅ 明确用对象语义,或者干脆约定"空结果返回 null"
echo json_encode(['data' => (object) []], JSON_THROW_ON_ERROR); // {"data":{}}
6. 文件存成 UTF-8 with BOM,body 前面多三个字节
php
// ❌ 某个被 require 的类文件带了 BOM,输出到响应最前面
// 前端 JSON.parse 报 "Unexpected token"
bash
# ✅ 排查时全局找一遍 BOM,并把编辑器统一设为 UTF-8 无 BOM
grep -rlP '^\xEF\xBB\xBF' app/
7. 缓存里存的和读的序列化方式不一致
php
// ❌ 写入方存的是 serialize() 结果,读取方却按 JSON 解析,得到空数组
$cache->set($key, serialize($data));
$data = json_decode((string) $cache->get($key), true); // null
php
// ✅ 读缓存前先用 json_validate() 验一遍(PHP 8.3),不一致就丢弃并记日志
if (json_validate((string) $raw)) {
$data = json_decode((string) $raw, true);
}
8. 只看日志不看响应头,把网关超时当成业务空数据
bash
# ❌ 日志里没有任何错误,就以为业务逻辑没问题
tail -n 50 /var/log/php-fpm/error.log
bash
# ✅ 先确认状态码与 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'
总结
| 步骤 | 动作 | 目的 |
|---|---|---|
| 1 | curl -i 看状态码、Content-Length、body 字节数 |
区分"真空""截断""网关错误" |
| 2 | xxd 看 body 头几个字节 |
排除 BOM 与不可见字符 |
| 3 | 统一响应出口 + JSON_THROW_ON_ERROR |
让编码失败不再静默 |
| 4 | normalizeForJson() 归一化 |
处理非法 UTF-8 与 INF/NAN |
| 5 | register_shutdown_function 兜底致命错误 |
拿到"已输出字节数",判断是否截断 |
| 6 | 对比三处超时(max_execution_time、FPM、nginx) |
定位 502/504 |
结论:排查"接口返回为空",先分形态,再找环节 ,不要一上来就怀疑 SQL。绝大多数"200 + 空 body"的根因就是 json_encode() 返回了 false 而没人检查------错误信息一直都在 json_last_error_msg() 里。把响应出口收敛到一个函数上,让它在失败时给出结构化的错误响应、在成功时保证 Content-Length 与实际字节数一致,这类问题就会从"偶发的玄学故障"变成"日志里一行明确的报错"。