Andlua,AndluaGG

  

  前言:你学过redis的基本东西,但没有做过具体的项目实践。你可以看这篇文章做一个项目来巩固你的知识。   

  

     

  

  相关需求描述一般来说,秒杀系统的功能并不多,包括:   

  

  制定秒杀计划。某天几点开始,卖什么货,卖多少,持续多久?显示秒杀计划列表。一般会显示当天的日期,这些有的8点10点都有卖。产品详情页面。下订单。以此类推,本文主要目的是用代码实现防止超卖商品的功能,所以不强调制定秒杀计划、商品展示等功能。   

  

  也有主要做SPU(比如iPhone 12,iPhone 11是两个SPU)和SKU(比如iPhone 12 64G白色,iPhone 12 128G黑色是两个SKU)的电商产品。显示SPU,购买SKU并从库存中扣除。为了方便,本文直接用product代替。   

  

  下单会有一些前提条件,比如通过风控系统确认自己是不是黄牛;营销体系,是否有相关优惠券,虚拟货币之类的。   

  

  为了完成订单,我们必须通过仓库管理、物流和积分,这些将不在本文中讨论。本文不涉及数据库,一切都在Redis上操作,但我还是想说说数据库和缓存数据的一致性问题。   

  

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

  

  悲观锁   

  

  select * from SKU _ id=1的SKU表进行更新;或者乐观锁.   

  

  更新SKU表集stock=stock-1,其中SKU标识=1,更新版本=旧版本号;例如,如果并发性较高,商品详细信息页面通常具有最高的并发性。为了减轻数据库的压力,使用了Redis等缓存。为了保证数据库与Redis的一致性,大多采用“修改删除”的方案。但是在并发较高的情况下,比如C10K,C10M等。当数据库被修改,Redis内容被删除时,大量的查询并发会传输到数据库,导致异常。在这种情况下,SPU详细信息的界面无法连接到数据库。这些步骤应该是:   

  

  b端管理系统操作数据库(这个并发不会高)。数据存储后,向MQ发送一条消息。在收到订阅的MQ的主题后,相关的处理程序从数据库中取出信息,并将其放入Redis。相关的服务接口只从Redis获取数据。该代码已在实际项目中实现。建议将ToC端与秒杀产品相关的接口组合成一个微服务,产品-服务器。销售界面被组合成一个微服务,订单服务器。编码可以参考之前的春云系列文章。本文简单地使用了一个Spring Boot项目。   

  

  秒杀计划实体类省略了get/set   

  

  公共类SecKillPlanEntity实现Serializable { private static final long serialVersionUID=8866797803960607461 l;/* * * id */private Long id;/* * *商品id */私有Long productId/* * *商品名称*/私有字符串productName/* * *价格单位:积分*/私长价;/* * *标记价格单位:点数*/私长线价格;/* * *库存*/私人多头库存;/* * *一个用户只买一件商品ID 0 No 1 Yes */private int buyOneFlag;/* * *计划状态0未提交,1已提交*/private int plan status;/* * *开始时间*/私人日期开始时间;/* * *结束时间*/私人日期结束时间;/* * *创建时间*/私人日期createTime}说明:   

  

  如前所述,秒杀产品应显示为SPU,而SKU则为库存扣除而出售。为了方便起见,本文只使用product。用户购买秒杀商品有两种方式:一个用户只允许购买一件。用户可以一次购买多件。所以这个班用buyOneFlag作为logo。   

  

  PlanStatus表示是否实际执行了这个峰值。0不给C看,不卖;1展示给C端出售。添加峰值计划查询峰值计划@ restcontrollerpublicclass product controller { @ resource   

private RedisTemplate<String, String> redisTemplate; // 随机生成秒杀计划设置到Redis中 @GetMapping("/addSecKillPlan") @ResponseBody public DefaultResult<List<SecKillPlanEntity>> addSecKillPlan(@RequestParam("saledate") String saleDate) { DateTimeFormatter dtf = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"); Random rand = new Random(); Gson gson = new Gson(); List<SecKillPlanEntity> list = Lists.newArrayList(); for (int i = 0; i < 10; i++) { long productId = rand.nextInt(100) + 1; long price = rand.nextInt(100) + 1; long stock = rand.nextInt(100) + 1; String saleStartTime = " 10:00:00"; String saleEndTime = " 12:00:00"; int buyOneFlag = 0; if (i > 4) { saleStartTime = " 14:00:00"; saleEndTime = " 16:00:00"; buyOneFlag = 1; } SecKillPlanEntity entity = new SecKillPlanEntity(); 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(new Date()); // 商品详情写入Redis ValueOperations<String, String> setProduct = redisTemplate.opsForValue(); setProduct.set("product_" + productId, gson.toJson(entity)); // 写入库存 if (buyOneFlag == 1) { // 一个用户只买一件商品 // 商品购买用户Set redisTemplate.opsForSet().add("product_buyers_" + productId, ""); // 商品库存 for (int j = 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)); return DefaultResult.success(list); } @GetMapping("/findSecKillPlanByDate") @ResponseBody public DefaultResult<List<SecKillPlanEntity>> findSecKillPlanByDate(@RequestParam("saledate") String saleDate) { Gson gson = new Gson(); String planJson = redisTemplate.opsForValue().get("seckill_plan_" + saleDate); List<SecKillPlanEntity> list = gson.fromJson(planJson, new TypeToken<List<SecKillPlanEntity>>() { }.getType()); // 设置新的库存 for (SecKillPlanEntity entity : list) { if (entity.getBuyOneFlag() == 1) { long newStock = redisTemplate.opsForList().size("product_one_stock_" + entity.getProductId()); entity.setStock(newStock); } else { long newStock = Long .parseLong(redisTemplate.opsForValue().get("product_stock_" + entity.getProductId())); entity.setStock(newStock); } } return DefaultResult.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:

  

--商品库存Key product_one_stock_XXXlocal stockKey = KEYS<1>--商品购买用户记录Key product_buyers_XXXlocal buyersKey = KEYS<2>--用户IDlocal uid = KEYS<3>--校验用户是否已经购买local result=redis.call("sadd" , buyersKey , uid )if(tonumber(result)==1)then --没有购买过,可以购买 local stock=redis.call("lpop" , stockKey ) --除了nil和false,其他值都是真(包括0) if(stock) then --有库存 return 1 else --没有库存 return -1 endelse --已经购买过 return -3end可售多件buymore.lua:

  

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

  

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

  

在从product_one_stock_商品ID中lpop出数值,如果还有库存,必会返回1,有库存,否则就是nil,无库存。2.、可售多件。之前讲过,不再描述。 将两个lua文件,放在Spring Boot工程的resources目录下。

  

售卖接口@RestControllerpublic class OrderController { @Resource private RedisTemplate<String, String> redisTemplate; @GetMapping("/addOrder") @ResponseBody public DefaultResult<Void> addOrder(@RequestParam("uid") long userId, @RequestParam("pid") long productId, @RequestParam("quantity") int quantity) { Gson gson = new Gson(); String productJson = redisTemplate.opsForValue().get("product_" + productId); SecKillPlanEntity entity = gson.fromJson(productJson, SecKillPlanEntity.class); //TODO 要校验售卖计划是否已提交,是否到了售卖时间 long code = 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("没有库存"); } else if (code == -2) { result.setMsg("库存不足"); } else if (code == -3) { result.setMsg("已经购买过"); } } return result; } private Long buyOne(String stockKey, String buysKey, long userId) { DefaultRedisScript<Long> defaultRedisScript = new DefaultRedisScript<Long>(); defaultRedisScript.setResultType(Long.class); defaultRedisScript.setScriptSource(new ResourceScriptSource(new ClassPathResource("buyone.lua"))); // "{pre}:" List<String> keys = Lists.newArrayList(stockKey, buysKey, userId + ""); Long result = redisTemplate.execute(defaultRedisScript, keys, ""); return result; } private Long buyMore(String stockKey, int quantity) { DefaultRedisScript<Long> defaultRedisScript = new DefaultRedisScript<Long>(); defaultRedisScript.setResultType(Long.class); defaultRedisScript.setScriptSource(new ResourceScriptSource(new ClassPathResource("buymore.lua"))); List<String> keys = Lists.newArrayList(stockKey); Long result = redisTemplate.execute(defaultRedisScript, keys, quantity+""); return result; }}说明: 1、主要看buyOne、buyMore两个私有方法,里面写的是如何使用RedisTemplate执行lua脚本。

  

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

  

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

  

A用户购买仅售一件商品1,成功。A用户再购买仅售一件商品1,失败。N用户购买仅售一件商品1,库存不足。A用户购买可售多件商品2,成功。A用户购买可售多件商品2,库存不足。最后,感谢大家的观看,谢谢大家的支持,点赞关注我最好!希望大家都能早日升职加薪,走上人生巅峰!

相关文章