Redis实战—优惠卷秒杀(锁/事务/代理对象的应用)

2024-06-20 15:20

本文主要是介绍Redis实战—优惠卷秒杀(锁/事务/代理对象的应用),希望对大家解决编程问题提供一定的参考价值,需要的开发者们随着小编来一起学习吧!

本博客为个人学习笔记,学习网站与详细见:黑马程序员Redis入门到实战 P50 - P54 

目录

优惠卷秒杀下单功能实现

超卖问题

悲观锁与乐观锁

实现CAS法乐观锁

一人一单功能实现

代码优化 

代码细节分析 


优惠卷秒杀下单功能实现

Controller层代码实现

@RestController
@RequestMapping("/voucher-order")
public class VoucherOrderController {@Resourceprivate IVoucherOrderService voucherOrderService;@PostMapping("seckill/{id}")public Result seckillVoucher(@PathVariable("id") Long voucherId) {return voucherOrderService.seckillVoucher(voucherId);}
}

Service层代码实现

//接口类
public interface IVoucherOrderService extends IService<VoucherOrder> {Result seckillVoucher(Long voucherId);
}//实现类
@Service
public class VoucherOrderServiceImpl extends ServiceImpl<VoucherOrderMapper, VoucherOrder> implements IVoucherOrderService {@Resourceprivate ISeckillVoucherService seckillVoucherService;@Resourceprivate RedisIdWorker redisIdWorker;@Override@Transactionalpublic Result seckillVoucher(Long voucherId) {// 1.查询优惠券信息SeckillVoucher voucher = seckillVoucherService.getById(voucherId);// 2.根据优惠券信息判断秒杀是否开始if (voucher.getBeginTime().isAfter(LocalDateTime.now())) {// 秒杀尚未开始return Result.fail("秒杀尚未开始!");}// 3.判断秒杀是否已经结束if (voucher.getEndTime().isBefore(LocalDateTime.now())) {// 秒杀已经结束return Result.fail("秒杀已经结束!");}// 4.判断库存是否充足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("库存不足!");}// 6.创建订单VoucherOrder voucherOrder = new VoucherOrder();// 6.1订单idlong orderId = redisIdWorker.nextId("order");voucherOrder.setId(orderId);// 6.2用户idLong userId = UserHolder.getUser().getId();voucherOrder.setUserId(userId);// 6.3代金卷idvoucherOrder.setVoucherId(voucherId);save(voucherOrder);// 7.返回订单idreturn Result.ok(orderId);}
}

超卖问题

假设秒杀优惠卷库存为1,正常情况抢票逻辑如下图所示。 

假设秒杀优惠卷库存为1,高并发情况抢票逻辑如下图所示。 


悲观锁与乐观锁

由于悲观锁采用串行执行线程的方式,因此性能劣于乐观锁。

乐观锁的关键是判断之前查询到的数据是否被修改过,常见的方式有以下两种:

1.版本号法
为每种秒杀优惠卷添加版本号属性,每当该优惠卷的库存被更改时,令版本号也发生变化。因此只需要在查询库存的同时查询并记录版本号,在修改库存时,通过查询当前版本号并判断其是否与先前记录的版本号一致来判断查询到的库存是否已经被其他线程修改过。

2.CAS法(即版本号法的简化,利用库存代替版本号)


实现CAS法乐观锁

首先查询秒杀优惠卷信息并保存到变量voucher,当判断该优惠卷可以被抢购后,在修改该优惠卷库存时,需要满足当前优惠卷库存与voucher.getStock()相等的条件。

尽管添加了乐观锁,但在测试时发现用户抢购优惠卷的成功率过低。原因在于,在高并发的情况下,假设10个用户同时抢购一张优惠卷,那么只有一个用户能够抢购成功,其他用户均抢购失败。而在抢票的业务场景下,当用户同时进行抢购时,只要优惠卷库存大于0即可进行抢购,因此只需修改判断条件,在修改库存时,判断当前库存是否大于0即可。


一人一单功能实现

修改Service层实现类中优惠卷秒杀下单seckillVoucher方法,代码(未优化版本)如下:

@Override
@Transactional
public Result seckillVoucher(Long voucherId) {//1.查询优惠券SeckillVoucher voucher = seckillVoucherService.getById(voucherId);//2.判断秒杀是否开始if (voucher.getBeginTime().isAfter(LocalDateTime.now())) {//尚未开始return Result.fail("秒杀尚未开始");}//3.判断秒杀是否已经结束if (voucher.getEndTime().isBefore(LocalDateTime.now())) {//尚未开始return Result.fail("秒杀已经结束");}//4.判断库存是否充足if (voucher.getStock()<1) {//库存不足return Result.fail("库存不足");}//5.一人一单Long userId = UserHolder.getUser().getId();//5.1查询订单int count = query().eq("user_id", userId).eq("voucher_id", voucherId).count();//5.2判断是否存在if(count>1){//用户已经购买过了return Result.fail("用户已经购买过一次");}//6.扣减库存boolean success=seckillVoucherService.update().setSql("stock = stock - 1") //set stock =stock - 1.eq("voucher_id",voucherId).gt("stock",0)//where id=? and stock > 0.update();if(!success){return Result.fail("库存不足!");}//7.创建订单VoucherOrder voucherOrder = new VoucherOrder();//7.1订单Idlong orderId = redisIdWorker.nextId("order");voucherOrder.setId(orderId);//7.2用户IdvoucherOrder.setUserId(userId);//7.3代金券IdvoucherOrder.setVoucherId(voucherId);save(voucherOrder);//8.返回订单IDreturn Result.ok(orderId);
}

代码优化 

存在的问题:尽管代码经过修改,但在高并发的情况下仍然存在问题,即单个用户在同一刻多次下单,多个线程均查询到count=0(该用户未购买过当前优惠券),此时多个线程同时创建订单,从而导致一人多单问题。

实现:为实现一人一单功能,我们需要加锁,相比超卖问题中每个线程只是对数据进行修改,所以可以使用乐观锁,通过判断数据值是否被修改来判断其他线程是否已经对当前数据进行操作。但一人多单的问题中,每个线程需要进行添加数据而非修改数据的操作,即创建新的订单,所以这种情况下,我们需要使用悲观锁。

优化后的代码如下所示:

@Service
public class VoucherOrderServiceImpl extends ServiceImpl<VoucherOrderMapper, VoucherOrder> implements IVoucherOrderService {@Resourceprivate ISeckillVoucherService seckillVoucherService;@Resourceprivate RedisIdWorker redisIdWorker;@Overridepublic Result seckillVoucher(Long voucherId) {// 1.查询优惠券信息SeckillVoucher voucher = seckillVoucherService.getById(voucherId);// 2.根据优惠券信息判断秒杀是否开始if (voucher.getBeginTime().isAfter(LocalDateTime.now())) {// 秒杀尚未开始return Result.fail("秒杀尚未开始!");}// 3.判断秒杀是否已经结束if (voucher.getEndTime().isBefore(LocalDateTime.now())) {// 秒杀已经结束return Result.fail("秒杀已经结束!");}// 4.判断库存是否充足if (voucher.getStock() < 1) {// 库存不足return Result.fail("库存不足!");}Long userId = UserHolder.getUser().getId();synchronized (userId.toString().intern()) {// 获取当前代理对象IVoucherOrderService proxy = (IVoucherOrderService) AopContext.currentProxy();return proxy.createVoucherOrder(voucherId);}}@Transactionalpublic synchronized Result createVoucherOrder(Long voucherId) {// 5.一人一单模式Long userId = UserHolder.getUser().getId();// 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")  //使用MP,设置sql语句.eq("voucher_id", voucherId).gt("stock", 0).update();if (!success) {// 扣减失败return Result.fail("库存不足!");}// 7.创建订单VoucherOrder voucherOrder = new VoucherOrder();// 7.1获取订单全局idlong orderId = redisIdWorker.nextId("order");voucherOrder.setId(orderId);// 7.2用户idvoucherOrder.setUserId(userId);// 7.3代金卷idvoucherOrder.setVoucherId(voucherId);save(voucherOrder);// 8.返回订单idreturn Result.ok(orderId);}
}

代码细节分析 

有以下几个细节我们需要特别注意

1. 首先,我们将查询、判断、创建订单等过程封装成createVoucherOrder方法,但是我们并不能直接对这个方法加锁,如果对这个方法加锁的话将导致创建订单的业务是串行执行的,在高并发多用户的情况下,性能会很差。

2. 因为是一人一单功能,我们需要做的是对同一个用户创建订单的流程进行加锁,所以我们根据用户ID来加锁。我们为相同的用户ID配置一把锁,从而将锁的范围缩小,因此我们将createVoucherOrder方法进行修改,如下所示。

3. 然而,我们需要确保synchronized()传入的参数对象是相同的,synchronized锁才能起作用。因此我们不传入查询得到的userId对象,因为我们每次查询后都会赋值给新的userId对象,这样会导致传入synchronized()的参数始终不是同一个对象,因此我们采用的是synchronized(userId.toString())。

4.但是,如果我们直接使用userId.toString() 方法,拿到的对象实际上是不同的,toString() 的底层逻辑是new出一个新对象,因此我们需要使用intern()方法,该方法是从常量池中拿到数据,能确保我们每次取得的对象都是相同的。得到的代码如下所示。

@Transactional
//将创建订单封装成一个方法
public  Result createVoucherOrder(Long voucherId) {//获取用户IdLong userId = UserHolder.getUser().getId();synchronized(userId.toString().intern()) {查询、判断、创建订单等流程...}
}//错误情况:synchronized(userId.toString())

5.由于我们是在方法里面加锁,因此代码的逻辑是先释放锁,再提交事务,因此在高并发的环境下可能会出现安全问题(即释放锁后,提交事务还未完成,其他线程又进来了)。所以我们应该先提交事务,再释放锁。因此,我们应该在方法外加锁,代码如下。

@Override
public Result seckillVoucher(Long voucherId) {...Long userId = UserHolder.getUser().getId();synchronized (userId.toString().intern()) {return createVoucherOrder(voucherId);}
}

6.在以上代码中,我们调用createVoucherOrder方法,其实是通过this.的方式调用的,而this代表的是当前VoucherOrderServiceImpl对象,并不是createVoucherOrder的代理对象,因此事务是不生效的。要想让事务生效,我们需要利用代理对象调用createVoucherOrder方法,才能使事务生效,代码如下。

@Override
public Result seckillVoucher(Long voucherId) {...Long userId = UserHolder.getUser().getId();synchronized (userId.toString().intern()) {// 获取当前代理对象IVoucherOrderService proxy = (IVoucherOrderService) AopContext.currentProxy();return proxy.createVoucherOrder(voucherId);}
}

此外,我们还需做以下三步。
1.添加动态代理依赖

<dependency><groupId>org.aspectj</groupId><artifactId>aspectjweaver</artifactId>
</dependency>

2.在接口中添加createVoucherOrder(Long voucherId); 

public interface IVoucherOrderService extends IService<VoucherOrder> {Result seckillVoucher(Long voucherId);Result createVoucherOrder(Long voucherId);
}

3.在启动类中添加注解,暴露该代理对象

@EnableAspectJAutoProxy(exposeProxy = true)
@MapperScan("com.hmdp.mapper")
@SpringBootApplication
public class HmDianPingApplication {public static void main(String[] args) {SpringApplication.run(HmDianPingApplication.class, args);}}

这篇关于Redis实战—优惠卷秒杀(锁/事务/代理对象的应用)的文章就介绍到这儿,希望我们推荐的文章对编程师们有所帮助!



http://www.chinasem.cn/article/1078505

相关文章

网页解析 lxml 库--实战

lxml库使用流程 lxml 是 Python 的第三方解析库,完全使用 Python 语言编写,它对 XPath表达式提供了良好的支 持,因此能够了高效地解析 HTML/XML 文档。本节讲解如何通过 lxml 库解析 HTML 文档。 pip install lxml lxm| 库提供了一个 etree 模块,该模块专门用来解析 HTML/XML 文档,下面来介绍一下 lxml 库

中文分词jieba库的使用与实景应用(一)

知识星球:https://articles.zsxq.com/id_fxvgc803qmr2.html 目录 一.定义: 精确模式(默认模式): 全模式: 搜索引擎模式: paddle 模式(基于深度学习的分词模式): 二 自定义词典 三.文本解析   调整词出现的频率 四. 关键词提取 A. 基于TF-IDF算法的关键词提取 B. 基于TextRank算法的关键词提取

水位雨量在线监测系统概述及应用介绍

在当今社会,随着科技的飞速发展,各种智能监测系统已成为保障公共安全、促进资源管理和环境保护的重要工具。其中,水位雨量在线监测系统作为自然灾害预警、水资源管理及水利工程运行的关键技术,其重要性不言而喻。 一、水位雨量在线监测系统的基本原理 水位雨量在线监测系统主要由数据采集单元、数据传输网络、数据处理中心及用户终端四大部分构成,形成了一个完整的闭环系统。 数据采集单元:这是系统的“眼睛”,

性能分析之MySQL索引实战案例

文章目录 一、前言二、准备三、MySQL索引优化四、MySQL 索引知识回顾五、总结 一、前言 在上一讲性能工具之 JProfiler 简单登录案例分析实战中已经发现SQL没有建立索引问题,本文将一起从代码层去分析为什么没有建立索引? 开源ERP项目地址:https://gitee.com/jishenghua/JSH_ERP 二、准备 打开IDEA找到登录请求资源路径位置

高效+灵活,万博智云全球发布AWS无代理跨云容灾方案!

摘要 近日,万博智云推出了基于AWS的无代理跨云容灾解决方案,并与拉丁美洲,中东,亚洲的合作伙伴面向全球开展了联合发布。这一方案以AWS应用环境为基础,将HyperBDR平台的高效、灵活和成本效益优势与无代理功能相结合,为全球企业带来实现了更便捷、经济的数据保护。 一、全球联合发布 9月2日,万博智云CEO Michael Wong在线上平台发布AWS无代理跨云容灾解决方案的阐述视频,介绍了

csu 1446 Problem J Modified LCS (扩展欧几里得算法的简单应用)

这是一道扩展欧几里得算法的简单应用题,这题是在湖南多校训练赛中队友ac的一道题,在比赛之后请教了队友,然后自己把它a掉 这也是自己独自做扩展欧几里得算法的题目 题意:把题意转变下就变成了:求d1*x - d2*y = f2 - f1的解,很明显用exgcd来解 下面介绍一下exgcd的一些知识点:求ax + by = c的解 一、首先求ax + by = gcd(a,b)的解 这个

hdu1394(线段树点更新的应用)

题意:求一个序列经过一定的操作得到的序列的最小逆序数 这题会用到逆序数的一个性质,在0到n-1这些数字组成的乱序排列,将第一个数字A移到最后一位,得到的逆序数为res-a+(n-a-1) 知道上面的知识点后,可以用暴力来解 代码如下: #include<iostream>#include<algorithm>#include<cstring>#include<stack>#in

zoj3820(树的直径的应用)

题意:在一颗树上找两个点,使得所有点到选择与其更近的一个点的距离的最大值最小。 思路:如果是选择一个点的话,那么点就是直径的中点。现在考虑两个点的情况,先求树的直径,再把直径最中间的边去掉,再求剩下的两个子树中直径的中点。 代码如下: #include <stdio.h>#include <string.h>#include <algorithm>#include <map>#

在JS中的设计模式的单例模式、策略模式、代理模式、原型模式浅讲

1. 单例模式(Singleton Pattern) 确保一个类只有一个实例,并提供一个全局访问点。 示例代码: class Singleton {constructor() {if (Singleton.instance) {return Singleton.instance;}Singleton.instance = this;this.data = [];}addData(value)

C#实战|大乐透选号器[6]:实现实时显示已选择的红蓝球数量

哈喽,你好啊,我是雷工。 关于大乐透选号器在前面已经记录了5篇笔记,这是第6篇; 接下来实现实时显示当前选中红球数量,蓝球数量; 以下为练习笔记。 01 效果演示 当选择和取消选择红球或蓝球时,在对应的位置显示实时已选择的红球、蓝球的数量; 02 标签名称 分别设置Label标签名称为:lblRedCount、lblBlueCount