关于秒杀系统的几点记录

校验请求是否合法

  • 活动是否开始
  • 校验参数是否合法
  • 校验用户是否合法
  • 验证码等防止机器人
  • 校验用户是否重复购买
  • 校验库存
  • 库存需实时获取

性能优化

  • 前端优化,如控制按钮点击、合并css|js、浏览器缓存、使用CDN、页面静态化、开启gzip压缩;
  • 未开始或结束后通过rewrite规则访问指定js文件,不请求后端接口;
  • 后端/接口限流,可使用semaphore或redis进行请求频率控制;
  • 使用redis的队列实现商品锁,避免并发造成超卖;
  • 优化数据库表、减少数据表数据量;
  • 商品或其他信息提前写入redis缓存;
  • 快速终止(如100件商品,则仅允许500人进入系统,其他返回抢完);
  • 通知等引入消息队列;

从redis中获取商品库存,判断是否抢完,如有库存,则从redis队列中取出一个商品锁,如队列已空,则返回“当前人数过多,请稍后再试”;
如果取到商品锁,则进行事务操作,将库存-1并在mysql中添加订单记录。如事务提交成功,则返回抢购成功,否则提示“系统繁忙,请稍后再试”;
进行信息填写、支付订单;
释放未支付订单或扣除库存后未提交订单,则定时清理;

数据库与缓存无法做到强一致性,需保证最终一致性。

为了降低秒杀倒计时中的误差,可以采取以下几种策略:

  • 使用服务器时间:
    最关键的是确保倒计时基于服务器时间而非客户端时间,因为客户端时间可能因用户设置或时区问题而不准确。
    在页面加载时,通过API请求从服务器获取活动的开始时间和/或剩余时间,以此作为倒计时的基准。
  • 同步时间补偿:
    考虑到网络延迟,首次请求服务器时间时,可以计算请求往返时间并加以补偿。即获取服务器时间的同时记录下客户端发出请求的时间,用服务器返回的时间减去这段时间差,以更接近服务器的真实时间。
  • 定期校准:
    前端定期(比如每分钟或根据实际需求设定频率)向服务器请求时间进行校准,以修正因客户端时间漂移带来的误差。
  • 心跳机制:
  • 实施心跳机制,即前端定时向服务器发送心跳包,服务器在响应中包含当前时间戳,这样可以持续更新和校正前端的计时器。
  • 预加载和缓存:
    对于即将开始的秒杀活动,可以在用户浏览页面时预先加载活动信息和倒计时设置,减少倒计时期间因额外网络请求造成的延迟。
  • 优化网络连接:
    尽可能减少网络延迟,比如使用CDN服务来缩短用户与服务器之间的物理距离,或者采用WebSocket保持长连接,以便实时同步数据。
  • 前端优化:
    使用requestAnimationFrame代替setTimeout或setInterval进行计时更新,特别是在需要高精度动画或计时的场景下,因为它能更好地与浏览器的刷新机制同步。
  • 考虑时间跳变:
    处理好闰秒和夏令时等可能导致时间跳变的情况,确保倒计时逻辑能够平滑处理这些特殊时刻。
    通过上述方法的组合使用,可以显著提高秒杀倒计时的准确性,确保用户体验的一致性和公平性。

代码示例

注:需使用事务

/**
 * 检测是否抢到
 */
public function stockCheck($openid='', $schedule_id=0)
{
    //获取redis对象
    $redis = Cache::store('redis')->handler();

    //判断是否到预约时间(考虑性能,放到add方法校验)

    //判断是否重复购买
    $buyKey = 'hryy_buy:'.$openid.'_'.$schedule_id;
    $count = $redis->get($buyKey);
    if(false !== $count && $count >= 1){
        return json(['code'=>-1, 'msg'=>'每人仅可抢购一次']);
    }

    //获取当前库存信息
    $stockKey = 'hryy_stock:'.$schedule_id;
    $total = $redis->get($stockKey);
    if($total <= 0){
        return json(['code'=>0, 'msg'=>'预约已满,请下次再来!']);
    }

    //库存-1
    $newStock = $redis->decrby($stockKey, 1);
    if($newStock < 0){
        //库存不足
        $redis->incrby($stockKey, 1);
        return json(['code'=>0, 'msg'=>'预约已满,请下次再来']);
    }

    //记录购买
    $redis->set($buyKey, 1, 86400);

    //返回抢到并进入下一页
    return json(['code'=>1, 'msg'=>'预约成功,请前往填写预约信息']);
}

//创建订单
public function create($data=[])
{
    //校验post数据是否合法

    //获取redis对象
    $redis = Cache::store('redis')->handler();

    //判断是否抢到
    $buyKey = 'hryy_buy:'.$openid.'_'.$schedule_id;
    $count = $redis->get($buyKey);
    if(false == $count){
        $this->error("请先预约!", config('setting.init_url'));
    }

    //实时校验库存是否已满
    $scheduleKey = "hryy_schedule:".$schedule_id;
    $schedule = $redis->get($scheduleKey);
    if(!$schedule){
        $schedule = Db::name('schedule')->where('id', '=', $schedule_id)->find();
        $redis->set($scheduleKey, json_encode($schedule, 320), 600);
    }else{
        $schedule = json_decode($schedule, true);
    }

    //校验是否合法
    if(time() < $schedule['date']){
        return show(0, '预约时间为:'.date('m-d H:i',$schedule['date']));
    }

    //清除超时订单 统一由定时任务处理

    //如已提交订单则直接返回单号
    $is_yuyue=Db::name('orders')->where(['schedule_id'=>$schedule_id, 'openid'=>$openid, 'status'=>1])->find();
    if($is_yuyue && $is_yuyue['pay_time']==0){
        return show(2, '', $is_yuyue);
    }

    //判断是否已预约满员
    $orderCount = Db::name('orders')->where(['schedule_id'=>$schedule_id, 'status'=>1])->where('doctor_id', 'neq', 0)->count();
    if($orderCount>=$schedule['amount']){
        return show(0, '预约已满,请关注下次预约');
    }

    //写入订单信息
    
    if(true){
        //便于统计提交订单的库存信息
        $buyCountKey = 'hryy_buy_count:'.$schedule_id;
        $redis->incrby($buyCountKey, 1);

        return show(1, 'ok', ['sn'=>$order_no]);
    }else{
        return show(0, '订单出错!');
    }
}

//跳转支付页面
public function pay()
{
    //获取redis对象
    $redis = Cache::store('redis')->handler();

    //查询订单是否合法
    $orderInfo = Db::name('orders')->where('order_no', '=', $order_no)->find();

    if(!$orderInfo){
        $this->error('订单不存在');
    }
    if($orderInfo['pay_time']>0){
        $this->error('订单已支付');
    }
    if($orderInfo['status']==2){
        $this->error('订单过期未支付', config('setting.init_url'));
    }

    //前台已验证
    if(time()-$orderInfo['order_time']>config('setting.pay_time')){
        $xxx = Db::name('orders')->where('id',$orderInfo['id'])->update(['status'=>2]);
        //追加新名额
        if($xxx){
            $order_no2 = create_sn(1,1);
            Db::name('orders')->insert(['order_no'=>$order_no2, 'schedule_id'=>$orderInfo['schedule_id']]);
            //库存回滚
            $redis->incrby('hryy_stock:'.$orderInfo['schedule_id'], 1);
        }
        $this->error('订单过期未支付', config('setting.init_url'));
        
        return false;
    }
    
    $openid = $orderInfo['openid'];

    //获取专家信息
    $doctorKey = 'hryy_doctor:'.$orderInfo['doctor_id'];
    $doctor = $redis->get($doctorKey);
    if(!$doctor){
        $doctor = Db::name('doctor')->where('id', '=', $orderInfo['doctor_id'])->find();
        $redis->set($doctorKey, json_encode($doctor, 320), 600);
    }else{
        $doctor = json_decode($doctor, true);
    }

    $config_biz = [
        'out_trade_no' => $order_no,
        'total_fee' => (int)($doctor['fee']*100),
        'body' => '预约挂号费',
        'spbill_create_ip' => $this->request->ip(),
        'openid'=>$openid
    ];
    //'time_start' => date('YmdHis',time()),
        //'time_expire' => date('YmdHis',time()+config('setting.pay_time'))

    //20190705 支付到不同的商户
    if($orderInfo['shop_id']==1){
        $pay = new PayModel($this->config);
    }else{
        $pay = new PayModel($this->config2);
    }

    //获取日程信息
    $scheduleKey = "hryy_schedule:".$orderInfo['schedule_id'];
    $schedule = $redis->get($scheduleKey);
    if(!$schedule){
        $schedule = Db::name('schedule')->where('id', '=', $orderInfo['schedule_id'])->find();
        $redis->set($scheduleKey, json_encode($schedule, 320), 600);
    }else{
        $schedule = json_decode($schedule, true);
    }

    $section = ['name'=>$schedule['section_name']];

    try{
        $x = $pay->driver('wechat')->gateway('mp')->pay($config_biz);
    }catch(\Exception $e){
        //记录错误信息
    }

    return $this->fetch('pay', ['pay'=>$x, 'orderInfo'=>$orderInfo, 'doctor'=>$doctor, 'section'=>$section, 'schedule'=>$schedule, 'total_fee'=>$doctor['fee']]);
}

//微信回调
public function notify()
{
    $pay = new PayModel($this->config);
    $verify = $pay->driver('wechat')->gateway('mp')->verify($this->request->getContent());
    if ($verify) {
        //获取数据 暂不考虑金额是否正确
        $total_fee = number_format($verify['total_fee']/100, 2);
        $order_no = $verify['out_trade_no'];
        $order_sn = $verify['transaction_id'];
       
        $info = Db::name('orders')->where(['order_no'=>$order_no])->find();
        if(!$info){
            file_put_contents('buy1.txt', $order_no."\r\n", FILE_APPEND);
            die;
        }
            
        //检测当前订单状态 如果为1则返回success
        if(($info['order_status'] == 1) || $info['trade_no']){
            echo "success";
            die;
        }
        
        $data = [
            'costs'=>$total_fee,
            'trade_no'=>$order_sn,
            'order_status'=>1,
            'pay_time'=>strtotime($verify['time_end'])
        ];

        Db::name('doctor')->where('id', '=', $info['doctor_id'])->setInc('book_time');
        Db::name('schedule')->where('id', '=', $info['schedule_id'])->setInc('booked');
        $data['num']=Db::name('schedule')->where('id', '=', $info['schedule_id'])->value('booked');
        Db::name('orders')->where(['order_no'=>$order_no])->update($data);
     
        //发送消息模板
        $this->sendTpl($order_no);
    } else {
        file_put_contents('error.txt', serialize($verify)."\r\n", FILE_APPEND);
    }
    
    echo "success";
}

//定时关闭未支付订单
public function closeOrder(){
    $redis = Cache::store('redis')->handler();
    $schedule=Db::name('schedule')->whereDay('date')->select();
    foreach ($schedule as $v) {
        if($v['booked'] >= $v['amount']){
            continue;
        }

        //初始化库存
        if($v['date']>time() && $v['booked']==0){
            $redis->set('hryy_stock:'.$v['id'], $v['amount'], 86400);
        }

        //统计超时未支付订单数
        $unPayStock = 0;
        $orders=Db::name('orders')->where(['schedule_id'=>$v['id'], 'pay_time'=>0, 'status'=>1])->where('doctor_id', 'neq', 0)->where('order_time','<',time()-config('setting.pay_time'))->field('order_no, openid, schedule_id')->select();
        foreach ($orders as $kk=>$order) {
            /*$pay = new PayModel($this->config);
            $rem = $pay->driver('wechat')->gateway('mp')->close($order['order_no']);*/
            $xxx = Db::name('orders')->where('order_no',$order['order_no'])->update(['status'=>2]);
            if($xxx){
                $unPayStock++;
                $order_no2 = create_sn(1,1).$kk;
                Db::name('orders')->insert(['order_no'=>$order_no2, 'schedule_id'=>$v['id']]);
                //解除用户当前日程预约限制 todo 需解除抢到未下单的用户(后期可考虑通过遍历订单判断是否提交订单)
                $buyKey = 'hryy_buy:'.$order['openid'].'_'.$order['schedule_id'];
                $redis->del($buyKey);
            }
        }

        //统计抢到后未填写订单对应的库存数(todo 牵扯时间问题,可能未来得及支付 认为正常3分钟可完成订单填写,定时任务至少为2分钟)
        //当前调整为提交订单才写入hryy_buy_count,故未支付数即为回滚库存数
        //实际提交订单人数
        $buyCountKey = 'hryy_buy_count:'.$v['id'];
        $buyCount = $redis->get($buyCountKey);
        //实际扣除库存
        $stockKey = 'hryy_stock:'.$v['id'];
        $buyStock = $redis->get($stockKey);
        $stockDesc = $v['amount'] - $buyStock;
        //需回退的库存数(可能超出实际库存,后期通过订单创建限制)
        $rollbackStockNum = $stockDesc - $buyCount;
        if($rollbackStockNum > 0){
            $rollbackStockNum += $unPayStock;
            $redis->incrby('hryy_stock:'.$v['id'], $rollbackStockNum);
        }elseif($unPayStock > 0){
            $redis->incrby('hryy_stock:'.$v['id'], $unPayStock);
        }

    }
    echo 'done';
}

Tags: PHP

添加新评论