(优惠券秒杀) 本文为学习redis时做的笔记,学习内容来自黑马程序员Redis入门到实战教程,该教程是循序渐进的,所以不是一上来就讲完最后的解决方案了,请耐心看完 所需要的分布式锁知识请看我的下一篇博客

1. 全局id生成器

全局id生成器是一种分布式系统下的全局唯一id生成工具 image.png 不管有多少数据库表,redis只有一个,所以redis自增就是唯一的 为了增加安全性,可以不直接使用redis自增的数值,而是可以拼接一些其他信息 image.png

  • 符号位:1bit,永远为0
  • 时间戳:31bit,以秒为单位,可以使用69年
  • 序列号:32bit,计数器,每秒能产生2的32次方个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;
    }
}

调用示例:

RedisIdWorker redisIdWorker = new RedisIdWorker(stringRedisTemplate);
long orderId = redisIdWorker.nextId("order");
System.out.println(orderId);

2. 基础功能:添加秒杀优惠券

image.png 我们只需要在接口测试工具中(如apifox中)对应的接口中传入优惠券对象就可以

{
    "beginTime": "2023-10-26T21:00:00",
    "shopId": 1,
    "subTitle": "周一至周五均可使用",
    "type": 87,
    "payValue": 18000,
    "title": "200元代金券",
    "stock": 50,
    "endTime": "2023-11-11T12:48:08",
    "status": 57,
    "actualValue": 20000,
    "rules": "全场通用\\n无需预约\\n可无限叠加\\不兑现、不找零\\n仅限堂食"
}

image.png 我们的秒杀优惠券: image.png

3. 实现秒杀下单

下单时要判断:

  • 秒杀是否开始或结束
  • 库存是否充足

image.png 实现秒杀后返回订单id

@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 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("库存不足");
        }
       boolean success = seckillVoucherService.update().setSql("stock = stock-1")
                .eq("voucher_id", voucherId)
                .update();
        if(!success){
            Result.fail("库存不足!");
        }
        VoucherOrder order = new VoucherOrder();
        //6.1 订单id
        long orderId = redisIdWorker.nextId("order");
        order.setId(orderId);
        //6.2 用户id
        order.setUserId(UserHolder.getUser().getId());
        //6.3 优惠券id
        order.setVoucherId(voucherId);
        save(order);

        return  Result.ok(orderId);
    }
}

因为对库存表和订单表都进行了操作,所以要加上事物

image.png

4. 库存超卖问题

使用jmeter测试200次请求 image.png

记着设置token信息: image.png 记着设置json断言 image.png

最后的请求会显示库存不足 image.png 但是库存变为-9,创建了109个订单,这就是超卖问题 image.png image.png

4.1 问题分析

image.png 线程一查询库存为1,在判断库存是否大于0之前其他线程也查询到库存为1,之后线程1判断库存大于0,库存减1到0。其他线程因为之前查询到库存也为1,所以也减了库存,库存变为负数,出现线程安全问题,最后导致超卖

4.2 解决

对于多线程安全问题,常见的解决方案是锁 image.png 下面了解乐观锁的解决方案

4.2.1 乐观锁

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

  • 版本号法

image.png 查询的时候查询库存和版本号,之后更新库存的同时查询版本号是否和之前查到的一致,一致则说明数据未得到其他线程更改,保证线程安全

  • CAS法

image.png CAS法其实是版本号法的一种简化方式,省略了版本号,更新库存的时候判断现在的库存是否和一开始查询到的库存一致

4.3 实现

只需要在原来的逻辑上对删减库存修改,因为库存比较特殊,所以不需要库存一定和开始查询的时候一致,只需要库存大于0就行

boolean success = seckillVoucherService.update().setSql("stock = stock-1")
                .eq("voucher_id", voucherId)
                .gt("stock", 0)
                .update();
        if(!success){
            Result.fail("库存不足!");
        }

再用jmeter测试两百次请求 image.png image.png image.png 库存正好为0,异常情况(库存不足情况)也为一半左右

5. 一人一单

修改业务,要求一个优惠券,一个用户只能下一单 image.png 在判断库存充足后判断订单是否存在

5.1 问题

先在扣减库存之前加入一人一单的判断

//5.一人一单
Long userId = UserHolder.getUser().getId();
//查询
Integer count = query().eq("userId", userId).eq("voucher_id", voucherId).count();
//判断订单是否存在
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) {
    Result.fail("库存不足!");
}

使用jmeter测试: image.png image.png image.png 本来应该一个用户只能下一单,但现在却下了10单,说明之前的方法出现了问题

5.2 分析

现在我们的数据库中根本没有订单,现有一百个线程并发的查询之前是否有订单,查询到的都是0,然后就插入了n多个数据,也是多线程问题,常见的方式是用锁解决,因为现在数据不存在,没法判断数据是否被修改过,所以不能用乐观锁,可以加悲观锁解决

5.3 解决

5.3.1 悲观锁

先将判断订单是否已经存在,扣减库存,创建订单单独创建一个方法,并加上事物

@Transactional
public Result createVoucherOrder(Long voucherId) {
//5.一人一单
Long userId = UserHolder.getUser().getId();
//查询
Integer count = query().eq("user_id", userId).eq("voucher_id", voucherId).count();
//判断订单是否存在
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) {
    Result.fail("库存不足!");
}
//6.创建订单
VoucherOrder order = new VoucherOrder();
//6.1 订单id
long orderId = redisIdWorker.nextId("order");
order.setId(orderId);
//6.2 用户id

order.setUserId(userId);
//6.3 优惠券id
order.setVoucherId(voucherId);
save(order);

return Result.ok(orderId);
}

在原先的方法中调用创建订单方法,在方法外面加一层悲观锁synchronized

Long userId = UserHolder.getUser().getId();
//对用户id加锁,保证同一个用户加一个锁,不同的用户加不同的锁
//每次请求来,用户id对象都是一个新的对象,对象变了,锁就变了,我们要求值一样,所以用toString
//因为将id转为字符串,相当于new了一个字符串,同一个用户每次进来值虽然一样,但字符串地址
//不一样,锁还会变,所以用intern()方法,在字符串池中中找到与传入的值相同的字符串的地址
//保证用户id的值一样,锁就一样
synchronized (userId.toString().intern()) {
    //事务提交之后再释放锁
    //现在还有一些问题,下面会提到
    return createVoucherOrder(voucherId);
}

注意:

  • 上边的目的是对用户id加锁,保证同一个用户加一个锁,不同的用户加不同的锁,t提升性能
  • 每次请求来,用户id对象都是一个新的对象,对象变了,锁就变了,我们要求值一样,所以用toString()
  • 因为将id转为字符串,相当于new了一个字符串,同一个用户每次进来值虽然一样,但字符串地址不一样,锁还会变,所以用intern()方法,在字符串池中中找到与传入的值相同的字符串的地址,保证用户id的值一样,锁就一样

5.3.2 事物

上边内容是有错误的,seckillVoucher是一个非事物方法,而createVoucherOrder是一个事务方法,在同一个类里的非事物方法中调用事物方法,会导致事物失效

Spring的事务管理依靠的动态代理模式,当在同一个类中调用一个非事务方法,是不会生成代理对象的,自然也不会触发事务

return createVoucherOrder(voucherId);其实省略了this,指的是一个非代理对象IVoucherService,所以接下来我们只需要得到它的代理对象即可,再用代理对象调用事务方法即可

synchronized (userId.toString().intern()) {
    //获取当前的代理对象(事物)
    IVoucherOrderService proxy = (IVoucherOrderService) AopContext.currentProxy();
    //事务提交之后再释放锁
    //再用代理对象调用事务方法
    return proxy.createVoucherOrder(voucherId);
}

使用AopContext.currentProxy()方法,因为我们现在是在service的实现类是实现的事务方法,所以要记得在service里写入对应的方法 image.png

最后我们要引入一个动态代理模式的依赖

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

并且在启动类里添加注解暴露代理对象

@MapperScan("com.hmdp.mapper")
@SpringBootApplication
//暴露代理对象
@EnableAspectJAutoProxy(exposeProxy = true)
public class HmDianPingApplication {

    public static void main(String[] args) {
        SpringApplication.run(HmDianPingApplication.class, args);
    }

}

5.3.3 结果

使用jmeter测试一个用户200次请求 image.png image.png 只有一个请求能成功

image.png image.png 一个用户只生成了一个订单,库存只减少了1

5.4 集群下的线程并发安全问题

image.png 通过加锁可以解决在单机情况下的一人一单问题,但在集群下就不行了 image.png 在集群下,或者在分布式系统下,每个线程有自己的jvm,多个jvm存在,每个jvm都有自己的锁监视器,会有多个线程获得锁,可能出现安全问题,所以我们要想办法,让多个jvm使用同一把锁

请看我的下一篇博客