咚咚技术团队
  • 首页
  • 文章
    • 前端
      • 0.1 + 0.2 精度丢失深究
      • IOS H5 视频无法播放
      • H5 播放 amr 音频文件
      • IOS 10.x 版本在 Taro 中的兼容性问题
      • 百度 UEditor 引发的 cross-iframe 问题解决方案
      • 访问 www.banchengyun.com 时发生了什么
      • decodeURIComponent 与特殊符号
      • 前端埋点
    • 后端
      • Swoole 相关
        • MAC 本地环境执行 GuzzleHttp 时导致 Swoole 进程异常退出
      • Hyperf 相关
        • 在 phpstorm 中调试 hyperf 代码
        • Hyperf 1.x Proxy 缓存失效问题
      • K8s 相关
        • 搭建 k8s 集群
        • 使用 docker-compose 快速搭建 Hyperf + Redis 开发环境
        • Kubernetes Autoscaler
      • 其它
        • 幂等性和原子性
    • 测试
    • 效能提升
      • 优秀开发者的第一步:始于需求分析
      • 优秀开发者的第二步:如何阅读他人的代码
  • 活动
  • 课堂
  • 知识库
    • 公共
      • 什么是流程型组织
      • 半城云集成产品开发流程
      • 阿⾥云 Codeup 代码平台使⽤ & 迁移指南
      • git 使用规范
      • 关于第三方与服务号授权的问题
      • 收不到消息的排查方法
      • 系统安全
      • 前端编码规范
      • 后端编码规范
      • 测试规范
    • 前端
      • 规范
        • 前端编码规范
        • 咚咚技术栈
        • code-review 规范
        • git 工作流
        • Tapd 文档
      • 复盘经验
        • 2021.01 效能、规范、技术债讨论会
      • Code Review
        • SCRM 2020-07
    • 后端
      • 复盘经验
        • SCRM 2020 年 8 月
      • Code Review
        • SCRM 2020-07
    • 测试
      • 复盘经验
        • SCRM 2020 年 8 月
  • 项目文档
    • 前端
    • 后端:小程序
    • 后端:企业微信
  • 接口文档
  • 兴趣小组
    • golang 小组
    • 增长小组
    • 前端小组
  • 书单推荐
  • 生产环境 分析会
    • NO.2022.01
  • 生产环境 可用性
  • 团队活动
    • OpenTalk
      • NO.2021.Q3
      • NO.2020.Q2
    • WalkTogether
  • 关于我们
  • GitBook 使用说明
由 GitBook 提供支持
在本页

这有帮助吗?

  1. 文章
  2. 后端
  3. Hyperf 相关

Hyperf 1.x Proxy 缓存失效问题

上一页在 phpstorm 中调试 hyperf 代码下一页K8s 相关

最后更新于3年前

这有帮助吗?

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

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

image-20200925191614707

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

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这个字符串, 这能让我们快速定位到相关代码

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

 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方法

    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的生成规则

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

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

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了

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!

image-20200925193716874