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

相关文章

MyBatis分页查询实战案例完整流程

《MyBatis分页查询实战案例完整流程》MyBatis是一个强大的Java持久层框架,支持自定义SQL和高级映射,本案例以员工工资信息管理为例,详细讲解如何在IDEA中使用MyBatis结合Page... 目录1. MyBATis框架简介2. 分页查询原理与应用场景2.1 分页查询的基本原理2.1.1 分

Redis 的 SUBSCRIBE命令详解

《Redis的SUBSCRIBE命令详解》Redis的SUBSCRIBE命令用于订阅一个或多个频道,以便接收发送到这些频道的消息,本文给大家介绍Redis的SUBSCRIBE命令,感兴趣的朋友跟随... 目录基本语法工作原理示例消息格式相关命令python 示例Redis 的 SUBSCRIBE 命令用于订

使用Python批量将.ncm格式的音频文件转换为.mp3格式的实战详解

《使用Python批量将.ncm格式的音频文件转换为.mp3格式的实战详解》本文详细介绍了如何使用Python通过ncmdump工具批量将.ncm音频转换为.mp3的步骤,包括安装、配置ffmpeg环... 目录1. 前言2. 安装 ncmdump3. 实现 .ncm 转 .mp34. 执行过程5. 执行结

PHP应用中处理限流和API节流的最佳实践

《PHP应用中处理限流和API节流的最佳实践》限流和API节流对于确保Web应用程序的可靠性、安全性和可扩展性至关重要,本文将详细介绍PHP应用中处理限流和API节流的最佳实践,下面就来和小编一起学习... 目录限流的重要性在 php 中实施限流的最佳实践使用集中式存储进行状态管理(如 Redis)采用滑动

SpringBoot 多环境开发实战(从配置、管理与控制)

《SpringBoot多环境开发实战(从配置、管理与控制)》本文详解SpringBoot多环境配置,涵盖单文件YAML、多文件模式、MavenProfile分组及激活策略,通过优先级控制灵活切换环境... 目录一、多环境开发基础(单文件 YAML 版)(一)配置原理与优势(二)实操示例二、多环境开发多文件版

Spring 中的切面与事务结合使用完整示例

《Spring中的切面与事务结合使用完整示例》本文给大家介绍Spring中的切面与事务结合使用完整示例,本文通过实例代码给大家介绍的非常详细,对大家的学习或工作具有一定的参考借鉴价值,需要的朋友参考... 目录 一、前置知识:Spring AOP 与 事务的关系 事务本质上就是一个“切面”二、核心组件三、完

Three.js构建一个 3D 商品展示空间完整实战项目

《Three.js构建一个3D商品展示空间完整实战项目》Three.js是一个强大的JavaScript库,专用于在Web浏览器中创建3D图形,:本文主要介绍Three.js构建一个3D商品展... 目录引言项目核心技术1. 项目架构与资源组织2. 多模型切换、交互热点绑定3. 移动端适配与帧率优化4. 可

sky-take-out项目中Redis的使用示例详解

《sky-take-out项目中Redis的使用示例详解》SpringCache是Spring的缓存抽象层,通过注解简化缓存管理,支持Redis等提供者,适用于方法结果缓存、更新和删除操作,但无法实现... 目录Spring Cache主要特性核心注解1.@Cacheable2.@CachePut3.@Ca

深入浅出Spring中的@Autowired自动注入的工作原理及实践应用

《深入浅出Spring中的@Autowired自动注入的工作原理及实践应用》在Spring框架的学习旅程中,@Autowired无疑是一个高频出现却又让初学者头疼的注解,它看似简单,却蕴含着Sprin... 目录深入浅出Spring中的@Autowired:自动注入的奥秘什么是依赖注入?@Autowired

Redis实现高效内存管理的示例代码

《Redis实现高效内存管理的示例代码》Redis内存管理是其核心功能之一,为了高效地利用内存,Redis采用了多种技术和策略,如优化的数据结构、内存分配策略、内存回收、数据压缩等,下面就来详细的介绍... 目录1. 内存分配策略jemalloc 的使用2. 数据压缩和编码ziplist示例代码3. 优化的