无尘阁日记

无尘阁日记

一次非常隐蔽的 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

是否被意外关闭。