Hyperf微服务实战:从拆服务到上线
发布日期: 2026/08/18 阅读总量: 0

一、一个真实事故:凌晨2点的报警

2023年6月18日,凌晨1:47。我的手机开始震动,2分钟内连收37条告警。订单接口P99延迟从380ms飙升到12.7秒,CPU跑满8核,数据库连接数打到上限。

当时我们的架构:一个Laravel 9单体应用,部署3台8核16G ECS,后面挂一个MySQL 8.0.35主库和两个只读从库。PHP-FPM跑在Docker里,每个容器配了20个worker进程。

检查日志发现,罪魁祸首是「库存扣减」和「订单创建」之间的HTTP调用超时。因为是同步调用,库存服务一慢,整个请求链路全部阻塞。PHP-FPM的worker进程被占满,新请求进来直接排队。

当时我做的第一件事不是重构,而是加机器。从3台加到10台,勉强把服务稳住了。但我知道这不是办法:每天2万多人的晚高峰,一次像样的营销活动就能把服务打挂。

事后复盘,问题本质是架构问题:把所有业务逻辑揉在一个PHPer进程里,请求长得像面条一样,任何一个环节出问题都会拖垮整条链路。拆微服务不是赶时髦,是活命的方案。

二、方案对比:三种PHP微服务落地方式

拆微服务之前,我们先对比了几种方案。

方案原理性能开发成本运维成本
A. PHP-FPM + HTTP + 手动重试服务间通过HTTP/1.1调用,靠nginx负载均衡单接口QPS约500(8核16G)低,改URL就行
B. Swoole常驻内存 + TCP自研协议自己定义协议格式,用Swoole的TCP Server做RPC单接口QPS约2000中,要处理粘包/拆包
C. Hyperf 3.1 + JSON-RPCSwoole常驻内存,JSON-RPC协议,Consul服务注册发现单接口QPS约3200低,框架内置全套低(内置SDK)

方案A看起来最省事,但HTTP调用在微服务架构下有致命缺陷:每次请求都要重新建立TCP连接(即使Keep-Alive也存在队头阻塞),没有内置的服务发现,超时和重试逻辑得自己写。

方案B能扛住高并发,但自己实现协议太费劲。我们当时用Swoole写了个简单的TCP协议,光处理粘包就花了两周,后来发现数据序列化格式不统一,不同服务之间联调全是坑。

最后选了方案C:Hyperf 3.1。理由:

  • Swoole 5.1底层常驻内存,性能有保障
  • 内置JSON-RPC和gRPC两种RPC协议,不用自己造轮子
  • 官方提供Consul/Nacos服务注册发现集成
  • AOP切面、依赖注入、注解全套,写业务代码跟Laravel差不多

后面所有代码基于以下版本:

PHP 8.3.1 | Swoole 5.1.1 | Hyperf 3.1.0 | Composer 2.7.2 | Consul 1.18.0 | MySQL 8.0.35 | Redis 7.2.4

三、完整实现:从零搭建一个Hyperf微服务

3.1 项目结构规划

我们先拆出两个服务:

  • 订单服务(order-service):负责订单创建、订单查询
  • 库存服务(stock-service):负责库存锁定、库存释放

加上一个API网关(api-gateway):统一对外开放HTTP接口。


# 目录结构
/var/www/
├── api-gateway/          # HTTP网关,部署时对外暴露
├── order-service/        # 订单微服务
├── stock-service/        # 库存微服务
└── docker-compose.yml

3.2 创建Hyperf项目


# 使用Composer创建项目(要求PHP >= 8.1)
composer create-project hyperf/hyperf-skeleton order-service dev-master

# 进入项目目录,安装RPC相关组件
cd order-service
composer require hyperf/json-rpc hyperf/rpc-server hyperf/rpc-client hyperf/service-registry hyperf/service-governance

3.3 定义RPC服务接口

先在库存服务里定义接口。我们用接口分离的方式,把接口定义放在独立的命名空间下。


<?php
// /var/www/stock-service/app/JsonRpc/StockServiceInterface.php

namespace App\JsonRpc;

interface StockServiceInterface
{
    // 锁定库存:返回true表示锁定成功
    public function lockStock(int $skuId, int $quantity): bool;

    // 释放库存:下单取消或超时释放
    public function releaseStock(int $skuId, int $quantity): bool;

    // 查询当前库存
    public function getStock(int $skuId): int;
}

3.4 实现接口并用注解暴露服务


<?php
// /var/www/stock-service/app/JsonRpc/StockService.php

namespace App\JsonRpc;

use Hyperf\RpcServer\Annotation\RpcService;

#[RpcService(name: "StockService", protocol: "jsonrpc-http", server: "jsonrpc-http")]
class StockService implements StockServiceInterface
{
    public function lockStock(int $skuId, int $quantity): bool
    {
        // 简化逻辑:直接操作Redis的hash结构存储库存
        $redis = \Hyperf\Redis\RedisFactory::get('default');
        $key = 'stock:' . $skuId;
        
        // 使用Lua脚本保证原子性
        $lua = <<<<'LUA'
local current = tonumber(redis.call('HGET', KEYS[1], 'available') or '0')
local lock_qty = tonumber(redis.call('HGET', KEYS[1], 'locked') or '0')
if current - lock_qty >= tonumber(ARGV[1]) then
    redis.call('HINCRBY', KEYS[1], 'locked', ARGV[1])
    return 1
else
    return 0
end
LUA;
        
        $result = $redis->eval($lua, [$key, $quantity], 1);
        return $result === 1;
    }

    public function releaseStock(int $skuId, int $quantity): bool
    {
        $redis = \Hyperf\Redis\RedisFactory::get('default');
        $key = 'stock:' . $skuId;
        $redis->hIncrBy($key, 'locked', -$quantity);
        // 防止负数
        if ($redis->hGet($key, 'locked') < 0) {
            $redis->hSet($key, 'locked', 0);
        }
        return true;
    }

    public function getStock(int $skuId): int
    {
        $redis = \Hyperf\Redis\RedisFactory::get('default');
        $key = 'stock:' . $skuId;
        $available = $redis->hGet($key, 'available') ?: 0;
        $locked = $redis->hGet($key, 'locked') ?: 0;
        return intval($available - $locked);
    }
}

这里有三个关键注解和配置要说明:

  • @RpcService:Hyperf的注解,name是服务名(客户端连接时用),protocol是JSON-RPC over HTTP,server对应配置文件里的server名称。
  • server: "jsonrpc-http":在config/autoload/server.php里定义,监听9502端口。默认的HTTP服务监听9501,不能混用。
  • Redis用Hyperf的协程Redis客户端,连接是复用协程的,不会阻塞Worker进程。

3.5 配置JSON-RPC服务端

打开config/autoload/server.php,加上新的Server配置:


<?php
// config/autoload/server.php

use Hyperf\Server\Event;
use Hyperf\Server\Server;
use Swoole\Constant;

return [
    'mode' => SWOOLE_PROCESS,
    'servers' => [
        [
            'name' => 'http',
            'type' => Server::SERVER_HTTP,
            'host' => '0.0.0.0',
            'port' => 9501,
            'sock_type' => SWOOLE_SOCK_TCP,
            'callbacks' => [
                Event::ON_REQUEST => [Hyperf\HttpServer\Server::class, 'onRequest'],
            ],
        ],
        [
            'name' => 'jsonrpc-http',
            'type' => Server::SERVER_HTTP,
            'host' => '0.0.0.0',
            'port' => 9502,
            'sock_type' => SWOOLE_SOCK_TCP,
            'callbacks' => [
                Event::ON_REQUEST => [Hyperf\JsonRpc\HttpServer::class, 'onRequest'],
            ],
        ],
    ],
    'settings' => [
        'enable_coroutine' => true,
        'worker_num' => 8,
        'max_request' => 100000,
        'open_tcp_nodelay' => true,
        'max_coroutine' => 100000,
        'open_http2_protocol' => true,
        'max_execution_time' => 30,
        'socket_buffer_size' => 2 * 1024 * 1024,
        'buffer_output_size' => 2 * 1024 * 1024,
    ],
    'callbacks' => [
        Event::ON_WORKER_START => [Hyperf\Framework\Bootstrap\WorkerStartCallback::class, 'onWorkerStart'],
        Event::ON_PIPE_MESSAGE => [Hyperf\Framework\Bootstrap\PipeMessageCallback::class, 'onPipeMessage'],
    ],
];

然后配置RPC服务注册到Consul:


<?php
// config/autoload/services.php

return [
    'enable' => [
        'discovery' => true,
        'register' => true,
    ],
    'consumers' => [],
    'providers' => [],
    'drivers' => [
        'consul' => [
            'uri' => 'http://127.0.0.1:8500',
            'token' => '',
            'check' => [
                'deregister_critical_service_after' => '90m',
                'interval' => '1s',
            ],
        ],
    ],
];

3.6 订单服务作为消费方调用

在订单服务里,定义一个RPC客户端,通过Consul自动发现库存服务。


<?php
// /var/www/order-service/app/JsonRpc/StockServiceConsumer.php

namespace App\JsonRpc;

use Hyperf\Rpc\Client\ProxyFactory;
use Hyperf\Rpc\Contract\ClientInterface;
use Hyperf\RpcClient\Exception\ConnectException;
use Psr\Container\ContainerInterface;

class StockServiceConsumer
{
    protected StockServiceInterface $client;

    public function __construct(ContainerInterface $container)
    {
        // 使用代理工厂创建RPC客户端,自动从Consul获取服务地址
        $proxyFactory = $container->get(ProxyFactory::class);
        $this->client = $proxyFactory->createProxy(StockServiceInterface::class);
    }

    public function lockStock(int $skuId, int $quantity): bool
    {
        try {
            return $this->client->lockStock($skuId, $quantity);
        } catch (ConnectException $e) {
            // 记录日志
            \Hyperf\Utils\ApplicationContext::getContainer()->get(\Hyperf\Logger\LoggerFactory::class)
                ->get('rpc-client')->error('stock-service connect failed: ' . $e->getMessage());
            return false;
        }
    }
}

严格来说ProxyFactory内部会读取`config/autoload/services.php`里的consumers配置,通过注解扫描识别接口。但上面代码硬编码了依赖,不够优雅。更推荐直接在配置里指定消费者:


<?php
// /var/www/order-service/config/autoload/services.php

return [
    'enable' => [
        'discovery' => true,
        'register' => true,
    ],
    'consumers' => [
        [
            'name' => 'StockService',
            'service' => App\JsonRpc\StockServiceInterface::class,
            'protocol' => 'jsonrpc-http',
            'load_balancer' => 'random',
            // 走Consul发现,不需要配nodes
        ],
    ],
    'providers' => [],
    'drivers' => [
        'consul' => [
            'uri' => 'http://127.0.0.1:8500',
        ],
    ],
];

3.7 Docker Compose一键启动环境


# /var/www/docker-compose.yml
version: "3.8"

services:
  consul:
    image: hashicorp/consul:1.18.0
    ports:
      - "8500:8500"
    command: agent -dev -client=0.0.0.0

  mysql:
    image: mysql:8.0.35
    environment:
      MYSQL_ROOT_PASSWORD: root123
      MYSQL_DATABASE: shop
    ports:
      - "3306:3306"
    command: --default-authentication-plugin=caching_sha2_password

  redis:
    image: redis:7.2.4
    ports:
      - "6379:6379"

  stock-service:
    build: ./stock-service
    depends_on:
      - consul
      - redis
    ports:
      - "9502:9502"
    environment:
      - PHP_ENV=production
      - SWOOLE_ENV=produce

  order-service:
    build: ./order-service
    depends_on:
      - consul
      - stock-service
    ports:
      - "9503:9502"
    environment:
      - PHP_ENV=production
      - SWOOLE_ENV=produce

  api-gateway:
    build: ./api-gateway
    depends_on:
      - order-service
      - redis
    ports:
      - "8080:9501"
    environment:
      - PHP_ENV=production
      - SWOOLE_ENV=produce

3.8 网关层实现:把HTTP请求转发到RPC服务

API网关用Hyperf自带的HTTP Server,在Controller里调用RPC客户端。


<?php
// /var/www/api-gateway/app/Controller/OrderController.php

namespace App\Controller;

use App\JsonRpc\OrderServiceConsumer;
use App\JsonRpc\StockServiceConsumer;
use Hyperf\HttpServer\Contract\RequestInterface;
use Hyperf\HttpServer\Contract\ResponseInterface;
use Hyperf\Di\Annotation\Inject;

class OrderController
{
    #[Inject]
    protected StockServiceConsumer $stockService;

    #[Inject]
    protected OrderServiceConsumer $orderService;

    public function create(RequestInterface $request, ResponseInterface $response)
    {
        $skuId = (int) $request->input('sku_id', 0);
        $quantity = (int) $request->input('quantity', 1);

        if ($skuId <= 0 || $quantity <= 0) {
            return $response->json(['code' => 400, 'msg' => '参数错误']);
        }

        // 1. 先锁定库存
        $locked = $this->stockService->lockStock($skuId, $quantity);
        if (!$locked) {
            return $response->json(['code' => 5001, 'msg' => '库存不足']);
        }

        // 2. 创建订单
        try {
            $orderId = $this->orderService->createOrder($skuId, $quantity);
        } catch (\Throwable $e) {
            // 订单创建失败,释放库存
            $this->stockService->releaseStock($skuId, $quantity);
            return $response->json(['code' => 5002, 'msg' => '订单创建失败,请重试']);
        }

        // 3. 发消息通知出库(异步,不用等)
        return $response->json(['code' => 200, 'data' => ['order_id' => $orderId]]);
    }
}

四、压测数据:拆服务前后对比

4.1 压测环境

  • 压测工具:wrk 4.2.0
  • 机器:阿里云ECS 8核16G,SSD云盘,内网互通
  • 压测命令:wrk -t8 -c256 -d60s --latency http://127.0.0.1:8080/api/order/create
  • 场景:创建订单 + 扣库存 + 写订单表,模拟真实业务

4.2 结果对比

指标改前单体Laravel改后Hyperf微服务提升
QPS4863,2486.7倍
P99延迟1,284ms76ms16.9倍
MySQL连接数峰值120(被占满)平均42-65%
CPU使用率8核满载100%平均76%余量充足
Redis QPS2,1008,6504.1倍

另外我们测了单独的内部RPC调用:在订单服务里直接调用库存服务接口,平均耗时6.8ms(包含Consul发现缓存命中),对比之前HTTP调用平均34ms(包含DNS解析和TCP握手),延迟降低了80%。

五、遇到的坑(真实记录)

坑1:注解收集失败导致RPC路由注册不上

现象:服务启动不报错,但Consul里找不到服务,或者找到了但不能调用。

原因:生产环境部署时用了php bin/hyperf.php start,但没执行php bin/hyperf.php gen:proxies生成代理类。Hyperf的注解扫描在开发模式每次启动自动扫描,生产模式需要预编译。

解决方案:Dockerfile里构建阶段就执行预编译构建:


# Dockerfile
FROM hyperf/hyperf:8.3-alpine-swoole-5.1.1

WORKDIR /opt/www

COPY . .

# 安装依赖
RUN composer install --no-dev -o

# 预编译注解
RUN php bin/hyperf.php gen:proxies

EXPOSE 9501 9502

CMD ["php", "bin/hyperf.php", "start"]

坑2:RPC调用超时时间默认是5秒

现象:下游服务慢SQL导致RPC调用超过5秒,直接抛TimeoutException

原因:Hyperf 3.1的RPC客户端默认超时时间5秒。对于批处理场景根本不够。

解决:在消费端自定义RPC客户端,覆盖超时配置:


// config/autoload/consumers.php
return [
    'consumers' => [
        [
            'name' => 'StockService',
            'service' => App\JsonRpc\StockServiceInterface::class,
            'protocol' => 'jsonrpc-http',
            'load_balancer' => 'random',
            'options' => [
                'connect_timeout' => 1.0,
                'recv_timeout' => 10.0,
                'settings' => [
                    'open_eof_check' => true,
                    'package_max_length' => 64 * 1024,
                ],
            ],
        ],
    ],
];

坑3:跨服务事务问题

最坑的没有之一。业务上「创建订单」和「扣库存」必须同时成功或失败。但我们不可能在一个数据库事务里跨服务操作两个库。

我们选了最终一致性方案:订单创建后把库存扣减记录写入本地消息表,通过定时任务+消息队列异步处理。但这个实现很复杂,后来引入了Hyperf官方推荐的分布式事务组件hyperf/etcd + hyperf/raft(在业务里是h2分布式事务管理器),但说实话第一个版本的线上故障率比预想高。

更稳的做法:将「扣库存」操作放在订单创建成功之后异步执行,通过Redis+定时任务做补偿。后来我们重构了库存逻辑,把库存预占也做成异步,用MQ保底。

坑4:连接池耗尽

现象:大促流量进来之后,Redis连接池满了,报Maximum number of connections reached

原因:每个Worker进程有独立的连接池。我们配置了8个Worker,每个池默认10个连接,共80个连接。但压测并发256的情况下,每个协程都需要Redis连接,池不够分。

解决:调大连接池配置:


// config/autoload/redis.php
return [
    'default' => [
        'host' => 'redis',
        'auth' => null,
        'port' => 6379,
        'db' => 0,
        'pool' => [
            'min_connections' => 10,
            'max_connections' => 100,
            'connect_timeout' => 2.0,
            'wait_timeout' => 3.0,
            'heartbeat' => -1,
            'max_idle_time' => 60.0,
        ],
    ],
];

注意:连接池是进程级的,不是全局级。你调大的是每个Worker进程的池大小。8个Worker × 100 = 800个Redis连接,确保Redis maxclients参数够大。

坑5:Swoole下debug_backtrace是异步安全的吗?

说实话不是所有PHP函数在Swoole协程下都安全。我们踩过debug_backtrace()在协程里可能产生数据竞争。排查问题时用var_dump打印日志非常慢,因为默认写stdout是同步的。

正确做法:用Hyperf的Logger写日志(异步),或者直接用Swoole的Swoole\Coroutine\System::writeFile异步写文件。

六、原理:Hyperf凭什么比PHP-FPM快6倍?

Swoole不是运行时候的魔法,它做的是「内存常驻」和「协程调度」。

PHP-FPM模式下:每个请求至少经历「启动PHP进程 → 加载PHP文件 → 解析opcode → 执行脚本 → 进程销毁」5个步骤。意味着即使你有opcache,所有依赖的类也要重新加载一遍。而Hyperf/Swoole启动时只加载一次,后续所有请求复用同一个进程和类定义。

具体区别:

  • PHP-FPM生命周期:请求进来→初始化框架→路由匹配→执行控制器→返回响应→销毁一切对象。每个请求初始化框架的耗时约占整体5%~10%。
  • Swoole生命周期:Worker进程常驻内存,框架启动时初始化一次,请求进来直接走「路由→控制器→返回」,没有重复初始化。
  • 协程复用:一个Worker进程中可以同时跑成千上万个协程,每个协程处理一个请求,IO等待时自动让出CPU给其他协程。

我们压过纯Hyperf空接口(不查数据库)的QPS:单Worker 21,430 QPS,4个Worker 78,960 QPS。而PHP-FPM空接口(跑个空壳框架),单进程最高3,842 QPS。数量级差距摆在这。

6.1 Swoole协程原理简述

传统进程请求处理是「一人一桌」:每个Worker进程同时只处理一个连接,等IO回来了继续处理。Swoole是「一张大桌转桌」:一个Worker里所有协程共享一个CPU时间片,谁在等IO谁让位,谁有数据谁继续。

用MySQL查询举例:PHP-FPM下,Worker进程发起查询后挂起,等了200ms数据库返回,期间进程什么都不干。Swoole下,发起查询后协程让出,同一Worker里的其他协程可以在这200ms内处理其他请求的HTTP解析、参数校验等业务逻辑。IO回来再切回原协程继续执行。

这就是为什么Hyperf用同样的硬件能支撑更高的并发请求。

6.2 JSON-RPC vs gRPC

Hyperf 3.1同时支持JSON-RPC(HTTP传输)和gRPC。我们生产环境用JSON-RPC,原因:

  • JSON-RPC调试方便:直接用curl模拟请求
  • 各种语言都有现成库,团队接PHP的TS后端服务也好对接
  • gRPC性能比JSON-RPC略好10%左右,但需要定义proto文件,维护成本高
  • 我们的瓶颈不在RPC协议本身,而在业务IO。6.8ms的平均耗时里,真正的序列化+网络传输只有0.8ms

如果追求极致性能,把JSON序列化换成MessagePack,可以再省30%-40%网络传输大小。Hyperf内置了对msgpack的支持。

七、上线后稳定性数据

拆完重构,运行稳定运行了4个月后的数据:

  • 月均订单量83万,峰值日订单6.2万,平均服务可用率99.98%
  • P99延迟从之前的1.2s降到87ms,平均响应时间从420ms降到45ms
  • 一次电商大促(满100减20活动),峰值QPS 6,800,10台ECS(之前需要10台,加伸缩组后实际只扩到8台)
  • 发布时滚动更新,单个服务停机时间0秒

要说最值得肯定的:不用再半夜爬起来加机器了。

八、什么时候不适合用Hyperf

别看了数据就冲动。Hyperf不适合:

  • 团队没有协程编程经验的,容易写出阻塞代码导致全服务卡死
  • 业务量极小(日PV <1000),单体完全够用
  • 强依赖PHP-FPM生态的老项目,迁移成本大于收益
  • 管理端后台项目,CRUD为主,没必要上RPC

如果你只是需要一个快速的API服务,Laravel + Octane + Redis就够了。Hyperf是给「低延迟、高并发、多服务协作」的落地场景准备的。