关于秒杀系统的几点记录
校验请求是否合法
- 活动是否开始
- 校验参数是否合法
- 校验用户是否合法
- 验证码等防止机器人
- 校验用户是否重复购买
- 校验库存
- 库存需实时获取
性能优化
- 前端优化,如控制按钮点击、合并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';
}