一、一个真实事故:凌晨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-RPC | Swoole常驻内存,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微服务 | 提升 |
|---|---|---|---|
| QPS | 486 | 3,248 | 6.7倍 |
| P99延迟 | 1,284ms | 76ms | 16.9倍 |
| MySQL连接数 | 峰值120(被占满) | 平均42 | -65% |
| CPU使用率 | 8核满载100% | 平均76% | 余量充足 |
| Redis QPS | 2,100 | 8,650 | 4.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是给「低延迟、高并发、多服务协作」的落地场景准备的。