5.Redis实战—秒杀业务

优惠券秒杀 :

全局ID生成器 策略:

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

  1. 唯一性 :全局只有一个
  2. 高可用 :
  3. 递增性 :
  4. 安全性 : 规律不明显 , 让用户猜不到订单的信息
  5. 高性能 : 生成速度快 , 不影响其他业务的执行

为了增加ID的安全性 , 我们可以不直接使用Redis自增的数值 , 而是拼接一些其他信息

5.Redis实战—秒杀业务_第1张图片

  • ID的组成部分 :
    • 符号位 : 1bit , 永远为0
    • 时间戳 : 31bit , 可以使用60年
    • 序列号 : 32bit , 秒内的计数器 , 支持每秒产生2^32个不同ID

编写实现 :

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

import javax.annotation.Resource;
import java.time.LocalDateTime;
import java.time.ZoneOffset;
import java.time.format.DateTimeFormatter;

/**
 * 全局ID生成器
 */
@Component
public class RedisIDWorker {

    @Resource
    private StringRedisTemplate stringRedisTemplate;
    // TODO 指定的初始时间戳
    private static final Long BEGIN_TIMESTAMP = 946684800L;
    // TODO 序列号的位数 , 一般不写死
    private static final int COUNT_BITS = 32;


    //一般这个key , 是根据谁调用谁传递过来的字符串 , 进行拼接
    public Long nextId(String keyPrefix) {
        // TODO 1.生成时间戳
        LocalDateTime now = LocalDateTime.now();
        long nowSecond = now.toEpochSecond(ZoneOffset.UTC);
        long timeStamp = nowSecond - BEGIN_TIMESTAMP;

        // TODO 2.生成序列号 , 使用redis的自增长机制  ,
        //  不能使用同一个key作为自增长的key值 ,
        //  Redis单个key的最大自增数是2^64 , 我们设置的序列号最大是 2^32位
        //  一直使用同一个key会序列号溢出的问题的 ,
        //  可以在key的后边加一个当天的时间戳 , 一天使用一个key的值

        // TODO 2.1 获取当前日期 , 精确到天
        //  中间使用 : 分隔,在Redis中会进行分层 , 这个时候 , 就可以根据对应的前缀 , 来进行数据的统计
        String date = now.format(DateTimeFormatter.ofPattern("yyyy:MM:dd"));
        //自增长 , 默认是从1开始自增的 , 每次增加 1  
        Long count = stringRedisTemplate.opsForValue().increment("icr:" + keyPrefix + ":" + date);
        // TODO 3.拼接并返回 ,
        //  使用的是移位运算 , 将时间戳左移32位
        //  同时使用或运算 , 来进行拼接 ,
        //  或运算的逻辑是 , 在位移后的位置上 , 全部都是0 ,
        //  进行运算时 , 参加运算的两个对象,一个为1,其值为1 ,也就是count值是1就是1 , 是零就是0
        //  或的运算速度要比加快
        return timeStamp << COUNT_BITS | count ;
    }

    // TODO 使用下边的方法可以生成指定时间的时间戳
    public static void main(String[] args) {
        LocalDateTime time = LocalDateTime.of(2000, 1, 1, 0, 0, 0);
        long l = time.toEpochSecond(ZoneOffset.UTC);
        System.out.println(l);
    }
}

多线程检验:

@Resource
private RedisIDWorker redisIDWorker;

// TODO 创建一个线程池
private ExecutorService ex = Executors.newFixedThreadPool(500);

@Test
void testIdWorker() throws InterruptedException {
    CountDownLatch latch = new CountDownLatch(300);

    Runnable task = () -> {
        //一个线程循环执行100次 , 操作
        for (int i = 0; i < 100; i++) {
            Long id = redisIDWorker.nextId("order");
            System.out.println("id =" + id);
        }
        latch.countDown();
    };
    long begin = System.currentTimeMillis();
    for (int i = 0; i < 300; i++) {
        ex.submit(task);
    }
    latch.await();
    long end = System.currentTimeMillis();
    System.out.println("time =" + (end -begin));

}

优惠券秒杀 :

基础逻辑:

数据库表的创建 : 优惠券订单表

5.Redis实战—秒杀业务_第2张图片

image-20220415151401945

5.Redis实战—秒杀业务_第3张图片


代码实现 :

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

    @Resource
    private ISeckillVoucherService seckillVoucherService;

    @Resource
    private RedisIDWorker redisIDWorker;

    @Override
    public Result sekillVoucher(Long voucherId) {
        // TODO 1.查询优惠券
        SeckillVoucher seckillVoucher = seckillVoucherService.getById(voucherId);
        // TODO 2.判断秒杀是否开始
        //开始时间在当前时间之后
        if (seckillVoucher.getBeginTime().isAfter(LocalDateTime.now())) {
            return Result.fail("秒杀未开始");
        }
        // TODO 3.判断秒杀是否已经结束
        if (seckillVoucher.getEndTime().isBefore(LocalDateTime.now())) {
            return Result.fail("活动以结束");
        }
        // TODO 4.判断库存是否充足
        Integer stock = seckillVoucher.getStock();
        if (stock < 1){
            return Result.fail("库存不足");
        }
        // TODO 5.扣减库存
        boolean success = seckillVoucherService.update().setSql("stock = stock - 1").eq("voucher_id", voucherId).update();
        if (!success){
            return Result.fail("库存不足");
        }
        // TODO 6.创建订单
        VoucherOrder order = new VoucherOrder();
        // TODO 6.1订单id
        Long orderId = redisIDWorker.nextId("order");
        order.setId(orderId);
        // TODO 6.2用户id
        Long userId = UserHolder.getUser().getId();
        order.setUserId(userId);
        // TODO 6.3代金卷id
        order.setVoucherId(seckillVoucher.getVoucherId());
        // 写入数据库
        save(order);
        // TODO 7.返回订单id
        return Result.ok(orderId);
    }
}

秒杀的超卖问题 :

5.Redis实战—秒杀业务_第4张图片

5.Redis实战—秒杀业务_第5张图片

乐观锁优化超卖问题 :

  • 乐观锁的关键是判断之前查询得到的数据是否有被修改过 , 常见的方法有两种
    • 版本号法
      • 设置一个版本号 , 在每次执行操作之前 , 先查询库存和版本号 ,
      • 在每次执行操作库存扣减之前 , 先进行判断 , 看现在数据库中的版本号 , 和之前获取的版本号是否一致 , 一致就进行操作
      • 在执行完库存操作之后 , 将版本号加一
    • CAS法 : 在版本号法基础上做了一些改进 , 不用添加版本号 , 每次操作之前 , 先和自己查询的库存进行比较 , 出现不一致 , 就不操作
      • 先比较 , 后操作

5.Redis实战—秒杀业务_第6张图片

  • 但是直接和之前获取的库存数进行比较 , 会造成大量的失败 ,

  • 我们再次进行改进 , 在进行操作时 , 添加判断条件 , 只要库存大于 0 , 就可以操作

// TODO 5.扣减库存
boolean success = seckillVoucherService.update()
        .setSql("stock = stock - 1")  // TODO set stock = stock - 1
        .eq("voucher_id", voucherId)  // TODO where id = ?
        //.eq("stock", seckillVoucher.getStock()) 
    	// TODO and stock = ? , 这么做会造成大量的失败
        .gt("stock",0)  
    //TODO 使用gt , 添加的条件是  , stock > 0 , 这样刚好符合业务逻辑
        .update();  

一人一单问题 :

逻辑实现 :

5.Redis实战—秒杀业务_第7张图片

代码实现 :

使用乐观锁和悲观锁同时使用的形式 ,来优化超卖和一人一单问题 ,

将有关数据库的操作 , 抽取为一个方法 , 并且自定义悲观锁 , 以userid作为锁 , 确保多个相同id的线程中 , 只有一个线程能进行操作

@Transactional
public Result getResult(Long voucherId) {
    // TODO 6. 一人一单
    Long userId = UserHolder.getUser().getId();
    // TODO 给对应的id加锁 , 确保同一个id只能有一个线程能操作 , 这样就没有线程安全问题了, 
    synchronized(userId.toString().intern()){
        // TODO 6.1 查询订单
        Integer count = query().eq("user_id", userId).eq("voucher_id", voucherId).count();
        // TODO 6.2 判断是否存在
        if (count > 0){
            return Result.fail("您已经购买过了");
        }

        // TODO 5.扣减库存 , 要在判断表中是否有相同id的数据之后进行操作 , 
        boolean success = seckillVoucherService.update()
                .setSql("stock = stock - 1")  // TODO set stock = stock - 1
                .eq("voucher_id", voucherId)  // TODO where id = ?
                .gt("stock",0)  //TODO 使用gt , 添加的条件是  , stock > 0 , 这样刚好符合业务逻辑
                .update();
        
        if (!success){
            return Result.fail("库存不足");
        }
        // TODO 6.创建订单
        VoucherOrder order = new VoucherOrder();
        // TODO 6.1订单id
        Long orderId = redisIDWorker.nextId("order");
        order.setId(orderId);
        // TODO 6.2用户id
        order.setUserId(userId);
        // TODO 6.3代金卷id
        order.setVoucherId(voucherId);
        // 写入数据库
        save(order);
        // TODO 7.返回订单id
        return Result.ok(orderId);
    }
}

集群部署中的一人一单问题 :

之前使用悲观锁和乐观锁同时并存的方式 , 可以解决一人一单的问题 ,

这是因为 , 锁的原理是在jvm中创建了一个锁监视器 , 通过第一个线程的访问 , 获取锁的名称, 进jvm , 这是 , 其他用户在进行访问的时候 , 就无法获取到锁 , 无法进行操作

如果是使用集群部署的形式 , 那么就会创建两个或多个jvm , 创建多个锁监视器 , 这个时候再进行访问的时候 , 就会导致一个锁只能锁住一个服务器的请求 , 访问其他服务器也可以同时进行操作


分布式锁:

5.Redis实战—秒杀业务_第8张图片

在redis中 , 使用set操作 , 可以同时指定多个属性 , 来完成互斥 和 设置失效时间的操作

set key thread1 NX (设置互斥) EX 10 (设置过期时间)

在jdk中 , 有两种获取锁失败的操作 : 失败返回 或 阻塞

阻塞会不断重试获取锁 , 会对内存造成很大的浪费 , 这里我们使用失败返回的操作 ,

5.Redis实战—秒杀业务_第9张图片


尝试获取锁 :

@Override
public boolean tryLock(Long timeoutSec) {
    // TODO 获取线程的标识
    long threadId = Thread.currentThread().getId();
    Boolean aBoolean = stringRedisTemplate.opsForValue().setIfAbsent(KEY_PREFIX + name, threadId + "", timeoutSec, TimeUnit.SECONDS);
    return Boolean.TRUE.equals(aBoolean); //使用boolean中的方法 , 判断是否是true , 也可以解决自动拆箱造成的空指针异常情况
}

在原来的代码中 , 就不需要加 synchronize锁了 , 而是直接 尝试获取锁 , 如果获取锁失败 , 那么就直接输出错误信息 , 成功 , 再进行下一步业务操作


分布式锁的极端情况出现的线程问题 :

(1)阻塞超时 ,删除别的线程的锁 :

因为一个带锁线程发生阻塞, 导致锁超时失效 , 其他线程获取这个锁 , 导致 ,阻塞线程删除锁的时候 , 将其他线程获取的锁删除了 ,

5.Redis实战—秒杀业务_第10张图片

  • 解决方案 :
    • 在获取锁的时候 , 将UUID和线程iD拼接 , 作为value值 , 存入redis中 , 在释放锁的时候 , 进行判断 , 如果二者一致 , 就释放锁 , 如果不一致 , 说明这个锁已经被别人获取了 , 那么就不用进行释放了

5.Redis实战—秒杀业务_第11张图片

public class SimpRedisLock implements ILock {


    private StringRedisTemplate stringRedisTemplate;

    private String name;

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

    private static final String KEY_PREFIX = "lock:";
    private static final String ID_PREFIX = UUID.randomUUID().toString(true) + "-";

    @Override
    public boolean tryLock(Long timeoutSec) {
        // TODO 获取线程的标识
        String threadId = ID_PREFIX + Thread.currentThread().getId();
        Boolean aBoolean = stringRedisTemplate.opsForValue().setIfAbsent(KEY_PREFIX + name, threadId + "", timeoutSec, TimeUnit.SECONDS);
        return Boolean.TRUE.equals(aBoolean);
    }

    @Override
    public void unLock() {
        // TODO 获取线程标识
        String thread = ID_PREFIX + Thread.currentThread().getId();
        //TODO 获取redis中的数据
        String threadId = stringRedisTemplate.opsForValue().get(KEY_PREFIX + name);
        if (thread.equals(threadId)) {
            stringRedisTemplate.delete(KEY_PREFIX + name);
        }
    }
}

(2)使用lua脚本语言 , 解决释放锁时出现的问题

JVM执行垃圾回收的时候 , 所有的代码是无法工作的 , 造成在释放锁的时候发生阻塞

  • 极端情况 :
    • 线程1获取锁之后 , 顺利执行完业务 , 并且已经判断好了 , 锁是自己拥有的 , 正要执行释放锁的时候 , 垃圾回收进行触发 , 导致线程阻塞, 刚好 , 又到了锁的过期时间过期了 , 那么 , 这个时候 , 这个锁就会被别的线程2获取 , 但是线程1已经做了判断 , 认为这个锁是自己的了 , 继续执行 , 就将线程2的锁释放了
  • 解决方案 :
    • j将判断和释放锁 , 放在一个原子操作中
    • 使用lua脚本语言进行操作
-- 锁的key
-- local key = KEYS[1]
-- 当前线程标识 
-- local threadId = ARGV[1]

-- 获取锁中的线程标识 get key
-- local id = redis.call('get',KEYS[1])

-- 比较线程标识与锁中标识是否一致
if (ARGV[1] == redis.call('get',KEYS[1])) then
    -- 释放锁
    redis.call('del',KEYS[1])
end
return 0

5.Redis实战—秒杀业务_第12张图片

引入lua脚本 :

private static final DefaultRedisScript<Long> UNLOCK_SCRIPT;
static{
    UNLOCK_SCRIPT = new DefaultRedisScript<>(); //初始化脚本 , 
    //指定脚本的位置 , 一般是放在Resource包中 , 直接使用 ClassPathResource , 就是从Resource包中开始寻找 
    UNLOCK_SCRIPT.setLocation(new ClassPathResource("unlock.lua"));
    UNLOCK_SCRIPT.setResultType(Long.class);
}

编写释放锁代码 :

//释放锁
@Override
public void unLock() {
    // 调用lua脚本
    stringRedisTemplate.execute(
            UNLOCK_SCRIPT,
        	//这个操作 , 是指定是一个单元素的集合
            Collections.singletonList(KEY_PREFIX + name),
            ID_PREFIX + Thread.currentThread().getId());
}

Redisson:

Redisson是一个在Redis的基础上实现的java驻内存数据网络 , 它不仅提供了一系列的分布式的java常用对象 , 还提供了许多分布式服务 , 其中就包含了各种分布式锁的实现

官网地址 : https://redisson.org

GIthub地址 : https://github.com/redisson/redisson

5.Redis实战—秒杀业务_第13张图片

快速入门 :

引入依赖 :

<dependency>
    <groupId>org.redissongroupId>
    <artifactId>redissonartifactId>
    <version>3.16.8version>
dependency>

配置Redisson客户端

import org.redisson.Redisson;
import org.redisson.api.RedissonClient;
import org.redisson.config.Config;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

@Configuration
public class RedissonConfig {
    @Bean
    public RedissonClient redissonClient(){
        // 配置类
        Config config = new Config();
        //添加redis地址 , 这里添加了单点的地址 , 也可以使用config.userClusterServers()添加集群地址
        config.useSingleServer().setAddress("redis://127.0.0.1:6379");
        // 创建客户端
        return Redisson.create(config);
    }
}

使用Redisson的分布式锁 :

// TODO 获取锁(可重入),指定锁的名称
RLock lock = redissonClient.getLock("anyLock");
// TODO 尝试获取锁 , 参数分别是 : 获取锁的最大等待时间(期间会重试) , 锁自动释放时间 , 时间单位
//  如果最大等待时间结束 , 还是没有获取到锁 , 那么才会返回false
boolean isLock = lock.tryLock(1, 10, TimeUnit.SECONDS);

// TODO 释放锁
lock.unlock();

Redisson可重入锁的原理 :

使用hashMap的类型存储redis数据 , value中 设置一个线程标识作为key , 设置一个计数器作为value , 计数器从零开始 , 每次获取锁 , 计数器加一 , 每次释放锁 , 计数器减一

5.Redis实战—秒杀业务_第14张图片

5.Redis实战—秒杀业务_第15张图片


Redisson可重试锁机制:

利用信号量和PubSub功能实现等待 , 唤醒 , 获取锁失败的重试机制

Redisson超时续约 :

watchDog机制 : 每隔一段时间 (releaseTime / 3) , 重置超时时间

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-x7rnoNLK-1650630427616)(https://gitee.com/aisichen/sichen/raw/master/img/image-20220417110836261.png)]


Redisson的MultiLock连锁机制 :

在集群的情况下 , 有一种连锁机制 , 就是在集群中的每一个节点中 , 都设置一个相同的锁 , 在获取锁的时候 , 只有全部节点都获取到了锁 , 才算获取成功 ,

创建多个节点 , 在配置中 配置多个配置 , 注入的的时候 , 也要注入所有的RedissonClient

RLock lock1 = redissonClient1.getLock("anyLock");
RLock lock2 = redissonClient2.getLock("anyLock");
RLock lock3 = redissonClient3.getLock("anyLock");

lock = redissonClient1.getMultiLock(lock1 , lock2 , lock3);

总结 :

5.Redis实战—秒杀业务_第16张图片


Redis优化秒杀 将业务分成两部分组成 , 判断一人一单和库存数量放在lua脚本中执行 , 创建订单 , 放在阻塞队列中 ,异步处理请求:

5.Redis实战—秒杀业务_第17张图片

需求 :

  • 新增秒杀优惠券的同时 , 将优惠券信息保存到Redis中
  • 基于lua脚本 , 判断秒杀库存 , 一人一单 , 决定用户是否抢购成功
  • 如果抢购成功 , 将优惠券的id , 和用户id封装后存入阻塞队列
  • 开启线程任务 , 不断从阻塞队列中获取信息 , 实现异步下单

编写 lua 脚本 :

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

-- 2.数据key
-- 2.1 库存key
local stockKey = 'seckill:stock:' .. voucherId
-- 2.2 订单key
local orderKey = 'seckill:order:' .. voucherId

-- 3.脚本业务
-- 3.1 判断库存是否充足 get stockKey
-- tonumber 将字符串转为数字 
if (tonumber(redis.call('get', stockKey)) <= 0) then
    -- 3.2 库存不足 , 返回1
    return 1
end
-- 3.2 判断用户是否下单 SISMEMBER orderKey userId , 
-- sismember 是判断set集合中 , 是否包含 指定的元素 , 包含就返回1 
if (redis.call('sismember' , orderKey , userId) == 1) then
    -- 3.3说明是重复下单
    return 2
end

-- 3.4 扣库存 , incrby stockKey -1
redis.call('incrby',stockKey ,-1)
--3.4 扣库存 , 下单 (保存用户)
redis.call('sadd' , orderKey , userId)
return 0

执行lua脚本 . 并执行业务

import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import com.hmdp.dto.Result;
import com.hmdp.entity.VoucherOrder;
import com.hmdp.mapper.VoucherOrderMapper;
import com.hmdp.service.ISeckillVoucherService;
import com.hmdp.service.IVoucherOrderService;
import com.hmdp.utils.RedisIDWorker;
import com.hmdp.utils.UserHolder;
import org.redisson.api.RLock;
import org.redisson.api.RedissonClient;
import org.springframework.core.io.ClassPathResource;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.data.redis.core.script.DefaultRedisScript;
import org.springframework.stereotype.Service;

import javax.annotation.PostConstruct;
import javax.annotation.Resource;
import java.util.Collections;
import java.util.concurrent.ArrayBlockingQueue;
import java.util.concurrent.BlockingQueue;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;

/**
 * 

* 服务实现类 *

* * @author 虎哥 * @since 2021-12-22 */
@Service public class VoucherOrderServiceImpl extends ServiceImpl<VoucherOrderMapper, VoucherOrder> implements IVoucherOrderService { @Resource private ISeckillVoucherService seckillVoucherService; @Resource private RedisIDWorker redisIDWorker; @Resource private StringRedisTemplate stringRedisTemplate; @Resource private RedissonClient redissonClient; @Resource // 创建阻塞队列 , 需要指定阻塞队列的长度 private BlockingQueue<VoucherOrder> orderTasks = new ArrayBlockingQueue<>(1024 * 1024); // 创建线程池 , 单线程的线程池 private static final ExecutorService EXECUTORSERVICE = Executors.newSingleThreadExecutor(); @PostConstruct // TODO 被这个注解注释的方法 , 在当前类初始化完毕之后 , 开始执行 private void init() { EXECUTORSERVICE.submit(new VoucherOrderHandler()); } private class VoucherOrderHandler implements Runnable { @Override public void run() { while (true) { // 1.获取订单中的信息 try { // take . 就是获取阻塞队列中的信息 VoucherOrder voucherOrder = orderTasks.take(); // 2.创建订单 createVoucherOrder(voucherOrder); } catch (InterruptedException e) { log.error("处理订单异常"); } } } // 创建订单的方法 , 进行一人一单 , 校验 , 是为了进行最后的兜底 private void createVoucherOrder(VoucherOrder voucherOrder) { // TODO 6. 一人一单 Long userId = voucherOrder.getUserId(); // TODO 获取锁(可重入),指定锁的名称 RLock lock = redissonClient.getLock("anyLock"); // TODO 尝试获取锁 , 参数分别是 : 获取锁的最大等待时间(期间会重试) , 所自动释放时间 , 时间单位 // 如果最大等待时间结束 , 还是没有获取到锁 , 那么才会返回false boolean isLock = lock.tryLock(); // 判断获取锁是否成功 if (!isLock) { // TODO 获取锁失败 , 直接返回失败或者重试 log.error("请勿重复提交"); return; } try { // TODO 6.1 查询订单 Integer count = query().eq("user_id", userId).eq("voucher_id", voucherOrder.getVoucherId()).count(); // TODO 6.2 判断是否存在 if (count > 0) { log.error("您已经购买过了"); return ; } // TODO 5.扣减库存 , 要在判断表中是否有相同id的数据之后进行操作 , boolean success = seckillVoucherService.update() .setSql("stock = stock - 1") // TODO set stock = stock - 1 .eq("voucher_id",voucherOrder.getVoucherId() ) // TODO where id = ? .gt("stock", 0) //TODO 使用gt , 添加的条件是 , stock > 0 , 这样刚好符合业务逻辑 .update(); if (!success) { log.error("库存不足"); return; } // 写入数据库 save(voucherOrder); } catch (Exception e) { throw new RuntimeException(e); } finally { lock.unlock(); } } } // 创建lua脚本 private static final DefaultRedisScript<Long> UNLOCK_SCRIPT; // 初始化lua脚本 static { UNLOCK_SCRIPT = new DefaultRedisScript<>(); UNLOCK_SCRIPT.setLocation(new ClassPathResource("seckill.lua")); UNLOCK_SCRIPT.setResultType(Long.class); } @Override public Result sekillVoucher(Long voucherId) throws InterruptedException { Long userId = UserHolder.getUser().getId(); // TODO 1.执行lua脚本 Long aLong = stringRedisTemplate.execute( UNLOCK_SCRIPT, Collections.emptyList(), voucherId.toString(), userId.toString() ); // TODO 将long类型的数据 , 拆为int类型的 int i = aLong.intValue(); // TODO 2.判断结果是否为0 if (i != 0) { // TODO 2.1 不为0 , 表示没有购买资格 return Result.fail(i == 1 ? "库存不足" : "请勿重复下单"); } // TODO 2.2 为0 , 有购买资格 , 把下单信息保存到阻塞队列 Long orderId = redisIDWorker.nextId("order"); // TODO 保存阻塞队列 VoucherOrder voucherOrder = new VoucherOrder(); // 2.3 优惠券id voucherOrder.setVoucherId(voucherId); // 订单id voucherOrder.setId(orderId); // 用户id voucherOrder.setUserId(userId); // 放入阻塞队列 , orderTasks.add(voucherOrder); // TODO 返回订单id return Result.ok(orderId); } }

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

消息队列 : (Message Queue)

在Redis中提供了三种不同的方式来实现消息队列

  1. list结构 : 基于list结构模拟消息队列
    • 5.Redis实战—秒杀业务_第18张图片
    • 优点 :
      • 利用Redis存储 , 不受限于JVM内存上限
      • 基于Redis持久化机制 , 数据安全有保证
      • 可以满足消息有序性
    • 缺点 :
      • 无法避免消息丢失
      • 只支持单消费者
  2. PubSub : 基本的点对点消息模型
    • image-20220417160129419
      • SUBSCRIBE channel [channel] : 订阅一个或多个频道
      • PUBLISH channel msg : 向一个频道发送消息
      • PSUBSCRIBE pattern [pattern] : 订阅与pattern格式匹配的所有频道
    • 优点 :
      • 采用发布订阅模型 , 支持多生产 , 多消费
    • 缺点 :
      • 不支持数据持久化 ,
      • 无法避免消息丢失
      • 消息堆积有上限 , 超出时数据丢失
  3. Stream : 比较完善的消息队列模型

image-20220417161715648

  • 消息可回溯
  • 一个消息可以被多个消费者读取
  • 可以阻塞读取
  • 有消息漏读的风险

5.Redis实战—秒杀业务_第19张图片


基于Stream的消息队列 – 消费者组

5.Redis实战—秒杀业务_第20张图片

创建消费者组:

5.Redis实战—秒杀业务_第21张图片

  • 代码实现 :

  • // XGROUP 命令 
    // CREATE 创建
    // stream.orders : 队列名称
    // g1 : 消费者组名称
    // 0 : ID
    // MKSTREAM :队列不存在时自动创建队列
    XGROUP CREATE stream.orders g1 0 MKSTREAM
    

发送消息:

  • 5.Redis实战—秒杀业务_第22张图片

  • 代码中的实现 :

  • -- 3.6 发送消息到队列中 XADD stream.orders  *:由redis自动生成  k1  v1 k2 v2 k3 v3
    redis.call('xadd' , 'stream.orders' , '*' , 'userId' , userId , 'voucherId' , voucherId , 'id' , orderId)
    

读取消息 :

  • 5.Redis实战—秒杀业务_第23张图片

  • 5.Redis实战—秒杀业务_第24张图片

  • 代码中的实现 :

  • // 1.获取消息队列中的订单信息
    List<MapRecord<String, Object, Object>> list = stringRedisTemplate.opsForStream().read(
        // TODO 指定消费者所在的组 和 名称
        Consumer.from("g1", "c1"),
        // TODO 设置读取时的参数 , empty : 空 , count(1) : 读取的消息个数
        // TODO block() : 设置最大的等待时间 ,
        StreamReadOptions.empty().count(1).block(Duration.ofSeconds(2)),
        // TODO ReadOffset : 起始偏移量 , 枚举 , lastConsumed :最后消费
        StreamOffset.create("stream.orders", ReadOffset.lastConsumed())
    );
    

其他命令:

5.Redis实战—秒杀业务_第25张图片


基于Redis的Stream结构作为消息队列 , 实现异步秒杀下单 :

5.Redis实战—秒杀业务_第26张图片

  • XGROUP CREATE stream.orders g1 0 MKSTREAM
    
  • -- 3.6 发送消息到队列中 XADD stream.orders * k1 v1
    redis.call('xadd' , 'stream.orders' , '*' , 'userId' , userId , 'voucherId' , voucherId , 'id' , orderId)
    
  • private class VoucherOrderHandler implements Runnable {
        @Override
        public void run() {
            while (true) {
                try {
                    // 1.获取消息队列中的订单信息
                    List<MapRecord<String, Object, Object>> list = stringRedisTemplate.opsForStream().read(
                            // TODO 指定消费者所在的组 和 名称
                            Consumer.from("g1", "c1"),
                            // TODO 设置读取时的参数 , empty : 空 , count(1) : 读取的消息个数
                            // TODO block() : 设置最大的等待时间 ,
                            StreamReadOptions.empty().count(1).block(Duration.ofSeconds(2)),
                            // TODO ReadOffset : 起始偏移量 , 枚举 , lastConsumed :最后消费
                            StreamOffset.create("stream.orders", ReadOffset.lastConsumed())
                    );
                    // 2.判断订单信息是否为空
                    if (list == null || list.isEmpty()){
                        // 如果为null , 说明没有消息 , 继续下一个循环
                        continue;
                    }
                    // 解析消息
                    MapRecord<String, Object, Object> record = list.get(0);
                    Map<Object, Object> value = record.getValue();
                    VoucherOrder voucherOrder = BeanUtil.fillBeanWithMap(value, new VoucherOrder(), true);
                    // 3.创建订单
                    createVoucherOrder(voucherOrder);
                    // 4.确认消息  XACK
                    stringRedisTemplate.opsForStream().acknowledge("stream.orders","g1",record.getId());
                } catch (Exception e) {
                    log.error("处理订单异常",e);
                    // 处理异常
                    handlePenddingList();
                }
            }
        }
    

你可能感兴趣的:(Redis,redis)