maksim 发布的文章

在前段时间,我负责的推送中台出现了一个非常严重的 Bug,差点造成线上服务宕机。

我简单来描述一下当时的场景。

  1. 我们使用个推来来推送我们的 APP 通知,以及用户画像,用户分析等等。
  2. 每一次在 app 启动都会访问一个接口来判断用户信息,然后绑定标签,这个动作会触发 curl 函数访问个推的 API。
  3. 结果悲剧了,个推北京机房宕机。

我们当时的主要技术栈是 PHP,我们线上 curl 请求默认超时时间是 60 秒,这就导致我们的数据库链接近乎被打满。

由于 PHP-FPM 在脚本执行完成后才会释放进程,这也包括其中的资源,curl 请求的默认时间是 60 秒,这样也就意味着在 60 秒内,该进程也会被一直阻塞,直到超时为止,而 PDO 的生命周期是跟着 FPM 走的,除非调用手动 close() 这样才会关闭。

MySQL 连接断开也是有默认超时时间的,如果在超时时间范围内,客户端没有主动关闭,该连接会一直存在,但是 MySQL 的连接数是有限的,如果连接被打满那么新的连接将无法建,整个服务 GG 。

如果这个时候再有流量进来,那么就只有宕机一条死路了,于是立即找到运维修改默认配置,然后重启 php-fpm 释放 MYSQL 连接。

处理方式以及断路器

经过这一次的线上事故,总结出了一点,对于任何第三方提供的接口,都不要完全信赖!,都要去考虑如果对方出现问题,我这里应该如何操作!

经过复盘我们当时存在以下问题:

  1. 环境配置不透明,开发基本不关注线上配置。
  2. 没有考虑第三方失败该如何进行处理。
  3. 架构设计不完善,此类功能完全也可以异步化。

第一点是由于工作经验不足或者缺乏沟通导致的,很好解决,当这个事情解决之后,团队对线上的配置情况和运维一起进行了一次梳理。

第二点,我们选择了熔断器,首先说一下什么是熔断器。

第三点,对业务进行了梳理,将强依赖接口改成了消息队列进行异步处理。

de8a07841802fdcd985039e8afb1e58b

图 1.1 熔断器

如果你看过自己家中的电闸开关的话就会看到上图这个东西, 当电流正常情况下熔断器是关闭状态电流就可以正常通过,如果电流负载过大为了保护家用电器就会开启,然后关闭电源保证电器安全。

在我们的代码中熔断器其实就是一个设计模式,我们可以通过很简单的代码就可以实现熔断器。

图 1.2 无熔断器流程图

图 1.2 中是在没有熔断器我们在开发这类业务的时候,可能会对第三方服务进行 try catch 来判断异常,如果有异常在反复几次调用,多次尝试失败后,可能就直接返回报错,提供有损的服务,像上面我提到的那场事故中这个问题就会被放大,因为 60 秒后才会触发超时,也就意味着,如果重试三次那么该接口的耗时就会增加到 3 分钟,正常情况下是不会有业务接口能够接收三分钟这样耗时的。

当我们加上熔断器后,那么流程就会变成图1.3 那样。

图 1.3

引入断路器后,不再是 app server 直接访问第三方服务,而是将访问委托给 fuse 熔断器来进行,断路器判断当前的开闭状态来判断是否要发起请求。

  1. 如果是处于关闭状态,也就是第三方服务可以正常访问,那就直接访问第三方服务,
  2. 如果熔断器处于开启状态,那么正面目前第三方服务不可用,则直接调用备用函数,这也是微服务中一个重要的概念——服务降级

利用 Redis Sorted Set 实现简单的熔断器

由于我们使用的 PHP-FPM,这也就导致我们的 PHP 代码是无法常驻内存的,这个时候,就需要使用 Redis 来进行记数,在数据结构方面我们选择使用有序集合。

keyscoremember
circuit10pushMessage2User
circuit1pushMessage2List

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. 关:调用原函数,失败后计数器+1,并返回降级函数,到达一定阀值后进入“开”状态;
  2. 开:也就是直接返回降级函数,不调用原函数,但是我们可以给失败函数一些机会,于是出现半开状态,不过需要设定一定的定时器,一定时间后进入半开状态。
  3. 半开状态:处理半开妆容是熔断器的核心,调用函数时候有一定数量的调用时进入降级函数,有一定数量进入的是真实函数。如果函数已经稳定,那么就进入关状态。

状态值的定义如下:

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 行开始增加了对状态的判断,如果当前处于关闭状态,那么久打开,如果现在是半开状态,那证明半开尝试失败了,需要重新进入打开状态。

这里的半开状态的处理很简单,其实正常的熔断器还需要包括采样分析,来判断当前是否满足开合条件,同时上面的“随机”也无法控制到底有多少流量进入到正常函数,所以我们还需要增加真正的概率算法,将通过的流量控制在一个我们可以接受的范围内。

这个命令我想大家都不陌生,“ping”这个命名来自于声呐探测,在网络上用来完成对网络连通性的探测,这个命名可以说是恰如其分了。

$ ping www.sina.com.cn
PING www.sina.com.cn (202.102.94.124) 56(84) bytes of data.
64 bytes from www.sina.com.cn (202.102.94.124): icmp_seq=1 ttl=63 time=8.64 ms
64 bytes from www.sina.com.cn (202.102.94.124): icmp_seq=2 ttl=63 time=11.3 ms
64 bytes from www.sina.com.cn (202.102.94.124): icmp_seq=3 ttl=63 time=8.66 ms
64 bytes from www.sina.com.cn (202.102.94.124): icmp_seq=4 ttl=63 time=13.7 ms
64 bytes from www.sina.com.cn (202.102.94.124): icmp_seq=5 ttl=63 time=8.22 ms
64 bytes from www.sina.com.cn (202.102.94.124): icmp_seq=6 ttl=63 time=7.99 ms
^C
--- www.sina.com.cn ping statistics ---
6 packets transmitted, 6 received, 0% packet loss, time 5006ms
rtt min/avg/max/mdev = 7.997/9.782/13.795/2.112 ms

在上面的例子中,我使用 ping 命令探测了和新浪网的网络连通性。可以看到,每次显示是按照 sequence 序列号排序显示的,一并显示的,也包括 TTL(time to live),反映了两个 IP 地址之间传输的时间。最后还显示了 ping 命令的统计信息,如最小时间、平均时间等。

我们需要经常和 Linux 下的 ping 命令打交道,那么 ping 命令的原理到底是什么呢?它是基于 TCP 还是 UDP 开发的?

都不是。

其实,ping 是基于一种叫做 ICMP 的协议开发的,ICMP 又是一种基于 IP 协议的控制协议,翻译为网际控制协议,其报文格式如下图:

Ping 报文

ICMP 在 IP 报文后加入了新的内容,这些内容包括:

  • 类型:即 ICMP 的类型, 其中 ping 的请求类型为 0,应答为 8。
  • 代码:进一步划分 ICMP 的类型, 用来查找产生错误的原因。
  • 校验和:用于检查错误的数据。
  • 标识符:通过标识符来确认是谁发送的控制协议,可以是进程 ID。
  • 序列号:唯一确定的一个报文,前面 ping 名字执行后显示的 icmp_seq 就是这个值。

当我们发起 ping 命令时,ping 程序实际上会组装成如图的一个 IP 报文。报文的目的地址为 ping 的目标地址,源地址就是发送 ping 命令时的主机地址,同时按照 ICMP 报文格式填上数据,在可选数据上可以填上发送时的时间戳。

IP 报文通过 ARP 协议,源地址和目的地址被翻译成 MAC 地址,经过数据链路层后,报文被传输出去。当报文到达目的地址之后,目的地址所在的主机也按照 ICMP 协议进行应答。之所以叫做协议,是因为双方都会遵守这个报文格式,并且也会按照格式进行发送 - 应答。

应答数据到达源地址之后,ping 命令可以通过再次解析 ICMP 报文,对比序列号,计算时间戳等来完成每个发送 - 应答的显示,最终显示的格式就像前面的例子中展示的一样。

可以说,ICMP 协议为我们侦测网络问题提供了非常好的支持。另外一种对路由的检测命令 Traceroute 也是通过 ICMP 协议来完成的,这里就不展开讲了。

延迟队列是我们在业务中经常遇到的场景,例如订单过期,定时发布,定时推送等等。

在 Zset里面,每一个成员都有一个所谓的分数:score,把当前时间作为分数,因为 Zset 是有序的,时间越小的排名越靠前。所以使用Zset作为延时队列就充分利用了score。

我们还是用订单例子来距离,如果订单超过 24 小时未付款,那么久取消订单,这是在电商业务中最常见的处理模式。

那么在 redis 中的数据个是就如图 1.1。

图 1.1

而程序执行流程如下图

  1. 用户下单后将订单信息插入到数据库中
  2. 将数据插入到 redis 中,结构如图 1.1
  3. 启动死循环,定时从 zset 中获取接近当前时间戳的值,然后逻辑判断是否更新数据。

接下来我们回顾一下 zset相关的命令:

ZRANGEBYSCORE key min max [WITHSCORES] [LIMIT offset count]

注意排序是按照 score 从小到大排序的,这也是我们想要的,其中 min 和 max 可以是 -inf 和 +inf。

  • -inf 分数的最低值
  • +inf 分数的最高值
ZRANGEBYSCORE order:delay_queue -inf 5000  WINTHSCORES

这代表你不知道最低值是多少,取值范围是 <= 5000

ZRANGEBYSCORE order:delay_queue -inf current_timestamp WINTHSCORES limit 0 10

代表取 10 条最小等于当前时间的所有内容。

我们还是使用命令行脚本来模拟入队操作。

<?php

$orderId = $argv[1] ?? '';
if (empty($orderId)) {
    exit("请填写要压入队列的数据");
}
$redis = new Redis();

$redis->connect('127.0.0.1', 6379);
$redis->auth('123456');
$redis->select(0);
$queueName = 'order:delay_queue';

$redis->zAdd($queueName, time(), $orderId);

执行代码,向队列中插入四条数据。

# 执行入队操作
➜ php zset_client.php O20190101
➜ php zset_client.php O20190102
➜ php zset_client.php O20190103
➜ php zset_client.php O20190104

# redis 查看数据
127.0.0.1:6379> zrange order:delay_queue 0 -1 withscores
1) "O20190101"
2) "1667213048"
3) "O20190102"
4) "1667213049"
5) "O20190103"
6) "1667213050"
7) "O20190104"
8) "1667213051"

接下来我们来实现消费端的代码:

<?php


$redis = new Redis();

$redis->connect('127.0.0.1', 6379);
$redis->auth('123456');
$redis->select(0);
$queueName = 'order:delay_queue';

while (true) {

    $orderIds = $redis->zRangeByScore($queueName, "-inf", time(), ['limit' => [0, 10]]);
    $ids = implode(',', $orderIds);

    if (!empty($ids)) {
        //模拟执行 SQL 语句,4 代表已经取消
        $sql = "update `orders` set `status` = 4 where in ({$ids})";
        echo "$sql" . PHP_EOL;
        //执行其他业务逻辑,例如推送通知等操作
    }

    $redis->zRem($queueName, ...$orderIds);
    usleep(500 * 1000);
}

这里需要注意我们在更新数据库的时候不要一条一条的更新,同时这些代码只是用来演示基本原理,还有好多需要处理的内容。

  1. 处理程序的退出,监听信号
  2. 尽量使用异步方法处理业务逻辑,可以尝试使用 swoole,workman 带有多进程管理的框架或者已经封装好的类库。

Redis 本身是用来做缓存的,但是其中有一些特性是我们可以用来完成消息队列的功能,比如如果能够容忍数据的丢失,并且持久化方面要求不是很高的场景下完成任务的分布式处理。

队列的基本实现

Redis列表是简单的字符串列表,按照插入顺序排序。你可以添加一个元素到列表的头部(左边)或者尾部(右边),而我们取出的时候可以从头部或者尾部来获取数据。

我们可以先头lpush 插入数据,然后再rpop来取数据,来模拟队列的先进先出。

$ redis-cli -h 127.0.0.1
127.0.0.1:6379> auth 123456
OK
127.0.0.1:6379> select 0
OK
127.0.0.1:6379> lpush order:queue SN20190801
(integer) 1
127.0.0.1:6379> lpush order:queue SN20190802
(integer) 2
127.0.0.1:6379> lpush order:queue SN20190803
(integer) 3
127.0.0.1:6379> rpop order:queue
"SN20190801"
127.0.0.1:6379> rpop order:queue
"SN20190802"
127.0.0.1:6379> rpop order:queue
"SN20190803"
127.0.0.1:6379> rpop order:queue
(nil)

如果想要批量处理,我们可以使用 LRANGE 命令:

127.0.0.1:6379> lpush order:queue SN20190801
(integer) 1
127.0.0.1:6379> lpush order:queue SN20190802
(integer) 2
127.0.0.1:6379> lpush order:queue SN20190803
(integer) 3
127.0.0.1:6379> lrange order:queue 0 10
1) "SN20190803"
2) "SN20190802"
3) "SN20190801"

基于这三个命令,我们就可以实现一个异步的消息队列了,我们首先来模拟消息队列的生产,最终我们的消费模型就如图 1.1。

未命名绘图.drawio (1)

图 1.1 消费模型

<?php

// 接收参数
$orderId = $argv[1] ?? '';

if (empty($orderId)) {
    exit("请填写要压入队列的数据");
}

// 新建 redis 连接
$redis = new Redis();
$redis->connect('127.0.0.1', 6379);
$redis->auth('123456');
$redis->select(1);

try {
    echo $redis->lPush('order:queue', $orderId) . PHP_EOL;
} catch (Exception $exception) {
    //捕获异常,出现异常情况应该进行处理重传或者报警等等
    echo $exception->getMessage() . PHP_EOL;
}
// 释放资源
$redis->close();

这段代码非常简单,通过 $argv 来获取参数,也就是 order_id,通知消费端,该 id 有数据变更,要进行一些异步操作。

处理端我们使用 brPop 来阻塞获进行消费就可以了。

<?php

$redis = new Redis();

$redis->connect('127.0.0.1', 6379);
$redis->auth('123456');
$redis->select(1);

while (true) {
//阻塞获取,10 秒后没有获取到证明超时
    $result = $redis->brPop("order:queue", 10);
    //$result 包含两个返回值
    //$result[0] redis key 的名称 order:queue
    //$result[1] redis value 也就是我们压入队列的数据
    if ($result && $result[0]) {
        //业务处理逻辑
        echo "接收到订单号为 [" . $result[1] . "] 的订单,开始执行业务逻辑";
        usleep(500 * 1000); // 休眠500 毫秒,让出 CPU 时间片
    } else {
        continue;
    }
}

首先是使用 while 死循环来模拟一直在消费, brPop 设置一个十秒的阻塞,如果阻塞过了十秒就放弃获取,执行下一次逻辑,在处理完成后,我们要 usleep 500 毫秒,让出 CPU 时间片避免 CPU 资源被该进程一直占用。

这样一来一个简单的 redis 异步消息队列就实现好了,到这一步最好不要直接在生产环境这么写。

我们还需要处理 kill 信号来实现优雅退出,因为 kill 信号是直接杀死进程,这个时候任务可能正在处于执行状态,这种处理方式是不可重入的,因为你无法确定某个操作环节会出错。

利用bRPopLPush命令补救数据丢失

如果你不能容忍消息的丢失或者持久化,我们可以使用比较成熟的 MQ,例如 RocketMQ,RabbitMQ 或者 kafka,这些 MQ 都可以比较好的解决这个问题。

bRPopLPush 是 redis 提供给我们的一个命令,改命令从列表中取出最后一个元素,并插入到另外一个列表的头部; 如果列表没有元素会阻塞列表直到等待超时或发现可弹出元素为止。

我们可以利用这个提醒替换 BRPOP ,在获取队列内容的时候将其丢到一个备份 key 中,当消息执行结束后,再将备份的数据从备份 key 中删除。

<?php

$redis = new Redis();

$redis->connect('127.0.0.1', 6379);
$redis->auth('123456');
$redis->select(1);

$orderQueueKey = 'order:queue';
$backupQueueKey = 'order:queue:backup';

// 程序启动优先处理 backup key 中的数据
while (true) {
    $result = $redis->brPop($backupQueueKey, 1);
    if ($result && $result[0]) {
        //业务处理逻辑
        echo "接收到订单号为 [" . $result[1] . "] 的订单,开始执行业务逻辑" . PHP_EOL;
        sleep(3); // 模拟耗时操作
        echo "处理完成" . PHP_EOL;
        usleep(500 * 1000); // 休眠500 毫秒,让出 CPU 时间片
    } else {
        break;
    }
}

while (true) {

//阻塞获取,10 秒后没有获取到证明超时
    $result = $redis->bRPopLPush("order:queue", "order:queue:backup", 10);

    if ($result) {
        //业务处理逻辑
        echo "接收到订单号为 [" . $result . "] 的订单,开始执行业务逻辑" . PHP_EOL;
        sleep(3); // 模拟耗时操作
        echo "处理完成" . PHP_EOL;
        //处理完成后取出key
        $redis->lPop($backupQueueKey);
        
        usleep(500 * 1000); // 休眠500 毫秒,让出 CPU 时间片
    } else {
        continue;
    }
}

我们首先在真正的处理逻辑前增加了对备份key 的处理,让程序启动后先处理之前没有梳理完成的数据。

在最下方处理队列的地方也做了改动,在处理完成后取出备份 key 中的数据。

1. 学习目标和内容

1、能够理解描述网站业务访问流程

2、能够理解网站业务的优化方向

3、能够描述内存缓存软件Memcached的作用

4、能够通过命令行操作Memcached

5、能够操作安装php的memcached扩展 extension

6、能够实现session存储到memcached的案例

7、能够实现启动memcached多实例

2. 大型网站优化

2.1 网站访问流程

随着网站迭代开发,访问会变慢,LNMP架构中网站应用访问流程;

浏览器(app)=>web服务器=>后端服务(php)=>数据库(mysql)

访问流程越多,访问速度和出现问题的几率也越大

优化访问速度,就需要减少访问步骤或者提高单步骤的速度

2.2 如何优化

根据网页的访问流程,可以进行以下优化:

①提高web服务器并发 负载均衡(多台服务器架构) nginx

②页面静态化 把经常访问,但是数据不经常发生变动的动态页面,制作为静态页面

③内存缓存优化 把经常访问的数据,加载到内存中使用

④数据库优化 很多时候,还需要取数据库信息,所以优化数据库本身

3. 背景描述及其方案设计

3.1 业务背景描述

时间:2016.6.-2016.9

发布产品类型:互联⽹动态站点 商城

⽤户数量: 10000-12000(⽤户量猛增)

PV : 100000-500000(24⼩时访问次数总和)

QPS: 50-100*(每秒访问次数)

DAU: 2000(每⽇活跃⽤户数)

随着业务量增加,访问量越来越大,用户在访问某些页面数据时,通过慢查询日志发现慢查询SQL,经过优化之后效果还是不够明显。而此类数据发生变动的频率又较小,故提出使用缓存中间件(一般会将数据存储到内存中)的方式,降低MySQL的读压力,提高整个业务架构集群的稳定和快速响应能力

3.2 模拟运维设计方案

根据以上业务需求,准备加入缓存中间件服务器

1.jpg

根据以上业务需求和方案,服务器架构升级为如下示意图

2.jpg

在本次业务架构中,使用缓存中间件解决以下两个问题:

①session共享

②缓存热点数据,首页面的分类信息

4. memcached 介绍和安装启动

4.1 介绍

memory cache cache in memory 缓存放入内存中。

Memcached 是国外 社区 网站 LiveJournal 的开发团队开发的 高性能的分布式内存缓存服务器。一般的使用目的是,通过缓存数据库查询结果,减少数据库访问次数,以提高动态 Web 应用的速度、提高可扩展性

3.jpg

关于缓存的过期,有效期问题,由业务编程代码实现

4.2 安装启动软件

官方网址http://memcached.org/

①上传软件到服务器

软件包名称 memcached-1.5.8.tar.gz

②解压并编译安装

 #memcache依赖libevent  安装libevent-devel解决
 shell > yum -y install libevent-devel
 #编译安装memcached
 shell > tar xvf memcached-1.5.8.tar.gz
 shell > cd memcached-1.5.8
 shell > ./configure --prefix=/usr/local/memcached
 shell > make && make install

③查看软件启动参数,并启动

 shell > cd /usr/local/memcached/bin
 shell > ./memcached -h

-p是设置Memcache监听的端口,最好是1024以上的端口;

  • d是启动一个守护进程;
  • m是分配给Memcache使用的内存数量,单位是MB;
  • u是运行Memcache的用户;
  • l是监听的服务器IP地址,可以有多个地址;
  • c是最大运行的并发连接数,默认是1024;
  • P是设置保存Memcache的pid文件

启动memcached

 #进入memcached文件目录
 shell > cd /usr/local/memcached/bin
 #后台启动memcached  可以创建一个普通用户(memcached)  用来启动管理memcached软件
 shell > ./memcached -uroot -d
 #进程查看是否启动成功
 shell > ps aux |grep memcached

5. memcached使用

5.1 命令行连接和操作

5.1.1 telnet连接使用

memcached默认使用启动服务占用tcp 11211端口。可以通过telnet进行连接使用。

 #安装telnet客户端
 shell > yum -y install telnet
 #通过telnet连接11211端口
 shell > telnet 127.0.0.1 11211
 #连接之后敲击多次,如果看到error,即为连接成功
 #显示error的原因是,没有输入命令,所以memcached服务器回复error

5.1.2 存储命令

语法:set

set 命令用于将 value(数据值) 存储在指定的 key(键) 中

如果set的key已经存在,该命令可以更新该key所对应的原来的数据,也就是实现更新的作用
devops。

set key flag exptime bytes

value

flag 服务端提供的一个标识,默认没什么意义,默认可以传个0,这个标识是为了编程语言一个状态,例如:flag(0,1) 代表是否采用压缩机制 0代表不压缩,1代表压缩

bytes 字节 计算机存储的最小单位 KB MB GB TB BB YB ZB

4.jpg

5.jpg

语法:add

add 命令用于将 value(数据值) 存储在指定的 key(键) 中

如果 add 的 key 已经存在,则不会更新数据(过期的 key 会更新),之前的值将仍然保持相同,并且您将获得响应 NOT_STORED

add key flag exptime bytes

value

6.jpg

语法:replace

replace 命令用于替换已存在的 key(键) 的 value(数据值)

如果 key 不存在,则替换失败,并且您将获得响应 NOT_STORED

replace key flag exptime bytes

value

7.jpg

语法:append

append 命令用于向已存在 key(键) 的 value(数据值) 后面追加数据

append key flag exptime bytes

value

8.jpg

语法:prepend

prepend 命令用于向已存在 key(键) 的 value(数据值) 前面追加数据

prepend key exptime bytes value

9.jpg

5.1.3 删除命令

语法:delete

delete 命令用于删除已存在的 key(键)

delete key

10.jpg

语法:flush_all

注意此命令,在业务线上环境禁止执行。如果执行,可能会造成所有缓存清空不存在,所有的数据请求都直接到了数据库服务器。造成数据库压力瞬间变大。数据库宕机。

flush_all 命令用于清理缓存中的所有 key=>value(键=>值) 对

该命令提供了一个可选参数 time,用于在制定的时间后执行清理缓存操作

flush_all [time]

11.jpg

5.1.4 查找和计算命令

语法:get

get 命令获取存储在 key(键) 中的 value(数据值) ,如果 key 不存在,则返回空

get key1 key2 key3

语法:incr(相加)/decr(相减)

计数器 每做一次操作 +1

incr 与 decr 命令用于对已存在的 key(键) 的数字值进行自增或自减操作

incr 与 decr 命令操作的数据必须是十进制的32位无符号整数

incr key value

decr key value

12.jpg

5.1.5 统计状态命令

语法:stats

stats 命令用于返回统计信息例如 PID(进程号)、版本号、连接数等

stats

13.jpg

stats的参数参考。

参数意义
pidmemcache服务器进程ID
uptime服务器已运行秒数
time服务器当前Unix时间戳
time-uptime启动时间
versionmemcache版本
pointer_size操作系统指针大小
rusage_user进程累计用户时间
rusage_system进程累计系统时间
curr_connections当前连接数量
total_connectionsMemcached 运行以来连接总数
connection_structuresMemcached分配的连接结构数量
cmd_getget命令请求次数
cmd_setset命令请求次数
cmd_flushflush命令请求次数
get_hitsget命令命中次数
get_missesget命令未命中次数
delete_missesdelete命令未命中次数
delete_hitsdelete命令命中次数
incr_missesincr命令未命中次数
incr_hitsincr命令命中次数
decr_missesdecr命令未命中次数
decr_hitsdecr命令命中次数
cas_missescas命令未命中次数
cas_hitscas命令命中次数
cas_badval使用擦拭次数
auth_cmds认证命令处理的次数
auth_errors认证失败数目
bytes_read读取总字节数
bytes_written发送总字节数
limit_maxbytes分配的内存总大小(字节)
accepting_conns服务器是否达到过最大连接(0/1)
listen_disabled_num失效的监听数
threads当前线程数
conn_yields连接操作主动放弃数目
bytes当前存储占用的字节数
curr_items当前存储的数据总数
total_items启动以来存储的数据总数
evictions:LRU释放的对象数目
reclaimed已过期的数据条目来存储新数据的数目

缓存命中率:命中数(get获取到数据)/获取次数(get的次数)

get_hits/cmd_get,如果命中率低,业务代码缓存有问题,命中率为0,缓存没有起作用

缓存穿透:访问的数据,数据库不存在的数据,每次都不能够生成缓存,每次请求都直接访问数据库,穿透了缓存,缓存没有起到作用。数据库压力没有得到缓解。解决方案,数据库查不到的,也做一个空缓存。

缓存雪崩:缓存具有失效时间,如果缓存失效时间都是一样,本来应该请求缓存的,但是因为缓存失效了,全部请求到了数据库,数据库压力剧增,可能会造成数据库宕机,进而造成系统崩溃。解决方案,设置缓存的失效时间均匀分布。

5.2 小工具

memcached_tool.php软件,可以查看memcached运行状态、key(item)的数量了、内存使用量等

需要php的运行环境

①上传memcached_tool.php到web服务器

上传到虚拟机主机可以访问的目录即可。

本次上传到了server01服务器的/usr/local/nginx/html/tp5shop/public下

②修改连接参数

 shell > vim /usr/local/nginx/html/tp5shop/public/memcached_tool.php

示例配置

memcached_tool.jpg

登录地址看实际的地址,演示用,软件脚本在server01上,memcached服务在server08上,所以地址应该填写memcached服务所在地址server08的IP(192.168.17.107)

③查看使用

14.jpg

5.3 失效机制(了解)

①如果key过期了,value会及时删除么,空间会及时清空么?

②如果分配的存储空间,写满了,还允许写么?

5.3.1 Lazy Expiration

memcached 内部不会监视记录是否过期,而是在 get时查看记录的时间戳,检查记录是否过期。这种技术被称为 lazy(惰性)expiration。因此,memcached 不会在过期监视上耗费 CPU 时间

比如php的里session机制 懒惰机制 php垃圾回收机制 gc回收 python 变量垃圾回收机制

编程语言中,变量分配 栈空间(变量名称) 堆空间(变量值)

memcached1.4.25之后 就不是懒惰机制了。

5.3.2 LRU

memcached 会优先使用已超时的记录的空间,但即使如此,也会发生追加新记录时空间不足的情况,此时就要使用名为 Least Recently Used(LRU)机制来分配空间。顾名思义,这是删除“最近最少使用”的记录的机制。因此,当 memcached 的内存空间不足时,就从最近未被使用的记录中搜索,并将其空间分配给新的记录。从缓存的实用角度来看,该模型十分理想

不过,有些情况下 LRU 机制反倒会造成麻烦。memcached 启动时通过“­M”参数可以禁止 LRU

6. PHP使用memcached

6.1 PHP扩展安装

在LNMP架构中,如果需要使用到memcached。首先需要安装对应的扩展,php7以上需要安装memcached扩展。

官方扩展地址http://pecl.php.net/

①上传PHP扩展源码包

需要在web服务器的上安装扩展,server01和server03

memcached-3.1.3.tgz php的扩展 依赖libmemcached1.x以上版本

yum 本地源的 libmemcached依赖版本太低,不能够满足php扩展的依赖需要,需要手动源码编译libmemcached

libmemcached-1.0.18.tar.gz 依赖

②解压编译安装

 #1、解决libmemcached依赖
 shell > tar xvf libmemcached-1.0.18.tar.gz
 shell > cd libmemcached-1.0.18
 shell > ./configure --prefix=/usr/local/libmemcached && make && make install
 #2、源码编译php扩展
 shell > tar xvf memcached-3.1.3.tgz
 shell > cd memcached-3.1.3
 #扩展源码包和php关联生成configure文件
 #如果执行找不到phpize,说明之前没有给php安装目录bin目录配置环境变量,重新配置即可
 shell > phpize
 #编译安装php的memcached扩展
 shell > ./configure --with-libmemcached-dir=/usr/local/libmemcached --disable-memcached-sasl
 shell > make && make install

编译完成生成的扩展文件

15.jpg

③在php.ini配置文件里开启

 shell > vim /usr/local/php/etc/php.ini

16.jpg

重启php-fpm服务

④检测扩展

方法一:php -m

通过php -m调用查看php所加载的依赖模块

 shell > php -m |grep memcached

方法二:通过web页面访问phpinfo

 <?php
 phpinfo();

编写一个页面,通过web访问

17.jpg

6.2、PHP测试连接代码

php代码测试使用memcached

示例代码

 <?php
 //实例化类
 $mem = new memcached();
 //调用连接memcached方法 注意连接地址和端口号
 $mem->addServer('192.168.17.107',11211);
 //存数据
 var_dump($mem->set('name','lnmp'));
 //取数据
 var_dump($mem->get('name'));

7. 企业案例实现

7.1 session 入 memcached 共享

session共享:

分布式负载均衡架构中,web服务器间的session是不共享(默认session存储在本地的文件的),会造成session校验不一致。校验验证码不通过,登录之后session不一致,造成无法判断是否登录。

解决方案:

  1. session 生成校验在同一台服务器 nginx调度算法 ip_hash
  2. session 共享多台web服务器可以调用到session

文档参考

18.jpg

① 修改项目配置文件

server01和server03都需要修改,并且web服务器都要安装好memcached扩展

 shell > vim /usr/local/nginx/html/tp5shop/application/config.php

修改完成之后,之后的请求所生成的session,就被写入到memcached中了。

19.jpg

②访问测试

在memcached中,session_id作为key,session的内容作为value进行存储。

都是在业务代码中实现,服务架构中,只要按需配置即可。

7.2 缓存热点数据

把经常访问到的数据,发生变动较小,可以存储到内存缓存中,提供使用速度

20.jpg

注意修改web服务器的文件时,一定要都修改web1和web2

①修改缓存数据

修改代码文件

 shell > /usr/local/nginx/html/tp5shop/application/home/controller/Base.php

21.jpg

②修改项目缓存配置