当前位置: 首页 > 后端技术 > PHP

php解决高并发问题

时间:2023-03-29 20:16:33 PHP

我们通常用QPS(QueryPerSecond,每秒处理的请求数)来衡量一个web系统的吞吐率,这对于解决每秒上万的高并发场景是非常关键的。例如,假设处理业务请求的平均响应时间为100毫秒。同时系统中有20台Apacheweb服务器,MaxClients设置为500(代表Apache的最大连接数)。那么,我们web系统的理论峰值QPS为(理想化计算方式):20*500/0.1=100000(100,000QPS)咦?我们的系统貌似很强大,一秒能处理10万个请求,5w/s的秒杀看起来就像是“纸老虎”。实际情况当然不是那么理想。在高并发的实际场景中,机器处于高负载状态,此时平均响应时间会大大增加。一个普通的p4服务器每天最多可以支持100,000个IP左右。如果流量超过10W,就需要专门的服务器来解决。如果硬件不强大,软件再怎么优化也于事无补。主要影响服务器的速度是:网络-硬盘读写速度-内存大小-cpu处理速度。对于Web服务器来说,Apache打开的连接进程越多,CPU需要处理的上下文切换就越多,这就增加了CPU的消耗,直接导致平均响应时间的增加。所以,上面所说的MaxClients的数量要根据CPU、内存等硬件因素综合考虑,绝对不是越多越好。可以通过Apache自带的abench进行测试,取一个合适的值。那么,我们选择Redis作为内存操作级别的存储。在高并发状态下,存储的响应时间非常重要。虽然网络带宽也是一个因素,但是这样的请求包一般都比较小,一般很少成为请求的瓶颈。负载均衡很少会成为系统瓶颈,这里就不展开讨论了。那么问题来了,假设我们的系统,在5w/s的高并发状态下,平均响应时间从100ms变为250ms(实际情况,甚至更多):20*500/0.25=40000(40000QPS)所以,我们的系统还剩4w个QPS,面对每秒5w个请求,中间相差1w个。比如在高速路口,1秒有5辆车开过来,每秒有5辆车经过,高速路口运行正常。突然,一秒钟内只能有4辆车通过这个路口,车流还是一样。结果,肯定有大塞车。(5车道突然变成4车道的感觉)同理,在某一秒内,20*500个可用的连接进程在满负荷工作,但是还有10000个新的请求,没有连接进程可用。预计系统也会陷入异常状态。其实在正常的非高并发业务场景下,也有类似的情况。某个业务请求接口出现问题,响应时间极慢,拉长了整个web请求的响应时间,逐渐减少了web服务器的可用连接数。占用,其他正常业务请求,无连接处理可??用。更可怕的问题是,它是用户的行为特征。系统越不可用,用户的点击越频繁,恶性循环,最终导致“雪崩”(其中一台web机器挂了,导致流量分散到其他正常机器上,导致正常机器也挂掉,然后恶性循环),拖垮整个web系统。重启和过载保护如果系统“雪崩”,贸然重启服务并不能解决问题。最常见的现象就是开机后,马上就挂了。这个时候最好在ingress层拒绝流量,然后重启。如果redis/memcache服务也宕机了,重启时需要注意“预热”,可能需要很长时间。在秒杀和抢购的场景下,流量往往超出我们系统的准备和想象。这时候就需要过载保护了。如果检测到系统满载情况,拒绝请求也是一种保障。在前端设置过滤是最简单的方式,但这种方式是被用户“控诉”的行为。在CGI入口层设置过载保护比较合适,快速返回客户的直接请求。高并发下的数据安全我们知道,当多个线程写入同一个文件时,会存在“线程安全”问题(多个线程同时运行同一段代码。如果每次运行的结果都一样单线程运行,结果和预期的一样,是线程安全的)。如果是MySQL数据库,可以利用其内置的锁机制很好的解决问题。但是在大规模并发场景下,不推荐使用MySQL。在闪购、抢购场景中,还有一个问题,就是“超发”。如果这方面控制不慎,就会出现超发。我们也听说过一些电商搞抢购活动,买家拍照成功后,商家不承认订单有效,拒绝发货。这里的问题不一定是商家背信弃义,而是系统技术层面的超发风险。供过于求的原因假设在抢购场景中,我们总共只有100件商品,而在最后一刻,我们已经消耗了99件商品,只剩下最后一件。这时系统发送了多个并发请求,这些请求读取到的商品余额都是99,都通过了这个余额判断,最终导致超发。(同文前面提到的场景)上图中,并发用户B也“抢购成功”,让多了一个人拿到了商品。这种场景在高并发的情况下非常容易出现。优化一:将inventory字段的number字段设置为unsigned。当inventory为0时,因为该字段不能为负,所以会返回falsefetch_assoc();if($row['number']>0){//高并发会导致超卖if($row['number']<$number){returninsertLog('存货不足',3,$username);}$order_sn=build_order_no();//减少库存$sql="updateih_storesetnumber=number-{$number}wheresku_id='$sku_id'andnumber>0";$store_rs=mysqli_query($conn,$sql);if($store_rs){//生成订单insertOrder($order_sn,$user_id,$goods_id,$sku_id,$price,$username,$number);insertLog('减库存成功',1,$username);}else{insertLog('减库存失败',2,$username);}}else{insertLog('库存不够',3,$username);}?>悲观锁思路解决线程安全的思路有很多。我们可以从“悲观锁”的方向开始讨论悲观锁,即在修改数据时,使用锁定状态来排除外部请求的修改。当遇到锁定状态时,必须等待。虽然上面的方案确实解决了线程安全的问题,但是别忘了我们的场景是“高并发”的。也就是说,这样的修改请求会很多,每个请求都需要等待一个“锁”。有些线程可能永远没有机会抢到这个“锁”,这样的请求就会死在那里。同时,这样的请求会很多,会瞬间增加系统的平均响应时间。结果,可用连接数将被耗尽,系统将陷入异常。优化方案二:使用MySQL事务,锁定运行行fetch_assoc();if($row['number']>0){//构建顺序$order_sn=build_order_no();$sql="插入ih_order(order_sn,user_id,goods_id,sku_id,price)values('$order_sn','$user_id','$goods_id','$sku_id','$price')";$order_rs=mysqli_query($conn,$sql);//减少库存$sql="updateih_storesetnumber=number-{$numberr}wheresku_id='$sku_id'";$store_rs=mysqli_query($conn,$sql);if($store_rs){echo'减库存成功';insertLog('减库存成功');mysqli_query($conn,"COMMIT");//事务提交并解锁}else{echo'减库存失败';insertLog('减库存失败');}}else{echo'库存不足';insertLog('库存不足');mysqli_query($conn,"ROLLBACK");}?>FIFO队列的思路很好,我们把上面的场景稍微修改一下,我们直接把请求放入队列,使用FIFO(先输入先输出,firstinfirstout),所以这样的话,我们就不会造成一些请求永远不会得到锁了,看到这里,是不是觉得多线程变成单线程有点勉强呢?那么,现在问题已经解决了的锁,所有的请求都采用“先进先出”那么新的问题就来了,在高并发场景下,因为请求很多,很可能队列内存会“爆掉”d”瞬间,系统就会陷入异常状态。或者设计一个巨大的内存队列也是一种解决方案。但是,系统处理队列中一个请求的速度,是无法与疯狂涌入队列的数量相提并论的。也就是说,队列中的请求会越积越多,最终Web系统的平均响应时间还是会大幅度下降,系统还是会陷入异常。对于日常IP低或者并发量小的应用一般不会考虑文件加锁的思路!一般的文件操作方法是没有问题的。但是如果并发度很高的话,我们在读写文件的时候,很有可能会有多个进程对一个文件进行操作。如果此时我们不独占文件访问,很容易造成数据丢失。优化方案4:使用非阻塞文件排他锁fetch_assoc();if($row['number']>0){//库存是否大于0//模拟下单操作$order_sn=build_order_no();$sql="插入ih_order(order_sn,user_id,goods_id,sku_id,price)values('$order_sn','$user_id','$goods_id','$sku_id','$price')";$order_rs=mysqli_query($conn,$sql);//减少库存$sql="updateih_storesetnumber=number-{$number}wheresku_id='$sku_id'";$store_rs=mysqli_query($conn,$sql);if($store_rs){echo'减仓成功';insertLog('减仓成功');flock($fp,LOCK_UN);//释放锁}else{echo'减仓失败';insertLog('减库存失败');}}else{echo'库存不足';insertLog('库存不足');}fclose($fp);./mysql.php');//生成唯一订单号functionbuild_order_no(){returndate('ymd').substr(implode(NULL,array_map('ord',str_split(substr(uniqid(),7,13),1))),0,8);}//日志函数insertLog($event,$type=0){global$conn;$sql="insertintoih_log(event,type)values('$event','$type')";mysqli_query($conn,$sql);}$fp=fopen("lock.txt","w+");if(!flock($fp,LOCK_EX|LOCK_NB)){echo"系统繁忙,请重试之后”;return;}//Order$sql="selectnumberfromih_storewheregoods_id='$goods_id'andsku_id='$sku_id'";$rs=mysqli_query($conn,$sql);$row=$rs->fetch_assoc();if($row['number']>0){//库存是否为大于0//模拟订单操作$order_sn=build_order_no();$sql="insertintoih_order(order_sn,user_id,goods_id,sku_id,price)values('$order_sn','$user_id','$goods_id','$sku_id','$price')";$order_rs=mysqli_query($conn,$sql);//减少库存$sql="updateih_storesetnumber=number-{$number}wheresku_id='$sku_id'";$store_rs=mysqli_query($conn,$sql);if($store_rs){echo'减库存成功';insertLog('减库存成功');flock($fp,LOCK_UN);//释放锁}else{echo'库存减少失败';insertLog('库存减少失败');}}else{echo'Insufficientinventory';insertLog('Insufficientinventory');}fclose($fp);我们可以讨论一下思路“乐观锁”。乐观锁采用了比“悲观锁”更宽松的锁机制。大部分都是更新一个版本号(Version).实现是对这个数据的所有请求都可以修改,但是你会得到一个数据的版本号。只有匹配的版本号才能更新成功,其他返回抢购失败。在这种情况下,我们不需要考虑队列的问题,但是会增加CPU的计算开销,但是总体来说,这是一个比较好的解决方案。支持“乐观锁”功能的软件和服务有很多,例如Redis中的watch就是其中之一。通过这个实现,我们保证了数据的安全。优化方案5:redis中的watchconnect('127.0.0.1',6379);echo$mywatchkey=$redis->get("mywatchkey");/*//插入购买数据if($mywatchkey>0){$redis->watch("mywatchkey");//开始一个新的事务。$redis->multi();$redis->set("mywatchkey",$mywatchkey-1);$result=$redis->exec();if($result){$redis->hSet("watchkeylist","user_".mt_rand(1,99999),time());$watchkeylist=$redis->hGetAll("watchkeylist");echo"购买成功!
";$re=$mywatchkey-1;echo"剩余数量:".$re."
";echo"用户列表:

";print_r($watchkeylist);}else{echo"倒霉,再买!";exit;}}else{//$redis->hSet("watchkeylist","user_".mt_rand(1,99999),"12");//$watchkeylist=$redis->hGetAll("watchkeylist");回声“失败!
”;echo".没有结果
";echo"用户列表:
";//var_dump($watchkeylist);}*/$rob_total=100;//购买次数if($mywatchkey<=$rob_total){$redis->watch("我的手表钥匙");$redis->multi();//在当前连接上开始一个新的事务//插入抢购数据$redis->set("mywatchkey",$mywatchkey+1);$rob_result=$redis->exec();if($rob_result){$redis->hSet("watchkeylist","user_".mt_rand(1,9999),$mywatchkey);$mywatchlist=$redis->hGetAll("watchkeylist");echo"购买成功!
";echo"剩余数量:".($rob_total-$mywatchkey-1)."
";echo"用户列表:
";var_dump($mywatchlist);}else{$redis->hSet("watchkeylist","user_".mt_rand(1,9999),'meiqiangdao');echo"倒霉,再买!";exit;}}?>