Redis优惠券秒杀 | 黑马点评

目录

一、全局唯一ID

1、全局ID生成器

二、实现秒杀下单

1、基本的下单功能

2、超卖问题

3、乐观锁解决并发问题

三、实现一人一单

1、思路分析

2、代码初步实现 

3、关于锁的范围 

4、关于事务失效

5、集群下线程并发问题

四、最终优化版

1、异步秒杀思路

2、代码实践

3、秒杀优化总结


一、全局唯一ID

订单如果用自增长会存在的问题:

ID的规律性太明显了

受单表数量限制,因为如果商城很大订单表数量可能很多,要分库分表,到时候id自增从1开始的话肯定会出现重复的。订单表为了后边方便查询肯定不能重复

1、全局ID生成器

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

  • 唯一性
  • 高可用
  • 高性能(生成足够快)
  • 递增性(整体递增,方便创建索引)
  • 安全性(规律性不能太明显)

Redis肯定唯一的,性能也高,Redis也是采用递增方案的

生成器代码(Redis自增ID策略):

在最后做拼接的时候,我们不能直接拼接,因为是long类型来接收所以我们得用位运算,前面的左移动32位然后或运算后面的

key的设置是每天一个key,方便订单统计也防止可能会重复

import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.stereotype.Component;

import java.time.LocalDateTime;
import java.time.ZoneOffset;
import java.time.format.DateTimeFormatter;

@Component
public class RedisIdWorker {

    private static final long BEGIN_TIMESTAMP = 1640995200L;

    private static int COUNT_BITS = 32;

    @Autowired
    private StringRedisTemplate stringRedisTemplate;

    public Long nextId(String keyPrefix){
        LocalDateTime now = LocalDateTime.now();
        long nowSecond = now.toEpochSecond(ZoneOffset.UTC);
        long time = nowSecond - BEGIN_TIMESTAMP;

        String format = now.format(DateTimeFormatter.ofPattern("yyyy:MM:dd"));
        // Redis Incrby 命令将 key 中储存的数字加上指定的增量值。
        // 如果 key 不存在,那么 key 的值会先被初始化为 0 ,然后再执行 INCRBY 命令。
        Long count = stringRedisTemplate.opsForValue().increment("icr:" + keyPrefix + ":" + format);

        return time << COUNT_BITS | count;
    }

    public static void main(String[] args) {
        LocalDateTime of = LocalDateTime.of(2022, 1, 1, 0, 0, 0);
        long l = of.toEpochSecond(ZoneOffset.UTC);
        // LocalTime类的toEpochSecond()方法用于
        // 将此LocalTime转换为自1970-01-01T00:00:00Z以来的秒数
        System.out.println(l);
    }
}

二、实现秒杀下单

1、基本的下单功能

下单时需要满足两点:

  • 秒杀是否开始或结束,如果没开始或已结束则无法下单
  • 库存是否充足,不足则无法下单

实现代码

@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) {

        SeckillVoucher seckillVoucher = seckillVoucherService.getById(voucherId);

        // 判断秒杀是否还未开始
        if (seckillVoucher.getBeginTime().isAfter(LocalDateTime.now())) {
            Result.fail("秒杀尚未开始!");
        }

        // 判断秒杀是否已经结束
        if (seckillVoucher.getEndTime().isBefore(LocalDateTime.now())) {
            Result.fail("秒杀已经结束!");
        }

        // 判断库存是否充足
        if (seckillVoucher.getStock() < 1) {
            Result.fail("库存不足!");
        }

        // 扣减库存
        boolean success = seckillVoucherService.update().
                setSql("stock = stock - 1").
                eq("voucher_id", voucherId).update();

        // 扣减失败
        if(!success){
            return Result.fail("库存不足!");
        }

        // 创建订单
        VoucherOrder voucherOrder = new VoucherOrder();
        // 生成订单 id
        Long orderId = redisIdWorker.nextId("order");
        voucherOrder.setVoucherId(voucherId);
        // 用户 id
        Long userId = UserHolder.getUser().getId();
        voucherOrder.setUserId(userId);
        voucherOrder.setId(orderId);
        save(voucherOrder);

        return Result.ok(orderId);
    }
}

记得方法加上事务注解,一旦出问题可以回滚。

2、超卖问题

当线程1在查到还有1个库存,然后开始扣除的时候,在还没扣除完毕时,这个时候有其他线程看到还有1个库存,都会进行扣除,这种情况就会存在超卖问题了。 

针对这一问题常见解决方案就是加锁,常见有乐观锁悲观锁

乐观锁

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

版本号法(数据库多一个version来标记是否已经修改)

CAS法(除了多的字段,版本号信息,以库存信息本身有没有变化为判断依据,当线程修改库存时,当线程修改库存时,判断当前数据库中的库存与之前查询得到的库存数据是否一致,如果一致,则说明线程安全,可以执行扣减操作,如果不一致,则说明线程不安全,扣减失败。)

3、乐观锁解决并发问题

我们只需要在修改库存表前判断一下,跟之前查到的值是否相等就行

@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) {

        SeckillVoucher seckillVoucher = seckillVoucherService.getById(voucherId);

        // 判断秒杀是否还未开始
        if (seckillVoucher.getBeginTime().isAfter(LocalDateTime.now())) {
            Result.fail("秒杀尚未开始!");
        }

        // 判断秒杀是否已经结束
        if (seckillVoucher.getEndTime().isBefore(LocalDateTime.now())) {
            Result.fail("秒杀已经结束!");
        }

        // 判断库存是否充足
        if (seckillVoucher.getStock() < 1) {
            Result.fail("库存不足!");
        }

        // 扣减库存
        boolean success = seckillVoucherService.update().
                setSql("stock = stock - 1").
                eq("voucher_id", voucherId).
                eq("stock", seckillVoucher.getStock()).    // 增加对库存的判断,判断当前库存是否与查询出的结果一致
                update();

        // 扣减失败
        if(!success){
            return Result.fail("库存不足!");
        }

        // 创建订单
        VoucherOrder voucherOrder = new VoucherOrder();
        // 生成订单 id
        Long orderId = redisIdWorker.nextId("order");
        voucherOrder.setVoucherId(voucherId);
        // 用户 id
        Long userId = UserHolder.getUser().getId();
        voucherOrder.setUserId(userId);
        voucherOrder.setId(orderId);
        save(voucherOrder);

        return Result.ok(orderId);
    }
}

最后我们测试居然发现原本预测执行100条订单的,但是实际上只有76条,为什么呢?

因为我们这种设置乐观锁太保守了,只要查到库存与之前不一样就不能扣除库存,但是实际上在库存还有很多的时候,这种是不影响的还是可以扣除的。于是我们优化:

// 扣减库存
        boolean success = seckillVoucherService.update().
                setSql("stock = stock - 1").
                eq("voucher_id", voucherId).
                // 增加对库存的判断,判断当前库存是否与查询出的结果一致
                // eq("stock", seckillVoucher.getStock()).    
                // 修改判断逻辑,改为只要库存大于0,就允许线程扣减
                gt("stock", 0).        
                update();

只要库存还是大于0的就能够进行修改

三、实现一人一单

需求:修改秒杀业务,要求一个优惠券,一个用户只能下一单

1、思路分析

我们得从查询订到到判断订单到创建订单这三步都要加上悲观锁,我们是同一个用户来了才需要处理这个并发安全问题,不同的用户是不影响的,因此加的锁应该根据用户的id来加锁

所以用synchronize(userId.toString().intern())这个来锁,为什么要加intern(),因为如果不加每次获取的字符串对象可能不是一个都是不一样的,加了可以保证每次都是同一个,他会去常量池里面找一样的返回。

2、代码初步实现 

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

    @Autowired
    private ISeckillVoucherService seckillVoucherService;

    @Autowired
    private RedisIdWorker redisIdWorker;

    @Override
    public Result seckillVoucher(Long voucherId) {

        SeckillVoucher seckillVoucher = seckillVoucherService.getById(voucherId);

        // 判断秒杀是否还未开始
        if (seckillVoucher.getBeginTime().isAfter(LocalDateTime.now())) {
            Result.fail("秒杀尚未开始!");
        }

        // 判断秒杀是否已经结束
        if (seckillVoucher.getEndTime().isBefore(LocalDateTime.now())) {
            Result.fail("秒杀已经结束!");
        }

        // 判断库存是否充足
        if (seckillVoucher.getStock() < 1) {
            Result.fail("库存不足!");
        }

        return createVoucherOrder(voucherId);
    }

    @Transactional
    public Result createVoucherOrder(Long voucherId) {
        // 判断当前优惠券用户是否已经下过单
        // 用户 id
        Long userId = UserHolder.getUser().getId();
        synchronized (userId.toString().intern()) {
            // 查询订单
            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").
                    eq("voucher_id", voucherId).
//                eq("stock", seckillVoucher.getStock()).    // 增加对库存的判断,判断当前库存是否与查询出的结果一致
        gt("stock", 0).        // 修改判断逻辑,改为只要库存大于0,就允许线程扣减
                    update();

            // 扣减失败
            if (!success) {
                return Result.fail("库存不足!");
            }

            // 创建订单
            VoucherOrder voucherOrder = new VoucherOrder();
            // 生成订单 id
            Long orderId = redisIdWorker.nextId("order");
            voucherOrder.setVoucherId(voucherId);

            voucherOrder.setUserId(userId);
            voucherOrder.setId(orderId);
            save(voucherOrder);

            return Result.ok(orderId);
        }
    }
}

3、关于锁的范围 

这样加也有弊端,如果锁的范围是这里,锁先释放再提交的事务,假如我们刚改完释放锁还没提交事务,别人进来又改一次,然后再提交事务就会出现问题。

我们必须把锁加在外面,调用方法的时候锁住,锁住整个方法,事务先提交再释放锁

synchronize(userId.toString().intern()){
    return createVoucherOrder(voucherId);
}

4、关于事务失效

这样做会导致事务失效,我们现在给的是方法加的事务注解,seckillVoucher这个方法没有加,现在本质上是用this.createVoucherOrder来调用的,这个this拿到的是当前对象来调用的,而不是代理对象调用。

我们要想让事务生效,是spring对当前类做了动态代理,生成代理类,用代理对象来做的事务处理。现在用的是非代理对象来做的,所有没有事务功能。

我们要拿到事务代理对象才行。

我们可以用AopContext拿到代理对象,然后用代理对象来调用方法。

这样做我们要添加一个aspectjweaver的依赖,启动类添加@EnableAspectJAutoProxy(exposeProxy=true)注解来暴露代理对象

5、集群下线程并发问题

上面这种情况下只能保证单机部署下安全,在集群环境还是会出现问题

我们模拟集群的环境:

 测试发现集群模式下synchronize锁不住,为什么呢?

在集群模式下,每个都是不同tomcat,不同jvm的存在,每个jvm的每个锁都可以有一个线程来获取,就会出现并行安全问题。

要想解决这种问题,必须得想办法让多个jvm只能用同一个锁。分布式锁

关于redis实现分布式锁:

https://blog.csdn.net/weixin_54232666/article/details/128743568?csdn_share_tail=%7B%22type%22%3A%22blog%22%2C%22rType%22%3A%22article%22%2C%22rId%22%3A%22128743568%22%2C%22source%22%3A%22weixin_54232666%22%7D

四、最终优化版

1、异步秒杀思路

我们发现之前都是同步秒杀,效率太低了,我们判断完库存然后还要查询订单来校验一人一单,然后再减少库存,创建订单,效率太低了。

我们能不能把判断完成之后重新创建一个线程执行后面的操作呢?这就是异步秒杀了。

redis判断秒杀库存和校验一人一单:

用string来存库存,每次存了记得减少一,用set来保存用户下单(去重)

为了保证原子性,操作都在lua脚本里面执行。

2、代码实践

(1)新增秒杀优惠券同时,将优惠券信息保存到redis中

(2)基于lua脚本,判断秒杀库存,一人一单,决定用户是否抢购成功

先判断库存是否充足,再判断用户是否下单

最后判断还有库存,没有下过单就扣除库存,最后返回0代表成功

 

(3)如果抢购成功,将优惠券id和用户id封装后存入堵塞队列

先执行lua脚本,传入3个参数(脚本、key的集合、优惠券id和用户id)

然后我们判断没有问题就放到阻塞队列里头

先创建一个堵塞队列,堵塞队列是里面有东西的时候线程才会执行,没有线程就一直堵塞

初始化大小设置为1024*1024

private BlockingQueue<VoucherOrder> orderTasks = new ArrayBlockingQueue<>(1024*1024);

然后存入堵塞队列里头 

(4)开启线程任务,不断从堵塞队列中获取信息,实现异步下单功能

我们先定义线程池,从里面获取线程。然后要在类初始化的时候就能接收到信息,所以我们用spring的注解@PostConstrust定义方法,创建完线程池就提交方法,方法就是另外启动一个线程执行异步下单功能(获取队列订单信息和创建订单) 

优化的代码:

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

    @Autowired
    private ISeckillVoucherService seckillVoucherService;

    @Autowired
    private RedisIdWorker redisIdWorker;

    @Autowired
    private StringRedisTemplate stringRedisTemplate;

    @Autowired
    private RedissonClient redissonClient;

    private static final DefaultRedisScript<Long> SECKILL_SCRIPT;

    static {
        SECKILL_SCRIPT = new DefaultRedisScript();
        SECKILL_SCRIPT.setLocation(new ClassPathResource("seckill.lua"));
        SECKILL_SCRIPT.setResultType(Long.class);
    }

	 /***
     * 创建阻塞队列,并初始化阻塞队列的大小
     */
    private static final BlockingQueue<VoucherOrder> orderTasks =
            new ArrayBlockingQueue<>(1024*1024);
    /***
     * 创建线程池
     */
    private static final ExecutorService SECKILL_ORDER_EXECUTOR = Executors.newSingleThreadExecutor();

    /***
     * 标有 @PostConstruct 注解的方法,容器在 bean 创建完成并且属性赋值完成后,会调用该初始化方法。
     * 容器启动时,便开始创建独立线程,从队列中读取数据,创建订单
     */
    @PostConstruct
    private void init(){
        SECKILL_ORDER_EXECUTOR.submit(new VoucherOrderHandler());
    }

    private class VoucherOrderHandler implements Runnable {

        @Override
        public void run() {
        	// 系统启动开始,便不断从阻塞队列中获取优惠券订单信息
            while(true){
                try {
                	// 阻塞式获取订单信息
                    VoucherOrder voucherOrder = orderTasks.take();
                    createVoucherOrder(voucherOrder);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
            }

        }
    }

	
    private void createVoucherOrder(VoucherOrder voucherOrder) {
        // 判断当前优惠券用户是否已经下过单
        // 用户 id
        Long userId = UserHolder.getUser().getId();
        Long voucherId = voucherOrder.getVoucherId();

        RLock lock = redissonClient.getLock("lock:order:" + userId);
        // 获取互斥锁
        // 使用空参意味着不会进行重复尝试获取锁
        boolean isLock = lock.tryLock();
        if (!isLock) {
            // 获取锁失败,直接返回失败或者重试
            log.error("不允许重复下单!");
            return;
        }


        try {
            // 查询订单
            int count = query().eq("user_id", userId).eq("voucher_id", voucherId).count();
            if (count > 0) {
                log.error("不允许重复下单!");
                return;
            }

            // 扣减库存
            boolean success = seckillVoucherService.update().
                    setSql("stock = stock - 1").
                    eq("voucher_id", voucherId).
                    gt("stock", 0).
                    update();

            // 扣减失败
            if (!success) {
                log.error("库存不足!");
                return;
            }

            // 创建订单
            save(voucherOrder);
        } finally {
            // 释放锁
            lock.unlock();
        }
    }


    @Override
    public Result seckillVoucher(Long voucherId) {
        UserDTO user = UserHolder.getUser();
        // 执行 lua 脚本
        Long result = stringRedisTemplate.execute(
                SECKILL_SCRIPT,
                Collections.emptyList(),
                voucherId.toString(), user.getId().toString());
        int r = result.intValue();

        // 判断结果是否为 0
        if(r != 0){
            // 不为 0 ,代表没有购买资格
            Result.fail(r == 1 ? "库存不足!" : "不能重复下单!");
        }


        // 生成订单 id
        Long orderId = redisIdWorker.nextId("oder");
        
		// 为 0,有购买资格,把订单信息保存到阻塞队列
        VoucherOrder voucherOrder = new VoucherOrder();
        voucherOrder.setVoucherId(voucherId);
        voucherOrder.setUserId(user.getId());
        voucherOrder.setId(orderId);
        orderTasks.add(voucherOrder);
        
        // 返回订单 id
        return Result.ok(orderId);
    }
}

3、秒杀优化总结

秒杀业务的优化思路是什么?
① 先利用 Redis 完成库存余量、一人一单判断,完成抢单业务
② 再将下单业务放入阻塞队列,利用独立线程异步下单

基于阻塞队列的异步秒杀存在哪些问题?
① 内存限制问题。我们使用的 JDK 中的阻塞队列,使用的是 JVM 的内存,如果不加以限制,在高并发的情况下,就会有无数的订单对象需要去创建,并且存入阻塞队列中,可能会导致将来内存溢出,所以我们在创建阻塞队列的时候,设置了队列的长度。但是如果队列中订单信息存满了,后续新创建的订单就无法存入队列中。
② 数据安全问题。我们是基于内存保存的订单信息,如果服务突然宕机,那么内存中的订单信息也就丢失了。

  • 4
    点赞
  • 10
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 2
    评论
评论 2
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

卒获有所闻

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值