黑马点评part3 -- 优惠卷秒杀

1&2 用redis实现全局唯一ID : 

问题引入 : 

  • 因为按照数据库自增的id的话,那么就会造成规律性太明显,那么可能造成数据安全问题 ;
  • 首单表数据量的限制
  • 如果多表分布式 , 那么会造成id重复的情况 , 如果用id自增的话 ;

全局ID生成器 :是一种在分布式系统下用来生成全局唯一ID的工具,一般要满足下列特性:
 

对于安全性 : 

  1. 时间戳 : 使用当前时间的秒数减去起始时间的秒数来作为时间戳 ;
  2. 序列号 : 使用前缀"icr" + 传过来的 key + 当前的日期(精确到天)

完整代码 : 

package com.hmdp.utils;

import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.format.annotation.DateTimeFormat;
import org.springframework.stereotype.Component;

import javax.annotation.Resource;
import javax.swing.text.DateFormatter;
import java.text.DateFormat;
import java.time.LocalDateTime;
import java.time.ZoneOffset;
import java.time.format.DateTimeFormatter;

@Component
public class RedisIdWorker {

    /**
     * 开始时间戳
     */
    private static final long BEGIN_TIMESTAMP = 1712361600L ;
    /**
     * 序列号位移位数
     */
    private static final int COUNT_BITS = 32 ;
    @Resource
    private StringRedisTemplate stringRedisTemplate ;

    public Long nextId(String keyPrefix){
        // 1 . 生成时间戳
        long nowSecond = LocalDateTime.now().toEpochSecond(ZoneOffset.UTC) ;
        long timestamp = nowSecond - BEGIN_TIMESTAMP ;

        // 2 . 生成序列号
        // 2 . 1 获取当前日期 , 精确到天
        String date = LocalDateTime.now().format(DateTimeFormatter.ofPattern("yyyy:MM:dd")) ;
        // 2 . 2 自增长
        long count = stringRedisTemplate.opsForValue().increment("icr:"+keyPrefix+":"+date) ;

        // 3 . 拼接并返回(使用位运算)
        return timestamp <<  COUNT_BITS | count  ;
    }

    public static void main(String[] args) {
        LocalDateTime time = LocalDateTime.of(2024,4,6,0,0,0) ;
        long second = time.toEpochSecond(ZoneOffset.UTC) ;
        System.out.println("second : " + second) ;
    }
}

测试 : 

关于countdownlatch

countdownlatch名为信号枪:主要的作用是同步协调在多线程的等待于唤醒问题

我们如果没有CountDownLatch ,那么由于程序是异步的,当异步程序没有执行完时,主线程就已经执行完了,然后我们期望的是分线程全部走完之后,主线程再走,所以我们此时需要使用到CountDownLatch

CountDownLatch 中有两个最重要的方法

1、countDown

2、await

await 方法 是阻塞方法,我们担心分线程没有执行完时,main线程就先执行,所以使用await可以让main线程阻塞,那么什么时候main线程不再阻塞呢?当CountDownLatch 内部维护的 变量变为0时,就不再阻塞,直接放行,那么什么时候CountDownLatch 维护的变量变为0 呢,我们只需要调用一次countDown ,内部变量就减少1,我们让分线程和变量绑定, 执行完一个分线程就减少一个变量,当分线程全部走完,CountDownLatch 维护的变量就是0,此时await就不再阻塞,统计出来的时间也就是所有分线程执行完后的时间。

可以发现生成3w个id大概用了2s;

总结 : 

3 . 实现优惠卷秒杀下单

每个店铺都可以发布优惠券,分为平价券和特价券。平价券可以任意购买,而特价券需要秒杀抢购:

tb_voucher:优惠券的基本信息,优惠金额、使用规则等 tb_seckill_voucher:优惠券的库存、开始抢购时间,结束抢购时间。特价优惠券才需要填写这些信息

平价卷由于优惠力度并不是很大,所以是可以任意领取

而代金券由于优惠力度大,所以像第二种卷,就得限制数量,从表结构上也能看出,特价卷除了具有优惠卷的基本信息以外,还具有库存,抢购时间,结束时间等等字段

优惠卷表 : 

秒杀卷表 : 

添加秒杀卷 : 

首先测试一下这个新增秒杀卷的功能 (用 postman测):

 注意这里你可能会出现报错401,因为header里面没有authorization,这里随便去浏览器中找一个请求cv一下粘贴到postman中就可以了 ;

实现秒杀卷下单 : 

流程图 : 

代码 : 

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.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

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


@Service
public class VoucherOrderServiceImpl extends ServiceImpl<VoucherOrderMapper, VoucherOrder> implements IVoucherOrderService {

    @Resource
    private RedisIdWorker redisIdWorker ; // 注入id生成器

    @Resource
    private ISeckillVoucherService seckillVoucherService ;

    /**
     * 优惠卷秒杀下单
     * @param voucherId
     * @return
     */
    @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 . 扣减库存
        boolean tag = seckillVoucherService.update()
                .setSql("stock = stock - 1")
                .eq("voucher_id",voucherId).update();
        if(!tag){
            return Result.fail("库存不足");
        }
        // 6 . 创建订单
        VoucherOrder voucherOrder = new VoucherOrder() ;
        // 6 . 1 订单id
        long orderId = redisIdWorker.nextId("order");
        voucherOrder.setId(orderId) ;
        // 6 . 2 用户id
        Long userId = UserHolder.getUser().getId();
        voucherOrder.setUserId(userId) ;
        // 6 . 3 代金卷id
        voucherOrder.setVoucherId(voucherId) ;
        save(voucherOrder) ;

        // 7 . 返回订单id
        return Result.ok(orderId) ;
    }
}

测试 : 这里使用jmeter来测试 : 

jmeter安装教程 : Jmeter安装教程-优快云博客

基础使用讲解 : Jmeter性能测试工具-详细使用教程_jmeter菜鸟教程-优快云博客

4 . 超卖分析

之前的查询和扣减库存的逻辑代码 : 

分析 : 

超卖问题是典型的线程安全问题 , 解决这种问题的常见方案就是加锁 : 

悲观锁:

悲观锁可以实现对于数据的串行化执行,比如syn,和lock都是悲观锁的代表,同时,悲观锁中又可以再细分为公平锁,非公平锁,可重入锁,等等

乐观锁:

版本号法 : 

乐观锁:会有一个版本号,每次操作数据会对版本号+1,再提交回数据时,会去校验是否比之前的版本大1 ,如果大1 ,则进行操作成功,这套机制的核心逻辑在于,如果在操作过程中,版本号只比原来大1 ,那么就意味着操作过程中没有人对他进行过修改,他的操作就是安全的,如果不大1,则数据被修改过;

cas法:

当然乐观锁还有一些变种的处理方式比如cas

乐观锁的典型代表:就是cas,利用cas进行无锁化机制加锁,var5 是操作前读取的内存值,while中的var1+var2 是预估值,如果预估值 == 内存值,则代表中间没有被人修改过,此时就将新值去替换 内存值

其中do while 是为了在操作失败时,再次进行自旋操作,即把之前的逻辑再操作一次。

int var5;
do {
    var5 = this.getIntVolatile(var1, var2);
} while(!this.compareAndSwapInt(var1, var2, var5, var5 + var4));

return var5;

5 . 乐观锁解决线程安全问题 :
 

修改扣减库存逻辑即可 : 

但是会出现下面问题 : 

b在a修改前查询,但是在a修改后修改,然后两个stock不等,就会直接报错 , 造成错误率升高;

这里只需要与0比较即可 ;

总结 : 

6 . 一人一单

修改订单逻辑,判断之前是否购买过 , 如果之前购买过,直接return 即可 ;

但是这里在高并发的情况下,也会出现像前面的前面的线程安全问题 :

实现细节:

1 . 锁的范围尽量小。synchronized尽量锁代码块,而不是方法,锁的范围越大性能越低

2 . 锁的对象一定要是一个不变的值。我们不能直接锁 Long 类型的 userId,每请求一次都会创建一个新的 userId 对象,synchronized 要锁不变的值,所以我们要将 Long 类型的 userId 通过 toString()方法转成 String 类型的 userId,toString()方法底层(可以点击去看源码)是直接 new 一个新的String对象,显然还是在变,所以我们要使用 intern() 方法从常量池中寻找与当前 字符串值一致的字符串对象,这就能够保障一个用户 发送多次请求,每次请求的 userId 都是不变的,从而能够完成锁的效果(并行变串行)

3 . 我们要锁住整个事务,而不是锁住事务内部的代码。如果我们锁住事务内部的代码会导致其它线程能够进入事务,当我们事务还未提交,锁一旦释放,仍然会存在超卖问题

4 . Spring的@Transactional注解要想事务生效,必须使用动态代理。Service中一个方法中调用另一个方法,另一个方法使用了事务,此时会导致@Transactional失效,所以我们需要创建一个代理对象,使用代理对象来调用方法。

这篇文章对@Transactional注解事务失效的常见场景进行了一个总结:【Java面试篇】Spring中@Transactional注解事务失效的常见场景_java事务注解失效场景-优快云博客

让代理对象生效的步骤:

①引入AOP依赖,动态代理是AOP的常见实现之一

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


②暴露动态代理对象,默认是关闭的

@EnableAspectJAutoProxy(exposeProxy = true)

7 . 集群环境下的并发问题

在IDEA中启动两个SpringBoot应用 , 端口分别为8081和8082 :

之前已经有一个8081端口,这里新建一个8082端口,在services哪里ctrl + D即可 ;

创建成功之后重启两个项目 ;

然后打开nginx的config配置文件,修改成下面这样 :  

然后通过下面命令重启nginx : 

nginx.exe -s reload

可以发现这里访问的是8080端口 ;

然后IDEA下两个端口都遇到了访问情况 : 

准备两个接口  , 模拟多用户模式下的重复下单 :

先在锁内打下一个断点 : 

 两个用户发送请求 : 

可以发现两个相同用户进行访问还是出现了能够进入锁中并且出现能够买两次的情况 :

然后在扣减库存哪里打一个断点 : 

 从上面断点放行,发现都能得到count = 0 : 

那么表示出现了问题 ;

再放行,可以发现创建了两条订单 : 

这样 , 两个进程都进入了锁的进程 ;

有关锁失效原因分析

        由于现在我们部署了多个tomcat,每个tomcat都有一个属于自己的jvm,那么假设在服务器A的tomcat内部,有两个线程,这两个线程由于使用的是同一份代码,那么他们的锁对象是同一个,是可以实现互斥的,但是如果现在是服务器B的tomcat内部,又有两个线程,但是他们的锁对象写的虽然和服务器A一样,但是锁对象却不是同一个,所以线程3和线程4可以实现互斥,但是却无法和线程1和线程2实现互斥,这就是 集群环境下,syn锁失效的原因,在这种情况下,我们就需要使用分布式锁来解决这个问题。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值