高并发红包整体设计方案

公司前段时间根据业务方需求需要做一个抢红包的活动,网上也搜索了很多资料。记录下整体的设计思路以及运营过程中的各种问题。

产品需求:

1.红包支持配置开始时间、结束时间、类型(随机金额或固定金额)、单个最小红包金额、单个最大红包金额

2.可领取红包的业务条件(根据业务信息指定某些满足条件的人可以抢)
《高并发红包整体设计方案》

设计思路:

难点1:红包算法(根据红包配置最大、最小金额、数量生成符合条件的红包集合)

因为红包有配置单个红包的最大和最小金额,所以不能完全使用随机分配的方式。

所以要求:

    * 单个红包金额既要大于最小金额,又要小于最大金额
    * 根据红包总金额和个数要正好将钱分完

* 单个红包精确到分,也就是小数点后两位

实现代码:

    /*
    * @todo 设置随机红包金额
    * return array
    */
    public function setRandMoney()
    {
        $result = [];
        //取小数点后两位将金额乘100
        $this->total = $this->total * 100;//红包总金额
        $this->min = $this->min * 100;//单个红包最小金额
        $this->max = $this->max * 100;//单个红包最大金额
        //获取红包平均金额
        $average = $this->total / $this->num;

        for ($i = 0; $i < $this->num; $i++) {
        //因为小红包的数量通常是要比大红包的数量要多的,因为这里的概率要调换过来。
        //当随机数>平均值,则产生小红包
        //当随机数<平均值,则产生大红包
            if (rand($this->min, $this->max) > $average) {
        // 在平均线上减钱
                $temp = $this->min + $this->xRandom($this->min, $average);
                $result[$i] = $temp;
                $this->total -= $temp;
            } else {
        // 在平均线上加钱
                $temp = $this->max - $this->xRandom($average, $this->max);
                $result[$i] = $temp;
                $this->total -= $temp;
            }
        }
        // 如果还有余钱,则尝试加到小红包里,如果加不进去,则尝试下一个。
        while ($this->total > 0) {
            for ($i = 0; $i < $this->num; $i++) {
                if ($this->total > 0 && $result[$i] < $this->max) {
                    $result[$i]++;
                    $this->total--;
                }
            }
        }
      // 如果钱是负数了,还得从已生成的小红包中抽取回来
        while ($this->total < 0) {
            for ($i = 0; $i < $this->num; $i++) {
                if ($this->total < 0 && $result[$i] > $this->min) {
                    $result[$i]--;
                    $this->total++;
                }
            }
        }
        if (!empty($result)) {
            //将红包放入队列之中
            foreach ($result as $val) {
                $this->redis->lPush($this->redpack_money_queue . $this->act_id, $val / 100);
            }
            return ['code' => '0', 'msg' => 'success'];
        }
        return ['code' => '1', 'msg' => '创建红包失败,请检查参数'];

    }


    /**
     * 生产min和max之间的随机数,但是概率不是平均的,从min到max方向概率逐渐加大。
     * 先平方,然后产生一个平方值范围内的随机数,再开方,这样就产生了一种“膨胀”再“收缩”的效果。
     */
    private function xRandom($bonus_min, $bonus_max)
    {
        $sqr = intval($this->sqr($bonus_max - $bonus_min));
        $rand_num = rand(0, ($sqr - 1));
        return intval(sqrt($rand_num));
    }

    private function sqr($n)
    {
        return $n * $n;
    }

因为取最小和最大金额之间随机数的时候使用了intval()函数导致该算法只能处理整数,故在处理的时候将金额乘100 ,在最后入队列的时候再将其 除100,这样就将其精确到小数点后两位。

难点2:高并发时对服务器的访问压力
类似抢红包、1元抢购,秒杀等业务场景都是在同一时间大量请求堆积到服务器,从而导致服务器资源紧张,程序处理不过来。那么我们要做的就是将流量控制住,不让大量的请求透过web服务器直接打到数据库层。那么从用户访问url到收到返回结果整体流程是什么样子呢?

  • 客户端层,用户在微信中打开URL,DNS解析域名至服务器
  • web服务器层, Apache、Nginx或Tomcat等
  • 服务器层,分配php-fpm进程,代码接收参数进行逻辑处理
  • 数据持续化层次,将结果保存至mysql或Redis层次

客户端层优化方案:(限流)

  1. 前端URL使用html静态页面显示内容,并将页面显示图片尽量压缩,减少服务器带宽压力。推荐使用base64解码图片
  2. 使用连接池控制流量,用户点击抢红包时,发起ajax请求,调用后台使用java写的redis incr 接口,每次调用则键值 +1,并将自增id返回,当后台代码处理完后再将其键值减掉,因为incr自增为原子级别,所以前端可以根据当前有多少用户在等待中。 根据自身服务器配置以及业务场景预估N多请求会导致服务器出现问题,如果当前等待处理的请求数大于N则前端提示用户 “当前请求过多,请稍后再试”,反之则可以正常发起请求。

Web层优化方案(lua+nginx实现频率控制)

  1. Nginx来处理访问控制的方法有多种,实现的效果也有多种,访问IP段,访问内容限制,访问频率限制等。

用Nginx+Lua+Redis来做访问限制主要是考虑到高并发环境下快速访问控制的需求。

Nginx处理请求的过程一共划分为11个阶段,分别是:

`post-read、server-rewrite、find-config、rewrite、post-rewrite、 preaccess、access、post-access、try-  files、content、log`.


在openresty中,可以找到:

`set_by_lua`,`access_by_lua`,`content_by_lua`,`rewrite_by_lua`等方法。

那么访问控制应该是,`access`阶段。


2.根据请求的ip段来控制访问流量,每次接收到抢红包的url后将redis连接池中id自增,当超过某个峰值时跳转到等待页。
具体配置方案参考:http://homeway.me/2015/08/11/…

php代码层(防止出现发多、重复领取、权限等情况)

  1. 使用redis queue 队列功能来控制超发的情况,将每个算出来的小红包lpush至队列中,每次收到请求后消费最后一个小红包,因为redis的的队列为阻塞模式,所以当队列中为空时是不返回数据的,也就可以保证出现并发时不会一个红包分配给多人。
  2. 使用 redis list集合来控制重复领取的情况,每次接收到请求后将用户id放置已领取的集合中(这点很重要,一定要在消费队列前放置集合中,要不会出现因为并发导致重复领取),消费成功则跳出,反之则将其移出已领取集合。
  3. 因为业务需求处理起来很繁琐,所以在活动创建的时候就根据活动规则将可领取的人员放置集合中,权限判断可以使用待领取集合来控制。

以下为我的代码实现(小菜一枚,大神勿喷)

   /*
    * @todo 获取红包金额
    * @return array
    */
    public function doRush()
    {
        $act_info = $this->getPackInfo($this->act_id);
        if(empty($act_info)){
            return ['code'=>'1','msg'=>'活动信息错误,请联系管理员'];
        }
        if($act_info['start_time'] > now()){
            return ['code'=>'2','msg'=>"红包尚未开抢,请稍后再试"];
        }

        if($act_info['end_time'] <= now()){
            return ['code'=>'1','msg'=>'活动已结束'];
        }
         //将请求用户先放置已领取的集合中
        if(!$this->redis->sAdd($this->rushed_list_key,$this->user_id)){
            return ['code'=>'1','msg'=>'每个红包只能领取一次哦'];
        }
        $money = $this->redis->lPop($this->redpack_money_queue);
        if(empty($money)){
            $this->redis->sRem($this->rushed_list_key,$this->user_id);
            return ['code'=>'1','msg'=>'您来完了呦,红包已抢光'];
        }

        //将已抢的用户和金额记录至队列中
        $add_res = $this->amountAdd($money);
        if($add_res['code'] != 0){
            return ['code'=>'1','msg'=>'系统繁忙,请稍后再试'];
        }
        return ['code'=>'0','msg'=>'success','data'=>$money.'元'];

    }

数据层(使用异步持续化)

  1. 用户领取成功后,将用户id及领取的金额存至已领取的redis queue中,异步进程根据其中的user_id和money值将其数据更新至mysql表中

————————————————–我是万恶的分割线——————————————————————

补充说明:
本人第一次将实际开发过程以及想法落实到书面上,对于我这种小菜来说已经很不错了,恳求各位大神勿喷。其中红包算法和一些处理方案也是第一次接触,参考了网上很多资料,学到了很多。如果你有更好的方案的话多多交流~~

                                                                                                                                ----PHP小菜一枚------





    原文作者:皇埔铁蛋
    原文地址: https://segmentfault.com/a/1190000014759973
    本文转自网络文章,转载此文章仅为分享知识,如有侵权,请联系博主进行删除。
点赞