> For the complete documentation index, see [llms.txt](https://tech.banchengyun.com/llms.txt). Markdown versions of documentation pages are available by appending `.md` to page URLs; this page is available as [Markdown](https://tech.banchengyun.com/archives/backend/hyperf/hyperf-proxy-cache.md).

# 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!
