一次非常隐蔽的 Yii2 Bug:为什么第一次请求返回结构不对,第二次却正常?
2026-03-09
在很多 Yii2 项目里,都会做一件事情:
统一接口返回结构。
例如所有接口统一返回:
{
"code": 0,
"msg": "success",
"data": {}
}这样前端调用接口就会非常稳定。
最常见的实现方式,就是使用:
Yii2 的 Response::EVENT_BEFORE_SEND。
例如:
Yii::$app->response->on(\yii\web\Response::EVENT_BEFORE_SEND, function ($event) {
$response = $event->sender;
if ($response->format === \yii\web\Response::FORMAT_JSON) {
$response->data = [
'code' => 0,
'msg' => 'success',
'data' => $response->data
];
}
});这样 controller 里只需要写:
return ['name' => 'test'];
最终返回给前端的就是:
{
"code":0,
"msg":"success",
"data":{
"name":"test"
}
}一切看起来非常完美。
直到有一天,我遇到了一个非常诡异的问题。
第一次请求接口:
返回结构 不正常。
第二次请求接口:
返回结构 正常。
而且每次清缓存之后,第一次又会出问题。
一、问题现象
接口返回结构如下:
第一次请求:
{
"routes": [...]
}第二次请求:
{
"code": 0,
"msg": "success",
"data": {
"routes": [...]
}
}也就是说:
统一返回结构失效了。
但更诡异的是:
只在第一次请求出现。
二、第一反应:缓存问题
系统里有一段代码负责扫描系统 Route,并缓存结果。
代码大致是:
public function getAppRoutes($module = null)
{
if ($module === null) {
$module = Yii::$app;
}
$key = [__METHOD__, Yii::$app->id, $module->getUniqueId()];
$cache = Configs::instance()->cache;
if ($cache === null || ($result = $cache->get($key)) === false) {
$result = [];
$this->getRouteRecursive($module, $result);
$cache->set($key, $result);
}
return $result;
}逻辑非常简单:
1 如果缓存存在 → 直接返回
2 如果缓存不存在 → 扫描 Route → 写缓存
这时候问题出现了。
只要第一次扫描 Route,返回结构就会异常。
而读取缓存的时候又正常。
这就非常容易让人误以为:
缓存内容不正确。
三、验证缓存内容
于是我第一时间打印缓存数据:
Yii::error($cache->get($key));
结果发现:
缓存数据完全正常。
Route 列表没有任何问题。
说明:
问题根本不在缓存。
四、继续排查:统一返回结构去哪了?
统一返回结构依赖的是:
Response::EVENT_BEFORE_SEND
于是我检查 Response 是否还绑定了事件:
Yii::$app->response->hasEventHandlers(\yii\web\Response::EVENT_BEFORE_SEND);
结果发现:
在 Route 扫描之前:
true
在 Route 扫描之后:
false
也就是说:
beforeSend 事件被关闭了。
五、是谁关闭了 beforeSend?
继续搜索代码,最终发现:
在扫描 Route 时,为了避免输出干扰,有代码执行了:
Yii::$app->response->off(\yii\web\Response::EVENT_BEFORE_SEND);
理论上这一步是合理的。
因为扫描 controller / module 时,有些代码可能会触发 Response。
为了避免干扰,临时关闭 beforeSend。
但问题是:
扫描结束后没有恢复。
于是整个应用后续请求都失去了 beforeSend。
统一返回结构当然也就失效了。
六、为什么“读取缓存”时反而正常?
这就是最迷惑人的地方。
来看完整逻辑:
第一次请求 ↓ 缓存不存在 ↓ 执行 Route 扫描 ↓ 扫描过程中关闭 beforeSend ↓ 扫描结束没有恢复 ↓ 返回结果(未经过 beforeSend) ↓ 结构异常
第二次请求:
缓存存在 ↓ 直接读取缓存 ↓ Route 扫描不会执行 ↓ beforeSend 没被关闭 ↓ 统一返回结构正常
所以就出现了一个非常诡异的现象:
第一次请求结构错误,第二次请求结构正常。
七、最终解决方案
解决办法其实非常简单。
只需要在扫描完成之后:
重新绑定 beforeSend 事件。
例如:
if ($cache === null || ($result = $cache->get($key)) === false) {
$result = [];
$this->getRouteRecursive($module, $result);
// 重新绑定 beforeSend
Yii::$app->response->on(
\yii\web\Response::EVENT_BEFORE_SEND,
[ResponseFormatter::class, 'format']
);
$cache->set($key, $result);
}也就是说:
Route 扫描过程中可以关闭事件,但必须恢复。
这样:
第一次请求:
扫描 Route
恢复 beforeSend
返回结构正常
第二次请求:
直接读缓存
结构依然正常
问题彻底解决。
八、这次排查带来的一个重要经验
很多 Yii2 项目都会在某些组件里:
修改全局状态。
例如:
Response 事件
ErrorHandler
Formatter
DI Container
如果这些状态在使用之后没有恢复,就会产生非常诡异的问题。
这类问题通常有几个特征:
1 看起来像缓存问题
2 只在第一次请求出现
3 与初始化顺序有关
4 很难第一时间定位
九、总结
这次 Bug 的真实原因其实很简单:
Route 扫描关闭了 beforeSend ↓ 扫描结束没有恢复 ↓ 第一次请求未经过统一封装 ↓ 返回结构异常
而读取缓存时不会执行扫描,所以结构又恢复正常。
最终解决方案就是:
在扫描完成后重新绑定 beforeSend 事件。
十、一句话总结
这次问题最迷惑人的地方是:
看起来像缓存问题,本质却是 Response 事件被关闭。
如果你在 Yii2 项目中遇到:
接口返回结构突然改变
第一次请求异常
第二次请求正常
一定要检查:
Response::EVENT_BEFORE_SEND
是否被意外关闭。
发表评论: