PHP高并发高负载下的3种实战场景解决方法

前言:

在实际开发项目中,产品一旦推广开来,总能遇到一些小问题。比如某个接口突然就请求崩掉了,某个提交接口明明做了限制为什么就多出了好多重复的记录。还有是某个记录超过限制进行修改了,以下就以这几个小问题总结一下平时采取的解决方法。 

场景:

1.缓存失效场景,就比如某个接口做了数据缓存,缓存过期导致突然某个时刻大量请求直接读数据库。解决方法设置redis缓存回调事件,订阅失效频道。所以这个也可以用来处理某些业务场景到期处理方式。

2.接口幂等性场景,就比如注册接口,通过手机号查询是否存在记录。但有时出现网络延迟用户连点等情况,会出现数据库出现几条一样的用户数据记录。

3.商品库存超卖场景,比如某个活动商品下单,多个用户同时下一个商品的订单,从而导致库存超卖的现象。解决方法可以使用乐观锁或者悲观锁解决此问题。
 

场景一,缓存失效回调。

1. 设置Redis回调事件方法。

(1). 打开Redis客户终端,输入命令非持久性的回调事件设置

config set notify-keyspace-events Ex

(2). windows平台打开Redis安装目录中找到"redis.windows-service.conf",然后打开编辑找到notify-keyspace-events那一行,去掉"#",改为notify-keyspace-events “Ex"。

(3). 其中Redis还可以设置订阅键名的回调,比如订阅某个键名的del操作等,可以在conf中设置不同的,方法网上也有的。

2. 订阅redis某个库的键失效的频道名。

可以在命令测试,也可以通过PHP代码订阅然后cli环境下运行脚本。

命令:
subscribe __keyevent@0__:expired

3. 设置有限期

重新打开一个新的redis客户终端输入一个带有效期的键值对,如下(键名test_key_name, 时间30s, 值ceshi)

命令:
setex test_key_name 30 ceshi

4. 查看键失效回调订阅的命令窗口是否出现失效的键名。

5. 代码实现键名的失效事件订阅。

<?php
 
//设置php脚本执行时间
set_time_limit(0);
//设置socket连接超时时间
ini_set('default_socket_timeout', -1);
class redisSubscribe 
{
 protected $config = [
 "host" => "127.0.0.1",
 "password" => "6379"
 ];
 
 protected $redis;
 
 public function __construct() 
 {
 
 try {
 
 $this->redis = new \Redis();
 $this->redis->pconnect($this->config['host'],$this->config['password']);
 
 } catch(\Exception $e) {
 echo "redis错误:".$e->getMessage().PHP_EOL;
 }
 }
 
 
 // 普通消息订阅
 public function normal()
 {
 
 //声明频道名称
 $channelName = "test";
 try {
 $this->redis->subscribe([$channelName], function ($redis, $channel, $msg)
 {
 echo 'channel:' . $channel . ',message:' . $msg . PHP_EOL;
 
 file_put_contents('subscribe.log',"\".$msg."-\n",FILE_APPEND);
 });
 } catch (\Exception $e) {
 echo $e->getMessage();
 }
 }
 
 
 // 订阅Key失效事件的频道
 public function keyNotify()
 {
 echo "wathc keyNotify start~~".PHP_EOL;
 
 // Key事件回调
 //$channel = "__keyevent@0__:expired"; // 0号库的Key过期事件频道名
 $channel = "__keyevent@*__:expired"; // 所有库的Key过期事件频道名
 
 try {
 $this->redis->subscribe([$channel], function ($redis, $channel, $msg)
 {
 echo 'channel:' . $channel . '===========' . ',message:' . $msg . PHP_EOL;
 
 file_put_contents('subscribe.log',"\".$msg."-\n",FILE_APPEND);
 });
 } catch (\Exception $e) {
 echo $e->getMessage();
 }
 }
}
(new redisSubscribe())->keyNotify();
?>

6. 通过PHP-cli运行该脚本

然后也可以setex一个短时间的键,然后查看命令是否输出该失效的键名。

7. 缓存失效应用展开。

(1). 代码中设置的所有键名都配置到项目的全局配置文件中。
(2). 服务器中开一个守护进程(持续运行订阅某个库或者所有库的键失效回调事件脚本)。
(3). 当该脚本有回调时,取出键名去全局缓存键名数组中匹配。
(4). 规则业务可以自行设计。
(5). 比如取出一个"cate5"的键名,则可以取资讯表中查询分类ID为5的所有数据然后再进行缓存。
(6). 缓存失效事件还一个高端玩法,就是取代某些定时任务。比如可以将某个订单作为键名缓存,当该键名失效就可以取出键名拿到ID去数据库中将订单状态修改为失效。 

场景二,接口幂等性。

接口重复数据也就是在高并发下的数据添加场景。最典型的是注册接口,用户在网络延迟大或者信号不稳定的情况下。并且同时大量用户在进行注册操作,用户点击了一次没反应然后再次点击多个。    

在没有做幂等性处理只是拿到手机号查询数据库是否存在,用户表又没分库分表,查询缓慢,查询出来后,多条并发的请求都绕过了手机号已经存在的条件判断,所以就出现了ID不同,但是其他字段一样的记录。

  1. 对于高并发数据添加,可以使用Redis的setnx。
  2. setnx是设置键并且在有效期内有值时,再次对该键名进行重复赋值无法进行,会返回0。

  1. 可以代码在对某些条件查询是否存在时,可以将条件组成键名赋值。添加记录时再次对键名重新赋值,返回null则表示已经存在。
  2. 以下代码是项目中的一个测试方法,使用的redis是封装的,借鉴需要修改。
/**
 * @Notes: 高并发防止重复提交(插入数据) 【保证接口的幂等性】
 * @Interface preventRepeatSubmit
 * @return mixed
 * @author: bqs
 * @Time: 2020/6/19 14:56
 */
 public function preventRepeatSubmit()
 {
 /* 比如查询某条(什么条件)记录是否存在,分布式锁机制[redis的原子性setnx]
 * 1. 通过条件拼接为唯一的键名,将键名setnx设置一个30s有效期的值
 * 2. setnx设置键名不成功(返回0)表示已经存在,接口则直接返回记录已经存在
 * 3. 根据该条件查询数据库记录,如果存在,接口再返回记录已经存在
 * 【只要添加记录前需要查询什么是否存在则都需要考虑高并发情况,则通过此方案】
 */
 $redis = Redis::db(0);
 $no = date('YmdHis',time()).mt_rand(1000,9999);
 //$no = 202006191537447811;
 // 是否添加锁表
 $addLock = false;
 if ($redis->setnx($no,1)) {
 $redis->expire($no,30); //设置30s过期时间
 } else {
 $addLock = true; // 订单已经存在则锁住
 }
 // 数据库查询是否存在
 $isExist = Db::name('ztest')->where(['no'=>$no])->find();
 if ($isExist) {
 $addLock = true;
 }
 if (!$addLock) {
 $data = [
 "no" => $no,
 "tab_num" => 2,
 "stock" => 20,
 "create_time" => time()
 ];
 $res = Db::name('ztest')->insertGetId($data);
 }
 return "添加数据成功";
 }

场景三,库存超卖。

库存超卖是一个很常见的秒杀或者其他高并发场景下的数据更新问题。网络上的解决方法也是多种多样,对该问题延伸的数据库乐观锁,悲观锁的知识点也是数不胜数。   

所以,这里我也不再介绍数据库的存储引擎机制,事务,表锁等概念。直接以代码展现,以下是以乐观锁实现的数据库更新问题。

  1. 高并发下,对单条记录的修改。一般修改前会对某字段进行判断,但是并发情况下,拿查询的结果进行拦截是极其的不靠谱。不过也可以对查询进行加锁,但是需要在同一事务中。
  2. 库存字段添加无符号的字段约束,所以再大的并发在修改为0之后也不会出现负数了,在修改的操作时捕捉修改为负数时的数据库异常。

  1. 表中添加version字段,这个也是网上盛传的乐观锁经典实例了,后面的原理和流程我就不介绍了,代码也是这样写的,所以直接贴代码了。
/**
 * @Notes: 高并发乐观锁 - (更新数据)
 * @Interface testConcurrence
 * @return mixed
 * @author: bqs
 * @Time: 2020/6/19 14:25
 */
 public function testConcurrence()
 {
 // 开启事务
 Db::startTrans();
 // 查询ID25当前的库存和版本号
 $curr = Db::name('ztest')->field('stock,version')->where(['id'=>25])->find();
 // 判断库存是否小于0
 if ($curr && $curr['stock'] <= 0) {
 throw new \Exception('物品已售罄',302);
 }
 try {
 // 修改库存 - 获取ID25的行琐
 $updateRes = Db::name('ztest')->where(['id'=>25,'version'=>$curr['version']])->update(['stock'=>$curr['stock']-1,'version'=>$curr['version']+1]);
 // 标识并发过来修改的,拿到的version太旧,事务回滚重新回到查询再走一遍
 if (!$updateRes) {
 Db::rollback();
 }
 } catch(\Exception $e) {
 Db::rollback();
 // 记录日志,或者返回
 }
 // 事务提交
 Db::commit();
 return '购买成功了';
 } 


 

作者:北桥苏原文地址:https://segmentfault.com/a/1190000043831899

%s 个评论

要回复文章请先登录注册