1

场景需求

对于已有的mysql主从项目,应对数据量大时往往采取分库分表的做法,为了缩短页面响应采用一主多从的 主写+从读 的读写分离架构。

使用redis等级:一使用redis作为php的缓存层,存储常用、相对固定的公共数据;二:添加redis长用缓存,组成 mysql写+ redis读 的架构;三:甚至直接采用 redis读+写 的架构。mysql迁移redis需要后台程序的紧密配合。“读写分离”容易出现数据不一致的问题。

一些优化相关

传统前后端优化途径:
 1. 前端 
    减少请求次数:css精灵(小图合并到大图),data-image(data-icon:src=data:image/jpg;base64;xx,小图合并到js文件);
 2. 网关
    web资源防盗链refer监测,nginx限流,nginx负载均衡、nginx缓存静态资源、gzip等,http2.0;
 3. 后端
    使用redis、memcache缓存,mysql优化;

进阶:
 1. 网关
    mysql迁移redis,分布式集群部署;
 2. 后端
    添加针对高并发的消息队列,多线程、协程化,使用连接池;

分布式部署常见问题:
    登录session共享问题;读写分离的同步数据问题。
另外还各种诸如图片数据库、对象存储等。

要同时在多台服务器上处理比如:库存超卖、订单支付问题需要频繁的过程校验,所以对并行任务串行化、使用一台机器、单一线程处理这种一致性问题最为稳妥。应对大数据量的情况采用消息队列,平衡服务器压力。

1.swoole的消息队列相关

利用swoole_table+协程可以使用队列,把这些驻内存的数据放到mysql、redis可持久化。

a.swoole_table共享内存表

服务器端:

//参考官方 https://wiki.swoole.com/wiki/page/292.html
$table = new Swoole\Table(1024); //$size参数指定表格的最大行数,必须为2的指数,如1024,8192,65536等
$table->column('fd', swoole_table::TYPE_INT);
$table->column('from_id', swoole_table::TYPE_INT);
$table->column('data', swoole_table::TYPE_STRING, 1024);
$table->create();

$serv = new Swoole\Server('127.0.0.1', 9501, SWOOLE_BASE, SWOOLE_SOCK_TCP);
//将table保存在serv对象上
$serv->table = $table;

$serv->on('receive', function ($serv, $fd, $from_id, $data) {
    $pre = substr($data, 0, 1);
    $data = substr($data, 1);
    if($pre == \Swoole\Table::TYPE_STRING){
        /*
        foreach ($serv->table as $row) var_dump($row);
        print_r($serv->table);
        */
        $table = [];
        foreach ($serv->table as $k => $row) {
            $row['key'] = $k;
            $table[] = $row;
        }
        $count = count($serv->table);
        print_r("当前表行数". $count);
        $serv->send($fd, json_encode($table, JSON_UNESCAPED_UNICODE));
    }elseif($pre == \Swoole\Table::TYPE_FLOAT){
        $key = $data;
        $exist = $serv->table->exist($key);
        if($exist){
            $row = $serv->table->get($key);
            $exist = $serv->table->del($key);
            $data = json_decode($row['data'], true);
            $serv->send($fd, "消费:" .($exist===true?'true':'false'). ' '. $data['order'].PHP_EOL);
        }
    }elseif($pre == \Swoole\Table::TYPE_INT){
        $ret = $serv->table->set($fd, array('from_id' => $from_id, 'data' => $data, 'fd' => $fd));
        $data = json_decode($data, true);
        $serv->send($fd, "服务器:". ($ret===true?'true':'false'). " from ".$fd.' order:'.$data['order']);
    }else{
        print_r($data);
        $serv->send($fd, 'others');
    }
});

$serv->start();

客户端:

for($i=1; $i<=50; $i++){
    go(function () use($i) {
        $client = new \Swoole\Client(SWOOLE_SOCK_TCP);
        usleep(mt_rand(10,1000));
        $res = $client->connect('127.0.0.1', 9501, 0.5);
        if (!$res) {
            exit("connect failed. Error: {$client->errCode}\n");
        }

        echo "客户端:". $i. PHP_EOL;
        $data = ['order'=>$i, 'data'=>md5(mt_rand(10,1000).time())];
        echo \Swoole\Table::TYPE_INT. json_encode($data, JSON_UNESCAPED_UNICODE). PHP_EOL;
        $client->send(\Swoole\Table::TYPE_INT. json_encode($data, JSON_UNESCAPED_UNICODE));
        //echo $client->recv(). PHP_EOL;
        $client->close();
    });
}

$client = new \Swoole\Client(SWOOLE_SOCK_TCP);
$res = $client->connect('127.0.0.1', 9501, 0.5);
if (!$res) {
    exit("connect failed. Error: {$client->errCode}\n");
}
$client->send(\Swoole\Table::TYPE_STRING. "GET_TABLE_INFO");
$table = $client->recv();
$client->close();

$table = json_decode($table, true);
while($row = array_pop($table)){
    go(function () use($row) {
        $client = new \Swoole\Client(SWOOLE_SOCK_TCP);
        usleep(mt_rand(10,1000));
        $res = $client->connect('127.0.0.1', 9501, 0.5);
        if (!$res) {
            exit("connect failed. Error: {$client->errCode}\n");
        }
        
        echo "处理{$row['fd']}发来的{$row['data']}". PHP_EOL;
        $client->send(\Swoole\Table::TYPE_FLOAT. $row['key']);
        $client->close();
    });
}

swoole的协程对于普通处理效果不明显,而当任务中包含有mysql、redis、curl等的远程连接时才体现异步非阻塞的高效率。异步编程要始终考虑执行顺序的问题,上面例子的客户端中,sleep是真阻塞而不是异步的,下半部分读取swoole_table表回调报告完成、可以单独放到文件请求。

b.swoole+redis/mysql的消息处理

同上理,好处是多了持久化。

2.rabbitmq消息队列

特点:被动接受任务,不拒绝。选择持久化配置,若未完成,全部退出/php中断退出 再启动还会继续执行。

a.安装RabbitMQ消息队列

rabbitmq官方docker说明,参考《Rabbitmq集群高可用部署详细》这里部署普通模式。网络加入之间建立的mybridge,使用固定ip。

[]:~/tmp/dk/rabbitmq# docker pull rabbitmq
#docker run --name rabbit --network mybridge -e RABBITMQ_ERLANG_COOKIE='123456' -d rabbitmq 
#docker cp rabbit:/etc/rabbitmq/rabbitmq.conf ./复制出配置文件
[]:~/tmp/dk/rabbitmq# docker run --name rbt1 -p 15672:15672 \
    --hostname rbt1 \
    --network mybridge --ip=172.1.12.13 \
    -v /root/tmp/dk/rabbitmq/rabbitmq.conf \
    -v /root/tmp/dk/rabbitmq/data13:/var/lib/rabbitmq/mnesia \
    -e RABBITMQ_ERLANG_COOKIE='123456' \
    -e RABBITMQ_DEFAULT_USER=root -e RABBITMQ_DEFAULT_PASS=123456 \
    -d rabbitmq
docker run --name rbt2 --hostname rbt2 \
    --network mybridge --ip=172.1.12.14 \
    -v /root/tmp/dk/rabbitmq/rabbitmq.conf \
    -v /root/tmp/dk/rabbitmq/data14:/var/lib/rabbitmq/mnesia \
    -e RABBITMQ_ERLANG_COOKIE='123456' \
    -e RABBITMQ_DEFAULT_USER=root -e RABBITMQ_DEFAULT_PASS=123456 \
    -d rabbitmq

普通集群模式

[]:~/tmp/dk/rabbitmq# docker exec -it rbt1 bash
root@rbt1:/# rabbitmqctl stop_app
Stopping rabbit application on node rabbit@rbt1 ...
root@rbt1:/# rabbitmqctl join_cluster --ram rabbit@rbt2
Clustering node rabbit@rbt1 with rabbit@rbt2
root@rbt1:/# rabbitmqctl start_app
Starting node rabbit@rbt1 ...
 completed with 0 plugins.
root@rbt1:/# rabbitmqctl cluster_status
Cluster status of node rabbit@rbt1 ...
[{nodes,[{disc,[rabbit@rbt2]},{ram,[rabbit@rbt1]}]},
 {running_nodes,[rabbit@rbt2,rabbit@rbt1]},
 {cluster_name,<<"rabbit@rbt2">>},
 {partitions,[]},
 {alarms,[{rabbit@rbt2,[]},{rabbit@rbt1,[]}]}]
root@rbt1:/# rabbitmq-plugins enable rabbitmq_management

然后可以登录访问 http://remote_host:15672/ 管理页面。
php添加amqp扩展,Dockerfile添加(cffycls/php7:1.8):

apk add rabbitmq-c-dev
pecl install amqp

b.消息添加和消费测试

用法参考一,参考二:phpstrom中输入new AMQPConnection(),按Ctrl和鼠标确认键进入amqp.php方法用法注释(php.net官方暂时找不到)。
生产者(publish.php):

date_default_timezone_set("Asia/Shanghai");
//配置信息
$conn_args = array(
    'host' => '172.1.12.13',
    'port' => '5672',
    'login' => 'root',
    'password' => '123456',
    'vhost'=>'/'
);
$k_route = 'k_route_1'; //路由key,用来绑定交换机和队列
$e_name = 'e_switches'; //交换机名称

//创建连接和channel
$conn = new AMQPConnection($conn_args);
if (!$conn->connect()) {
    die("Cannot connect to the broker!PHP_EOL");
}

$channel = new AMQPChannel($conn);
echo "<font color='red'>生产者</font>PHP_EOL已连接成功!准备发布信息...".PHP_EOL;

//创建交换机对象
$ex = new AMQPExchange($channel);
$ex->setName($e_name);
$ex->setType(AMQP_EX_TYPE_DIRECT); // 设置交换机类型
$ex->setFlags(AMQP_DURABLE); // 设置交换机是否持久化消息

//发送消息
$channel->startTransaction(); //开始事务
for($i=1; $i<=50000; ++$i){
    usleep(100);//休眠1秒
    $message = "消息数据".$i. ' '.date("Y-m-d H:i:s A");
    echo "消息发送返回:".$ex->publish($message, $k_route).PHP_EOL;
}
$channel->commitTransaction(); //提交事务

$conn->disconnect();

消费者(consumer.php):

date_default_timezone_set("Asia/Shanghai");
//配置信息
$conn_args = array(
    'host' => '172.1.12.13',
    'port' => '5672',
    'login' => 'root',
    'password' => '123456',
    'vhost'=>'/'
);
$k_route = 'k_route_1'; //路由key,用来绑定交换机和队列
$e_name = 'e_switches'; //交换机名称
$q_name = 'q_queue'; //队列名

//创建连接和channel
$conn = new AMQPConnection($conn_args);
if (!$conn->connect()) {
    die("Cannot connect to the broker!".PHP_EOL);
}
$channel = new AMQPChannel($conn);
echo "<font color='red'>消费者</font>:".PHP_EOL ."已连接成功!准备接收信息...".PHP_EOL;

//创建交换机
$ex = new AMQPExchange($channel);
$ex->setName($e_name);
//direct类型:[AMQP_EX_TYPE_DIRECT,AMQP_EX_TYPE_FANOUT, AMQP_EX_TYPE_HEADERS or AMQP_EX_TYPE_TOPIC]
$ex->setType(AMQP_EX_TYPE_DIRECT);
$ex->setFlags(AMQP_DURABLE); //持久化

//创建队列
$q = new AMQPQueue($channel);
$q->setName($q_name);
$q->setFlags(AMQP_DURABLE); //持久化
$q->declareQueue();

//绑定交换机与队列,并指定路由键
$q->bind($e_name, $k_route);

//阻塞模式接收消息
echo "阻塞模式接收消息:".PHP_EOL;
while(True){
    $q->consume(function ($envelope, $queue) {
        $msg = $envelope->getBody();
        echo '收到:'. $msg.PHP_EOL; //处理消息
        sleep(1);//接收模拟处理休眠1秒
    }, AMQP_AUTOACK); //ACK应答
}
$conn->disconnect();

在页面可以执行,只在cli交互模式下的才显示收到的结果,(发布者再web访问、消费者在php-cli处理)。上面开启事务:所有的消息 被接收才开始处理。
只要保持有一个以上的消费者在工作,任务就会继续进行,这是中间加入新的消费者(2个同时执行):

[]:~/tmp/dk/html# php rabbitmq/consumer.php 
<font color='red'>消费者</font>:
已连接成功!准备接收信息...
阻塞模式接收消息:
收到:消息数据7201 2019-07-08 16:08:05 PM
收到:消息数据7202 2019-07-08 16:08:05 PM
... ...

像redis、mysql一样,使用swoole协程并发会有明显提速。

3.秒杀/红包场景

可以使用redis消息队列专门处理秒杀问题,核心是解决库存超卖问题,测试。

a.服务端sale.php:添加促销信息,初始化消息队列

require "../vendor/autoload.php";

//从集群当中读取
$servers = ['172.1.50.11:6379', '172.1.50.12:6379', '172.1.50.13:6379', '172.1.50.21:6379'];
define('PROMOTION_KEY_PREFIX', 'promotionList_');
define('TIME_LIMIT_PREFIX', 'timeLimit_');

//一、初始化:查出所有节点分布,lPush、lRange函数准备,缓存基本信息
$redisServers = [];
$slotNodes = [];
foreach ($servers as $addr){
    //随机
    $r = new Redis();
    $server=explode(':',$addr);
    $r->connect($server[0], (int) $server[1]);
    $r->auth('123456');
    $redisServers[$addr] = $r;

    if(empty($slotInfo)){
        //单一节点可以看到所有存在槽的节点
        $slotInfo = $r->rawCommand('cluster','slots');
        foreach ($slotInfo as $ix => $value){
            $slotNodes[$value[2][0].':'.$value[2][1].' '.($ix+1)]=[$value[0], $value[1]];
        }
    }
}
$crc = new \Predis\Cluster\Hash\CRC16();
//注意method=lRange时args传参是($key, $start, $end)
$opt = function ($method, $key, ...$args) use (&$redisServers, &$slotNodes, &$crc) {
    $code = $crc->hash($key) % 16384;
    foreach ($slotNodes as $addr => $boundry){
        if( $code>=$boundry[0] && $code<=$boundry[1] ){
            $host =explode(' ', $addr)[0];
            if(empty($args)){
                return $redisServers[$host]->$method($key);
            }elseif(count($args)==1){
                return $redisServers[$host]->$method($key, $args[0]);
            }else{ //...
                return $redisServers[$host]->$method($key, $args);
            }
        }
    }
};

//二、添加数据:集中添加商品,初始化消息队列
/**
 * @param String $key 商品key
 * @param int $stock 库存
 */
$createQueue = function (String $key, int $stock) use (&$opt) {
    for ($i=0; $i<$stock; $i++){
        $opt('lPush', $key, $i);
    }
};
//第一个任务
go(function () use (&$opt, &$limit1, &$createQueue){
    $limit1 = [
        'goods_id'=>'9505900000',
    ];
    $createQueue(PROMOTION_KEY_PREFIX. $limit1['goods_id'], 20);
    $has = $opt('lLen', PROMOTION_KEY_PREFIX. $limit1['goods_id']);
    print_r('创建goods1 '. $has. ' 个'. PROMOTION_KEY_PREFIX. $limit1['goods_id'].PHP_EOL);
    $has = 0;
    swoole_timer_tick(10, function ($timer_id) use (&$opt, &$limit1, &$has){
        $now = $opt('lLen', PROMOTION_KEY_PREFIX. $limit1['goods_id']);
        if($has != $now) {
            $has = $now;
            if ($now) {
                print_r('goods1 还有个' . $has . '剩余' . PHP_EOL);
            } else {
                print_r('OK DONE1!'. PHP_EOL . PROMOTION_KEY_PREFIX . $limit1['goods_id'] . PHP_EOL);
                Swoole\Timer::clear($timer_id);
            }
        }
    });
});

//第二个任务:增加时段限制
go(function () use (&$opt, &$limit1, &$has, &$createQueue){
    $limit2 = [
        'goods_id'=>'2302500000',
        'start'=>date("Y-m-d H:i:s", strtotime('+ 1 minutes')),
        'end'=>date("Y-m-d H:i:s", strtotime('+ 5 minutes')),
    ];
    $createQueue(PROMOTION_KEY_PREFIX. $limit2['goods_id'], 500);
    //保存时间条件
    $opt('lPush', TIME_LIMIT_PREFIX. $limit2['goods_id'], json_encode($limit2, JSON_UNESCAPED_UNICODE));

    $has = $opt('lLen', PROMOTION_KEY_PREFIX. $limit2['goods_id']);
    print_r('创建goods2 '. $has. ' 个'. PROMOTION_KEY_PREFIX. $limit2['goods_id'].PHP_EOL);
    $has = 0;
    swoole_timer_tick(2000, function ($timer_id2) use (&$opt, &$limit2, &$has){
        $now = $opt('lLen', PROMOTION_KEY_PREFIX. $limit2['goods_id']);
        if($has != $now) {
            $has = $now;
            if ($now) {
                print_r('goods2 还有个' . $has . '剩余' . PHP_EOL);
            } else {
                print_r('OK DONE2!'. PHP_EOL . PROMOTION_KEY_PREFIX . $limit2['goods_id'] . PHP_EOL);
                Swoole\Timer::clear($timer_id2);
            }
        }
    });
});

b.客户端buy.php:消费数据

//每次购买请求的客户端工作--swoole常驻内存,缓存运行第一步
//一、初始化:查出所有节点分布,lPush、lRange函数准备,同服务端(略)

//二、消费数据:模拟分散请求
$goods1 = '9505900000'; //商品已知
$goods2 = '2302500000';

//无限制:100人抢 -- goods1=20个
go(function () use (&$opt, &$limit1, &$has, &$createQueue, $goods1){
    $cart = [];
    $cart['key'] = PROMOTION_KEY_PREFIX. $goods1;
    for ($i=0; $i<100; $i++) {
        $cart['user'] = 'tom'.$i;
        go(function () use($cart, &$opt){
            co::sleep(mt_rand(1,500)*0.001); //增加redis网络连接耗时
            $state = $opt('lPop', $cart['key']);
            if($state){
                print_r($cart['user'] .' 购买成功!' .PHP_EOL);
                return true;
            }else{
                //print_r('--已被抢光 ' .$cart['user'] .PHP_EOL);
                print_r('*');
                return false;
            }
        });
    }
});
//时段限制:1000人抢 -- goods2=500个
go(function () use (&$opt, &$limit1, &$has, &$createQueue, $goods2){
    $cart = [];
    $cart['key'] = PROMOTION_KEY_PREFIX. $goods2;
    //获取时间条件
    $limit2 = json_decode($opt('lIndex', TIME_LIMIT_PREFIX. $goods2, 0), true);
    for ($i=0; $i<10000; $i++) {
        $cart['user'] = 'jack'.$i;
        go(function () use($cart, &$opt, &$limit2){
            $min = strtotime($limit2['start']);
            $max = strtotime($limit2['end']);
            co::sleep(mt_rand(2,6*60)); //请求分散 2s-6min/(1-5min)

            $cur = time();
            if($cur<$min){
                print_r('--尚未开始 ' .$cart['user'] .PHP_EOL);
                return true;
            }elseif($min<=$cur && $cur<=$max){
                co::sleep(mt_rand(100,500)*0.001); //增加redis网络连接耗时
                $state = $opt('lPop', $cart['key']);
                if($state){
                    echo $cart['user'] .' 购买成功!' .PHP_EOL;
                    return true;
                }else{
                    echo '.';
                    //echo '++已被抢光 ' .$cart['user'] .json_encode([$limit2,date('Y-m-d H:i:s',$cur)], JSON_UNESCAPED_UNICODE) .PHP_EOL;
                    return false;
                }
            }else{
                echo '++已被抢光 ' .$cart['user'] .json_encode([$limit2,date('Y-m-d H:i:s',$cur)], JSON_UNESCAPED_UNICODE) .PHP_EOL;
                return false;
            }
        });
    }
});

c.运行结果

[]:~/tmp/dk/html/php-swoole# php sale.php
创建goods1 20 个promotionList_9505900000
创建goods2 500 个promotionList_2302500000
goods1 还有个20剩余
goods2 还有个500剩余
goods1 还有个20剩余
goods2 还有个500剩余
goods1 还有个10剩余
OK DONE!
promotionList_9505900000
goods2 还有个500剩余
goods2 还有个496剩余
goods2 还有个441剩余
goods2 还有个383剩余
goods2 还有个314剩余
goods2 还有个263剩余
goods2 还有个208剩余
goods2 还有个144剩余
goods2 还有个88剩余
goods2 还有个39剩余
OK DONE!
promotionList_2302500000

[]:~/tmp/dk/html/php-swoole# php buy.php 
#达到预期效果,篇幅过长略

小结

这里swoole+redis异步对秒杀执行速度很快,大量协程时注意内存占用。

你可能感兴趣的

载入中...