莫张周刘王 发表于 2023-4-25 23:31:21

day06-优惠券秒杀02

功能03-优惠券秒杀02

4.功能03-优惠券秒杀

4.4一人一单

4.4.1需求分析

要求:修改秒杀业务,要求同一个优惠券,一个用户只能下一单。
在之前的做法中,加入一个对用户id和优惠券id的判断,如果在优惠券下单表中已经存在,则表示该用户对于这张优惠券已经下过单了,不允许重复购买
https://img2023.cnblogs.com/blog/2192446/202304/2192446-20230425211932873-1597030055.png4.4.2代码实现

(1)修改VoucherOrderServiceImpl的seckillVoucher方法,在扣减库存之前,加入如下逻辑:
//一人一单
Long userId = UserHolder.getUser().getId();
//查询订单
int count = query().eq("user_id", userId).eq("voucher_id", voucherId).count();
if (count > 0) {//说明已经该用户已经对该优惠券下过单了
    return Result.fail("用户已经购买过一次!");
}(2)使用jemeter进行测试:由同一个用户发起200个并发线程,进行下单请求
https://img2023.cnblogs.com/blog/2192446/202304/2192446-20230425211937061-1461833502.pnghttps://img2023.cnblogs.com/blog/2192446/202304/2192446-20230425211940480-70237857.png测试结果:查看数据库发现,秒杀券原本有100张,现在只剩下94张,也就是说一个用户抢购了多张同样的券
https://img2023.cnblogs.com/blog/2192446/202304/2192446-20230425211943663-81116488.png(3)原因分析:
因为是多线程并发操作,假设当前数据库中没有某个用户的对应券的订单,这时,有100个线程来执行(1)代码的逻辑,大家都来查询订单,都发现该用户没有下过订单,因此都进行之后的下单操作,于是一个用户就连续插入了多条订单记录。根本原因还是线程并发的安全问题。
(4)解决方案:使用悲观锁。
修改VoucherOrderServiceImpl:
我们将查询用户是否购买过某个优惠券的功能,以及扣减库存、下单功能抽取到一个方法createVoucherOrder()中,在seckillVoucher方法中,通过synchronized锁定对象(用户id),这样同一个用户发起多个线程时,多个线程同时只能有一个线程进入到createVoucherOrder()中(不同用户的不同线程不受影响),然后去判断是否符合业务,从而实现一人一单的问题。
package com.hmdp.service.impl;

import com.hmdp.dto.Result;
import com.hmdp.entity.SeckillVoucher;
import com.hmdp.entity.VoucherOrder;
import com.hmdp.mapper.VoucherOrderMapper;
import com.hmdp.service.ISeckillVoucherService;
import com.hmdp.service.IVoucherOrderService;
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import com.hmdp.utils.RedisIdWorker;
import com.hmdp.utils.UserHolder;
import org.springframework.aop.framework.AopContext;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

import javax.annotation.Resource;
import java.time.LocalDateTime;

/**
* 服务实现类
*
* @author 李
* @version 1.0
*/
@Service
public class VoucherOrderServiceImpl extends ServiceImpl<VoucherOrderMapper, VoucherOrder> implements IVoucherOrderService {
    @Resource
    private ISeckillVoucherService seckillVoucherService;
    @Resource
    private RedisIdWorker redisIdWorker;

    @Override
    public Result seckillVoucher(Long voucherId) {
      //根据id查询优惠券信息
      SeckillVoucher voucher = seckillVoucherService.getById(voucherId);
      if (voucher == null) {
            return Result.fail("该优惠券不存在,请刷新!");
      }
      //判断秒杀券是否在有效时间内
      //若不在有效期,则返回异常结果
      if (voucher.getBeginTime().isAfter(LocalDateTime.now())) {
            return Result.fail("秒杀尚未开始!");
      }
      if (voucher.getEndTime().isBefore(LocalDateTime.now())) {
            return Result.fail("秒杀已经结束!");
      }
      //若在有效期,判断库存是否充足
      if (voucher.getStock() < 1) {//库存不足
            return Result.fail("秒杀券库存不足!");
      }

      Long userId = UserHolder.getUser().getId();

      //即使是同一个userId,在不同线程中调用toString得到的是不同的字符串对象,synchronized无法锁定
      //因此这里还要使用intern()方法:
      //调用intern()时,如果常量池中已经包含一个等于这个String对象(由equals(Object)方法确定)的字符串,
      //则返回池中的字符串。否则将此String对象添加到常量池中并返回该String对象的引用
      
      //先获取锁,然后提交createVoucherOrder()的事务,再释放锁,才能确保线程是安全的
      synchronized (userId.toString().intern()) {
            //spring声明式事务的原理,通过aop的动态代理实现,获取到这个动态代理,让动态代理去调用方法
            IVoucherOrderService proxy =(IVoucherOrderService) AopContext.currentProxy();
            return proxy.createVoucherOrder(voucherId);
      }
    }

    @Transactional
    public Result createVoucherOrder(Long voucherId) {
      //一人一单
      Long userId = UserHolder.getUser().getId();
      //查询订单
      int count = query().eq("user_id", userId).eq("voucher_id", voucherId).count();
      if (count > 0) {//说明已经该用户已经对该优惠券下过单了
            return Result.fail("用户已经购买过一次!");
      }
      //库存充足,则扣减库存(操作秒杀券表)
      boolean success = seckillVoucherService.update()
                .setSql("stock = stock -1")//set stock = stock -1
                //where voucher_id =? and stock>0
                .gt("stock", 0).eq("voucher_id", voucherId).update();
      if (!success) {//操作失败
            return Result.fail("秒杀券库存不足!");
      }
      //扣减库存成功,则创建订单,返回订单id
      VoucherOrder voucherOrder = new VoucherOrder();
      //设置订单id
      long orderId = redisIdWorker.nextId("order");
      voucherOrder.setId(orderId);
      //设置用户id
      //Long userId = UserHolder.getUser().getId();
      voucherOrder.setUserId(userId);
      //设置代金券id
      voucherOrder.setVoucherId(voucherId);
      //将订单写入数据库(操作优惠券订单表)
      save(voucherOrder);

      return Result.ok(orderId);
    }
}(5)引入依赖
<dependency>
    <groupId>org.aspectj</groupId>
    <artifactId>aspectjweaver</artifactId>
</dependency>(6)主程序中添加注解@EnableAspectJAutoProxy:
https://img2023.cnblogs.com/blog/2192446/202304/2192446-20230425211950044-1063718120.png(7)IVoucherOrderService中添加方法声明:
https://img2023.cnblogs.com/blog/2192446/202304/2192446-20230425211953258-1293221959.png(8)重新进行(2)的测试。可以看到,同一个用户对一种优惠券同时发起200个线程请求下单,结果是:成功下单,且只能下单一次
https://img2023.cnblogs.com/blog/2192446/202304/2192446-20230425211956360-1340124259.png4.5分布式锁

4.5.1问题提出(集群模式下的线程并发问题)

通过加锁,可以解决在单机情况下的一人一单安全问题,但是在集群模式下就不行了:
(1)我们将服务启动两份,端口分别为8081,8082:
View--Tool Windows--Services
https://img2023.cnblogs.com/blog/2192446/202304/2192446-20230425212000529-2118675037.png点击add service,选择Run Configuration Type,选择SpringBoot
https://img2023.cnblogs.com/blog/2192446/202304/2192446-20230425212009102-224259953.png https://img2023.cnblogs.com/blog/2192446/202304/2192446-20230425212011976-268293653.png
按如下步骤配置,然后点击apply
https://img2023.cnblogs.com/blog/2192446/202304/2192446-20230425212015425-1047655612.png点击启动新的项目,形成一个集群:
https://img2023.cnblogs.com/blog/2192446/202304/2192446-20230425212022821-688805151.png(2)然后修改nginx的conf目录下的nginx.conf文件,配置反向代理和负载均衡:
https://img2023.cnblogs.com/blog/2192446/202304/2192446-20230425212026105-1929981358.png命令行重新加载nginx配置:nginx.exe -s reload
https://img2023.cnblogs.com/blog/2192446/202304/2192446-20230425212029315-698450809.png(3)测试集群情况下,4.4实现的“一人一单”功能是否生效:
在VoucherOrderServiceImpl如下位置打上断点:
https://img2023.cnblogs.com/blog/2192446/202304/2192446-20230425212036446-1147801616.png以debug方式启动两个服务端:
https://img2023.cnblogs.com/blog/2192446/202304/2192446-20230425212043072-1567234001.png我们用一个用户发起两次请求:
https://img2023.cnblogs.com/blog/2192446/202304/2192446-20230425212045823-1398644723.pnghttps://img2023.cnblogs.com/blog/2192446/202304/2192446-20230425212048754-1091168147.png测试结果如下:同一个用户的两个线程同时进入了对象锁中,对象锁失效了!
https://img2023.cnblogs.com/blog/2192446/202304/2192446-20230425212051929-477838637.pnghttps://img2023.cnblogs.com/blog/2192446/202304/2192446-20230425212055511-313768922.pnghttps://img2023.cnblogs.com/blog/2192446/202304/2192446-20230425212058958-1339006957.png原来的数据:
https://img2023.cnblogs.com/blog/2192446/202304/2192446-20230425212102497-2029246127.png现在:
https://img2023.cnblogs.com/blog/2192446/202304/2192446-20230425212105353-1576647116.png说明在集群模式下出现了线程并发的安全问题。
4.5.2原因分析

在单机服务器的情况下:
利用互斥锁解决了一人一单问题,确保了串行执行
https://img2023.cnblogs.com/blog/2192446/202304/2192446-20230425212108034-1335649032.png在集群服务器的情况下:
https://img2023.cnblogs.com/blog/2192446/202304/2192446-20230425212111423-596806330.png如上图,在JVM1中,synchronized修饰的是对象(UserId),synchronized依赖于monitor对象—监视器锁来实现锁机制。由于userId相同,锁的监视器对象相同,因此当线程1来获取锁的时候,锁监视器会记录获取锁的对象。当线程2再来获取锁的时候,此时锁监视器发现不是记录的线程,于是线程2获取互斥锁失败。
但是当我们做集群部署的时候,一个节点意味着一个新的tomcat,同时也意味着一个新的JVM。不同的JVM拥有各自的堆、栈、方法区。
JVM2中,synchronized修饰的是也是对象(UserId),它的锁监视器和JVM1的不是同一个对象,当线程3来获取锁的时候,JVM2的锁监视器是空的,线程3可以获取互斥锁。
综上,锁监视器在JVM的内部可以监视到线程,实现互斥。但是,如果有多个JVM,就会有多个锁监视器,那么每一个JVM内部都会有一个线程获取互斥锁成功。这意味着在集群的情况下,可能出现线程的并发安全问题。
锁底层原理
要解决上述问题,我们需要想办法,让多个JVM只能使用同一把锁。
4.5.3解决方案

经过上述分析,我们已经知道在集群模式下,synchronized的锁失效了,要想解决这个问题,需要使用分布式锁。
分布式锁:满足分布式系统或集群模式下多进程可见并且互斥的锁。
https://img2023.cnblogs.com/blog/2192446/202304/2192446-20230425212116008-1818971400.pnghttps://img2023.cnblogs.com/blog/2192446/202304/2192446-20230425212119342-845466207.png不同的分布式锁的实现方案:
分布式锁的核心是实现多线程之间互斥,满足这一点的方式有很多,常见的有三种:
https://img2023.cnblogs.com/blog/2192446/202304/2192446-20230425212122837-106562971.png这里利用redis来实现分布式锁。
4.5.4实现思路(基于Redis的分布式锁)

实现分布式锁时需要实现的两个基本方法:
a. 获取锁:

[*]互斥,确保只能有一个线程获取锁
[*]非阻塞式:尝试一次,成功返回true,失败返回false
#添加锁,利用setnx的互斥特性
SETNX lock thread1
#添加锁过期时间,避免服务器宕机(非redis服务宕机)引起的死锁
EXPIRE lock 10此外,还要保证senx lock value和expire lock,两个操作是原子性的,否则可能会出现添加锁之候服务宕机的情况,这样就会出现死锁。因此,最好使用set命令一次性添加“锁”和设置过期时间。
操作说明:
127.0.0.1:6379> help SET

SET key value
summary: Set the string value of a key
since: 1.0.0
group: string#获取锁的最终方案:添加锁,NX是互斥,EX是设置超时时间
SET lock thread1 EX 10 NXb. 释放锁:

[*]手动释放
[*]超时释放:获取锁时添加一个超时时间
#释放锁,删除即可
DEL key整个流程:
https://img2023.cnblogs.com/blog/2192446/202304/2192446-20230425212127195-297545484.png4.5.5基于Redis实现分布式锁(初级版本)

(1)定义一个类,实现下面接口,利用Redis实现分布式锁功能
package com.hmdp.utils;

/**
* @author 李
* @version 1.0
*/
public interface ILock {
    /**
   * 尝试获取锁
   *
   * @param timeoutSec 锁持有的时间,过期后自动释放
   * @return true代表获取锁成功,false代表获取锁失败
   */
    public boolean tryLock(long timeoutSec);

    /**
   * 释放锁
   */
    public void unLock();
}(2)创建SimpleRedisLock.java
使用redis的setnx来实现分布式互斥锁
package com.hmdp.utils;

import org.springframework.data.redis.core.StringRedisTemplate;

import java.util.concurrent.TimeUnit;

/**
* @author 李
* @version 1.0
*/
public class SimpleRedisLock implements ILock {
    private String name;
    private StringRedisTemplate stringRedisTemplate;

    public SimpleRedisLock(String name, StringRedisTemplate stringRedisTemplate) {
      this.name = name;
      this.stringRedisTemplate = stringRedisTemplate;
    }

    private static final String KEY_PREFIX = "lock:";

    @Override
    public boolean tryLock(long timeoutSec) {
      //获取线程标识
      long threadId = Thread.currentThread().getId();
      //获取锁
      Boolean success = stringRedisTemplate.opsForValue()
                .setIfAbsent(KEY_PREFIX + name, threadId + "", timeoutSec, TimeUnit.SECONDS);
      return Boolean.TRUE.equals(success);//防止空指针
    }

    @Override
    public void unLock() {
      //释放锁
      stringRedisTemplate.delete(KEY_PREFIX + name);
    }
}(3)修改VoucherOrderServiceImpl的seckillVoucher()方法:
package com.hmdp.service.impl;

import ...

/**
* 服务实现类
*
* @author 李
* @version 1.0
*/
@Service
public class VoucherOrderServiceImpl extends ServiceImpl<VoucherOrderMapper, VoucherOrder> implements IVoucherOrderService {
    @Resource
    private ISeckillVoucherService seckillVoucherService;
    @Resource
    private RedisIdWorker redisIdWorker;
    @Resource
    private StringRedisTemplate stringRedisTemplate;

    @Override
    public Result seckillVoucher(Long voucherId) {
      //根据id查询优惠券信息
      SeckillVoucher voucher = seckillVoucherService.getById(voucherId);
      if (voucher == null) {
            return Result.fail("该优惠券不存在,请刷新!");
      }
      //判断秒杀券是否在有效时间内
      //若不在有效期,则返回异常结果
      if (voucher.getBeginTime().isAfter(LocalDateTime.now())) {
            return Result.fail("秒杀尚未开始!");
      }
      if (voucher.getEndTime().isBefore(LocalDateTime.now())) {
            return Result.fail("秒杀已经结束!");
      }
      //若在有效期,判断库存是否充足
      if (voucher.getStock() < 1) {//库存不足
            return Result.fail("秒杀券库存不足!");
      }

      Long userId = UserHolder.getUser().getId();
      //--------------start---------------------
      //创建锁对象
      SimpleRedisLock lock = new SimpleRedisLock("order:" + userId, stringRedisTemplate);
      //获取锁
      boolean isLock = lock.tryLock(1200);
      //判断是否获取锁成功
      if (!isLock) {//获取锁失败
            //直接返回错误,不阻塞
            return Result.fail("不允许重复下单!");
      }
      try {
            //获取代理对象(事务)
            IVoucherOrderService proxy = (IVoucherOrderService) AopContext.currentProxy();
            //这里应该先获取锁,然后提交createVoucherOrder()的事务,再释放锁,才能确保线程是安全的
            return proxy.createVoucherOrder(voucherId);
      } finally {
            //释放锁
            lock.unLock();
      }
         //--------------end---------------------
    }
   

    @Transactional
    public Result createVoucherOrder(Long voucherId) {
      ...
    }
}(4)测试:以debug方式启动两个服务端:
https://img2023.cnblogs.com/blog/2192446/202304/2192446-20230425212133546-121143705.png在如下位置打上断点:
https://img2023.cnblogs.com/blog/2192446/202304/2192446-20230425212136694-1869598674.png仍使用postman测试:用一个用户发起两次请求
https://img2023.cnblogs.com/blog/2192446/202304/2192446-20230425212139670-1913676356.pnghttps://img2023.cnblogs.com/blog/2192446/202304/2192446-20230425212142689-1557899561.png测试结果:在集群模式下,只有一个请求获取锁成功了
https://img2023.cnblogs.com/blog/2192446/202304/2192446-20230425212145886-2002521376.pnghttps://img2023.cnblogs.com/blog/2192446/202304/2192446-20230425212149427-843580950.pngredis存储的数据:1025号用户,线程id为29
https://img2023.cnblogs.com/blog/2192446/202304/2192446-20230425212156374-220527981.png4.5.6Redis分布式锁误删问题

4.6Redis优化秒杀

4.7Redis消息队列实现异步秒杀


免责声明:如果侵犯了您的权益,请联系站长,我们会及时删除侵权内容,谢谢合作!
页: [1]
查看完整版本: day06-优惠券秒杀02