# Hyperf 1.x Proxy 缓存失效问题

> 本文仅针对Hyperf 1.x版本, 实际上2.x版本也遭遇到同样的现象, 但是和1.x版本是不一样的问题

在对SCRM项目后端应用进行迁移时, 收到集群的一个告警

![image-20200925191614707](https://bcy-tech.oss-cn-hangzhou.aliyuncs.com/uPic/image-20200925191614707.png)

OOM的全称是`Out Of Memory`, 翻译成中文就是内存用完了, 和后面的killed连起来就是这个容器组的内存用完了所以k8s把它杀掉了, 容器组的内存限制配置如下

```yaml
resources:
  limits:
    cpu: '1'
    memory: 1Gi
  requests:
    cpu: 500m
    memory: 200Mi
```

可以看到上面的配置运行容器组使用1G的内存, 这对正常的PHP应用来说是足够的, 况且这个容器组刚启动还没有任何流量的情况下就被OOM killed了, 这显然是不正常的

在传统的Nginx + PHP-FPM架构中, 一个请求对应一个进程, 请求结束后进程占用的内存会被释放, 但Swoole是常驻型的, 一个进程处理所有请求, 内存只会在GC时或者进程结束时被释放

由于容器是刚启动就被OOM killed, 也就是说不是由于接口导致内存泄露的, 那就是启动阶段的某个环节出了问题, 启动时的这两行日志引起了我的注意

```
Detected an available cache, skip the app scan process.
Detected an available cache, skip the vendor scan process.
```

翻译过来就是发现了可用的缓存, 跳过app/vendor的扫描, 这里的扫描指的是Hyperf的注解扫描, 注解在业务代码中大量使用, 极大的提高了开发效率, 但是注解并不是PHP原生支持的功能, 框架读取我们写的注解实际上用的是很原始的方式, 即是对所有代码进行语法分析, 语法分析的本质是字符串操作, 也就是把所有代码加载到内存中, 然后转换成AST, 再读取其中的注解, 如果不理解这个过程也没关系, 只要记住一点扫描的动作需要把所有代码当做字符串加载到内存中

Hyperf提供了预生成缓存的功能, 正如上面两行输出, 我们可以先生成一个缓存然后应用启动的时候就不需要扫描了, 这个预生成的动作放在镜像构建里, 这会增加镜像构建时的开销, 但减轻了容器运行时的开销

```
RUN composer install --no-dev -vvv
COPY . .
RUN composer dump-autoload -o
```

在Dockerfile中使用了`composer dump-autoload -o`命令进行预生成缓存, 执行完后会生成两个缓存文件

```
2m@2ms-MacBook-Pro scrm % ls -la runtime/container
total 80
drwxr-xr-x  5 2m  staff    160  9 25 17:36 .
drwxr-xr-x  3 2m  staff     96  9 25 17:36 ..
-rw-r--r--  1 2m  staff  15895  9 25 19:09 annotations.app.cache
-rw-r--r--  1 2m  staff  21618  9 25 19:08 annotations.vendor.cache
drwxr-xr-x  6 2m  staff    192  9 25 17:36 proxy
```

上面两个以`.cache`为后缀的就是缓存文件, 照理说有缓存了Hyperf启动时就会读取, 然后跳过上面说的注解扫描动作, 但奇怪的事情发生了, 在k8s里的容器启动时输出的日志是这样的

```
2m@2ms-MacBook-Pro scrm % php bin/hyperf.php
Scanning app ...
Scan app completed, took 1636.6400718689 milliseconds.
Detected an available cache, skip the vendor scan process.
```

从日志中看到框架并没有读取app缓存, 而是重新进行了一次扫描, 基本可以确定这个扫描动作就是导致OOM的原因, 那为什么框架会不读取app缓存呢, 下面进行debug

如果需要逐个环节去追Hyperf的启动流程, 这会很复杂, 所以使用一种比较简单粗暴的方法快速定位相关代码, 搜索关键字

我们直接在`vendor/hyperf`目录下搜索`Scanning`这个字符串, 这能让我们快速定位到相关代码

![image-20200925193716874](https://bcy-tech.oss-cn-hangzhou.aliyuncs.com/uPic/image-20200925193716874.png)

追进去找到一个`loadMetadata`方法, 大概长这样

```php
 private function loadMetadata(array $paths, $type)
    {
        if (empty($paths)) {
            return true;
        }
        $cachePath = $this->cachePath.'.'.$type.'.cache';
        $pathsHash = md5(implode(',', $paths));
        if ($this->hasAvailableCache($paths, $pathsHash, $cachePath)) {
            $this->printLn('Detected an available cache, skip the '.$type.' scan process.');
            [, $serialized] = explode(PHP_EOL, file_get_contents($cachePath));
            $this->scanner->collect(unserialize($serialized));

            return false;
        }
        $this->printLn('Scanning '.$type.' ...');
                // 扫描注解 & 生成缓存逻辑
        return true;
    }
```

从这个方法代码看起来只能是第八行的`hasAvailableCache`调用返回了`false`导致代码走到15行后面的生成注解逻辑, 继续追`hasAvailableCache`方法

```php
    private function hasAvailableCache(array $paths, string $pathsHash, string $filename): bool
    {
        if (!$this->enableCache) {
            return false;
        }
        if (!file_exists($filename) || !is_readable($filename)) {
            return false;
        }
        $handler = fopen($filename, 'r');
        while (!feof($handler)) {
            $line = fgets($handler);
            if (trim($line) !== $pathsHash) {
                return false;
            }
            break;
        }
        $cacheLastModified = filemtime($filename) ?? 0;
        $finder = new Finder();
        $finder->files()->in($paths)->name('*.php');
        foreach ($finder as $file) {
            if ($file->getMTime() > $cacheLastModified) {
                return false;
            }
        }

        return true;
    }
```

可以看到有四个地方会返回`false`, 在每个可能返回`false`的地方打上标记, 发现是走到了第13行的false返回了, 这段代码的逻辑是读取缓存文件(runtime/container/annotations.app.cache)的第一行, 然后与`$pathsHash`变量进行比较, 所以问题就是缓存文件的第一行和`$pathsHash`的值不一致导致的, 回到`loadMetadata`方法查看`$pathsHash`的生成规则

```php
$pathsHash = md5(implode(',', $paths));
```

可以看到`$pathsHash`是由`$paths`数组使用逗号拼成字符串后的md5值, 那`$paths`是什么呢, 篇幅所限这里就不展开追踪, `$paths`的值是从配置`annotations.scan.paths`字段获取的, 先来看一下这个配置

```php
use Symfony\Component\Finder\Finder;

return [
    'scan' => [
        'paths'              => value(function () {
            $paths = [];

            $dirs = Finder::create()->in(BASE_PATH.'/app')
                ->depth('< 1')
                ->exclude(['Model'])
                ->directories();

            /** @var SplFileInfo $dir */
            foreach ($dirs as $dir) {
                $paths[] = $dir->getRealPath();
            }

            return $paths;
        }),
        'ignore_annotations' => [
            'mixin',
        ],
    ],
];
```

这个配置指示了注解扫描器要扫描哪些目录下的文件, 从上面的代码来看就是扫描app/目录下除了`Model`下的所有文件, 打印这个数组可以看到

```
Array
(
    [0] => /opt/www/app/WorkWechat
    [1] => /opt/www/app/Wechat
    [2] => /opt/www/app/Admin
    [3] => /opt/www/app/Exception
    [4] => /opt/www/app/Manage
    [5] => /opt/www/app/Command
    [6] => /opt/www/app/Middleware
    [7] => /opt/www/app/Common
    [8] => /opt/www/app/Listener
    [9] => /opt/www/app/Validate
    [10] => /opt/www/app/PublicApi
    [11] => /opt/www/app/DataCollection
    [12] => /opt/www/app/Job
)
```

这看起来没什么问题, 因为这些目录是固定的, 那为什么把这些目录拼起来之后md5的值会改变呢? 这问题的答案很简单, PHP的遍历是不保证顺序的, 就比如上面的输出是镜像构建时生成缓存的时候输出的, 但是当容器启动时, 输出就变成了

```
Array
(
    [0] => /opt/www/app/Middleware
    [1] => /opt/www/app/Exception
    [2] => /opt/www/app/Listener
    [3] => /opt/www/app/Wechat
    [4] => /opt/www/app/WorkWechat
    [5] => /opt/www/app/Common
    [6] => /opt/www/app/Manage
    [7] => /opt/www/app/Job
    [8] => /opt/www/app/PublicApi
    [9] => /opt/www/app/Command
    [10] => /opt/www/app/Validate
    [11] => /opt/www/app/DataCollection
    [12] => /opt/www/app/Admin
)
```

虽然里面的内容没变但是顺序变了, 拼接成字符串之后自然就不一样了! 解决方法也很简单, 只要确保`$paths`数组内的目录顺序始终保持一致就ok了

```php
use Symfony\Component\Finder\Finder;

return [
    'scan' => [
        'paths'              => value(function () {
            $paths = [];

            $dirs = Finder::create()->in(BASE_PATH.'/app')
                ->depth('< 1')
                ->exclude(['Model'])
                ->directories();

            /** @var SplFileInfo $dir */
            foreach ($dirs as $dir) {
                $paths[] = $dir->getRealPath();
            }

              // Fix
            asort($paths);

            return $paths;
        }),
        'ignore_annotations' => [
            'mixin',
        ],
    ],
];
```

仅加了一行代码, 在返回`$paths`数组前使用`asort`函数对数组的值进行一次排序, 这样就确保了每次返回的`$paths`数组内容都是完全一致的了

修改前

```
2m@2ms-MacBook-Pro scrm % kubectl top po -n scrm
NAME                        CPU(cores)   MEMORY(bytes)   
app-prod-5f494856cd-8f49h   0m           1118Mi          
app-prod-5f494856cd-pvv2x   35m          1122Mi
```

修改后

```
2m@2ms-MacBook-Pro scrm % kubectl top po -n scrm
NAME                       CPU(cores)   MEMORY(bytes)   
app-prod-7d65bcd67-5sf5r   0m           656Mi           
app-prod-7d65bcd67-hbpq5   0m           655Mi
```

内存使用量下降了一倍, Nice!


---

# Agent Instructions: Querying This Documentation

If you need additional information that is not directly available in this page, you can query the documentation dynamically by asking a question.

Perform an HTTP GET request on the current page URL with the `ask` query parameter:

```
GET https://tech.banchengyun.com/archives/backend/hyperf/hyperf-proxy-cache.md?ask=<question>
```

The question should be specific, self-contained, and written in natural language.
The response will contain a direct answer to the question and relevant excerpts and sources from the documentation.

Use this mechanism when the answer is not explicitly present in the current page, you need clarification or additional context, or you want to retrieve related documentation sections.
