微服务 Spring Boot 整合Redis 实现优惠卷秒杀 一人一单
mhr18 2024-11-08 12:14 17 浏览 0 评论
文章目录
- :partly_sunny:全局唯一ID
- :zap:Redis实现全局唯一ID
- :hourglass_flowing_sand:问题分析
- :watch: 乐观锁解决库存超卖:
- :white_check_mark:Jmeter 测试
- 五、优惠卷秒杀 实现一人一单
一、什么是全局唯一ID
:partly_sunny:全局唯一ID
在分布式系统中,经常需要使用 全局唯一ID 查找对应的数据。产生这种ID需要保证系统全局唯一,而且要高性能以及占用相对较少的空间。
全局唯一ID在数据库中一般会被设成 主播 ,这样为了保证数据插入时索引的快速建立,还需要保持一个有序的趋势。
这样全局唯一ID就需要保证这两个需求:
- 全局唯一
- 趋势有序
我们的场景是 优惠卷秒杀抢购, 当用户抢购时,就会生成订单 并保存到 数据库 的订单表中,而订单表 如果使用数据库自增ID就会存在以下问题
- id的规律性太明显
- 受单表数据量限制
场景分析: 如果我们的id具有太明显的规则, 用户或者说商业对手很容易猜测出来我们的一些敏感信息 ,比如 商城在一天时间内,卖出了多少单 ,这明显不合适。
场景分析二:随着我们商城规模越来越大, MySQL 的单表的容量不宜超过500W ,数据量过大之后,我们要 进行拆库拆表 ,但拆分表了之后,他们从 逻辑上讲他们是同一张表,所以他们的id是不能一样的 , 于是乎我们需要保证id的唯一性。
全局ID生成器,是一种在分布式系统下用来生成全局唯一ID的工具,一般要满足下列特性:
为了 增加ID的安全性,我们可以不直接使用Redis自增的数值 ,而是拼接一些其它信息:
ID的组合为
- 符号位: 1bit,永远为0
- 时间戳: 31bit,以秒为单位可以使用69年
- 序列号: 32bit,秒内的计数器,支持每秒产生 2^32 个 不同ID
:zap:Redis实现全局唯一ID
编写工具类
@Component
public class RedisIdWorker {
/**
* 开始时间戳
*/
private static final long BEGIN_TIMESTAMP = 1640995200L;
/**
* 序列号的位数
*/
private static final int COUNT_BITS = 32;
private StringRedisTemplate stringRedisTemplate;
public RedisIdWorker(StringRedisTemplate stringRedisTemplate) {
this.stringRedisTemplate = stringRedisTemplate;
}
public long nextId(String keyPrefix) {
// 1.生成时间戳
LocalDateTime now = LocalDateTime.now();
long nowSecond = now.toEpochSecond(ZoneOffset.UTC);
long timestamp = nowSecond - BEGIN_TIMESTAMP;
// 2.生成序列号
// 2.1.获取当前日期,精确到天
String date = now.format(DateTimeFormatter.ofPattern("yyyy:MM:dd"));
// 2.2.自增长
long count = stringRedisTemplate.opsForValue().increment("icr:" + keyPrefix + ":" + date);
// 3.拼接并返回
return timestamp << COUNT_BITS | count;
}
}
测试存入Redis
@Autowired
private RedisIdWorker redisIdWorker;
private ExecutorService es = Executors.newFixedThreadPool(500);
@Test
public void testWorkerId() throws InterruptedException {
CountDownLatch latch = new CountDownLatch(300);
Runnable task = () -> {
for (int i = 0; i < 100; i++) {
long id = redisIdWorker.nextId("order");
System.out.println("id = " + id);
}
latch.countDown();
};
long begin = System.currentTimeMillis();
for (int i = 0; i < 300; i++) {
es.submit(task);
}
latch.await();
long end = System.currentTimeMillis();
System.out.println("times = " + (end- begin));
}
这里用到了 CountDownlatch,简单的介绍一下:
CountDownLatch名为信号枪:主要的作用是 同步协调在多线程的等待与唤醒问题
我们如果没有CountDownLatch ,那么由于程序是异步的,当异步程序没有执行完时,主线程就已经执行完了,然后我们期望的是分线程全部走完之后,主线程再走,所以我们此时需要使用到CountDownLatch
CountDownLatch 中有两个最重要的方法
- countDown
- await
await 是阻塞方法, 我们担心线程没有执行完时,main线程就执行,所以可以 使用await就阻塞主线程 , 那么什么时候main线程不再阻塞呢? 当 CountDownLatch 内部维护的变量为0时,就不再阻塞,直接放行 。
什么时候 CountDownLatch 维护的变量变为0 呢,我们只需要调用一次countDown ,内部变量就减少1 ,我们让分线程和变量绑定, 执行完一个分线程就减少一个变量,当分线程全部走完,CountDownLatch 维护的变量就是0,此时await就不再阻塞,统计出来的时间也就是所有分线程执行完后的时间。
二、环境准备
需要搭建登录环境,基础环境代码和sql文件均已上传 GitCode 链接:基础环境和SQL
三、实现秒杀下单
添加优惠卷
VoucherServiceImpl 核心代码
@Service
public class VoucherServiceImpl extends ServiceImpl<VoucherMapper, Voucher> implements IVoucherService {
// 该类无代码,直接MyBatis-Plus继承实现类 即可,自动完成持久化
@Autowired
private ISeckillVoucherService seckillVoucherService;
@Override
public ResultBean<List<Voucher>> queryVoucherOfShop(Long shopId) {
// 查询优惠券信息
List<Voucher> vouchers = getBaseMapper().queryVoucherOfShop(shopId);
// 返回结果
return ResultBean.create(0, "success", vouchers);
}
@Override
public void addSeckillVoucher(Voucher voucher) {
// 保存优惠券
save(voucher);
// 保存秒杀信息
SeckillVoucher seckillVoucher = new SeckillVoucher();
seckillVoucher.setVoucherId(voucher.getId());
seckillVoucher.setStock(voucher.getStock());
seckillVoucher.setBeginTime(voucher.getBeginTime());
seckillVoucher.setEndTime(voucher.getEndTime());
seckillVoucherService.save(seckillVoucher);
}
}
VoucherController 接口层
@RestController
@CrossOrigin
@RequestMapping("/voucher")
public class VoucherController {
@Autowired
private IVoucherService voucherService;
/**
* 新增秒杀券
* @param voucher 优惠券信息,包含秒杀信息
* @return 优惠券id
*/
@PostMapping("seckill")
public ResultBean addSeckillVoucher(@RequestBody Voucher voucher) {
voucherService.addSeckillVoucher(voucher);
return Result.ok(voucher.getId());
}
}
编写下单业务
VoucherOrderServiceImpl 优惠卷订单核心业务类
@Service
public class VoucherOrderServiceImpl extends ServiceImpl<VoucherOrderMapper, VoucherOrder> implements IVoucherOrderService {
@Autowired
private ISeckillVoucherService seckillVoucherService;
@Autowired
private RedisIdWorker redisIdWorker;
@Override
@Transactional
public Result seckillVoucher(Long voucherId) {
//1. 查询优惠卷
SeckillVoucher seckillVoucher = seckillVoucherService.getById(voucherId);
//2. 判断秒杀是否开始 开始时间大于当前时间表示未开始抢购
if (seckillVoucher.getBeginTime().isAfter(LocalDateTime.now())) {
return Result.fail("秒杀尚未开始!");
}
//3. 判断秒杀是否结束
if (seckillVoucher.getEndTime().isBefore(LocalDateTime.now())) {
return Result.fail("秒杀已经结束!");
}
//4. 判断库存是否充足
if (seckillVoucher.getStock() < 1) {
return Result.fail("库存不足!");
}
Long userId = UserHolder.getUser().getId();
//5. 查询订单
//5.1 查询订单
int count = query().eq("user_id", userId).eq("voucher_id", voucherId).count();
//5.2 判断并返回
if (count > 0) {
return Result.fail("用户已经购买过!");
}
//6. 扣减库存
boolean success = seckillVoucherService.update().setSql("stock = stock -1")
.eq("voucher_id", voucherId).update();
if (!success) {
return Result.fail("库存不足!");
}
//7. 创建订单
VoucherOrder voucherOrder = new VoucherOrder();
long orderId = redisIdWorker.nextId("order");
voucherOrder.setId(orderId);
voucherOrder.setUserId(userId);
voucherOrder.setVoucherId(voucherId);
save(voucherOrder);
//8. 返回订单id
return Result.ok(orderId);
}
}
VoucherOrderController 接口层
@RestController
@CrossOrigin
@RequestMapping("/voucher_order")
public class VoucherOrderController {
@Autowired
private IVoucherOrderService voucherOrderService;
@PostMapping("seckill/{id}")
public Result seckillVoucher(@PathVariable("id") Long voucherId) {
return voucherOrderService.seckillVoucher(voucherId);
}
}
测试抢购秒杀优惠卷
ApiFox 新增以下接口
添加秒杀卷
测试返回成功即可。
抢购秒杀优惠卷接口
测试无误,抢购成功!
四、库存超卖问题
:hourglass_flowing_sand:问题分析
有关超卖问题分析:在我们原有代码中是这么写的
if (voucher.getStock() < 1) {
// 库存不足
return Result.fail("库存不足!");
}
//5,扣减库存
boolean success = seckillVoucherService.update()
.setSql("stock= stock -1")
.eq("voucher_id", voucherId).update();
if (!success) {
//扣减库存
return Result.fail("库存不足!");
}
假设线程1过来查询库存,判断出来库存大于1,正准备去扣减库存,但是还没有来得及去扣减,此时线程2过来,线程2也去查询库存,发现这个数量一定也大于1,那么这两个线程都会去扣减库存,最终多个线程相当于一起去扣减库存,此时就会出现库存的超卖问题。
超卖问题是典型的多线程安全问题,这种情况下 常见的解决方案就是 加 锁 :而对于加锁,我们 通常有两种解决方案 :
悲观锁:
悲观锁 可以实现对于数据的串行化执行 ,比如syn,和lock都是悲观锁的代表,同时, 悲观锁中又可以再细分为公平锁,非公平锁,可重入锁,等等
乐观锁:
会有一个版本号,每次操作数据会对版本号+1,再提交回数据时,会去校验是否比之前的版本大1 ,如果大1 ,则进行操作成功,这套机制的核心逻辑在于,**如果在操作过程中,版本号只比原来大1 ,那么就意味着操作过程中没有人对他进行过修改,他的操作就是安全的,**如果不大1,则数据被修改过,当然乐观锁还有一些变种的处理方式比如cas
乐观锁的典型代表:就是 CAS ,利用CAS 进行无锁化机制加锁 ,varNum是操作前读取的内存值,while中的var1+var2 是预估值,如果预估值 == 内存值,则代表中间没有被人修改过,此时就将新值去替换 内存值
其中do while 是为了在操作失败时,再次进行自旋操作,即把之前的逻辑再操作一次。
int varNum;
do {
varNum = this.getIntVolatile(var1, var2);
} while(!this.compareAndSwapInt(var1, var2, var5, var5 + var4));
return var5;
我们采用的方式为:
在操作时, 对版本号进行+1 操作,然后要求version 如果是1 的情况下,才能操作 ,那么第一个线程在操作后,数据库中的version变成了2,但是他自己满足version=1 ,所以没有问题,此时线程2执行, 线程2 最后也需要加上条件version =1 ,但是现在由于线程1已经操作过了,所以线程2,操作时就不满足version=1 的条件了,所以线程2无法执行成功
:watch: 乐观锁解决库存超卖
加入以下代码解决超卖问题
之前的方式要修改前后都保持一致,但是这样我们分析过,成功的概率太低,所以我们的乐观锁需要变一下,改成stock大于0 即可
boolean success = seckillVoucherService.update()
.setSql("stock= stock -1")
.eq("voucher_id", voucherId).update().gt("stock",0); //where id = ? and stock > 0
知识拓展
针对CAS中的自旋压力过大,我们可以使用Longaddr这个类去解决
Java8 提供的一个对AtomicLong改进后的一个类,LongAdder
大量线程并发更新一个原子性的时候,天然的问题就是自旋,会导致并发性问题,当然这也比我们直接使用syn来的好
所以利用这么一个类,LongAdder来进行优化
如果获取某个值, 则会对cell和base的值进行递增,最后返回一个完整的值
以上的解决方式,依然有些问题,下面使用Jmeter进行测试
:white_check_mark:Jmeter 测试
添加线程组
添加JSON断言,我们认为返回结果为false的就是请求失败
在线程组右击选择断言 --> JSON 断言
加入以下判断
判断success字段,值是否为true,是true就是返回成功~反之失败
查看结果树、HTTP信息请求头、汇总报告、聚合报告等均在http请求右击添加即可
启动,查看返回的结果
查看聚合报告
异常率这么高,再来看数据库
数量正确,我们再看订单表
id都一样,这可不行啊,我们 真实场景下,发放优惠卷不会让一个用户去抢购所有的订单秒杀优惠卷,这样商家就太亏了 ,全让黄牛给抢走了,这可不行,我们 需要限制用户的抢购数量。
五、优惠卷秒杀 实现一人一单
初步实现
int count = query().eq("user_id", userId).eq("voucher_id", voucherId).count();
if (count > 0) {
return Result.fail("用户已经购买过!");
}
存在问题:现在的问题还是和之前一样,并发过来,查询数据库,都不存在订单,所以我们还是需要加锁,但是乐观锁比较适合更新数据,而现在是插入数据,所以我们需要使用悲观锁操作
注意:在这里提到了非常多的问题,我们需要慢慢的来思考,首先我们的初始方案是封装了一个createVoucherOrder方法,同时为了确保他线程安全,在方法上添加了一把synchronized 锁
加上悲观锁
@Override
public Result seckillVoucher(Long voucherId) {
//1. 查询优惠卷
SeckillVoucher seckillVoucher = seckillVoucherService.getById(voucherId);
//2. 判断秒杀是否开始 开始时间大于当前时间表示未开始抢购
if (seckillVoucher.getBeginTime().isAfter(LocalDateTime.now())) {
return Result.fail("秒杀尚未开始!");
}
//3. 判断秒杀是否结束
if (seckillVoucher.getEndTime().isBefore(LocalDateTime.now())) {
return Result.fail("秒杀已经结束!");
}
//4. 判断库存是否充足
if (seckillVoucher.getStock() < 1) {
return Result.fail("库存不足!");
}
Long userId = UserHolder.getUser().getId();
synchronized (userId.toString().intern()) {
IVoucherOrderService proxy = (IVoucherOrderService) AopContext.currentProxy();
return proxy.createVoucherOrder(voucherId, userId);
}
}
@Transactional
@Override
public Result createVoucherOrder(Long voucherId, Long userId) {
//5. 查询订单
//5.1 查询订单
int count = query().eq("user_id", userId).eq("voucher_id", voucherId).count();
//5.2 判断并返回
if (count > 0) {
return Result.fail("用户已经购买过!");
}
//6. 扣减库存
boolean success = seckillVoucherService.update().setSql("stock = stock -1")
.eq("voucher_id", voucherId).gt("stock", 0).
update();
if (!success) {
return Result.fail("库存不足!");
}
//7. 创建订单
VoucherOrder voucherOrder = new VoucherOrder();
long orderId = redisIdWorker.nextId("order");
voucherOrder.setId(orderId);
voucherOrder.setUserId(userId);
voucherOrder.setVoucherId(voucherId);
save(voucherOrder);
//8. 返回订单id
return Result.ok(orderId);
}
在启动类加入以下注解,启动AspectJ
@EnableAspectJAutoProxy(exposeProxy = true)
以上代码,采用悲观锁解决了高并发下,一人多单的场景,同时,也解决了事务失效。引入了AspectJ解决!
Jmeter 测试
再次测试,查看结果
可见返回的结果异常率如此高,再看请求信息
可见已经成功的拦截了错误请求,JSON断言正确。
查看数据库 信息
优惠卷数量
可见成功的完成了 在高并发请求下 的一人一单功能。
:boat:小结
以上就是【 Bug 终结者 】对 微服务Spring Boot 整合Redis 实现优惠卷秒杀 一人一单 的简单介绍, 在分布式系统下,高并发的场景下,会出现此类库存超卖问题,本篇文章介绍了采用乐观锁来解决,但是依然是有弊端,下章节,我们将继续进行优化,持续关注!
相关推荐
- 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来...
你 发表评论:
欢迎- 一周热门
- 最近发表
- 标签列表
-
- oracle位图索引 (63)
- oracle批量插入数据 (62)
- oracle事务隔离级别 (53)
- oracle 空为0 (50)
- oracle主从同步 (55)
- oracle 乐观锁 (51)
- redis 命令 (78)
- php redis (88)
- redis 存储 (66)
- redis 锁 (69)
- 启动 redis (66)
- redis 时间 (56)
- redis 删除 (67)
- redis内存 (57)
- redis并发 (52)
- redis 主从 (69)
- redis 订阅 (51)
- redis 登录 (54)
- redis 面试 (58)
- 阿里 redis (59)
- redis 搭建 (53)
- redis的缓存 (55)
- lua redis (58)
- redis 连接池 (61)
- redis 限流 (51)