一个基于k8s的PHP Lumen项目的Readme文档

说明: 文档中进行了大量的替换操作,不能完全保证所有值均正确,如有细节问题请忽略

项目环境变量

目前项目采用单一环境变量加载模式,即只加载一个.env.xxx的文件。

判断模式有两种:参考代码bootstrap/app.php:10

  1. 读取当前环境变量 ROCKETOS_ENVIRONMENT 进行配置加载
  2. .env.local, .env.production, .env.develop, .env.test, .env.stress的顺序进行文件加载

实际运行时,仅本地会存在多个配置文件,此时只需要执行以下命令进行环境变量的复制操作:

# 本地使用的是 .env.local, 而非 .env, 两个文件均在.gitignore中,不会进行版本控制
.env.example', '.env.local

为什么使用 .env.local 而不是 .env:
由于历史原因, 之前的项目需要考虑非容器化部署,而使用.env意味着要进行一些额外的发布操作,
曾经有过发布事故,导致生产环境配置混乱,才使用严格区分。

运行时的环境判断参数:APP_ENV=production, APP_ENV=develop, APP_ENV=local, 这几个参数通过指定的.env文件进行配置

对应的PHP代码进行环境判断可以通过如下方式:

use App\Utils\ServerEnv\ServerStatus;
// 当前是否是正式环境
dump(ServerStatus::isProduction()); // return bool
dump(app()->environment('production')); // return bool
dump(env('APP_ENV')==='production'); // env('APP_ENV') return string

代码修正

代码修正采用 https://cs.symfony.com/ 进行代码优化和修正,https://github.com/FriendsOfPHP/PHP-CS-Fixer 查看源码。

安装

安装建议采用composer global模式,如果希望使用其他方式,参考官方文档

composer global require friendsofphp/php-cs-fixer

PATH环境变量添加:

# linux
export PATH="$PATH:$HOME/.composer/vendor/bin"

# windows 手动添加路径
C:\Users\[你的用户名]\AppData\Roaming\Composer\vendor\bin

配置

配置参考文件: .php_cs, 这里添加了大量的常见配置,需要注意的是,这里启用了严格模式(declare(strict_types=1);),
执行过程要注意添加--allow-risky=yes参数,有可能会对代码进行破坏性改动。

# 执行修复
php-cs-fixer fix --allow-risky=yes

# 快速执行 (windows下的封装)
cs-fixer.bat

# 检查,但不执行,可以用于检查有那些文件需要修改
php-cs-fixer fix --dry-run --allow-risky=yes

代码格式

开发工具推荐使用phpStorm, 如果使用其他工具请确保要支持.editorconfig进行基本功能格式化,否则会导致严重的代码风格混乱。

默认代码格式使用.editorconfig进行配置,遵循一些基本的标准,相对复杂格式与一些强制性的规范会通过.php_cs进行配置,
更细化的配置通过phpStorm进行配置,推荐统一使用Laravel风格配置。

本地Docker运行

本地打包一个可以执行的完整镜像

注: 本地运行不支持在纯windows下进行操作,必须使用linux兼容环境下执行, 本地执行使用的环境变量为.env.develop

./cli/shell/run.local.sh

执行过程中可能遇到的问题:

  1. 提示bash\r未找到,可能是因为在windows下开发,换行符未\r\n, 需要先调整换行符为\n
  2. docker commnd not found. 需要自己安装docker-ce

使用本地挂载等形式进行开发:

注:目前不提供专门用于本地开发所需的基础镜像,需要通过基础镜像进行build

  1. 手动编写 docker build . 执行脚本文件
  2. 添加环境变量 php.opcache.enable=0 禁用opcache
  3. 挂载目录 /data/htdocs/k8s-php-api/ 到你的本地目录
  4. 绑定 80 端口到宿主机
  5. 调整本地日志目录有写入权限chmod 777 storage/logs, 避免因为日志无法写入导致的异常

参考执行本地开发运行脚本, 注意,此处使用apollo的配置文件, 写入文件到run-local-docker.sh, 该文件会被忽略

#!/usr/bin/env bash
# location: run-local-docker.sh
localWeb=/root/k8s-php-api
docker build \
    -f ./docker/Dockerfile.dev \
    -t k8s-php-api:local \
    --build-arg CI_COMMIT_SHA=local \
    --build-arg CI_COMMIT_REF_NAME=local \
    --build-arg CI_BUILD_DATETIME=local . \
&& docker run -ti --rm k8s-php-api:local bash -c "nginx -v && php -v && nginx -t" \
&& docker run -ti --rm \
    --publish 8888:8080 \
    --name k8s-php-api-local-tmp \
    -e php.opcache.enable=0 \
    --volume ${localWeb}:/data/htdocs/k8s-php-api \
    k8s-php-api:local bash -c \
    "cd /data/htdocs/k8s-php-api \
   && composer dump-autoload \
   && supervisord"

最终运行说明:一定要重新进行composer dump-autoload操作,否则一些新增类会提示无法找到。

如果需要使用本地的mso文件,则需要进行如下调整,主要是关闭apollo自启和开启nginx,php-fpm的自启

msoPath=/root/mso
docker run -ti --rm \
    --publish 8888:8080 \
    --name k8s-php-api-local-tmp \
    -e php.opcache.enable=0 \
    --volume ${localWeb}:/data/htdocs/k8s-php-api \
    --volume ${msoPath}:/data/core_config \
    k8s-php-api:local bash -c \
    "cd /data/htdocs/k8s-php-api \
   && composer dump-autoload \
   && rm -f /opt/docker/etc/supervisor.d/apollo.dev.conf \
   && sed -i "
s#autostart = false#autostart = true#g" /opt/docker/etc/supervisor.d/nginx.conf \
    && sed -i "s#autostart = false#autostart = true#g" /opt/docker/etc/supervisor.d/php-fpm.conf \
    && supervisord"

测试环境运行

测试环境的构建一般通过gitlab-ci进行镜像构建,具体参考.gitlab-ci.yml文件,目前仅针对dev分支开启。

一般流程为:

  1. 登陆 harbor.example.net, 避免因不同runner环境导致登陆失效问题
  2. 下拉最新基础底包,当前使用harbor.example.net/k8s-php/php-base:1.0.1, 大多数的场景更新底包不会调整版本号,只有涉及较大更新才会进行变更。
  3. 执行docker build操作,此步骤会生成一个完整的镜像,可直接进行运行和测试
  4. 执行测试操作,一般操作为测试nginx配置是否正常,后续会添加更多操作
  5. 推送镜像到仓库
  6. 触发测试环境的镜像重启钩子

生产环境运行

生产环境目前会在master分支进行打包操作,过程同样通过gitlab-ci触发,整个过程与测试分支基本一致,主要区别在于master分支不会自动触发,而需要通过GoPub进行发布。

GoPub发布流程分为如下几步:

  1. 触发发布操作
  2. 登陆发布机器
  3. 拉取master镜像到本地
  4. 依据发布时间对镜像进行tag标记,然后推送到harbor.example.net, 这一步的主要目的是便于进行回滚操作
  5. 从Apollo拉取进行配置文件,如本项目的配置为: k8s-php-api, 并生成k8s环境所需的yaml配置文件
  6. 执行yaml文件,命令如:kubectl apply -f /data/htdocs/k8s-php-api/deployment.yaml --...

Apollo配置中心说明

公共核心配置 config-common

该配置与公司的其他配置一致,主要是解决一些数据库密码,数据库连接,核心密钥等信息的存储。
生成数据一般为数组格式,参考文件:/data/core_config/zzc_config.arr.php,该文件为动态生成文件,为apollo处理。

系统读取该配置文件的形式步骤一般为:

  1. 文件 bootstrap/app.php 执行 $app->configure('mso'); 用于加载mso配置到系统
  2. 使用 config('mso')config('mso.mysql') 等形式读取配置文件

注: 一旦配置文件变更,会触发系统文件/cli/apollo/apollo_hook.php的更新操作,该操作主要执行以下内容:

  1. 清理opcache缓存
  2. 重启消费者
  3. 初次加载启动nginx与php-fpm

APP K8S 配置 k8s-php-api

该配置文件仅用于K8s的配置文件生成,文件分为几个部分:

  1. deployment.yaml 关键性的部署文件,主要涉及容器的副本数量,关联启动的容器,容器启动时的环境变量等。
  2. service.yaml 当前K8s的service的配置,一般不需要做调整,生产环境覆盖即可
  3. ingress.yaml 当前的ingress.yaml配置,不需要做调整,仅覆盖即可。对于IDC的k8s不生效。

生产环境部分的说明:

  1. 调整spec.replicas: 2,生产环境至少设置2个副本,保证服务一个节点故障时的可用性
  2. 调整spec.template.spec.imagePullSecrets.-name: harbor-registry-key,使用生产的key, 用于解决权限的问题
  3. 调整spec.template.spec.containers.resources.limits,调整资源限制到2C4G, 目前我们生产2C4G一般已经足够,至少可以同时执行64个php-fpm
  4. 添加环境变量spec.template.spec.containers.env, 调整部分生产配置

一个完整的样例配置:

apiVersion: apps/v1
kind
: Deployment
metadata
:
  name
: app-ref
  labels
:
    app
: app
    ref
: ref
spec
:
  replicas
: 2
  selector
:
    matchLabels
:
      app
: app
      ref
: ref
  template
:
    metadata
:
      annotations
:
        last_update
: ""
        "traffic.sidecar.istio.io/excludeOutboundPorts"
: "3306,8500,8400,8301,8302,8300,8600,6379,11800"
        "traffic.sidecar.istio.io/includeOutboundIPRanges"
: "10.16.0.0/14"
      labels
:
        app
: app
        ref
: ref
    spec
:
      volumes
:
        - name
: sky-agent
          emptyDir
: {}
      imagePullSecrets
:
        - name
: harbor-registry-key
      containers
:
        - name
: app-ref
          image
: image
          imagePullPolicy
: Always
          resources
:
            limits
:
              cpu
: "2"
              memory
: 4Gi
          env
:
            - name
: "PHP_DISPLAY_ERRORS"
              value
: "0"
            - name
: "php.skywalking.enable"
              value
: "1"
            - name
: "php.skywalking.version"
              value
: "6"
            - name
: "php.skywalking.app_code"
              value
: "k8s-php-api"
            - name
: "php.skywalking.sock_path"
              value
: "/var/run/sky_agent/sky_agent.sock"
          volumeMounts
:
            - name
: sky-agent
              mountPath
: /var/run/sky_agent/
          ports
:
            - containerPort
: 8080
            - containerPort
: 8081
          livenessProbe
:
            exec
:
              command
:
               - bash
                - "-c"
                - (cat < /dev/null > /dev/tcp/127.0.0.1/80) && (cat < /dev/null > /dev/tcp/127.0.0.1/9000)
            timeoutSeconds
: 1
            periodSeconds
: 10
            successThreshold
: 1
            failureThreshold
: 3
          readinessProbe
:
            exec
:
              command
:
               - bash
                - "-c"
                - (cat < /dev/null > /dev/tcp/127.0.0.1/80) && (cat < /dev/null > /dev/tcp/127.0.0.1/9000) && (head /data/core_config/zzc_config.arr.php)
            initialDelaySeconds
: 5
            timeoutSeconds
: 5
            periodSeconds
: 10
            successThreshold
: 1
            failureThreshold
: 5
        - name
: skywalking-php-agent
          image
: "harbor.example.net/k8s-php-skywalking-php-agent:1.0.0"
          command
:
           - /usr/bin/sky_php_agent_linux_x64
            - "--grpc"
            - "skywalking-oap.example.com:11800"
            - "--socket"
            - /var/run/sky_agent/sky_agent.sock
          resources
:
            limits
:
              cpu
: 100m
              memory
: 200Mi
          volumeMounts
:
            - name
: sky-agent
              mountPath
: /var/run/sky_agent/
          terminationMessagePath
: /dev/termination-log
          terminationMessagePolicy
: File
          imagePullPolicy
: Always
          securityContext
:
            runAsUser
: 0

服务平滑重启流程

k8s的服务平滑重启主要依赖k8s自身的探针,可以查看到livenessProbereadinessProbe两个配置,及存活探针和就绪探针。

  • readinessProbe (就绪探针)
    指在启动时要执行的脚本,如果正常返回则表示已经成功启动,否则表示当前服务还未启动。在容器切换过程中,需要等待该探针就绪之后才会停止另一个容器。
    如果长时间该探针都未就绪,该容器会自动重启。
# 这里做了3个判断,判断80,9000端口是否开启,同时判断mso配置文件是否存在
(cat < /dev/null > /dev/tcp/127.0.0.1/80) && (cat < /dev/null > /dev/tcp/127.0.0.1/9000) && (head /data/core_config/zzc_config.arr.php)
  • livenessProbe (存活探针)
    指每隔一段时间检查当前服务是否运行正常,如果不正常就会依据策略进行重启。
# 存活检查,保证80端口和9000端口存活即可
(cat < /dev/null > /dev/tcp/127.0.0.1/80) && (cat < /dev/null > /dev/tcp/127.0.0.1/9000)

Docker基础镜像及配置说明

当前镜像基于 webdevops/php-nginx:7.1 构建,目前使用的是 php7.1.33 版本。

项目基础镜像(harbor.example.net/k8s-php-base:1.0.1)是通过对镜像webdevops/php-nginx:7.1的二次封装,添加自定义的扩展,增加基础工具包(如 ping, top, ps等),
同时提供一些基础的配置服务。

docker                                    --- Docker Build相关的配置文件目录
|-- config                                --- 配置文件目录
|   |-- crontab.cron                      --- crontab 定时执行任务,具体命令格式参考文件注释说明,
|   |                                         文件替换 /opt/docker/etc/cron/crontab
|   |-- nginx.10-log.conf                 --- nginx 日志格式部分配置,
|   |                                         文件替换 /opt/docker/etc/nginx/vhost.common.d/10-log.conf
|   |-- nginx.http.conf                   --- nginx 最外层的http配置,用于配置gzip, log_format, client_max_body_size等数据,
|   |                                         文件替换 /etc/nginx/nginx.conf
|   |-- nginx.vhost.conf                  --- 当前项目 server 的nginx配置, 文件替换 /opt/docker/etc/nginx/vhost.conf
|   |-- php-fpm.application.dev.conf      --- 测试环境 php-fpm 的配置,这里区分正式和测试主要原因是部分配置环境变量无法进行覆盖,
|   |                                         需要使用两个配置来区分, 文件追加到 /opt/docker/etc/php/fpm/pool.d/application.conf
|   |-- php-fpm.application.pro.conf      --- 正式环境 php-fpm 的配置,同测试
|   |-- php-fpm.global.conf               --- php-fpm 的全局配置文件,文件追加到 /opt/docker/etc/php/fpm/php-fpm.conf
|   `-- php.ini                           --- 自定义的php配置文件,追加到/opt/docker/etc/php/php.webdevops.ini,可以被环境变量覆盖
|-- Dockerfile.dev                        --- 测试环境的Dockerfile文件
|-- Dockerfile.pro                        --- 正式环境的Dockerfile文件,区分两个文件的主要目的是很多东西和变量都具有差异,不适合放一起
`-- supervisor.d                          --- supervisor 的配置文件目录
    |-- apollo.dev.conf                   --- 测试环境apollo配置,仅在测试环境执行
    |-- apollo.pro.conf                   --- 正式环境apollo配置,仅在正式环境执行,由于不打算使用过多的环境变量,所以拆分多个码
    |-- app.conf                          --- 应用APP自定义的 supervisor 文件,不是强制需要的,
    |-- nginx.conf                        --- nginx的配置,主要改动是禁用了自启,等apollo就绪后再启动
    |-- php-fpm.conf                      --- 同nginx配置调整,是针对php-fpm的启动流程
    `-- tail-logs.conf                    --- 日志输出的进程,将文件日志输出到控制台,便于实时观察

一些使用到及常见的环境变量(更多请查看基础包官方文档):

环境变量 值说明
ROCKETOS_ENVIRONMENT 当前k8s的环境,dev,pro,设置该变量后有助于启动时读取配置的性能优化
ROCKETOS_SERVICE_TYPE 兼容旧环境变量,rocketos-ui
TZ 系统时区:Asia/Shanghai
PHP_DISMOD 当前要禁用的php模块,移除不需要的模块有助于php的性能提升,如:ioncube,memcached,mongodb,vips,amqp,gearman,memcache,opencc,pgsql,pdo_pgsql,soap,sysvmsg,sysvsem,sysvshm,imagick,gd,ldap,imap
FPM_PM_MAX_CHILDREN PHP-FPM相关参数: pm.max_children
FPM_PM_START_SERVERS PHP-FPM相关参数: pm.start_servers
FPM_PM_MIN_SPARE_SERVERS PHP-FPM相关参数: pm.min_spare_servers
FPM_PM_MAX_SPARE_SERVERS PHP-FPM相关参数: pm.max_spare_servers
php.error_reporting 设置PHP的错误报告级别,如 E_ALL
php….. 其他PHP的环境变量

框架二次封装功能

日志

日志的处理受环境变量LOG_CHANNEL的控制,测试和生产环境值配置均为LOG_CHANNEL=monolog,
推荐本地使用值LOG_CHANNEL=stack, 可以减少本地开发时日志写入的问题,同时注意对storage/logs设置写入权限

LOG_CHANNEL=monolog # 日志渠道配置
LOG_SLACK_WEBHOOK_URL= # 一般留空,monolog 情形下不需要
MONOLOG_INDEX=1 # monolog 的配置,具体值参考 mso 的配置,详见apollo的配置部分。

如何写入日志:

use Illuminate\Support\Facades\Log;
// 使用 Facades 写入日志,推荐
Log::error('test-error-face', []);

// 使用 app('log') 写入日志
app('log')->info('test-info', ['channel' => 'supplier-api-channel']);

// 依赖注入方式类似 app('log')

写入日志的过程中会添加 track_id 字段的context, 该值在一个请求中固定,如:T-20200902172933-96782382,可以作为跟踪参数,
同时,在链路跟踪中如果启用了Skywalking,则值会变为: T-20200902172933-96782382, 0.140.15990385890001

缓存

目前缓存使用自定义驱动,用来解决缓存debug的问题,使用过程中要在环境变量中配置CACHE_DRIVER=redis-self

注:当前缓存存储使用json格式,特意不使用serialize存储,主要的原因是序列化缓存在跨项目调用时存在对象反序列化不一致的风险,
故极力避免此类情形。另外使用json序列化也便于第三方工具操作。

# 缓存驱动器
CACHE_DRIVER=redis-self
# 缓存前缀
CACHE_PREFIX=api_cache
# Redis的配置文件,参考mso,注:这里的缓存和redis共用一个redis
REDIS_INDEX=35
# Redis使用的DB,用于和其他业务做区分
REDIS_DB=12

以下三种缓存操作等价,通过_debug=参数,可以输出缓存在redis层面的操作

use App\Utils\Redis\RedisCache;
use Illuminate\Support\Facades\Cache;
app('cache')->get('test_key');
RedisCache::getCache()->get('test_key');
Cache::get('test_key');

注意事项:

  1. 使用json存储的性能不如serialize, 但更加便利
  2. 如果需要存储超大缓存,比如超过10KB的数据,请使用RedisCache::getCache()->putGz, getGz 进行压缩存储,
    对于字符串可以达到90%以上的压缩率,极大提升网络传输效率,CPU占用会提升,但CPU压缩解压带来的时间消耗远小于网络传输的消耗。
  3. 并非全部的场景都适合调用cache,当前实现对数字的一些操作并不友好,建议改用Redis直接操作。
  4. 对于缓存键名,一定要统一管理,定义const常量,这在跨项目开发与缓存写作中,将极大提升缓存维护的效率,参考类\App\Configs\CacheKey

对于需要使用其他缓存的场景,可以通过如下形式进行调用,这些缓存将不被其他项目共享:

  1. 文件缓存: Cache::store('file'), 可以在本地使用
  2. 数据库缓存:Cache::store('database'),可以在整个project维度缓存使用,存储一些额外的数据

Redis与锁

前面提到了关于Redis的配置部分, 主要这两部分:

# Redis的配置文件,参考mso,注:这里的缓存和redis共用一个redis
REDIS_INDEX=35
# Redis使用的DB,用于和其他业务做区分
REDIS_DB=12

基本Redis操作

当前仅配置了一个Redis,所以redis的调用方法有以下几种:

use Illuminate\Support\Facades\Redis;
use App\Utils\Redis\RedisClient;

// 设置一个Redis值
dump(RedisClient::getRedis()->set('example_key', 1));

// 三种不同方式读取Redis,默认情形使用`default`连接
dump(RedisClient::getRedis()->get('example_key')); // 推荐方式,有debug信息输出
dump(Redis::get('example_key')); // 框架自带方式,可以使用,不过无debug信息
dump(Redis::connection('default')->get('example_key')); // 如果有多个redis连接时使用,一般不建议这样使用,太多的default会导致后期维护困难

常见问题:

  1. 为什么要对redis操作进行二次封装:主要解决debug问题
  2. 全部RedisKey都需要通过\App\Configs\RedisKey 进行管理,理由和缓存Key一样,便于后期维护和跨项目协作。

锁的使用

锁的使用一般分为两个常见,互斥锁,循环等待锁。循环等待锁为互斥锁的二次封装实现。

目前锁以静态方法的形式进行调用,如果需要以服务形式或依赖注入形式进行调用,则需要自己进行二次封装。

使用建议,建立 \App\Configs\RedisLockKey 类进行锁管理。同时,目前并发锁的键名前缀为DSF-RL:,
如果需要调整请涉及关联项目一起调整`\App\Utils\Redis\RedisLock::buildKey“

并发锁

# 以用户并发锁为例。
use \App\Configs\RedisLockKey;
use App\Exceptions\Utils\FailGetRedisLockExceptions;
use \App\Utils\Redis\RedisLock;
$userId = 123456;
$key = sprintf(RedisLockKey::EXAMPLE_LOCK_KEY, $userId);

try{
    // 获取一个锁,并且锁定时间60s
    $lock = RedisLock::getLock($key, 60);
    // do something    
    $lock->unlock();
}catch (FailGetRedisLockExceptions $exception){
    // 获取锁失败
}

循环等待锁

# 以用户并发锁为例。
use \App\Configs\RedisLockKey;
use App\Exceptions\Utils\FailGetRedisLockExceptions;
use \App\Utils\Redis\RedisLock;
$userId = 123456;
$key = sprintf(RedisLockKey::EXAMPLE_LOCK_KEY, $userId);

try{
    // 获取一个循环等待锁,并且锁定时间60s,每次休眠时间1000微秒(1ms, 1/1000s),最大休眠时间10^7微秒(10^4毫秒, 10s)
    // 内部实现原理是usleep,并调用RedisLock::getLock
    $lock = RedisLock::getSleepLock($key, 60, 1000, 10*1000*1000);
    // do something    
    $lock->unlock();
}catch (FailGetRedisLockExceptions $exception){
    // 获取锁失败
}

多个不同的Redis实例锁

use \App\Utils\Redis\RedisLock;
// 通过指定不同的实例类型来实现多个锁的区分,需要在内部调整关于多个客户端的实现
// @see \App\Utils\Redis\RedisLock::setRedisClient
RedisLock::getLock('key', 60, 'instance-type');

数据库

目前项目使用多Mysql数据连接,需要在环境中配置多个数据库连接,同时数据库配置接入Apollo。实际项目有4个,这里列举两个。

# 如果设置为false,默认操作全是master操作,除非手动指定从库。如果设置为true,默认链接按配置进行读写分离,默认为false
DB_WRITE_READ_AUTO_MODE=false

# 当前的数据库列表,对应的是环境变量中的 `${DB}_DATABASE,${DB}_MASTER,${DB}_SLAVE`
DB_LIST=ACCOUNT_DB,BASE_DB

ACCOUNT_DB_DATABASE=db_account_db
ACCOUNT_DB_MASTER=70011
ACCOUNT_DB_SLAVE=70012

BASE_DB_DATABASE=db_base_db
BASE_DB_MASTER=70021
BASE_DB_SLAVE=70022

环境变量分为两个部分,一部分为多个数据库的列表及相关设置,一份为每个数据库的主从配置。

以上配置会生成如下数据库配置(Yaml格式),已经移除数据量连接信息,当前情形是关闭了自动主从操作分离的操作。

# 当 DB_WRITE_READ_AUTO_MODE=false 时的主从配置,主库强制主库,从库强制从库,不会自动切换
ACCOUNT_DB
:
  driver
: mysql
  charset
: utf8mb4
  collation
: utf8mb4_unicode_ci
  strict
: true
  timezone
: "+08:00"
  database
: db_account_db
ACCOUNT_DB_SLAVE
:
  driver
: mysql
  charset
: utf8mb4
  collation
: utf8mb4_unicode_ci
  strict
: true
  timezone
: "+08:00"
  database
: db_account_db
BASE_DB
:
  driver
: mysql
  charset
: utf8mb4
  collation
: utf8mb4_unicode_ci
  strict
: true
  timezone
: "+08:00"
  database
: db_base_db
BASE_DB_SLAVE
:
  driver
: mysql
  charset
: utf8mb4
  collation
: utf8mb4_unicode_ci
  strict
: true
  timezone
: "+08:00"
  database
: db_base_db

数据库的默认连接使用第一个配置,此处即ACCOUNT_DB, 在多个数据库的连接中,一定不要再使用默认连接,
可能会导致不可预估的错误,比如直接使用Db::commit()进行事务操作,就会导致此类问题,这类方法均会导致使用默认连接。

如果设置了自动进行主从读取,即:DB_WRITE_READ_AUTO_MODE=true,
此时在ACCOUNT_DB中会配置writeread属性,表示在读的情形下读取从库数据,写入的情形下使用主库的数据。
不过需要特别注意的是参数strict: true表示一旦有写入数据,后续的读操作也会使用主库。
如果采用这种方式依然会出现多个不同请求数据不一致的情形。

# 当 DB_WRITE_READ_AUTO_MODE=true 时的主从配置,主库自动切换,从库强制从库
ACCOUNT_DB
:
  driver
: mysql
  charset
: utf8mb4
  collation
: utf8mb4_unicode_ci
  strict
: true
  timezone
: "+08:00"
  write
:
    host
: 127.0.0.1
  read
:
    host
: 127.0.0.1
  database
: db_account_db
ACCOUNT_DB_SLAVE
:
  driver
: mysql
  charset
: utf8mb4
  collation
: utf8mb4_unicode_ci
  strict
: true
  timezone
: "+08:00"
  database
: db_account_db
BASE_DB
:
  driver
: mysql
  charset
: utf8mb4
  collation
: utf8mb4_unicode_ci
  strict
: true
  timezone
: "+08:00"
  write
:
    host
: 127.0.0.1
  read
:
    host
: 127.0.0.1
  database
: db_base_db
BASE_DB_SLAVE
:
  driver
: mysql
  charset
: utf8mb4
  collation
: utf8mb4_unicode_ci
  strict
: true
  timezone
: "+08:00"
  database
: db_base_db

数据库Model的配置

/**
 * Class TestModel, 模型类,实际使用过程中我们会进行二次封装,
 * 如 BaseModel(基础模型) -> BusinessDbModel(数据库业务模型,配置数据库连接) -> TableModel(表模型,配置表名)
 */

class TestModel extends \Illuminate\Database\Eloquent\Model
{
    /**
     * @var string 使用BASE_DB数据库链接
     */

    protected $connection = 'BASE_DB';

    /**
     * @var string 表名
     */

    protected $table = 'test_tbl';
}

数据库事务的操作

注:对于有性能要求的WEB应用,能不使用事务就不使用事务

能通过分布式锁解决的数据一致性问题就通过redis锁解决,而不是数据库事务。

可能需要使用事务的两种场景:

  1. 涉及财务或数据敏感类型的更新操作,使用事务保持一致性
  2. 复杂批量更新或插入操作,使用事务以保证提交性能
use App\Models\Supplier\BaseModel;
use Illuminate\Support\Facades\DB;

// 事务方式1, Model模式, 【推荐】
$model = new BaseModel(); // 这里可以替换为自己的模型
$db = $model->getConnection();
$db->beginTransaction();
$db->commit();

// 事务方式2, Facades模式,【不推荐但可用】,直接获取一个数据库连接,一般在直接操作数据库或多个库做事务时处理
$db = DB::connection('SUPPLIER_DB');
$db->beginTransaction();
$db->commit();

网络请求

目前Lumen(5.8)框架没有封装网络请求的组件,只能使用第三方网络组件:guzzlehttp/guzzle, symfony/http-client

  • guzzlehttp/guzzle 使用较为广泛且更流行,目前主要使用这个进行二次封装,但封装相对复杂。可以满足绝大多的业务需求。
  • symfony/http-client 封装更为底层更为标准,可以自定义更复杂的处理流程

:由于guzzlehttp本身默认不具备任何网络DEBUG能力,需要对类进行二次封装才能具有调试能,所以不建议直接 new Client([]);,
任何业务场景下都建议根据特定的业务逻辑进行封装,用于处理特定的异常和报错。
由于网络请求的特殊性,不要使用类似 app(Client::class) 之类的方式将客户端请求单例化,可能会导致不可预期的错误。

简单无具体业务逻辑的封装

// 使用一个原始的简单封装,集成了基本的DEBUG流程,超时日志,错误日志逻辑
// 如果有其他封装,也可以使用自定义的封装
use App\Utils\Network\HttpClient;
$client = HttpClient::getClient();
$resp = $client->get("https://baidu.com");
$contents = $resp->getBody()->getContents();

一个具体的业务流程封装

在业务流程中未配置 ['http_errors' => false] 时,此时httpCode非2xx,3xx时会抛出异常,一般建议是依据具体业务流程做判断,
如果能自行处理HttpCode的各种场景则设置,否则一定需要手动处理各种异常,注意:不要直接try{ ... }catch(\Exception $e){ ... },
这种做法将导致在网络异常、网络质量差、服务器负载高等情形下的非业务流程故障。

原则:对于非可丢弃的网络请求,一定要处理网络重试的逻辑,一般建议在3次左右,同时依据条件添加响应的并发控制锁

// 参考的网络请求及重试流程
use App\Utils\Network\HttpClient;
use GuzzleHttp\Exception\ConnectException;
use GuzzleHttp\Exception\GuzzleException;
use GuzzleHttp\Exception\RequestException;
use Illuminate\Support\Facades\Log;

class TestHttpClient
{
    public function example(): void
    {
        $successCodes = [200, 201, 202, 203, 204, 205, 206, 207, 208, 226];
        $retryCodes = [409, 413, 417, 504, 502, 503];
        $tryTimes = 3; // 重试次数,非总次数
        $status = false;
        do {
            $status = $this->doRequest('GET', 'http://www.baidu.com', [], $successCodes, $retryCodes);
            if ($status) {
                // 如果请求成功
                break;
            }
        } while ($tryTimes-- > 0);
        if ($status === false) {
            // 请求一直未成功
            // Log::error(...);
        }
    }

    /**
     * 执行请求
     * @param string $method 请求的方法
     * @param string $url 请求的URL
     * @param array $options 请求的选项
     * @param array $successCodes 请求成功Code
     * @param array $retryCodes 请求失败Code
     * @return bool 如果返回true, 表示请求已成功,不需要进行重试,否则需要重试
     */

    private function doRequest(
        string $method,
        string $url,
        array $options,
        array $successCodes,
        array $retryCodes
    ): bool {
        $httpClient = HttpClient::getClient();
        $statusCode = 0;
        try {
            $response = $httpClient->request($method, $url, array_merge($options, [
                'http_errors' => false,
            ]));

            $statusCode = (int) $response->getStatusCode();
            $headers = $response->getHeaders();
            $content = $response->getBody()->getContents();

            if (!in_array($statusCode, $successCodes, true)) {
                $msg = "请求出现错误:{$method}: {$url}, HttpCode: {$statusCode}";
                Log::error($msg, compact('url', 'headers', 'content'));

                if (in_array($statusCode, $retryCodes, true)) {
                    //当前错误需要进行重试
                    return false;
                }

                // @TODO 出错的处理逻辑
                // @TODO something
                return true;
            }
        } catch (ConnectException $connectException) {
            $exName = get_class($connectException);
            $msg = "链接出现异常:[{$exName}]{$connectException->getMessage()}, \n".
                json_encode(func_get_args(), JSON_UNESCAPED_UNICODE);
            Log::error($msg);

            // 链接异常,需要进行重试, 返回false
            return false;
        } catch (RequestException $connectException) {
            $exName = get_class($connectException);
            $msg = "网络异常:[{$exName}]{$connectException->getMessage()}, \n".
                json_encode(func_get_args(), JSON_UNESCAPED_UNICODE);
            Log::error($msg);

            // 链接异常,需要进行重试, 返回false
            return false;
        } catch (GuzzleException $e) {
            $exName = get_class($e);
            $msg = "请求异常:[{$exName}]{$e->getMessage()}, \n".
                json_encode(func_get_args(), JSON_UNESCAPED_UNICODE);
            Log::error($msg);

            // 链接异常,需要进行重试, 返回false
            return false;
        }

        // @TODO 正常处理content逻辑
        // @TODO something
        return true;
    }
}

异步网络请求

由于PHP的特性,在PHP-FPM模式下,无法进行异步请求,需要借助消息队列来实现,方案(采用消息队列实现)如下:

use App\Service\Message\Runner\HttpClientRunner;
dump(HttpClientRunner::push('http://baidu.com', 'GET', []));

链路跟踪

SkyWalking 是观察性分析平台和应用性能管理系统。 提供分布式追踪、服务网格遥测分析、度量聚合和可视化一体化解决方案。

链路跟踪使用的配置主要有以下两个(这个参数在本地不生效,同时需要启用拓展才可用):

# SKYWALKING 应用名称,如果设置为空,则不会进行采集, 注:生产环境目前不支持
SKYWALKING_APP_NAME=k8s-php-api
# SKYWALKING 采样率,百分比,0~10000 10000表示完全采样,如果小于等于0则忽略
SKYWALKING_SAMPLING_RATE=10000

K8s配置问题

k8s的配置采用附加pod的形式和进行容器捆绑,同时通过共享目录 /var/run/sky_agent/ 来实现 socket 连接的共同读写。

同时在PHP容器配置以下环境变量来启用链路跟踪, 这部分的配置的效果等同于在php.ini中写入配置说启用skywalking

env:
  - name
: "php.skywalking.enable"
    value
: "1"
  - name
: "php.skywalking.version"
    value
: "6"
  - name
: "php.skywalking.app_code"
    value
: "k8s-php-api"
  - name
: "php.skywalking.sock_path"
    value
: "/var/run/sky_agent/sky_agent.sock"

查询使用问题

  1. 代码层查询: skywalking_get_trace_info();, 注意: 如果skywalking注册失败或未注册,该函数会返回空数组
  2. PHP请求的Header头中会添加如X-ZZC-TRACE-ID: 1154.136.15991057120002的头,该值即链路跟踪值, 注意: 如果注册失败,该值依然存在,但无法查询
  3. 代码层: ServerStatus::getTrackId() 会返回如T-20200903120152-73262776, 1154.136.15991057120002形式的跟踪ID,注:如注册失败会返回T-20200903120152-73262776, 该函数依赖skywalking_get_trace_info
  4. 日志中:track_id 字段会附加 ServerStatus::getTrackId() 的值

消息队列

当前消息队列采用redis list实现,即通过redis实现队列。类似的方案还有 rabbitMQ, Kafka 等,redis是一种相对更简单的实现。

消息队列配置部分:

# 队列链接模式, 可选值还有sync表示使用同步形式执行队列,不具备实际意义
QUEUE_CONNECTION=redis

Lumen的队列通过 Job 类来实现, 默认情形下每个Job的实例都作为一个单独的任务处理。实际上,我们的业务需求复杂层度远远超出这个范围。
比如:多种不同优先级的队列,限制并发的队列等,所以对于队列必须进行二次封装,以提高易用性。

实际任务的关系图可能是这样的,

Job (基类)
   |
CommonJob (公共任务类) <--- 组合任务Trait类
   |
   | 包含可以执行JobInterface
   |
CommonQueueJob (执行队列的任务类)
   |
   | 处理任务 (如: HttpAsyncRunner)
  DONE

在创建一个任务时,比如异步网络请求:

use App\Configs\Queue\RunnerMap;
use App\Jobs\Common\CommonJob;use App\Service\Message\CommonQueueJob;
use App\Service\Message\Runner\HttpClientRunner;
HttpClientRunner::push('http://baidu.com', 'GET', []); // 发送异步的百度GET请求

// 传递给下层的值
$data = [];

//push 内部调用
CommonQueueJob::getInstance()->publish(RunnerMap::HTTP_CLIENT, $data);

//publish 内部调用
$job = new CommonJob(json_encode($data));
app('queue')->push($job, '', 'common');// 将Job指派给 common 队列

我们在消费时即可以使用如下命令来进行队列消费:

# 实际上,还可以添加更多的参数来实现更多的功能,比如重试逻辑
php artisan queue:work --daemon --quiet --queue=common

部署过程中使用supervisor进行部署(不同的supervisor版本配置会有一些差异):

[program:customer-common]
directory = /data/htdocs/k8s-php-api/
command = php artisan queue:work --daemon --quiet --queue=common
process_name=%(program_name)s
startsecs = 0
stopsignal = TERM
user = application
autostart = false
autorestart = true
stdout_logfile=/docker.stdout
stdout_logfile_maxbytes=0
stderr_logfile=/docker.stderr
stderr_logfile_maxbytes=0

注: 异步队列并不能完全解决性能问题,它可以解决一些同步请求响应过长和流量高峰的问题。不能解决的如SQL性能问题,数据库大量插入问题。

当前还没有任何评论

写下你最简单的想法