黑马Redis实战项目——黑马点评笔记04 | 优惠券秒杀

黑马Redis实战项目——黑马点评笔记04 | 优惠券秒杀

在这里插入图片描述


1、redis应用场景一:全局唯一ID

问题:
在这里插入图片描述

1.1 生成策略

在这里插入图片描述
在这里插入图片描述

1.2 实践

@Component
public class RedisIdWorker {
    /**
     * 开始时间戳
     */
    private static final long BEGIN_TIMESTAMP = 1672531200;
    /**
     * 序列号位数
     */
    private static final int COUNT_BITS = 32;

    @Resource
    private StringRedisTemplate stringRedisTemplate;

    /**
     * ID自动生成器并返回
     *
     * @param keyPrefix 业务前缀
     * @return
     */
    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;
    }

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

1.3 总结

在这里插入图片描述


2、优惠券秒杀下单

2.1 流程分析

在这里插入图片描述使用postman添加秒杀券
在这里插入图片描述

//秒杀券信息
{
    "shopId": 1,
    "title": "100元代金券",
    "subTitle": "周一到周日均可使用",
    "rules": "全场通用\\n无需预约\\n可无限叠加\\不兑现、不找零\\n仅限堂食",
    "payValue": 8000,
    "actualValue": 10000,
    "type": 1,
    "stock": 100,
    "beginTime":"2023-04-18T15:40:00",
    "endTime":"2023-04-18T23:40:00"
}

在这里插入图片描述

2.2 代码实现

在这里插入图片描述

//VoucherOrderController类中
	@PostMapping("seckill/{id}")
    public Result seckillVoucher(@PathVariable("id") Long voucherId) {
        return voucherOrderService.seckillVoucher(voucherId);
    }
//IVoucherOrderService接口中声明方法
    Result seckillVoucher(Long voucherId);
@Service
public class VoucherOrderServiceImpl extends ServiceImpl<VoucherOrderMapper, VoucherOrder> implements IVoucherOrderService {

    @Resource
    private ISeckillVoucherService seckillVoucherService;
    @Resource
    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("库存不足!");
        }
        //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 订单id
        long orderId = redisIdWorker.nextId("order");
        voucherOrder.setId(orderId);
        //6.2 用户id
        voucherOrder.setUserId(UserHolder.getUser().getId());
        //6.3 代金券id
        voucherOrder.setVoucherId(voucherId);
        save(voucherOrder);
        //7 返回订单id
        return Result.ok(orderId);
    }
}

3、超卖问题

3.1 原因分析

在这里插入图片描述

3.2 解决方案选择:悲观锁or乐观锁

在这里插入图片描述

3.3 乐观锁实现

方案一:版本号法

在这里插入图片描述

方案二:CAS法

在这里插入图片描述

CAS法代码实现

在VoucherOrderServiceImpl类的seckillVoucher方法中修改操作数据库条件

		//5 扣减库存
        boolean success = seckillVoucherService.update()
                .setSql("stock = stock - 1") //set stock=stock-1
                .eq("voucher_id",voucherId).eq("stock",voucher.getStock()) //where id=? and stock=?
                .update();

改进,提高成功率

		//5 扣减库存
        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();

3.4 线程安全总结

在这里插入图片描述


4、一人一单

4.1 实现流程

在这里插入图片描述

4.2 代码实现

这个实现过程比较复杂,包含spring事务失效、aop代理对象、synchronized锁等知识点,可以多看几遍。

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

    @Resource
    private ISeckillVoucherService seckillVoucherService;
    @Resource
    private RedisIdWorker redisIdWorker;

    @Override
    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("库存不足!");
        }
        //synchronized悲观锁,给用户加锁
        //要在事务外层加锁,因为要在事务提交之后释放锁,才能确保线程安全
        Long userId = UserHolder.getUser().getId();
        synchronized(userId.toString().intern()){
            //使用代理对象的createVouterOrder方法才能开启事务
            //获取代理对象
            IVoucherOrderService proxy = (IVoucherOrderService) AopContext.currentProxy();
            return proxy.createVouterOrder(voucherId);
        }
    }


    @Transactional
    public Result createVouterOrder(Long voucherId) {
        //A 新增一人一单业务
        Long userId = UserHolder.getUser().getId();


        //A1 查询订单
        int count = query().eq("user_id", userId).eq("voucher_id", voucherId).count();
        //A2 判断是否存在当前id订单
        if(count>0){
            //用户已购买过
            return Result.fail("用户已购买过一次!");
        }
        //5 扣减库存
        boolean success = seckillVoucherService.update()
                .setSql("stock = stock - 1") //set stock=stock-1
                .eq("voucher_id", voucherId).gt("stock",0) //where id=? and stock=?
                .update();
        if(!success){
            //扣减失败
            return Result.fail("库存不足!");
        }
        //6 创建订单
        VoucherOrder voucherOrder = new VoucherOrder();
        //6.1 订单id
        long orderId = redisIdWorker.nextId("order");
        voucherOrder.setId(orderId);
        //6.2 用户id
        voucherOrder.setUserId(userId);
        //6.3 代金券id
        voucherOrder.setVoucherId(voucherId);
        save(voucherOrder);
        //7 返回订单id
        return Result.ok(orderId);

    }
}

在接口中声明createVouterOrder方法

	Result createVouterOrder(Long voucherId);

在获取代理对象防止事务失效时,要在pom文件中增加一个依赖

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

并在启动类HmDianPingApplication增加注解

@EnableAspectJAutoProxy(exposeProxy = true)

5、分布式锁

5.1 集群模式下的并发安全问题

在这里插入图片描述在这里插入图片描述集群中,有多个JVM存在,每个JVM内部都维护着各自的锁,因此仍然有若干个线程能获取到不同JVM中的锁,这就是集群导致的线程安全问题。因此需要让多个JVM共用一把锁。

5.2 分布式锁的工作原理

在这里插入图片描述

5.3 分布式锁方案对比

在这里插入图片描述

5.4 基于Redis的分布式锁(初级版本,不可重入)

5.4.1 流程

**Redis分布式锁原理:**基于setnx命令–>key存在的情况下,不更新value,而是返回nil
利用key是唯一的特性来加锁,比如一人一单业务,key名称精确到userId,那么同一个用户无论发多少次请求,能成功创建键值的只有一个,因为setnx命令,后面的请求在获取锁创建键值就会失败。

在这里插入图片描述

5.4.2 代码实现

锁接口

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

    /**
     * 释放锁
     */
    void unLock();
}

锁实现:

public class SimpleRedisLock implements ILock {

    private StringRedisTemplate stringRedisTemplate;
    //锁名称(业务名)
    private String name;
    //锁前缀
    private static final String KEY_PREFIX = "locks:";

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

    @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);
    }
}

在抢券业务中使用锁实现一人一单:
VoucherOrderServiceImpl类的seckillVoucher方法修改:

@Override
    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("库存不足!");
        }
        //synchronized悲观锁,给用户加锁
        //要在事务外层加锁,因为要在事务提交之后释放锁,才能确保线程安全
        Long userId = UserHolder.getUser().getId();
//        synchronized(userId.toString().intern()){
        //创建所对象
        SimpleRedisLock lock = new SimpleRedisLock(stringRedisTemplate, "order:" + userId);
        //获取锁
        boolean isLock = lock.tryLock(5);
        //判断是否获取锁成功
        if(!isLock){
            //失败,返回错误信息
            return Result.fail("不允许重复下单");
        }
        try {
            //使用代理对象的createVouterOrder方法才能开启事务
            //获取代理对象
            IVoucherOrderService proxy = (IVoucherOrderService) AopContext.currentProxy();
            return proxy.createVouterOrder(voucherId);
        } finally {
            //释放锁
            lock.unLock();
        }
//        }
    }

5.4.3中 可能存在的问题

1、误删问题

把别的线程的锁删了。
在这里插入图片描述
解决方案: 获取锁时生成线程标识,在释放锁时判断线程标识是否是自己的,不一致可能是别人的锁,不释放。
在这里插入图片描述
修正:
在这里插入图片描述

public class SimpleRedisLock implements ILock {

    private StringRedisTemplate stringRedisTemplate;
    //锁名称(业务名)
    private String name;
    //锁前缀
    private static final String KEY_PREFIX = "locks:";
    private static final String ID_PREFIX = UUID.randomUUID().toString(true) + "-";

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

    @Override
    public boolean tryLock(long timeoutSec) {
        // 获取线程标识
        String threadId = ID_PREFIX + Thread.currentThread().getId();

        // 获取锁
        Boolean success = stringRedisTemplate.opsForValue().setIfAbsent(KEY_PREFIX + name, threadId, timeoutSec, TimeUnit.SECONDS);
        //自动拆箱会有空指针风险
        return Boolean.TRUE.equals(success);
    }

    @Override
    public void unLock() {
        //获取线程标识
        String threadId = ID_PREFIX + Thread.currentThread().getId();
        //获取锁中的标识
        String id = stringRedisTemplate.opsForValue().get(KEY_PREFIX + name);
        //判断标识是否一致
        if(threadId.equals(id)){
            //释放锁
            stringRedisTemplate.delete(KEY_PREFIX + name);
        }
    }
}

2、原子性问题

判断锁和释放锁之间产生阻塞导致问题,因此这两个操作要变成原子性操作。
在这里插入图片描述
Redis事务是批处理,不能实现先判断锁一致再删除。并且Redis事务只能保证原子性,不能保证一致性。因此,使用Redis的Lua脚本来实现判断锁和释放锁操作的原子性。
在这里插入图片描述
修正:
利用lua的原子性特征,将判断和删除锁绑定为原子性操作。
在这里插入图片描述
Lua脚本:(放在resources目录下)

-- 这里的 KEY[1] 这就是锁的key,这里的ARGV[1] 就是当前线程标示
-- 比较线程标示和锁中标示是否一致
if(redis.call('get', KEYS[1]) == ARGV[1]) then
	-- 释放锁 del key
	return redis.call('del',KEYS[1])
end
return 0

SimpleRedisLock类中unlock方法调用lua脚本

public class SimpleRedisLock implements ILock {

    private StringRedisTemplate stringRedisTemplate;
    //锁名称(业务名)
    private String name;
    //锁前缀
    private static final String KEY_PREFIX = "locks:";
    private static final String ID_PREFIX = UUID.randomUUID().toString(true) + "-";
    private static final DefaultRedisScript<Long> UNLOCK_SCRIPT;
    static {
        //提前加载lua脚本
        UNLOCK_SCRIPT = new DefaultRedisScript<>();
        UNLOCK_SCRIPT.setLocation(new ClassPathResource("unlock.lua"));
        UNLOCK_SCRIPT.setResultType(Long.class);
    }

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

    @Override
    public boolean tryLock(long timeoutSec) {
        // 获取线程标识
        String threadId = ID_PREFIX + Thread.currentThread().getId();

        // 获取锁
        Boolean success = stringRedisTemplate.opsForValue().setIfAbsent(KEY_PREFIX + name, threadId, timeoutSec, TimeUnit.SECONDS);
        //自动拆箱会有空指针风险
        return Boolean.TRUE.equals(success);
    }

    @Override
    public void unLock() {
        //调用lua脚本
        stringRedisTemplate.execute(
                UNLOCK_SCRIPT,
                Collections.singletonList(KEY_PREFIX + name),
                ID_PREFIX + Thread.currentThread().getId());
    }
}

基于Redis的分布式锁(5.4, 5.5)总结

在这里插入图片描述

5.5 基于Redis的分布式锁优化(可重入)

在这里插入图片描述在这里插入图片描述

5.5.1 Redisson入门

1、配置POM依赖

		<dependency>
            <groupId>org.redisson</groupId>
            <artifactId>redisson</artifactId>
            <version>3.13.6</version>
        </dependency>

2、配置Redisson

@Configuration
public class RedissonConfig {

    @Bean
    public RedissonClient redissonClient(){// RedissonClient是工厂类
        //配置
        Config config = new Config();
        //添加redis地址,这里添加的单点地址,集群要使用config.useClusterServers()添加地址
        config.useSingleServer().setAddress("redis://【虚拟机ip】:6379").setPassword("【redis密码】");
        //创建Redisson对象
        return Redisson.create(config);
    }
}

3、使用Redisson分布式锁

// VoucherOrderServiceImpl类中注入RedissonClient对象
    @Resource
    private RedissonClient redissonClient;
// VoucherOrderServiceImpl类seckillVoucher方法中【创建锁,获取和释放锁语句修改】
//        创建锁对象
//        SimpleRedisLock lock = new SimpleRedisLock(stringRedisTemplate, "order:" + userId);
//        获取锁
//        boolean isLock = lock.tryLock(1200);
//        释放锁
//        lock.unlock();
        //创建锁对象(可重入)
        RLock lock = redissonClient.getLock("lock:order:" + userId);
        //尝试获取锁,参数是:获取锁的最大等待时间(默认不等待),锁自动释放时间,时间单位
        boolean isLock = lock.tryLock();
        //释放锁
        lock.unlock();

5.5.2 Redisson分布式锁原理

在这里插入图片描述

1 Redisson可重入原理

利用 hash 结构,记录线程标示和重入次数。
原理
在这里插入图片描述

2 Redisson可重试原理

【利用信号量控制锁重试等待】:消息订阅+信号量机制,不是无休止重试,是有人释放锁之后再重试。
前14分钟

3 Redisson防止业务未完成时锁的超时释放

watchDog(看门狗)锁续约时间实现
14:00之后

在这里插入图片描述

5.6 Redisson解决主从一致原理

在这里插入图片描述连锁策略:不再有主从节点,都获取成功才能获取锁成功,有一个节点获取锁不成功就获取锁失败。
在这里插入图片描述如果多个主节点保证锁的话,一个主节点宕机了,其它线程只能获得一个新主节点的锁,获取不到其它两个锁,还会获取失败
这里主要是防止主节点宕机后,其它线程获得新主节点的锁,引起线程安全问题。

(5.4-5.6总结)

在这里插入图片描述


6、Redis优化秒杀(异步)

6.1 流程图

在这里插入图片描述为避免所有操作都在数据库上执行,分离成两个线程:

  1. 一个线程判断用户的购买资格,发现用户有购买资格。(相当于收银台下单给小票)
  2. 再开启一个独立的线程来处理耗时较久的减库存、写订单的操作。(相当于后厨拿订单做菜)

可以将耗时较短的第一个线程操作放到 Redis 中,在 Redis 中处理对应的秒杀资格的判断。Redis 的性能是比 MySQL 要好的。此外,还需要引入【异步队列】记录相关的信息。

1、redis部分处理逻辑, Lua脚本封装操作保证原子性, redis这里选择的存储类型为set,因为key不能重复,而set恰好是无序不重复的
在这里插入图片描述

6.2 代码实现(基于阻塞队列的异步下单)

在这里插入图片描述
1.新增优惠券的业务时,把秒杀优惠券的库存信息保存到redis

// VoucherServiceImpl类中
		//保存秒杀库存到redis
        stringRedisTemplate.opsForValue().set(SECKILL_STOCK_KEY+voucher.getId(),voucher.getStock().toString());

2、编写lua脚本,按照下面的业务流程逻辑,在脚本中完成业务实现
在这里插入图片描述

-- 1.参数列表
-- 1.1 优惠券id
local voucherId = ARGV[1]
-- 1.2 用户id
local userId = ARGV[2]

-- 2.数据key
-- 2.1 库存key   key 是优惠的业务名称加优惠券id  value 是优惠券的库存数
local stockKey = 'seckill:stock:' .. voucherId
-- 2.2 订单key   key 也是拼接的业务名称加优惠权id  而value是用户id, 这是一个set集合,凡购买该优惠券的用户都会将其id存入集合中
local orderKey = 'seckill:order:' .. voucherId

-- 3.脚本业务
-- 3.1 判断库存是否充足 get stockKey
if (tonumber(redis.call('get', stockKey)) <= 0)  then  --将get的value先转为数字类型才能判断比较
    -- 3.2 库存不足,返回1
    return 1
end
-- 3.3 判断用户是否下单 sismember orderKey userId命令,判断当前key集合中,是否存在该value;返回1存在,0不存在
if (redis.call('sismember', orderKey, userId) == 1) then
    --3.4 存在说明是重复下单,返回2
    return 2
end
-- 3.5 扣库存
redis.call('incrby', stockKey, -1)
-- 3.6 下单(保存用户)
redis.call('sadd', orderKey, userId)
return 0

3、VoucherOrderServiceImpl类中执行lua脚本,并判断,抢购成功的生成订单并存入阻塞队列

//注入脚本
	private static final DefaultRedisScript<Long> SECKILL_SCRIPT;
    static {
        //提前加载lua脚本
        SECKILL_SCRIPT = new DefaultRedisScript<>();
        SECKILL_SCRIPT.setLocation(new ClassPathResource("seckill.lua"));
        SECKILL_SCRIPT.setResultType(Long.class);
    }
//运行脚本,且判断不满足的请求直接返回提示信息
	@Override
    public Result seckillVoucher(Long voucherId) {
        //获取用户
        Long userId = UserHolder.getUser().getId();
        //1 执行lua脚本
        Long result = stringRedisTemplate.execute(
                SECKILL_SCRIPT,
                Collections.emptyList(),
                voucherId.toString(), userId.toString()
        );
        //2 判断结果是否为0
        if(result.intValue() != 0){
            //2.1 不为0,没有购买资格
            return Result.fail(result.intValue()==1?"库存不足":"不能重复下单");
        }
        //2.2 为0,有购买资格;把下单信息保存到阻塞队列
        long orderId = redisIdWorker.nextId("order:");
        //TODO: 保存阻塞队列
        //3 返回订单id
        return Result.ok(orderId);
    }

4、如果抢购成功,将优惠券 id 和用户 id 封装后存入阻塞队列
4.1、创建BlockingQueue阻塞队列
BlockingQueue阻塞队列特点:当一个线程尝试从队列获取元素的时候,如果没有元素该线程阻塞,直到队列中有元素才会被唤醒并获取元素。

	//阻塞队列
    private BlockingQueue<VoucherOrder> orderTasks = new ArrayBlockingQueue<>(1024*1024);

4.2、将满足条件的请求,生成订单,并把订单对象add到阻塞队列中

public Result seckillVoucher(Long voucherId) {
        //获取用户
        Long userId = UserHolder.getUser().getId();
        //1 执行lua脚本
        Long result = stringRedisTemplate.execute(
                SECKILL_SCRIPT,
                Collections.emptyList(),
                voucherId.toString(), userId.toString()
        );
        //2 判断结果是否为0
        if (result.intValue() != 0) {
            //2.1 不为0,没有购买资格
            return Result.fail(result.intValue() == 1 ? "库存不足" : "不能重复下单");
        }
        //2.2 为0,有购买资格;把下单信息保存到阻塞队列
        //2.2.1 封装订单id,用户id,代金券id
        VoucherOrder voucherOrder = new VoucherOrder();
        long orderId = redisIdWorker.nextId("order");
        voucherOrder.setId(orderId);
        voucherOrder.setUserId(userId);
        voucherOrder.setVoucherId(voucherId);
        //2.2.2 放入阻塞队列
        orderTasks.add(voucherOrder);
        // 提前获取代理对象
        proxy = (IVoucherOrderService) AopContext.currentProxy();
        //3 返回订单id给客户端
        return Result.ok(orderId);
    }

5、开启线程任务,实现异步下单功能
5.1、首先创建一个线程池,再定义一个线程任务
【注意】线程任务需要在用户秒杀订单之前开始,用户一但开始秒杀,队列就会有新的订单,线程任务就应该立即取出订单信息,这里利用spring提供的注解,在类初始化完毕后立即执行线程任务。

	//线程池
    private static final ExecutorService SECKILL_ORDER_EXECUTOR = Executors.newSingleThreadExecutor();

    @PostConstruct
    private void init(){
        SECKILL_ORDER_EXECUTOR.submit(new VoucherOrderHandler());
    }

5.2、线程任务代码

	//线程任务,内部类方式
    private class VoucherOrderHandler implements Runnable{
        @Override
        public void run() {
            while(true){
                //1 获取队列中订单信息
                try {
                    VoucherOrder voucherOrder = orderTasks.take();
                    //2 创建订单
                    handleVoucherOrder(voucherOrder);
                } catch (Exception e) {
                    log.error("处理订单异常"+e);
                }
            }
        }
    }

    private void handleVoucherOrder(VoucherOrder voucherOrder) {
        //1 获取用户id
        Long userId = voucherOrder.getUserId();
        //2 创建锁对象
        RLock lock = redissonClient.getLock("lock:order:" + userId);
        //3 尝试获取锁,参数是:获取锁的最大等待时间(默认不等待),锁自动释放时间,时间单位
        boolean isLock = lock.tryLock();
        //判断是否获取锁成功
        if(!isLock){
            //失败,返回错误信息
            log.error("不允许重复下单");
            return;
        }
        try {
            proxy.createVoucherOrder(voucherOrder);
        } finally {
            //释放锁
            lock.unlock();
        }
    }

    @Transactional
    public void createVoucherOrder(VoucherOrder voucherOrder) {
        //A 新增一人一单业务
        Long userId = voucherOrder.getUserId();

        //A1 查询订单
        int count = query().eq("user_id", userId).eq("voucher_id", voucherOrder.getVoucherId()).count();
        //A2 判断是否存在当前id订单
        if(count>0){
            //用户已购买过
            log.error("用户已经购买一次!");
            return;
        }
        //5 扣减库存
        boolean success = seckillVoucherService.update()
                .setSql("stock = stock - 1") //set stock=stock-1
                .eq("voucher_id", voucherOrder.getVoucherId()).gt("stock",0) //where id=? and stock=?
                .update();
        if(!success){
            //扣减失败
            log.error("库存不足!");
            return;
        }
        //创建订单
        save(voucherOrder);
    }

6.3优化思路总结

在这里插入图片描述在这里插入图片描述


7、Redis消息队列实现异步秒杀

由于前面的阻塞队列是基于JVM的内存实现,那么不可避免的两个大问题
①高并发海量访问,创建订单,队列很快就超出上限,造成内存溢出;
②JVM内存没有持久化机制,若服务出现重启或宕机,阻塞队列中的所有任务都会丢失。
在这里插入图片描述

MQ(消息队列)优点: MQ是JVM以外的服务,不受JVM内存限制,且MQ中的所有消息会做持久化,这样即使重启或宕机,数据不会丢失。消息投递给消费者后需要消费者确认,未确认消息会一直存在下一次继续投递,确保消息至少被消费一次。

P69使用测试类创建Jmeter压力测试用的tokens.txt文件,并将所有用户存到redis。

package com.hmdp;

import cn.hutool.core.bean.BeanUtil;
import cn.hutool.core.bean.copier.CopyOptions;
import cn.hutool.core.lang.UUID;
import com.hmdp.dto.UserDTO;
import com.hmdp.entity.User;
import com.hmdp.service.IUserService;
import lombok.SneakyThrows;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.data.redis.core.StringRedisTemplate;
import java.io.*;
import java.nio.charset.StandardCharsets;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import static com.hmdp.utils.RedisConstants.LOGIN_USER_KEY;

/**
 * @author xsy
 * @version 1.0
 */
// P69创建 tokens.txt文件
@SpringBootTest
@AutoConfigureMockMvc
public class VoucherOrderControllerTest {
    @Autowired
    private IUserService userService;

    @Autowired
    private StringRedisTemplate stringRedisTemplate;

    @SneakyThrows
    @Test
    void getTokens(){
        FileOutputStream outputStream = new FileOutputStream(new File("D:\\JavaCode\\redis\\hm-dianping\\tokens.txt"));//tokens.txt文件存储路径
        List<User> userList = userService.query().list();
        userList.forEach(user -> {
            String token = UUID.randomUUID().toString(true);
            UserDTO userDTO = BeanUtil.copyProperties(user, UserDTO.class);
            Map<String, Object> userMap = BeanUtil.beanToMap(userDTO, new HashMap<>(),
                    new CopyOptions().
                            ignoreNullValue().
                            setFieldValueEditor((keyType, valueType) -> valueType.toString()));
            String key = LOGIN_USER_KEY + token;
            stringRedisTemplate.opsForHash().putAll(key, userMap);
            try {
                outputStream.write(token.getBytes(StandardCharsets.UTF_8));
                outputStream.write("\n".getBytes(StandardCharsets.UTF_8));
                outputStream.flush();
            } catch (IOException e) {
                throw new RuntimeException(e);
            }
        });
    }
}

7.1 基于List结构模拟消息队列

在这里插入图片描述在这里插入图片描述

7.2 PubSub(发布订阅)基本的点对点消息模型

在这里插入图片描述在这里插入图片描述

7.3 Stream 比较完善的消息队列模型

7.3.1 单消费者模式

在这里插入图片描述在这里插入图片描述在这里插入图片描述在这里插入图片描述

7.3.2 消费者组模式

在这里插入图片描述在这里插入图片描述在这里插入图片描述在这里插入图片描述

7.4三种Redis消息队列总结

在这里插入图片描述

7.5 代码实现基于stream消息队列的异步秒杀

在这里插入图片描述

7.5.1、redis客户端创建消息队列

XGROUP CREATE stream.orders g1 0 MKSTREAM

7.5.2、修改Lua脚本

-- 1.参数列表
-- 1.1 优惠券id
local voucherId = ARGV[1]
-- 1.2 用户id
local userId = ARGV[2]
-- 1.3.订单 id
local orderId = ARGV[3]

-- 2.数据key
-- 2.1 库存key   key 是优惠的业务名称加优惠券id  value 是优惠券的库存数
local stockKey = 'seckill:stock:' .. voucherId
-- 2.2 订单key   key 也是拼接的业务名称加优惠权id  而value是用户id, 这是一个set集合,凡购买该优惠券的用户都会将其id存入集合中
local orderKey = 'seckill:order:' .. voucherId

-- 3.脚本业务
-- 3.1 判断库存是否充足 get stockKey
if (tonumber(redis.call('get', stockKey)) <= 0)  then  --将get的value先转为数字类型才能判断比较
    -- 3.2 库存不足,返回1
    return 1
end
-- 3.3 判断用户是否下单 sismember orderKey userId命令,判断当前key集合中,是否存在该value;返回1存在,0不存在
if (redis.call('sismember', orderKey, userId) == 1) then
    --3.4 存在说明是重复下单,返回2
    return 2
end
-- 3.5 扣库存
redis.call('incrby', stockKey, -1)
-- 3.6 下单(保存用户)
redis.call('sadd', orderKey, userId)
-- 3.7 发送消息到队列中:XADD stream.orders * k1 v1 k2 v2 ...
redis.call('xadd', 'stream.orders', '*', 'userId', userId, 'voucherId', voucherId, 'id', orderId)

return 0

7.5.3、修改对应java脚本

	public Result seckillVoucher(Long voucherId) {
        //获取用户
        Long userId = UserHolder.getUser().getId();
        //获取订单ID
        long orderId = redisIdWorker.nextId("order");
        //1 执行lua脚本
        Long result = stringRedisTemplate.execute(
                SECKILL_SCRIPT,
                Collections.emptyList(),
                voucherId.toString(), userId.toString(),String.valueOf(orderId)
        );
        //2 判断结果是否为0
        if (result.intValue() != 0) {
            //2.1 不为0,没有购买资格
            return Result.fail(result.intValue() == 1 ? "库存不足" : "不能重复下单");
        }
        // 提前获取代理对象
        proxy = (IVoucherOrderService) AopContext.currentProxy();
        //3 返回订单id给客户端
        return Result.ok(orderId);
    }

7.5.4、修改线程对象

	private class VoucherOrderHandler implements Runnable{
        String queueName = "stream.orders";
        @Override
        public void run() {
            while(true){
                try {
                    //1 获取消息队列中订单信息 XREADGROUP GROUP g1 c1 COUNT 1 BLOCK 2000 streams.orders >
                    List<MapRecord<String, Object, Object>> list = stringRedisTemplate.opsForStream().read(
                            Consumer.from("g1", "c1"),
                            StreamReadOptions.empty().count(1).block(Duration.ofSeconds(2)),
                            StreamOffset.create(queueName, ReadOffset.lastConsumed())
                    );
                    //2 判断消息是否获取成功
                    if(list == null || list.isEmpty()){
                        //2.1 如果获取失败,说明没有消息,继续下一次循环
                        continue;
                    }
                    // 解析消息中的订单信息
                    MapRecord<String, Object, Object> record = list.get(0);
                    Map<Object, Object> value = record.getValue();
                    VoucherOrder voucherOrder = BeanUtil.fillBeanWithMap(value, new VoucherOrder(), true);
                    //2.2 如果获取成功,可以下单创建订单
                    System.out.println("2-handleVoucherOrder(voucherOrder)");
                    handleVoucherOrder(voucherOrder);
                    //3 ACK确认 SACK stream.orders g1 id
                    stringRedisTemplate.opsForStream().acknowledge(queueName,"g1",record.getId());
                } catch (Exception e) {
                    handlePendingList();
                    log.error("处理订单异常"+e);
                }
            }
        }

        private void handlePendingList(){
            while(true){
                try {
                    //1 获取消息队列中订单信息 XREADGROUP GROUP g1 c1 COUNT 1 streams.orders 0
                    List<MapRecord<String, Object, Object>> list = stringRedisTemplate.opsForStream().read(
                            Consumer.from("g1", "c1"),
                            StreamReadOptions.empty().count(1),
                            StreamOffset.create(queueName, ReadOffset.from("0"))
                    );
                    //2 判断消息是否获取成功
                    if(list == null || list.isEmpty()){
                        //2.1 如果获取失败,说明pending-list没有消息,结束循环
                        break;
                    }
                    // 解析消息中的订单信息
                    MapRecord<String, Object, Object> record = list.get(0);
                    Map<Object, Object> value = record.getValue();
                    VoucherOrder voucherOrder = BeanUtil.fillBeanWithMap(value, new VoucherOrder(), true);
                    //2.2 如果获取成功,可以下单创建订单
                    System.out.println("1-handleVoucherOrder(voucherOrder)");
                    handleVoucherOrder(voucherOrder);
                    //3 ACK确认 SACK stream.orders g1 id
                    stringRedisTemplate.opsForStream().acknowledge(queueName,"g1",record.getId());
                } catch (Exception e) {
                    log.error("处理pending-list订单异常"+e);
                    try {
                        Thread.sleep(20);
                    } catch (InterruptedException ex) {
                        throw new RuntimeException(ex);
                    }
                }
            }
        }
    }

8、【总结】Redis在秒杀业务中的应用

  1. 缓存
  2. 分布式锁
  3. 超卖问题
  4. Lua脚本
  5. Redis消息队列
  • 0
    点赞
  • 9
    收藏
    觉得还不错? 一键收藏
  • 6
    评论
这篇笔记是关于黑马点评项目中使用Redis的学习笔记笔记中的图片来源于黑马ppt,并提供了联系方式,如果有侵权问题可以联系删除。笔记内容包括了Redis的安装配置以及一些相关的知识点。需要注意的是,笔记中的配置是按照黑马2022的Redis进行的,仅供学习参考,并可以自由转载。另外,作者使用的是云服务器,所以IP配置不是127.0.0.1,大家需要根据自己的实际情况进行配置。在笔记中还对一些知识进行了补充,例如设置RedisSerializer来解决乱码问题。此外,笔记还提到了Redis的5种常见数据结构,包括String、List、Set、Hash和ZSet。<span class="em">1</span><span class="em">2</span><span class="em">3</span> #### 引用[.reference_title] - *1* [redis项目-黑马点评 项目笔记](https://blog.csdn.net/qq_48617775/article/details/127497077)[target="_blank" data-report-click={"spm":"1018.2226.3001.9630","extra":{"utm_source":"vip_chatgpt_common_search_pc_result","utm_medium":"distribute.pc_search_result.none-task-cask-2~all~insert_cask~default-1-null.142^v93^chatsearchT3_1"}}] [.reference_item style="max-width: 50%"] - *2* *3* [Redis黑马2022笔记(基础篇)](https://blog.csdn.net/m0_56079407/article/details/123453958)[target="_blank" data-report-click={"spm":"1018.2226.3001.9630","extra":{"utm_source":"vip_chatgpt_common_search_pc_result","utm_medium":"distribute.pc_search_result.none-task-cask-2~all~insert_cask~default-1-null.142^v93^chatsearchT3_1"}}] [.reference_item style="max-width: 50%"] [ .reference_list ]

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 6
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值