PHP 8.3 接口返回数据为空怎么排查

前言

"接口返回为空"是所有 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 与实际字节数一致,这类问题就会从"偶发的玄学故障"变成"日志里一行明确的报错"。

相关推荐
谢亮_vipxieliang1 小时前
Java 21 新特性实战:Record、Sealed、模式匹配
java·开发语言
霸道流氓气质1 小时前
LLM 应用限流与熔断机制完全指南:从多层防护架构到Java生产级弹性实战
java·开发语言·架构
大侠归来1 小时前
C语言内存管理:从栈到堆的完整指南
c语言·开发语言·python
m0_380743872 小时前
PHP7.0字符串在Docker怎么用
开发语言·php
LeoCrawls2 小时前
Python 读取 JSON 常见报错排查,附完整处理函数
python·json·php
朝朝辞暮i2 小时前
C++ 第 10 课:函数 Function
开发语言·c++
朝朝辞暮i2 小时前
C++ 第7课 while 循环
开发语言·c++·算法
小朱爱编程1232 小时前
我用 Jev 做了三个实用工具:整理标签页、分诊飞书反馈、找回 GitHub 收藏
java·开发语言·人工智能·后端·python·架构·ai编程
谢亮_vipxieliang3 小时前
Java 8/11/17/21/25 怎么选?一篇讲清 LTS 升级路线
java·开发语言