怎么用SpringBoot实现秒杀系统

怎么用SpringBoot实现秒杀系统

今天小编给大家分享一下怎么用SpringBoot实现秒杀系统的相关知识点,内容详细,逻辑清晰,相信大部分人都还太了解这方面的知识,所以分享这篇文章给大家参考一下,希望大家阅读完这篇文章后有所收获,下面我们一起来了解一下吧。

相关需求&说明

一般来说秒杀系统的功能不会很多,有:

怎么用SpringBoot实现秒杀系统

  1. 制定秒杀计划。在某天几点开始,售卖什么商品,准备卖多少个,持续多久。

  2. 展示秒杀计划列表。一般都是显示当天的,8点卖一些,10点卖一些这种。

  3. 商品详情页。

  4. 下单购买。

  5. 等等

本文主要目的还是用代码实现一下防止商品超卖的功能,所以像制定秒杀计划,展示商品等功能就不着重写了。

还有电商的商品主要是SPU(例如iPhone 12,iPhone 11就是两个SPU)及SKU(例如iPhone 12 64G 白色,iPhone 12 128G 黑色就是两个SKU)的处理,展示的是SPU,购买扣库存的是SKU,本文为了方便,就直接用product来替代了。

下单购买还会有一些前置条件,比如要经过风控系统,确认你是不是黄牛;营销系统,有没有相关的优惠券,虚拟货币之类的。

下单完成还要走库管、物流,还有积分之类的,本文就不涉及了。
本文不涉及数据库,一切都在Redis上操作,不过还是想说一下数据库与缓存数据一致性的问题。

如果我们的系统并发不高,数据库撑得住,则直接操作数据库即可,为防止超卖,可以采用:

悲观锁

select*fromSKU表wheresku_id=1forupdate;

乐观锁

updateSKU表setstock=stock-1wheresku_id=1andupdate_version=旧版本号;

果并发高一些,例如商品详情页一般并发最高,为了减少数据库的压力,都会使用Redis等缓存,为了保证数据库与Redis的一致性,多是采用“修改后删除”方案。
但是这个方案在更高并发情况下,如C10K、C10M等,在修改数据库并删除Redis内容的一瞬间,大量查询并发会传导至数据库,产生异常。
这种情况,SPU详情这种接口就坚决不能与数据库连接起来。
步骤应该是:

  1. B端管理系统操作数据库(这个并发不会高)。

  2. 数据入库后,发送消息给MQ。

  3. 相关处理程序在接收到订阅的MQ的Topic后,从数据库取出信息,放入Redis。

  4. 相关服务接口只从Redis取数据。

代码实现

在实际项目中,建议将ToC端的秒杀产品相关接口组合为一个微服务,product-server。售卖接口组合为一个微服务,order-server。

秒杀计划实体类

省略get/set

publicclassSecKillPlanEntityimplementsSerializable{privatestaticfinallongserialVersionUID=8866797803960607461L;/***id*/privateLongid;/***商品id*/privateLongproductId;/***商品名称*/privateStringproductName;/***价格单位:分*/privateLongprice;/***划线价单位:分*/privateLonglinePrice;/***库存数*/privateLongstock;/***一个用户只买一件商品标识0否1是*/privateintbuyOneFlag;/***计划状态0未提交,1已提交*/privateintplanStatus;/***开始时间*/privateDatestartTime;/***结束时间*/privateDateendTime;/***创建时间*/privateDatecreateTime;}

说明:

  • 正如前文所说,秒杀的商品应该展示的是SPU,售卖扣库存的是SKU,本文为了方便,只用product来替代。

  • 用户购买秒杀商品,有两种方式:

    • 一个用户只允许购买一件。

    • 一个用户可以多次购买多件。

所以本类使用buyOneFlag做标识。

  • planStatus代表本次秒杀是否真正执行。0不展示给C端,不进行售卖;1展示给C端,进行售卖。

添加秒杀计划&查询秒杀计划

@RestControllerpublicclassProductController{@ResourceprivateRedisTemplate<String,String>redisTemplate;//随机生成秒杀计划设置到Redis中@GetMapping("/addSecKillPlan")@ResponseBodypublicDefaultResult<List<SecKillPlanEntity>>addSecKillPlan(@RequestParam("saledate")StringsaleDate){DateTimeFormatterdtf=DateTimeFormatter.ofPattern("yyyy-MM-ddHH:mm:ss");Randomrand=newRandom();Gsongson=newGson();List<SecKillPlanEntity>list=Lists.newArrayList();for(inti=0;i<10;i++){longproductId=rand.nextInt(100)+1;longprice=rand.nextInt(100)+1;longstock=rand.nextInt(100)+1;StringsaleStartTime="10:00:00";StringsaleEndTime="12:00:00";intbuyOneFlag=0;if(i>4){saleStartTime="14:00:00";saleEndTime="16:00:00";buyOneFlag=1;}SecKillPlanEntityentity=newSecKillPlanEntity();entity.setId(i+1L);entity.setProductId(productId);entity.setProductName("商品"+productId);entity.setBuyOneFlag(buyOneFlag);entity.setLinePrice(999999L);entity.setPlanStatus(1);entity.setPrice(price*100);entity.setStock(stock);entity.setEndTime(Date.from(LocalDateTime.parse(saleDate+saleEndTime,dtf).atZone(ZoneId.systemDefault()).toInstant()));entity.setStartTime(Date.from(LocalDateTime.parse(saleDate+saleStartTime,dtf).atZone(ZoneId.systemDefault()).toInstant()));entity.setCreateTime(newDate());//商品详情写入RedisValueOperations<String,String>setProduct=redisTemplate.opsForValue();setProduct.set("product_"+productId,gson.toJson(entity));//写入库存if(buyOneFlag==1){//一个用户只买一件商品//商品购买用户SetredisTemplate.opsForSet().add("product_buyers_"+productId,"");//商品库存for(intj=0;j<stock;j++){redisTemplate.opsForList().leftPush("product_one_stock_"+productId,"1");}}else{//用户可买多个redisTemplate.opsForValue().set("product_stock_"+productId,stock+"");}list.add(entity);System.out.println(gson.toJson(entity));}redisTemplate.opsForValue().set("seckill_plan_"+saleDate,gson.toJson(list));returnDefaultResult.success(list);}@GetMapping("/findSecKillPlanByDate")@ResponseBodypublicDefaultResult<List<SecKillPlanEntity>>findSecKillPlanByDate(@RequestParam("saledate")StringsaleDate){Gsongson=newGson();StringplanJson=redisTemplate.opsForValue().get("seckill_plan_"+saleDate);List<SecKillPlanEntity>list=gson.fromJson(planJson,newTypeToken<List<SecKillPlanEntity>>(){}.getType());//设置新的库存for(SecKillPlanEntityentity:list){if(entity.getBuyOneFlag()==1){longnewStock=redisTemplate.opsForList().size("product_one_stock_"+entity.getProductId());entity.setStock(newStock);}else{longnewStock=Long.parseLong(redisTemplate.opsForValue().get("product_stock_"+entity.getProductId()));entity.setStock(newStock);}}returnDefaultResult.success(list);}}

说明:

  • addSecKillPlan就是随机生成10个售卖计划,有仅售一件的,也有售多件的。并将相关数据压入Redis。

  • seckill_plan_日期,代表某日的所有秒杀计划,列表展示用。

  • product_商品ID,代表某商品信息,详情页使用。

  • product_one_stock_商品ID,代表仅售一件商品的库存数,值是List,有多少库存,就往里面push多少个“1”。

  • product_buyers_商品ID,代表仅售一件商品的购买者,已购买过的用户不允许再买。

  • product_stock_商品ID,代表可售多件商品的库存数,值是库存数。

findSecKillPlanByDate,展示某日秒杀售卖计划。库存数从库存相关的两个KEY取。

LUA脚本

仅售一件buyone.lua:

--商品库存Keyproduct_one_stock_XXXlocalstockKey=KEYS[1]--商品购买用户记录Keyproduct_buyers_XXXlocalbuyersKey=KEYS[2]--用户IDlocaluid=KEYS[3]--校验用户是否已经购买localresult=redis.call("sadd",buyersKey,uid)if(tonumber(result)==1)then--没有购买过,可以购买localstock=redis.call("lpop",stockKey)--除了nil和false,其他值都是真(包括0)if(stock)then--有库存return1else--没有库存return-1endelse--已经购买过return-3end

可售多件buymore.lua:

--商品Keylocalkey=KEYS[1]--购买数localval=ARGV[1]--现有总库存localstock=redis.call("GET",key)if(tonumber(stock)<=0)then--没有库存return-1else--获取扣减后的总库存=总库存-购买数localdecrstock=redis.call("DECRBY",key,val)if(tonumber(decrstock)>=0)then--扣减购买数后没有超卖,返回现库存returndecrstockelse--超卖了,把扣减的再加回去redis.call("INCRBY",key,val)return-2endend

说明:

1、仅售一件。先把购买者的ID用命令“sadd”进product_buyers_商品ID,如果返回1,代表此用户之前没有购买过,否则返回-3,已经购买过。

  • 在从product_one_stock_商品ID中lpop出数值,如果还有库存,必会返回1,有库存,否则就是nil,无库存。

【参考文档】
2.、可售多件。之前讲过,不再描述。
将两个lua文件,放在Spring Boot工程的resources目录下。

售卖接口

@RestControllerpublicclassOrderController{@ResourceprivateRedisTemplate<String,String>redisTemplate;@GetMapping("/addOrder")@ResponseBodypublicDefaultResult<Void>addOrder(@RequestParam("uid")longuserId,@RequestParam("pid")longproductId,@RequestParam("quantity")intquantity){Gsongson=newGson();StringproductJson=redisTemplate.opsForValue().get("product_"+productId);SecKillPlanEntityentity=gson.fromJson(productJson,SecKillPlanEntity.class);//TODO要校验售卖计划是否已提交,是否到了售卖时间longcode=0;if(entity.getBuyOneFlag()==1){//用户只买一件code=this.buyOne("product_one_stock_"+productId,"product_buyers_"+productId,userId);}else{//用户买多件code=this.buyMore("product_stock_"+productId,quantity);}DefaultResult<Void>result=DefaultResult.success(null);//错误代码的处理应该使用ENUM,本文就节省了if(code<0){result.setCode(code);if(code==-1){result.setMsg("没有库存");}elseif(code==-2){result.setMsg("库存不足");}elseif(code==-3){result.setMsg("已经购买过");}}returnresult;}privateLongbuyOne(StringstockKey,StringbuysKey,longuserId){DefaultRedisScript<Long>defaultRedisScript=newDefaultRedisScript<Long>();defaultRedisScript.setResultType(Long.class);defaultRedisScript.setScriptSource(newResourceScriptSource(newClassPathResource("buyone.lua")));//"{pre}:"List<String>keys=Lists.newArrayList(stockKey,buysKey,userId+"");Longresult=redisTemplate.execute(defaultRedisScript,keys,"");returnresult;}privateLongbuyMore(StringstockKey,intquantity){DefaultRedisScript<Long>defaultRedisScript=newDefaultRedisScript<Long>();defaultRedisScript.setResultType(Long.class);defaultRedisScript.setScriptSource(newResourceScriptSource(newClassPathResource("buymore.lua")));List<String>keys=Lists.newArrayList(stockKey);Longresult=redisTemplate.execute(defaultRedisScript,keys,quantity+"");returnresult;}}

说明:
1、主要看buyOne、buyMore两个私有方法,里面写的是如何使用RedisTemplate执行lua脚本。

另外我看有资料说如果使用的是Redis集群,则会报错,因为我没有Redis的集群环境,所以也没法测试,大家有环境的可以试一试。

2、addOrder有一些代码为了节省时间,就写得很low了,比如一些校验没有加,错误码应该使用ENUM等。
测试用例:

  1. A用户购买仅售一件商品1,成功。

  2. A用户再购买仅售一件商品1,失败。

  3. N用户购买仅售一件商品1,库存不足。

  4. A用户购买可售多件商品2,成功。

  5. A用户购买可售多件商品2,库存不足。

以上就是“怎么用SpringBoot实现秒杀系统”这篇文章的所有内容,感谢各位的阅读!相信大家阅读完这篇文章都有很大的收获,小编每天都会为大家更新不同的知识,如果还想学习更多的知识,请关注恰卡编程网行业资讯频道。

发布于 2022-03-29 22:27:39
收藏
分享
海报
0 条评论
33
上一篇:SpringBoot整合redis客户端超时怎么解决 下一篇:基于springboot怎么构建链路调用监控系统
目录

    0 条评论

    本站已关闭游客评论,请登录或者注册后再评论吧~

    忘记密码?

    图形验证码