基于代码实操SpringBoot、Redis、LUA秒杀系统

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

[[410299]]

前言

那些吧redis基本的东西学的差不多了,却没有做过什么具体的项目实践的,可以看看这篇文章做一个项目来巩固知识。

相关需求&说明

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

  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 * from SKU表 where sku_id=1 for update
  • 1.

乐观锁

update SKU表 set stock=stock-1 where sku_id=1 and update_version=旧版本号; 
  • 1.

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

  1. B端管理系统操作数据库(这个并发不会高)。
  2. 数据入库后,发送消息给MQ。
  3. 相关处理程序在接收到订阅的MQ的Topic后,从数据库取出信息,放入Redis。
  4. 相关服务接口只从Redis取数据。

代码实现

在实际项目中,建议将ToC端的秒杀产品相关接口组合为一个微服务,product-server。售卖接口组合为一个微服务,order-server。可以参考之前的Spring Cloud系列文章进行编码,本文就简单使用了一个Spring Boot工程。

秒杀计划实体类

省略get/set

public class SecKillPlanEntity implements Serializable { 
    private static final long serialVersionUID = 8866797803960607461L; 
 
    /** 
     * id 
     */ 
    private Long id; 
 
    /** 
     * 商品id 
     */ 
    private Long productId; 
 
    /** 
     * 商品名称 
     */ 
    private String productName; 
 
    /** 
     * 价格 单位:分 
     */ 
    private Long price; 
 
    /** 
     * 划线价 单位:分 
     */ 
    private Long linePrice; 
 
    /** 
     * 库存数 
     */ 
    private Long stock; 
 
    /** 
     * 一个用户只买一件商品标识 0否1是 
     */ 
    private int buyOneFlag; 
 
    /** 
     * 计划状态 0未提交,1已提交 
     */ 
    private int planStatus; 
 
    /** 
     * 开始时间 
     */ 
    private Date startTime; 
 
    /** 
     * 结束时间 
     */ 
    private Date endTime; 
 
    /** 
     * 创建时间 
     */ 
    private Date createTime; 

  • 1.
  • 2.
  • 3.
  • 4.
  • 5.
  • 6.
  • 7.
  • 8.
  • 9.
  • 10.
  • 11.
  • 12.
  • 13.
  • 14.
  • 15.
  • 16.
  • 17.
  • 18.
  • 19.
  • 20.
  • 21.
  • 22.
  • 23.
  • 24.
  • 25.
  • 26.
  • 27.
  • 28.
  • 29.
  • 30.
  • 31.
  • 32.
  • 33.
  • 34.
  • 35.
  • 36.
  • 37.
  • 38.
  • 39.
  • 40.
  • 41.
  • 42.
  • 43.
  • 44.
  • 45.
  • 46.
  • 47.
  • 48.
  • 49.
  • 50.
  • 51.
  • 52.
  • 53.
  • 54.
  • 55.
  • 56.
  • 57.
  • 58.

说明:

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

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

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

所以本类使用buyOneFlag做标识。

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

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

@RestController 
public class ProductController { 
 
    @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); 
    } 

  • 1.
  • 2.
  • 3.
  • 4.
  • 5.
  • 6.
  • 7.
  • 8.
  • 9.
  • 10.
  • 11.
  • 12.
  • 13.
  • 14.
  • 15.
  • 16.
  • 17.
  • 18.
  • 19.
  • 20.
  • 21.
  • 22.
  • 23.
  • 24.
  • 25.
  • 26.
  • 27.
  • 28.
  • 29.
  • 30.
  • 31.
  • 32.
  • 33.
  • 34.
  • 35.
  • 36.
  • 37.
  • 38.
  • 39.
  • 40.
  • 41.
  • 42.
  • 43.
  • 44.
  • 45.
  • 46.
  • 47.
  • 48.
  • 49.
  • 50.
  • 51.
  • 52.
  • 53.
  • 54.
  • 55.
  • 56.
  • 57.
  • 58.
  • 59.
  • 60.
  • 61.
  • 62.
  • 63.
  • 64.
  • 65.
  • 66.
  • 67.
  • 68.
  • 69.
  • 70.
  • 71.
  • 72.
  • 73.
  • 74.
  • 75.
  • 76.
  • 77.
  • 78.
  • 79.
  • 80.
  • 81.
  • 82.
  • 83.
  • 84.
  • 85.
  • 86.
  • 87.
  • 88.
  • 89.

 说明:

  • 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_XXX 
local stockKey = KEYS[1] 
--商品购买用户记录Key product_buyers_XXX 
local buyersKey = KEYS[2] 
--用户ID 
local 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 
    end 
else 
    --已经购买过 
    return -3 
end 
  • 1.
  • 2.
  • 3.
  • 4.
  • 5.
  • 6.
  • 7.
  • 8.
  • 9.
  • 10.
  • 11.
  • 12.
  • 13.
  • 14.
  • 15.
  • 16.
  • 17.
  • 18.
  • 19.
  • 20.
  • 21.
  • 22.
  • 23.
  • 24.
  • 25.

 可售多件buymore.lua:

--商品Key 
local key = KEYS[1] 
--购买数 
local val = ARGV[1] 
--现有总库存 
local stock = redis.call("GET"key
if (tonumber(stock)<=0)  
then 
    --没有库存 
    return -1 
else 
    --获取扣减后的总库存=总库存-购买数 
    local decrstock=redis.call("DECRBY"key, val) 
    if(tonumber(decrstock)>=0) 
    then 
        --扣减购买数后没有超卖,返回现库存 
        return decrstock 
    else 
        --超卖了,把扣减的再加回去 
        redis.call("INCRBY"key, val) 
        return -2 
    end 
end 
  • 1.
  • 2.
  • 3.
  • 4.
  • 5.
  • 6.
  • 7.
  • 8.
  • 9.
  • 10.
  • 11.
  • 12.
  • 13.
  • 14.
  • 15.
  • 16.
  • 17.
  • 18.
  • 19.
  • 20.
  • 21.
  • 22.
  • 23.

说明:

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

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

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

售卖接口

@RestController 
public 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.
  • 2.
  • 3.
  • 4.
  • 5.
  • 6.
  • 7.
  • 8.
  • 9.
  • 10.
  • 11.
  • 12.
  • 13.
  • 14.
  • 15.
  • 16.
  • 17.
  • 18.
  • 19.
  • 20.
  • 21.
  • 22.
  • 23.
  • 24.
  • 25.
  • 26.
  • 27.
  • 28.
  • 29.
  • 30.
  • 31.
  • 32.
  • 33.
  • 34.
  • 35.
  • 36.
  • 37.
  • 38.
  • 39.
  • 40.
  • 41.
  • 42.
  • 43.
  • 44.
  • 45.
  • 46.
  • 47.
  • 48.
  • 49.
  • 50.
  • 51.
  • 52.
  • 53.
  • 54.
  • 55.
  • 56.
  • 57.
  • 58.

 说明: 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,库存不足。

 

责任编辑:姜华 来源: 今日头条
相关推荐

2024-04-28 10:52:25

CentOS系统RHEL系统

2020-08-04 07:47:59

代码模板模式

2019-07-23 13:32:13

Java开发代码

2009-12-23 17:22:18

Linux系统rsyn

2020-04-01 17:31:03

Redis系统秒杀

2010-04-29 12:23:58

Oracle 获取系统

2010-01-06 10:38:16

Linux安装JDK

2010-04-12 09:36:29

Oacle merge

2010-05-04 14:10:53

Oracle表

2020-09-01 07:47:32

Redis秒杀微信

2021-09-22 15:36:31

勒索软件攻击数据泄露

2010-05-10 17:00:53

Oracle死锁进程

2010-04-15 14:18:30

Oracle创建

2010-04-09 10:13:13

Oracle数据字典

2010-04-13 14:00:00

Oracle inse

2009-12-01 18:03:56

Linux版本

2009-11-30 13:05:00

Suse防火墙

2010-05-20 15:53:15

配置MySQL

2010-05-18 12:24:16

MySQL binlo

2010-05-18 18:19:40

MySQL修改表结构
点赞
收藏

51CTO技术栈公众号