在前段时间,我负责的推送中台出现了一个非常严重的 Bug,差点造成线上服务宕机。
我简单来描述一下当时的场景。
- 我们使用个推来来推送我们的 APP 通知,以及用户画像,用户分析等等。
- 每一次在 app 启动都会访问一个接口来判断用户信息,然后绑定标签,这个动作会触发 curl 函数访问个推的 API。
- 结果悲剧了,个推北京机房宕机。
我们当时的主要技术栈是 PHP,我们线上 curl 请求默认超时时间是 60 秒,这就导致我们的数据库链接近乎被打满。
由于 PHP-FPM 在脚本执行完成后才会释放进程,这也包括其中的资源,curl 请求的默认时间是 60 秒,这样也就意味着在 60 秒内,该进程也会被一直阻塞,直到超时为止,而 PDO 的生命周期是跟着 FPM 走的,除非调用手动 close() 这样才会关闭。
MySQL 连接断开也是有默认超时时间的,如果在超时时间范围内,客户端没有主动关闭,该连接会一直存在,但是 MySQL 的连接数是有限的,如果连接被打满那么新的连接将无法建,整个服务 GG 。
如果这个时候再有流量进来,那么就只有宕机一条死路了,于是立即找到运维修改默认配置,然后重启 php-fpm 释放 MYSQL 连接。
处理方式以及断路器
经过这一次的线上事故,总结出了一点,对于任何第三方提供的接口,都不要完全信赖!,都要去考虑如果对方出现问题,我这里应该如何操作!
经过复盘我们当时存在以下问题:
- 环境配置不透明,开发基本不关注线上配置。
- 没有考虑第三方失败该如何进行处理。
- 架构设计不完善,此类功能完全也可以异步化。
第一点是由于工作经验不足或者缺乏沟通导致的,很好解决,当这个事情解决之后,团队对线上的配置情况和运维一起进行了一次梳理。
第二点,我们选择了熔断器,首先说一下什么是熔断器。
第三点,对业务进行了梳理,将强依赖接口改成了消息队列进行异步处理。
如果你看过自己家中的电闸开关的话就会看到上图这个东西, 当电流正常情况下熔断器是关闭状态电流就可以正常通过,如果电流负载过大为了保护家用电器就会开启,然后关闭电源保证电器安全。
在我们的代码中熔断器其实就是一个设计模式,我们可以通过很简单的代码就可以实现熔断器。
图 1.2 中是在没有熔断器我们在开发这类业务的时候,可能会对第三方服务进行 try catch
来判断异常,如果有异常在反复几次调用,多次尝试失败后,可能就直接返回报错,提供有损的服务,像上面我提到的那场事故中这个问题就会被放大,因为 60 秒后才会触发超时,也就意味着,如果重试三次那么该接口的耗时就会增加到 3 分钟,正常情况下是不会有业务接口能够接收三分钟这样耗时的。
当我们加上熔断器后,那么流程就会变成图1.3 那样。
引入断路器后,不再是 app server 直接访问第三方服务,而是将访问委托给 fuse
熔断器来进行,断路器判断当前的开闭状态来判断是否要发起请求。
- 如果是处于关闭状态,也就是第三方服务可以正常访问,那就直接访问第三方服务,
- 如果熔断器处于开启状态,那么正面目前第三方服务不可用,则直接调用备用函数,这也是微服务中一个重要的概念——服务降级。
利用 Redis Sorted Set 实现简单的熔断器
由于我们使用的 PHP-FPM,这也就导致我们的 PHP 代码是无法常驻内存的,这个时候,就需要使用 Redis 来进行记数,在数据结构方面我们选择使用有序集合。
key | score | member |
---|---|---|
circuit | 10 | pushMessage2User |
circuit | 1 | pushMessage2List |
score 用来存放失败的次数,member 用来存放失败的函数。
搭建骨架
我们先来按照上一小节的内容先将熔断器的挂架代码打起来。
<?php
class CircuitBreaker
{
private $zSetKey = 'circuit';
public function invoke(object $class, string $method, array $params, callable $fallback)
{
try {
return $class->$method(...$params);
} catch (Throwable $exception) {
return $fallback(); //函数降级
}
}
}
CircuitBreaker
就是我们的熔断器类。
其中设计了一个 invoke
方法,这个方法就是上面讲到的代理请求,我们来通过 invoke
方法来让熔断器代理我们的请求。
$class
: 我们首先要将类实例化后传入到invoke
中,这样的好处是进行依赖翻转,让熔断器可以通用。$method
: 是该类需要执行的方法。$params
: 是执行该方法时候需要传入的参数。$callback
:是一个闭包,当熔断器被打开,或者出现异常的时候,我们需要对服务进行降级。
下面是如何调用。
<?php
require 'CircuitBreaker.php';
class Pusher
{
public function pushMessage2User($username, $message): bool
{
echo "使用 Pusher 进行发送 $username [$message]";
return true;
}
}
$c = new CircuitBreaker();
$messageData = ['maksim', 'hello world!'];
$pusher = new Pusher();
$c->invoke($pusher, 'pushMessage2User', $messageData, function () use ($messageData) {
echo "使用站内信进行发送 $messageData[0] $messageData[1]";
return true;
});
这个时候就相当于是一个简单的代理模式
,由 CircuitBreaker
去执行 Pusher
,并且捕获异常,如果出现异常就用降级函数。
在这里,我们模拟发送通知的业务场景,如果 Pusher 类发送失败了,那么我们就降级使用站内信的方式进行发送,当然也可以扔到一个队列里面,等业务恢复后在开启异步消息队列将消息推送出去。
为熔断器添加计数器
接下里,我们对 CircuitBreaker 进行修改,增加错误计数。
public function invoke(object $class, string $method, array $params, callable $fallback)
{
try {
return $class->$method(...$params);
} catch (Throwable $exception) {
$member = get_class($class) . '_' . $method;
$this->redis->zIncrBy($this->zSetKey, 1, $member);
return $fallback(); //函数降级
}
}
这里的代码非常简单,当代码发生异常后,我们直接增加 redis 计数器,下面我们使用 Redis 的延迟队列来实现熔断器开关状态的切换。
首先,我们需要设置失败的阀值,当失败次数达到阀值后不再调用原函数。
public $failCount = 3;
private function isFail($member) {
if ($this->redis->zScore($this->zSetKey, $member) >= $this->failCount ) {
return true;
}
return false;
}
public function invoke(object $class, string $method, array $params, callable $fallback)
{
$member = get_class($class) . '_' . $method;
try {
if ($this->isFail($member)) {
return $fallback();
}
return $class->$method(...$params);
} catch (Throwable $exception) {
$this->redis->zIncrBy($this->zSetKey, 1, $member);
return $fallback(); //函数降级
}
}
实现熔断器的三种状态
现在我们就实现了熔断器的打开,接下来,我们需要实现熔断器的关闭,但是只有这两种状态熔断器是无法正常运作的,在日常生活中,当熔断器跳闸后,我们判断没有问题后会手动去尝试开启熔断器,同样在代码中,我们也需要一个这个样的过程,我们需要让熔断器再去尝试着去访问原来的方法,如果满足可关闭状态,那么把熔断器关掉。
而在这个试探的这个状态就是半开状态。
- 关:调用原函数,失败后计数器+1,并返回降级函数,到达一定阀值后进入“开”状态;
- 开:也就是直接返回降级函数,不调用原函数,但是我们可以给失败函数一些机会,于是出现半开状态,不过需要设定一定的定时器,一定时间后进入半开状态。
- 半开状态:处理半开妆容是熔断器的核心,调用函数时候有一定数量的调用时进入降级函数,有一定数量进入的是真实函数。如果函数已经稳定,那么就进入关状态。
状态值的定义如下:
define("BreakerStateOpen" , 1); // 开
define("BreakerStateClose" , 2); // 关,这是默认值
define("BreakerStateHalfOpen" , 3); // 半开
private funcion getState($member) {
$getSocre = $this->redis->zScore($this->zSetKey, $member);
if ($getScore >= $this->failCOunt {
return BreakerStateOpen;
}
if ($getScore < 0) {
return BreakerStateHalfOpen; // 如果值是 -1 则代表是半开状态
}
return BreakerStateClose;
}
接下来,利用定时器来完成进入半开状态,在 PHP-FPM 模式下定时器我们可以通过 Redis 实现,实现的方式有两种:
第一种:利用一个带有过期时间的 key 来完成,一旦进入“开”状态后,插入一个 key,设置过期时间,然后利用过期通知的方式来完成。
第二种:延迟队列,依然使用 Sorted Set 来完成,key 设置为 circuit_open,插入时间戳,编写一个死循环程序,反复监听。
通过这两种方式,监听过期时间,到期后把 circuit 对应的 member 的 score 设置为 -1,即代表是半开状态。
在这里我们通过延迟队列的方式来进行完成,如果不了解延迟队列可以通过《PHP 利用 Redis Sorted Set 实现延迟队列》这片文章来了解其原理。
<?php
while(true) {
$members = $redis->zRangeByScore("circuit_open", "-inf", time(), ['limit' => [0, 10]]);
if (count($members) > 0) {
foreach ($members as $member) {
$redis->zAdd('circuit', -1, $member);
}
//删掉 open key
$redis->zRem("circuit_open", ...$members);
}
usleep(500 * 1000);//休眠 500 毫秒
}
这样一来,我们就可以使用死循环程序来不断的设置半开状态了。
接下来我们在 invoke 方法来处理,当熔断器进入到开状态时那么就设置一个定时器。
public function invoke(object $class, string $method, array $params, callable $fallback)
{
$member = get_class($class) . '_' . $method;
$currentState = $this->getState($member); //获取当前状态
try {
if ($currentState == BreakerStateOpen) {
return $fallback();
} else if ($currentState == BreakerStateHalfOpen) {
//如果是半开状态
if (rand(0, 100) % 2 == 0) {
return $fallback();
} else {
$result = $class->$method(...$params);
// 半开状态下仍然要加计数器,目的是为了让他归 0
$this->redis->zIncrBy($this->zSetKey, 1, $member);
return $result;
}
}
return $class->$method(...$params);
} catch (Throwable $exception) {
if ($currentState == BreakerStateClose) {
//切换到开
$socre = $this->redis->zIncrBy($this->zSetKey, 1, $member);
if ($score == $this->failCount) {
//增加队列
$this->redis->zAdd($this->zSetKey_open, time() + $this->openTime, $member);
}
}
if ($currentState == BreakerStateHalfOpen) {
$this->redis->zIncrBy($this->zSetKey, $this->failCount, $member); // 将熔断器重新设置为开状态
$this->redis->zAdd($this->zSetKey_open, time() + $this->openTime, $member); //再次开启定时器
}
return $fallback(); //函数降级
}
}
在这段代码中,我们重点关注第 10 行到第 16 行,这里“随机”让其访问真实函数,如果函数正常,则将计数器归 0,让其进入到关闭状态。
在第 22 行开始增加了对状态的判断,如果当前处于关闭状态,那么久打开,如果现在是半开状态,那证明半开尝试失败了,需要重新进入打开状态。
这里的半开状态的处理很简单,其实正常的熔断器还需要包括采样分析,来判断当前是否满足开合条件,同时上面的“随机”也无法控制到底有多少流量进入到正常函数,所以我们还需要增加真正的概率算法,将通过的流量控制在一个我们可以接受的范围内。