百度360必应搜狗淘宝本站头条
当前位置:网站首页 > 技术教程 > 正文

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

mhr18 2024-11-08 12:14 19 浏览 0 评论

前言

那些吧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;

乐观锁

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

果并发高一些,例如商品详情页一般并发最高,为了减少数据库的压力,都会使用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;
}

说明:

  • 正如前文所说,秒杀的商品应该展示的是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);
    }
}

说明:

  • 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

可售多件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、仅售一件。先把购买者的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、主要看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,库存不足。

最后,感谢大家的观看,谢谢大家的支持,点赞关注我最好!希望大家都能早日升职加薪,走上人生巅峰!

相关推荐

redis 7.4.3更新!安全修复+性能优化全解析

一、Redis是什么?为什么选择它?Redis(RemoteDictionaryServer)是一款开源的高性能内存键值数据库,支持持久化、多数据结构(如字符串、哈希、列表等),广泛应用于缓存、消...

C# 读写Redis数据库的简单例子

CSRedis是一个基于C#的Redis客户端库,它提供了与Redis服务器进行交互的功能。它是一个轻量级、高性能的库,易于使用和集成到C#应用程序中。您可以使用NuGet包管理器或使用以下命令行命令...

十年之重修Redis原理

弱小和无知并不是生存的障碍,傲慢才是。--------面试者总结Redis可能都用过,但是从来没有理解过,就像一个熟悉的陌生人,本文主要讲述了Redis基本类型的使用、数据结构、持久化、单线程模型...

高频L2行情数据Redis存储架构设计(含C++实现代码)

一、Redis核心设计原则内存高效:优化数据结构,减少内存占用低延迟访问:单次操作≤0.1ms响应时间数据完整性:完整存储所有L2字段实时订阅:支持多客户端实时数据推送持久化策略:RDB+AOF保障数...

Magic-Boot开源引擎:零代码玩转企业级开发,效率暴涨!

一、项目介绍基于magic-api搭建的快速开发平台,前端采用Vue3+naive-ui最新版本搭建,依赖较少,运行速度快。对常用组件进行封装。利用Vue3的@vue/compiler-sfc单文...

项目不行简历拉胯?3招教你从面试陪跑逆袭大厂offer!

项目不行简历拉胯?3招教你从面试陪跑逆袭大厂offer!老铁们!是不是每次面试完都感觉自己像被大厂面试官婉拒的渣男?明明刷了三个月题库,背熟八股文,结果一被问项目就支支吾吾,简历写得像大学生课程设计?...

谷歌云平台:开发者部署超120个开源包

从国外相关报道了解,Google与Bitnami合作为Google云平台增加了一个新的功能,为了方便开发人员快捷部署程序,提供了120余款开源应用程序云平台的支持。这些应用程序其中包括了WordPre...

知名互联网公司和程序员都看好的数据库是什么?

2017年数据库领域的最大趋势是什么?什么是最热的数据处理技术?学什么数据库最有前途?程序员们普遍不喜欢的数据库是什么?本文都会一一揭秘。大数据时代,数据库的选择备受关注,此前本号就曾揭秘国内知名互联...

腾讯云发布云存储MongoDB服务

近日,著名安全专家兼Shodan搜索引擎的创建者JohnMatherly发现,目前至少有35000个受影响的MongoDB数据库暴露在互联网上,它们所包含的数据暴露在网络攻击风险之中。据估计,将近6...

已跪,Java全能笔记爆火,分布式/开源框架/微服务/性能调优全有

前言程序员,立之根本还是技术,一个程序员的好坏,虽然不能完全用技术强弱来判断,但是技术水平一定是基础,技术差的程序员只能CRUD,技术不深的程序员也成不了架构师。程序员对于技术的掌握,除了从了解-熟悉...

面试官:举个你解决冲突的例子?别怂!用这个套路……

面试官:举个你解决冲突的例子?别怂!用这个套路……最近收到粉丝私信,说被问到:团队技术方案有分歧怎么办?当场大脑宕机……兄弟!这不是送命题,是展示你情商+技术判断力的王炸题!今天教你们3招,用真实案例...

面试碰到MongoDB?莫慌,跟面试官这样吹MongoDB 复制集

推荐阅读:吊打MySQL:21性能优化实践+学习导图+55面试+笔记+20高频知识点阿里一线架构师分享的技术图谱,进阶加薪全靠它十面字节跳动,依旧空手而归,我该放弃吗?文末会分享一些MongoDB的学...

SpringBoot集成扩展-访问NoSQL数据库之Redis和MongoDB!

与关系型数据库一样,SpringBoot也提供了对NoSQL数据库的集成扩展,如对Redis和MongoDB等数据库的操作。通过默认配置即可使用RedisTemplate和MongoTemplate...

Java程序员找工作总卡项目关?

Java程序员找工作总卡项目关?3招教你用真实经历写出HR抢着要的简历!各位Java老哥,最近刷招聘软件是不是手都划酸了?简历投出去石沉大海,面试邀请却总在飞别人的简历?上周有个兄弟,13年经验投了5...

Java多租户SaaS系统实现方案

嗯,用户问的是Java通过租户id实现的SaaS方案。首先,我需要理解用户的需求。SaaS,也就是软件即服务,通常是指多租户的架构,每个租户的数据需要隔离。用户可能想知道如何在Java中利用租户ID来...

取消回复欢迎 发表评论: