欢迎来到“微服务,你这混乱又迷人的小妖精”讲座现场。我是你们的领路人,一个曾经在半夜三点被服务崩了电话震醒的资深老司机。
今天我们不谈虚的,我们要谈谈PHP。是的,你没听错,那个被很多人认为“只能跑跑博客、做做后台管理”的PHP。今天我们要把它变成微服务架构里的常青树,特别是当你手里攥着Consul这个“全科医生”的时候。
你们知道微服务最大的痛苦是什么吗?不是代码写得烂,也不是数据库设计崩了,而是“我不知道谁还活着”。
想象一下,你有一个叫User-Service的服务,它挂了。你的业务代码怎么知道?它还在那儿傻傻地尝试连接localhost:8080,然后收到一个Connection refused的报错。然后呢?你只好重启它,或者等运维去修。而在那几分钟甚至几秒钟的空白期,所有用户都在报错。
这就是我们需要Consul,特别是它的健康检查和故障转移能力的核心理由。
一、 Consul:那个唠叨的管家
首先,我们要搞清楚Consul到底是干嘛的。别一听到Consul就想到什么Raft协议、Gossip协议,那些东西留着给面试官听就行。在咱们今天的讲座里,Consul就是一个极其负责的管家。
你的微服务就是家里的一群佣人。
Consul就是管家。
健康检查就是管家每隔5分钟来敲你的门,问一句:“嘿,你还好吗?要是你晕倒了,我就得赶紧联系你的亲戚(其他微服务)来顶上。”
如果管家敲门,你打开门说:“我死了!”(或者敲门三次都没反应),管家就会在家族群(服务发现列表)里广播:“User-Service挂了!以后别找他干活了!”这就是健康检查。
二、 给管家报名:服务注册
在PHP里,怎么告诉Consul“我是User-Service,我很健康”?我们需要用到php-consul/api这个库。安装一下,这玩意儿就像Consul的官方SDK,挺好用。
假设你有一个简单的PHP脚本,我们叫它bootstrap.php。它负责启动时告诉Consul:“我在这儿,我有60秒的体检时间,体检地址是http://localhost:8080/health。”
<?php
require 'vendor/autoload.php';
use HashidsHashids;
use ConsulateClient;
// 初始化Consul客户端
// 你可以把它想象成你给管家打电话拨通了总机
$client = new Client([
'base_uri' => 'http://127.0.0.1:8500', // Consul默认地址
]);
try {
// 准备我们的服务信息
$serviceId = 'user-service-1'; // 给服务起个唯一的ID,防止ID冲突
$serviceName = 'User Service'; // 人类能看懂的名称
$tags = ['api', 'php', 'production']; // 打标签,方便以后按类型筛选
// 告诉管家,你要挂哪儿了
$address = '192.168.1.10'; // 你的服务实际IP
$port = 8080; // 你的服务实际端口
// 这里的关键:Health Check配置
// 我们告诉Consul,每隔10秒,管家会来检查这个URL,看返回200 OK没
$check = [
'id' => $serviceId . '-check',
'name' => 'User Service Health Check',
'http' => "http://{$address}:{$port}/health",
'interval' => '10s', // 10秒检查一次
'timeout' => '5s',
'status' => 'passing', // 初始状态必须是passing,否则注册会被拒绝
];
// 执行注册
$response = $client->agent()->registerService([
'id' => $serviceId,
'name' => $serviceName,
'tags' => $tags,
'address' => $address,
'port' => $port,
'check' => $check,
]);
echo "Service Registered Successfully! Service ID: {$serviceId}n";
} catch (Exception $e) {
echo "Whoops! Something went wrong: " . $e->getMessage() . "n";
}
看懂了吗?这就像你给管家递了张名片,上面写着地址、电话,还特意备注了“我有强迫症,每10分钟体检一次,不达标我就报警”。
三、 心跳:别让管家以为你死了
上面的代码只是注册。接下来,你的PHP服务必须真的去响应那个/health接口。这就像你人躺在床上,但听到敲门声必须得坐起来应答一声。
在你的User Service代码里,随便找个中间件或者路由,加一个/health接口:
// 在你的User Service里
$app->get('/health', function ($request, $response) {
// 这里可以加一些复杂的业务检查
// 比如:数据库连上了没?Redis缓存活蹦乱跳没?
// 如果都没问题,返回200
return $response->withStatus(200)->withHeader('Content-Type', 'text/plain');
// 如果数据库挂了,这里抛异常或者返回500
});
但是,光有这个还不够。PHP是脚本语言,也是CGI程序。如果你用PHP-FPM,脚本执行完就死了。怎么保证每10秒都响应一次呢?
我们需要一个守护进程。你可以用Supervisor或者简单的while(true)循环。
这就是那个永远在线的PHP脚本:
<?php
// heartbeat.php
require 'vendor/autoload.php';
use ConsulateClient;
$client = new Client(['base_uri' => 'http://127.0.0.1:8500']);
echo "Starting heartbeat...n";
while (true) {
try {
// 告诉管家:“我还在呢,状态良好!”
$client->agent()->check()->pass("http://127.0.0.1:8080/health");
// 可选:如果内部检查发现哪里不对,可以调用 fail()
// $client->agent()->check()->fail("Service is down");
} catch (Exception $e) {
echo "Failed to send heartbeat: " . $e->getMessage() . "n";
}
// 这里的sleep时间必须小于Consul注册时的interval,比如Consul是10s,你睡5s
sleep(5);
}
这个脚本一跑起来,管家就安心了。如果你程序崩溃了,这个脚本也会挂掉,管家敲门没人应,或者响应错误,他就会把你从名单上划掉。
四、 故障转移:死道友不死贫道
好了,现在我们有了一堆活着的微服务:User-Service在A机器,Order-Service在B机器,Payment-Service在C机器。现在有个Order-Service需要调用Payment-Service。
这时候,你绝对不能写死 http://192.168.1.20:8080。万一C机器挂了(比如被运维手滑删了,或者主机爆炸了),你的订单服务就得等着报错。
我们要实现客户端发现。
逻辑是这样的:
- Order-Service 拿着
Payment-Service的名字去问Consul:“Payment Service在哪?” - Consul把所有活着的Payment Service列表吐给Order-Service。
- Order-Service从列表里挑一个(轮询,或者随机)。
- 发起请求。
看看这段代码,这就是故障转移的灵魂:
<?php
require 'vendor/autoload.php';
use ConsulateClient;
// Order-Service 的逻辑
$client = new Client(['base_uri' => 'http://127.0.0.1:8500']);
try {
// 第一步:去Consul查表
// 这里的参数 name 是你之前注册的服务名
$services = $client->catalog()->services()->json();
if (!isset($services['services'])) {
throw new Exception("Consul returned no services. Is Consul running?");
}
// 找到Payment-Service
$paymentServices = array_filter($services['services'], function($service) {
return $service['ID'] === 'payment-service'; // 或者用 Name
});
if (empty($paymentServices)) {
throw new Exception("No payment service found in Consul.");
}
echo "Found Payment Services: " . print_r($paymentServices, true) . "n";
// 这里只是简单打印,实际逻辑是挑一个IP和Port
// 我们可以用 filter 参数只拿健康的
$healthyServices = $client->catalog()->service('payment-service', 'passing')->json();
if (!empty($healthyServices)) {
// 获取第一个健康的节点
$targetNode = $healthyServices[0]['ServiceAddress'];
$targetPort = $healthyServices[0]['ServicePort'];
echo "Trying to contact Payment Service at {$targetNode}:{$targetPort}n";
// 第二步:发起请求
// 这里用Guzzle举个例子
$guzzle = new GuzzleHttpClient();
$response = $guzzle->get("http://{$targetNode}:{$targetPort}/pay", [
'timeout' => 2 // 设置超时,防止Consul挂了你也傻等
]);
echo "Payment Success: " . $response->getBody() . "n";
} else {
echo "CRITICAL: All payment services are down!n";
}
} catch (Exception $e) {
echo "Error during service discovery or call: " . $e->getMessage() . "n";
// 这里可以放一条日志,或者触发熔断器
}
这段代码展示了故障转移的第一层:动态查找。Consul帮你屏蔽了IP的变化和节点的宕机。
五、 重试机制:再试一次,万一呢?
虽然Consul帮你找到了活着的节点,但网络是波动的。有时候,节点活着,网络卡顿了,或者节点刚好在处理另一个请求(拥堵了)。
这时候,简单的故障转移就不够了,我们需要重试。
GuzzleHttp库自带了一个Retry中间件,这是PHP界的神器。我们结合Consul来做。
use GuzzleHttpClient;
use GuzzleHttpHandlerStack;
use GuzzleHttpMiddleware;
use GuzzleHttpPsr7Request;
use GuzzleHttpPsr7Response;
use ConsulateClient;
// 1. 初始化Consul
$consul = new Client(['base_uri' => 'http://127.0.0.1:8500']);
// 2. 获取健康节点
$healthyServices = $consul->catalog()->service('payment-service', 'passing')->json();
if (empty($healthyServices)) {
die("No healthy services available.");
}
$node = $healthyServices[0];
$targetUrl = "http://{$node['ServiceAddress']}:{$node['ServicePort']}/pay";
// 3. 配置重试中间件
// 这里的逻辑是:如果失败,等1秒,再试,总共试3次
$retries = 3;
$delay = 1000; // 1秒
$stack = HandlerStack::create();
$stack->push(Middleware::retry(
function ($retries, $request, $response, $exception) use ($retries, $delay) {
if ($retries >= $retries) {
return false; // 超过次数,放弃
}
if ($exception instanceof GuzzleHttpExceptionConnectException) {
// 连接失败,重试
echo "Connection failed, retrying... (Attempt {$retries})n";
return true;
}
if ($response && $response->getStatusCode() >= 500) {
// 5xx服务器错误,重试
echo "Server error {$response->getStatusCode()}, retrying... (Attempt {$retries})n";
return true;
}
return false;
},
function ($retries) use ($delay) {
return $delay;
}
));
// 4. 发起请求
$client = new Client(['handler' => $stack]);
try {
$response = $client->get($targetUrl);
echo "Success!n";
} catch (Exception $e) {
echo "All retries failed. Service is likely down or circuit is open.n";
}
这就是重试机制。它给Consul找回来的节点一点“宽容度”。但是,记住我的警告:不要无脑重试。
六、 熔断器:别像疯子一样敲门
这就到了故障转移的高级形态——熔断器。
想象一下,Payment-Service挂了,或者网络全断了。
Consul告诉你:“Payment-Service活着。”
你的重试逻辑跑起来:
“我不信!再试!”
“我不信!再试!”
“我不信!再试!”
…
你会瞬间把Payment-Service的带宽打满,把CPU占满,把Consul的API请求打爆。这就是级联故障。
熔断器就是来阻止这种疯狂的。
熔断器有三种状态:
- 关闭: 正常状态,请求正常通过。
- 开启: 连续失败达到阈值(比如5次),状态切换为开启。此时所有请求直接失败,不再调用后端服务,也不再重试。
- 半开: 过了一段时间(比如10秒),熔断器打开个缝。放一个请求进去试探。如果成功,变回关闭;如果失败,变回开启。
我们用PHP实现一个简单的熔断器:
class CircuitBreaker
{
private $failureThreshold = 5; // 失败几次打开
private $timeout = 10; // 多久后尝试半开
private $status = 'closed'; // closed, open, half-open
private $failureCount = 0;
private $lastFailureTime = 0;
/**
* 执行带熔断保护的回调
*/
public function call(callable $callback)
{
// 如果是开启状态,直接抛异常,不再调用
if ($this->status === 'open') {
if ((time() - $this->lastFailureTime) > $this->timeout) {
$this->status = 'half-open'; // 时间到,进入半开试探
echo "[Circuit] Time's up, entering half-open state.n";
} else {
throw new Exception("Circuit is OPEN. Service is temporarily unavailable.");
}
}
try {
$result = $callback();
// 如果成功了,根据当前状态处理
if ($this->status === 'half-open') {
echo "[Circuit] Success in half-open. Resetting to CLOSED.n";
$this->reset();
}
return $result;
} catch (Exception $e) {
$this->failureCount++;
$this->lastFailureTime = time();
if ($this->failureCount >= $this->failureThreshold) {
$this->status = 'open';
echo "[Circuit] OPEN! Too many failures. Service blocked.n";
} else {
echo "[Circuit] FAILURE (Count: {$this->failureCount}).n";
}
throw $e;
}
}
private function reset()
{
$this->status = 'closed';
$this->failureCount = 0;
}
}
// --- 使用示例 ---
$breaker = new CircuitBreaker();
// 模拟调用 Payment Service
try {
// 这里就是你的 Guzzle 请求代码
// 假设我们有个函数 callPaymentService()
$breaker->call(function() {
// 这里写实际请求
// 模拟有时候成功,有时候失败
if (rand(0, 10) > 8) {
throw new Exception("Simulated Server Error 500");
}
return "Success!";
});
} catch (Exception $e) {
echo "Caught: " . $e->getMessage() . "n";
}
有了这个,你的PHP服务就智能了。当Payment Service彻底挂掉时,它不会像无头苍蝇一样一直重试,而是会直接说:“哎呀,这服务不行,我不管了,你自己看着办。”
七、 综合实战:Laravel中的完美结合
理论讲多了大家也累,我们把它落地。假设你在做一个Laravel项目,有一个PaymentController。
在这个控制器里,我们要注入一个“智能路由器”。这个路由器知道去Consul查表,知道怎么重试,也知道怎么熔断。
<?php
namespace AppServices;
use IlluminateSupportFacadesLog;
use GuzzleHttpClient;
use GuzzleHttpHandlerStack;
use GuzzleHttpMiddleware;
use ConsulateClient;
class SmartServiceClient
{
private $consul;
private $serviceName;
private $breaker;
public function __construct(string $serviceName)
{
$this->consul = new Client(['base_uri' => env('CONSUL_URL')]);
$this->serviceName = $serviceName;
// 初始化熔断器
$this->breaker = new CircuitBreaker();
}
/**
* 调用服务,自动处理发现、健康检查、重试和熔断
*/
public function call(string $endpoint, array $data = [])
{
return $this->breaker->call(function () use ($endpoint, $data) {
// 1. 获取健康节点
$services = $this->consul->catalog()->service($this->serviceName, 'passing')->json();
if (empty($services)) {
throw new RuntimeException("No healthy nodes found for {$this->serviceName}");
}
// 简单的轮询算法
$node = $services[array_rand($services)];
$url = "http://{$node['ServiceAddress']}:{$node['ServicePort']}{$endpoint}";
Log::info("Calling service", [
'service' => $this->serviceName,
'target' => $url,
'node' => $node['ServiceID']
]);
// 2. 执行请求 (带重试)
// 使用 Guzzle 中间件包装
$stack = HandlerStack::create();
$stack->push(Middleware::retry(
function ($retries, $request, $response, $exception) {
if ($retries > 2) return false; // Laravel默认3次重试
return $exception || ($response && $response->getStatusCode() >= 500);
},
function () { return 500; } // 500ms 延迟
));
$client = new Client(['handler' => $stack]);
$res = $client->post($url, ['json' => $data]);
return $res->getBody();
});
}
}
在Controller里怎么用?
// app/Http/Controllers/PaymentController.php
namespace AppHttpControllers;
use AppServicesSmartServiceClient;
use IlluminateHttpRequest;
class PaymentController extends Controller
{
protected $paymentService;
public function __construct(SmartServiceClient $paymentService)
{
$this->paymentService = $paymentService;
}
public function pay(Request $request)
{
try {
// 现在你只要管业务逻辑,底层的网络、熔断、Consul全不管了
$result = $this->paymentService->call('/api/v1/charge', [
'amount' => $request->input('amount'),
'card' => $request->input('card')
]);
return response()->json(['status' => 'ok', 'data' => $result]);
} catch (Exception $e) {
// 熔断器打开后,或者彻底失败后,会抛出异常
return response()->json(['status' => 'error', 'message' => $e->getMessage()], 503);
}
}
}
八、 进阶技巧:TCP Check vs Script Check
有时候,你的服务是HTTP服务,但有时候,你可能想检查TCP端口,或者检查一个脚本。
TCP Check:如果HTTP服务挂了,但端口还开着(比如还没发200,但在处理中),TCP Check会认为是活的。适合业务逻辑简单的检查。
Script Check:这很强大。你可以在Consul里配置一个脚本路径,比如 /usr/local/bin/check_mysql.sh。
Consul会去执行这个脚本。
如果是PHP,你可以写一个脚本,检查Redis连接,检查MySQL连接。
如果脚本返回0,Consul认为健康。
如果脚本返回非0(比如1),Consul认为不健康。
比如这个脚本:
#!/bin/bash
# check_payment.sh
# 假设Payment Service启动了一个Socket监听
# 或者用nc检查端口
if nc -z localhost 8081; then
echo "Payment port is open."
exit 0
else
echo "Payment port is closed."
exit 1
fi
然后在Consul注册时,把check里的http改成script。
这种方式比HTTP Check更底层,更能真实反映服务“能不能干活”,而不只是“能不能响应HTTP头”。
九、 总结(好吧,这次我们要正经点)
回顾一下,我们今天把PHP变成了微服务架构中的主力军。
- Consul是基础:它提供了服务注册表和健康检查机制。没有它,你就是个盲人摸象。
- 健康检查是关键:不管是HTTP还是Script,你得让Consul知道你是不是挂了。你的PHP脚本得有“心跳”。
- 故障转移是手段:通过Consul获取列表,剔除不健康的节点。
- 重试是宽容:给网络波动留点机会。
- 熔断器是保命符:防止级联故障,避免把系统搞崩。
这套组合拳下来,你的PHP应用就不再是一个脆弱的脚本,而是一个具备自愈能力的分布式组件。Consul负责监控,PHP负责响应,重试负责兜底,熔断负责止损。
记住,微服务不是银弹,但有了Consul和这套PHP组合,至少你的服务不会像断了线的风筝一样满天乱飞。保持健康,保持心跳,Happy Coding!