基于SpringBoot、Redis、LUA秒杀系统代码实现
mhr18 2024-11-08 12:14 27 浏览 0 评论
关联阅读
本文就是这篇文章在Spring Boot上的实现。关键知识点就是基于RedisTemplate来执行LUA脚本,从而实现防止商品超卖。
相关需求&说明
一般来说秒杀系统的功能不会很多,有:
1、制定秒杀计划。在某天几点开始,售卖什么商品,准备卖多少个,持续多久。
2、展示秒杀计划列表。一般都是显示当天的,8点卖一些,10点卖一些这种。
3、商品详情页。
4、下单购买。
等。
本文主要目的还是用代码实现一下防止商品超卖的功能,所以像制定秒杀计划,展示商品等功能就不着重写了。
还有电商的商品主要是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工程。
《SpringCloud2020替换Netflix套件实践一 》
《SpringCloud2020替换Netflix套件实践二》
《SpringCloud2020替换Netflix套件实践三》
《SpringCloud2020替换Netflix套件实践四 》
秒杀计划实体类:
省略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、正如前文所说,秒杀的商品应该展示的是SPU,售卖扣库存的是SKU,本文为了方便,只用product来替代。
2、用户购买秒杀商品,有两种方式:
A、一个用户只允许购买一件。
B、一个用户可以多次购买多件。
所以本类使用buyOneFlag做标识。
3、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、addSecKillPlan就是随机生成10个售卖计划,有仅售一件的,也有售多件的。并将相关数据压入Redis。
seckill_plan_日期,代表某日的所有秒杀计划,列表展示用。
product_商品ID,代表某商品信息,详情页使用。
product_one_stock_商品ID,代表仅售一件商品的库存数,值是List,有多少库存,就往里面push多少个“1”。
product_buyers_商品ID,代表仅售一件商品的购买者,已购买过的用户不允许再买。
product_stock_商品ID,代表可售多件商品的库存数,值是库存数。
2、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教程——数据类型(字符串、列表)
-
上篇文章我们学习了Redis教程——Redis入门,这篇文章我们学习Redis教程——数据类型(字符串、列表)。Redis数据类型有:字符串、列表、哈希表、集合、有序集合、地理空间、基数统计、位图、位...
- 说说Redis的数据类型(redis数据类型详解)
-
一句话总结Redis核心数据类型包括:String:存储文本、数字或二进制数据。List:双向链表,支持队列和栈操作。Hash:字段-值映射,适合存储对象。Set:无序唯一集合,支持交并差运算。Sor...
- Redis主从复制(Redis主从复制复制文件)
-
介绍Redis有两种不同的持久化方式,Redis服务器通过持久化,把Redis内存中持久化到硬盘当中,当Redis宕机时,我们重启Redis服务器时,可以由RDB文件或AOF文件恢复内存中的数据。不过...
- 深入解析 Redis 集群的主从复制实现方式
-
在互联网大厂的后端开发领域,Redis作为一款高性能的内存数据库,被广泛应用于缓存、消息队列等场景。而Redis集群中的主从复制机制,更是保障数据安全、实现读写分离以及提升系统性能的关键所在。今...
- Redis + MQ:高并发秒杀的技术方案与实现
-
大家好,我是一安~前言在电商秒杀场景中,瞬间爆发的海量请求往往成为系统的生死考验。当并发量达到数万甚至数十万QPS时,传统数据库单表架构难以支撑,而Redis与消息队...
- Redis面试题2025(redis面试题及答案2024)
-
Redis基础什么是Redis?它的主要特点是什么?Redis和Memcached有什么区别?Redis支持哪些数据类型?Redis的字符串类型最大能存储多少数据?Redis的列表类型和集合类型有什么...
- Redis学习笔记:过期键管理与EXPIRE命令详解(第七章)
-
在Redis中,过期键(ExpireKey)机制是实现缓存自动失效、临时数据管理的核心功能。EXPIRE命令作为设置键过期时间的基础工具,其工作原理与使用细节直接影响系统的内存效率和数据一致性。本章...
- Redis传送术:几分钟内将生产数据迁移到本地
-
在生产环境中使用Redis就像一把双刃剑。它快速、强大,存储了大量实时数据——但当你想要在本地调试问题或使用真实数据进行测试时,事情就变得棘手了。我们要做什么?我们想要从生产环境Redis实例中导出键...
- 使用redis bitmap计算日活跃用户数
-
Metrics(指标)在允许延迟的情况下,通常通过job任务定时执行(如按小时、每天等频率),而基于Redis的Bitmap使我们能够实时完成此类计算,且极其节省空间。以亿级用户计算“日活跃用户...
- 大部分.NET开发者都不知道的Redis性能优化神技!
-
你还在为Redis存储空间不够而发愁吗?还在为Json数据太大导致网络传输缓慢而头疼吗?今天我要告诉你一个让Redis性能飙升300%的秘密武器!这个技巧简单到让你怀疑人生,但效果却强大到让你的老板对...
- Redis学习笔记:内存优化实战指南(第六章)
-
Redis作为内存数据库,内存使用效率直接影响系统性能与成本。对于处理大规模数据的场景,合理的内存优化能显著降低资源消耗,提升服务稳定性。本章将基于Redis的内存管理特性,详解实用的优化技巧与最佳实...
- 大数据-47 Redis 内存控制、Key 过期与数据...
-
点一下关注吧!!!非常感谢!!持续更新!!!AI篇持续更新中!(长期更新)AI炼丹日志-30-新发布【1T万亿】参数量大模型!Kimi-K2开源大模型解读与实践,持续打造实用AI工具指南!...
- Redis学习笔记:内存优化进阶与实战技巧(第六章·续)
-
上一节我们介绍了Redis内存优化的基础策略,本节将深入更多实战技巧,包括数据结构的精细化选择、过期键的内存回收机制,以及大规模场景下的内存管理方案,帮助你在高并发场景下进一步提升内存利用率。七、数据...
- 低配服务器(2核3G)宝塔面板的Redis优化指南:512MB内存高效运行
-
在2核3G内存的低配服务器上部署Redis服务时,资源分配不当极易导致服务器崩溃。本文针对宝塔面板环境(PHP8.2+MariaDB10.6+Nginx),提供经过实战验证的Redis优化...
- Redis:为什么您应该多缓存少查询(为什么使用redis做缓存而不是其他的消息队列入kafka)
-
还在一次又一次地调用相同的API吗?这不仅效率低下——而且成本高昂。性能缓慢、成本更高,用户体验更差。让我们停止这种做法——从这篇文章开始。:D首先您需要了解Redis,简单来说,它是一个超快速的内存...
你 发表评论:
欢迎- 一周热门
- 最近发表
- 标签列表
-
- oracle位图索引 (74)
- oracle批量插入数据 (65)
- oracle事务隔离级别 (59)
- oracle 空为0 (51)
- oracle主从同步 (56)
- oracle 乐观锁 (53)
- redis 命令 (83)
- php redis (97)
- redis 存储 (67)
- redis 锁 (74)
- 启动 redis (73)
- redis 时间 (60)
- redis 删除 (69)
- redis内存 (64)
- redis并发 (53)
- redis 主从 (71)
- redis 订阅 (51)
- redis 登录 (54)
- redis 面试 (58)
- 阿里 redis (59)
- redis 搭建 (53)
- redis的缓存 (55)
- lua redis (58)
- redis 连接池 (61)
- redis 限流 (51)