秒杀事务= 减库存 + 插入购买明细

为什么说这是一个事务?
如果减了库存没有记录明细,或者记录了明细却没有减库存,这时候就会出现少卖或者超卖的情况。

来看看秒杀业务的逻辑:

java项目 sn码实现 java ssm项目实战_MySQL

通过这张图,我们来提取后台业务需要的接口(红色代表会出现高并发的点,下面分析性能优化时会说到):
假设我们的项目根目录为:/seckill

  • 详情页:/seckill/{seckillId}/detail
  • 获取系统时间:/seckill/time/now
  • 秒杀开启时需要暴露的秒杀接口:/seckill/{seckillId}/exposer
  • 执行秒杀接口:/seckill/{seckillId}/{md5}/execution

注:seckillId 为秒杀商品的ID即:productId

具体分析:

1. 获取商品详情页

初期,我们可能会这么实现接口:

//商品详情
    @RequestMapping(value = "/{seckillId}/detail",method = RequestMethod.GET)
    public String detail(@PathVariable("seckillId") Long seckillId,Model model){
        if(seckillId==null){
            return "redirect:/seckill/list";
        }
		//调用Service,Service调用DAO
        Seckill seckill=seckillService.getById(seckillId);
        if(seckill==null){
            return "forward:/seckill/list";
        }
        model.addAttribute("seckill",seckill);
        return "detail";
    }

也就是:
通过商品详情url -> 访问应用程序 (controller-> 商品详情service -> DAO)-> MySQL

但是实际情况是:在秒杀场景中,我们都会不停的刷新浏览器来查看秒杀是否开启,如果是上面的这个方案,就会对应用程序和数据库造成巨大压力,从而导致崩溃。

分析秒杀业务容易得知,用户其实并不关心商品详情,同时商品信息也不会变更,所以秒杀商品的Detail 可以做成静态页面,然后用CDN 加速。

java项目 sn码实现 java ssm项目实战_java项目 sn码实现_02


CDN 其实就是一个就近访问,也是类似于缓存的作用。

然后我们会有下面方案:

java项目 sn码实现 java ssm项目实战_高并发_03


商品详情页静态化、CDN化之后,并不需要访问应用程序了,此时我们需要判断秒杀是否开启,于是就有了下面这个接口:

2. 获取系统时间:/seckill/time/now

通过获取系统时间与秒杀开启时间比较来判断秒杀是否开启,此时,我们的接口只需要返回一个系统时间即可:

//返回服务器当前时间
    @RequestMapping(value = "/time/now",method = RequestMethod.GET)
    @ResponseBody
    public SeckillResult<Long> time(){
        Date now=new Date();
        return new SeckillResult<Long>(true,now.getTime());
    }

Java进行一个系统调用的时间时2ns,1s=100010001000ns,在一秒内能够抗住的并发量是很高的

在前台我们利用一个jq倒计时插件,当秒杀未开启时显示倒计时,秒杀开始后显示为秒杀按钮,并且暴露秒杀接口,如下图:

java项目 sn码实现 java ssm项目实战_高并发_04

3. 暴露秒杀接口 /seckill/{seckillId}/exposer

秒杀接口只在秒杀开启时暴露,防止通过url在秒杀开启前进行秒杀,通过js倒计时插件判断秒杀开启时,然后获取秒杀接口
客户端js代码:

countdown:function(seckillId,nowTime,startTime,endTime) {
        var seckillBox=$("#seckill-box");
        if(nowTime>endTime){
            //秒杀结束了
            seckillBox.html('秒杀结束');
        }else if(nowTime<startTime){
            //秒杀未开始,计时事件绑定
            var killTime=new Date(startTime+1000);
            seckillBox.countdown(killTime,function(event) {
                //时间格式
                var format=event.strftime('秒杀倒计时: %D天 %H时 %M分 %S秒');
                seckillBox.html(format);
                /*时间完成后回调事件*/
            }).on('finish.countdown',function() {
                //获取秒杀地址,控制实现逻辑,执行秒杀
                seckill.handleSeckillkill(seckillId,seckillBox);
            });
        }else{
            //秒杀开始
            seckill.handleSeckillkill(seckillId,seckillBox);
        }
    }
handleSeckillkill:function(seckillId,node) {
        //处理秒杀逻辑
        node.hide()
            .html('<button class="btn btn-primary btn-lg" id="killBtn">秒杀按钮</button>');
        $.post(seckill.URL.exposer(seckillId),{},function(result) {
            //在回调函数中执行交互流程
            if(result && result['success']){
                var exposer=result['data'];
                if(exposer['exposed']){
                    //开启秒杀
                    //获取秒杀的地址
                    var md5=exposer['md5'];
                    var killUrl=seckill.URL.execution(seckillId,md5);
                    console.log('killUrl:',killUrl);
                    //用one绑定,只绑定一次点击事件
                    $('#killBtn').one('click',function() {
                       //绑定执行秒杀请求的操作
                        //1.先禁用按钮
                        $(this).addClass('disabled');
                        //2.发送秒杀请求
                        $.post(killUrl,{},function(result) {
                            if(result ){
                                var killResult=result['data'];
                                var state=killResult['state'];
                                var stateInfo=killResult['stateInfo'];
                                //3.显示秒杀结果
                                node.html('<span class="label label-success">'+stateInfo+'</span>');
                            }
                        });
                    });
                    node.show();
                }else {
                    //未开启秒杀
                    var now=exposer['now'];
                    var start=exposer['start'];
                    var end=exposer['end'];
                    //重新计算计时逻辑
                    seckill.countdown(seckillId,now,start,end);
                }
            }else {
                console.log('result=',result);
            }
        });
    }

秒杀接口返回一个服务器生成的加密token,秒杀时带着这个token才能进行秒杀。

java项目 sn码实现 java ssm项目实战_高并发_05

public Exposer exportSeckillUrl(long seckillId) {
        //todo 在缓存超时的基础上维护一致性
        //1.先去缓存中找
        Seckill seckill = redisDao.getSeckill(seckillId);
        if(seckill==null){
            //2.缓存中没有则去DB里面找
            seckill = seckillDao.queryById(seckillId);
            if (seckill == null) {
                return new Exposer(false,seckillId);
            }else {
                //3.从数据库取出来之后再放入缓存
                redisDao.putSeckill(seckill);
            }
        }
        Date startTime=seckill.getStartTime();
        Date endTime=seckill.getEndTime();
        Date nowTime=new Date();
        if(nowTime.getTime()<startTime.getTime()
                || nowTime.getTime()>endTime.getTime()){
            return new Exposer(false,seckillId,nowTime.getTime(),startTime.getTime(),endTime.getTime());
        }
        //不可逆的转化md5字符串
        String md5=getMD5(seckillId);

        return new Exposer(true,md5,seckillId);
    }
5.执行秒杀 /seckill/{seckillId}/{md5}/execution

客户端带着商品id和加密token进行秒杀,此时会出现几种情况:

  • 重复秒杀:用户已经秒杀过,即重复插入
  • 秒杀关闭:秒杀时间结束或库存为0
  • 秒杀系统异常:系统不可避免的异常
    创建处理这几种情况的异常类直接抛出,系统捕获之后,返回对应结果
    RepeatKillException,SeckillCloseException,SeckillException

秒杀逻辑为一个事务:

java项目 sn码实现 java ssm项目实战_java项目 sn码实现_06

在update库存时开启事务,此时库存表会被加上行级锁。

在更新库存后,通过返回值来判断是否成功,此时java应用端就需要等待返回结果,再执行下面的操作:

java项目 sn码实现 java ssm项目实战_高并发_07


分析这其中的网络延迟:如果是同城机房

java项目 sn码实现 java ssm项目实战_MySQL_08


异地机房带来的延迟会更大:

java项目 sn码实现 java ssm项目实战_java项目 sn码实现_09


由于行级锁要在事务commit之后才会释放,那么优化的方向就放在了减少行级锁的持有时间,如果我们把整个事务管理放在MySQL数据库中,那么就会大大减少这种延迟,将提交放在MySQL服务端的方案:

  • 定制SQL级别方案:需要修改MySQL源码,难度太大
  • 使用存储过程,整个事务在服务端完成

Java端使用事务执行秒杀逻辑:

@Transactional
    public SeckillExecution executeSeckill(long seckillId, long userphone, String md5) throws SeckillException, RepeatKillException, SeckillCloseException {
        if(md5==null || !md5.equals(getMD5(seckillId))){
            throw new SeckillException("seckill data rewrite!");
        }
        //执行秒杀逻辑>减库存+记录购买记录
        Date nowTime=new Date();
        try {
            //记录购买行为
            int insertCount = successKilledDao.insertSuccessKilled(seckillId, userphone);
            //唯一:seckillId ,userphone
            if (insertCount <= 0) {
                throw new RepeatKillException("seckill repeat");
            } else {
            //减库存,todo 执行竞争条件,减库存,update获得行级锁
                int updateCount = seckillDao.reduceNumber(seckillId, nowTime);
                if (updateCount <= 0) {
                    //没有更新记录
                    throw new SeckillCloseException("seckill is closed");
                } else {
                    //秒杀成功
                    SuccessKilled successKilled = successKilledDao.queryByIdWithSeckill(seckillId, userphone);
                    return new SeckillExecution(seckillId, SeckillStatEnum.SUCCESS, successKilled);
                }
            }
        }catch (SeckillCloseException e1) {
            throw e1;
        }catch (RepeatKillException e2){
            throw e2;
        }catch (Exception e) {
            logger.error(e.getMessage(), e);
            //编译异常转换,spring发现后会回滚
            throw new SeckillException("seckill inner error:" + e.getMessage());
        }
    }

此方案适合一般需求,可以看到一条update语句MySQL的QPS效果:

java项目 sn码实现 java ssm项目实战_MySQL_10

当然还有其他更好秒杀系统解决方案:

java项目 sn码实现 java ssm项目实战_java项目 sn码实现_11

利用redis等来做一个原子计数器,将秒杀记录放到队列中进行削峰,控制进入的流量,最后让服务器从消息队列中进行消费落地。

这种方案适合更高需求的秒杀系统,百万级别不在话下,当然相应的成本也是巨大的。

java项目 sn码实现 java ssm项目实战_java项目 sn码实现_12