Redis实战之 秒杀优化

216 阅读9分钟

压力测试 目的:测试1000个用户抢购优惠券时秒杀功能的并发性能~

①数据库中创建1000+用户

这里推荐使用开源工具:www.sqlfather.com/ ,导入以下配置即可一键生成模拟数据

{"dbName":"hmdp","tableName":"tb_user","tableComment":"用户表","mockNum":100,"fieldList":[{"fieldName":"id","fieldType":"bigint(20)","defaultValue":null,"notNull":true,"comment":"主键id","primaryKey":true,"autoIncrement":true,"mockType":"递增","mockParams":2,"onUpdate":null},{"fieldName":"phone","fieldType":"varchar(33)","defaultValue":null,"notNull":false,"comment":null,"primaryKey":false,"autoIncrement":false,"mockType":"随机","mockParams":"手机号","onUpdate":null},{"fieldName":"password","fieldType":"varchar(384)","defaultValue":null,"notNull":false,"comment":null,"primaryKey":false,"autoIncrement":false,"mockType":"随机","mockParams":"字符串","onUpdate":null},{"fieldName":"nick_name","fieldType":"varchar(96)","defaultValue":null,"notNull":false,"comment":null,"primaryKey":false,"autoIncrement":false,"mockType":"规则","mockParams":"user_\w{10}$","onUpdate":null},{"fieldName":"icon","fieldType":"varchar(765)","defaultValue":null,"notNull":false,"comment":null,"primaryKey":false,"autoIncrement":false,"mockType":"固定","mockParams":"/imgs/blogs/blog1.jpg","onUpdate":null},{"fieldName":"create_time","fieldType":"timestamp","defaultValue":null,"notNull":false,"comment":null,"primaryKey":false,"autoIncrement":false,"mockType":"固定","mockParams":"2023-01-01 00:00:00","onUpdate":null},{"fieldName":"update_time","fieldType":"timestamp","defaultValue":null,"notNull":false,"comment":null,"primaryKey":false,"autoIncrement":false,"mockType":"固定","mockParams":"2023-01-01 00:00:01","onUpdate":null}]}

②将1000个用户处于登录状态(本质就是为1000个用户生成token,并保存到Redis中)

/**
 * 在Redis中保存1000个用户信息并将其token写入文件中,方便测试多人秒杀业务
 */
@Test
void testMultiLogin() throws IOException {
    List <User> userList = userService.lambdaQuery().last("limit 1000").list();
    for (User user : userList) {
        String token = UUID.randomUUID().toString(true);
        UserDTO userDTO = BeanUtil.copyProperties(user, UserDTO.class);
        Map <String,Object> userMap = BeanUtil.beanToMap(userDTO,new HashMap <>(),
                CopyOptions.create().ignoreNullValue()
                        .setFieldValueEditor((fieldName,fieldValue) -> fieldValue.toString()));
        String tokenKey = RedisConstants.LOGIN_USER_KEY + token;
        stringRedisTemplate.opsForHash().putAll(tokenKey, userMap);
        stringRedisTemplate.expire(tokenKey, 60,TimeUnit.MINUTES);
    }
    Set <String> keys = stringRedisTemplate.keys(RedisConstants.LOGIN_USER_KEY + "*");
    @Cleanup FileWriter fileWriter = new FileWriter(System.getProperty("user.dir") + "\\tokens.txt");
    @Cleanup BufferedWriter bufferedWriter = new BufferedWriter(fileWriter);
    assert keys != null;
    for (String key : keys) {
        String token = key.substring(RedisConstants.LOGIN_USER_KEY.length());
        String text = token + "\n";
        bufferedWriter.write(text);
    }
}

1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 ③在Jmeter中进行压力测试:1000个线程请求接口,观察结果

这接口被Leader发现,估计要被骂死~

6.1 秒杀优化-异步秒杀思路 我们来回顾一下下单流程

当用户发起请求,此时会请求nginx,nginx会访问到tomcat,而tomcat中的程序,会进行串行操作,分成如下几个步骤

1、查询优惠卷

2、判断秒杀库存是否足够

3、查询订单

4、校验是否是一人一单

5、扣减库存

6、创建订单

在这六步操作中,又有很多操作是要去操作数据库的,而且还是一个线程串行执行, 这样就会导致我们的程序执行的很慢,所以我们需要异步程序执行,那么如何加速呢?

在这里笔者想给大家分享一下课程内没有的思路,看看有没有小伙伴这么想,比如,我们可以不可以使用异步编排来做,或者说我开启N多线程,N多个线程,一个线程执行查询优惠卷,一个执行判断扣减库存,一个去创建订单等等,然后再统一做返回,这种做法和课程中有哪种好呢?答案是课程中的好,因为如果你采用我刚说的方式,如果访问的人很多,那么线程池中的线程可能一下子就被消耗完了,而且你使用上述方案,最大的特点在于,你觉得时效性会非常重要,但是你想想是吗?并不是,比如我只要确定他能做这件事,然后我后边慢慢做就可以了,我并不需要他一口气做完这件事,所以我们应当采用的是课程中,类似消息队列的方式来完成我们的需求,而不是使用线程池或者是异步编排的方式来完成这个需求

**优化方案:**我们将耗时比较短的逻辑判断放入到redis中,比如是否库存足够,比如是否一人一单,这样的操作,只要这种逻辑可以完成,就意味着我们是一定可以下单完成的,我们只需要进行快速的逻辑判断,根本就不用等下单逻辑走完,我们直接给用户返回成功, 再在后台开一个线程,后台线程慢慢的去执行queue里边的消息,这样程序不就超级快了吗?而且也不用担心线程池消耗殆尽的问题,因为这里我们的程序中并没有手动使用任何线程池。当然这里边有两个难点

第一个难点是我们怎么在redis中去快速校验一人一单,还有库存判断

第二个难点是由于我们校验和tomct下单是两个线程,那么我们如何知道到底哪个单他最后是否成功,或者是下单完成,为了完成这件事我们在redis操作完之后,我们会将一些信息返回给前端,同时也会把这些信息丢到异步queue中去,后续操作中,可以通过这个id来查询我们tomcat中的下单逻辑是否完成了。【饭店的运营流程】

我们现在来看看整体思路:当用户下单之后,判断库存是否充足只需要到redis中去根据key找对应的value是否大于0即可,如果不充足,则直接结束,如果充足,继续在redis中判断用户是否可以下单,如果set集合中没有这条数据,说明他可以下单,如果set集合中没有这条记录,则将userId和优惠卷存入到redis中,并且返回0,整个过程需要保证是原子性的,我们可以使用lua来操作

当以上判断逻辑走完之后,我们可以判断当前redis中返回的结果是否是0 ,如果是0,则表示可以下单,则将之前说的信息存入到到queue中去,然后返回,然后再来个线程异步的下单,前端可以通过返回的订单id来判断是否下单成功。

6.2 秒杀优化-Redis完成秒杀资格判断 需求:

新增秒杀优惠券的同时,将优惠券信息保存到Redis中

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

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

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

VoucherServiceImpl

@Override @Transactional public void addSeckillVoucher(Voucher voucher) { // 保存优惠券 save(voucher); // 保存秒杀信息 SeckillVoucher seckillVoucher = new SeckillVoucher(); seckillVoucher.setVoucherId(voucher.getId()); seckillVoucher.setStock(voucher.getStock()); seckillVoucher.setBeginTime(voucher.getBeginTime()); seckillVoucher.setEndTime(voucher.getEndTime()); seckillVoucherService.save(seckillVoucher); // 保存秒杀库存到Redis中 stringRedisTemplate.opsForValue().set(SECKILL_STOCK_KEY + voucher.getId(), voucher.getStock().toString()); } 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 完整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 if (tonumber(redis.call('get', stockKey)) <= 0) then -- 3.1.2 库存不足,返回1 return 1 end

-- 3.2 判断用户是否已经下过单 if (redis.call('sismember', orderKey, userId) == 1) then -- 3.2.2 存在,说明重复下单,返回2 return 2 end

-- 3.3 扣库存 incrby stockKey -1 redis.call('incrby', stockKey, -1)

-- 3.4 下单(保存用户) sadd orderKey userId redis.call('sadd', orderKey, userId)

-- 3.5 用户有下单资格,返回0 return 0

1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 当以上lua表达式执行完毕后,剩下的就是根据步骤3,4来执行我们接下来的任务了

VoucherOrderServiceImpl

/** * 使用Lua脚本+消息队列实现秒杀下单 * * @param voucherId * @return */ @Override public Result seckillVoucher(Long voucherId) { // 获取用户id Long userId = UserHolder.getUser().getId(); // 1.执行Lua脚本 Long result = stringRedisTemplate.execute( SECKILL_SCRIPT, Collections.emptyList(), voucherId.toString(), userId.toString() );

// 2.判断结果是否为0
if (result != 0) {
    // 2.1 不为0,代表没有购买资格
    return Result.fail(result == 1 ? "库存不足" : "不能重复下单");
}
//TODO 保存阻塞队列

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

}

1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 **压力测试:**因为目前前两步骤做完,后面的加入阻塞队列执行时间就很短了~

可以看到并发性能大大提升,请求响应值在0.1s左右,吞吐量可达到1500/sec~ 速度飞起

6.3 秒杀优化-基于阻塞队列实现秒杀优化 VoucherOrderServiceImpl

修改下单动作,现在我们去下单时,是通过lua表达式去原子执行判断逻辑,如果判断我出来不为0 ,则要么是库存不足,要么是重复下单,返回错误信息,如果是0,则把下单的逻辑保存到队列中去,然后异步执行

/**
 * 将代理对象声明成全局
 */
private IVoucherOrderService proxy;

/**
 * 存放任务的阻塞队列
 * 特点:当一个线程尝试从队列中获取元素,没有元素,线程就会被阻塞,直到队列中有元素,线程才会被唤醒,并去获取元素
 */
private BlockingQueue <VoucherOrder> orderTasks = new ArrayBlockingQueue <>(1024 * 1024);

/**
 * 思考一个问题:为什么要使用线程池呢,而不是直接创建一个线程?
 * 其实直接创建一个线程也行,但是创建一个线程开销很大的,用阻塞队列+线程池的形式实现了线程的的复用
 */
private static final ExecutorService SECKILL_ORDER_EXECUTOR = Executors.newSingleThreadExecutor();

/**
 * 由于用户秒杀的时间可能是随时的,所以需要我们项目已启动 线程池就应该从消息队列获取任务,然后工作...
 *
 * @PostConstruct类初始花后立刻执行
 */
@PostConstruct
private void init() {
    SECKILL_ORDER_EXECUTOR.submit(new VoucherOrderHandler());
}

private class VoucherOrderHandler implements Runnable {

    @Override
    public void run() {
        while (true) {
            try {
                // 1.获取队列中的订单信息
                // take():获取和删除该队列的头部,如果没有则阻塞等待,直到有元素可用。所以使用该方法,如果有元素,线程就工作,没有线程就阻塞(卡)在这里,不用担心CPU会空转~
                VoucherOrder voucherOrder = orderTasks.take();
                // 2.创建订单
                handleVoucherOrder(voucherOrder);
            } catch (Exception e) {
                log.error("处理订单异常:", e);
            }
        }
    }
}

/**
 * 创建订单
 *
 * @param voucherOrder
 */
private void handleVoucherOrder(VoucherOrder voucherOrder) {

    /**
     * 其实这里可以不加锁了:(方式一)
     * ①:前面的Lua脚本已经进判断过库存和一人一单了,并且也可以保证执行的原子性(一次只有一个线程执行)。
     * ②:此时线程池中只有一个线程,是单线程哦~
     * ③:之后从消息队列取任务执行并不需要保证其原子性,因为就不存在并发安全问题了
     * 加锁算是一种兜底~
     */

    // 方式一:加分布式锁再创建订单
    // // 1.获取用户
    // // 注意:这里userId不能从UserHolder中去取,因为当前并不是主线程,而是子线程,无法拿到父线程ThreadLocal中的数据
    // Long userId = voucherOrder.getUserId();
    // // 2.获取分布式锁
    // RLock lock = redissonClient.getLock("lock:order:" + userId);
    // boolean isLock = lock.tryLock();
    // // 3.判断是否获取锁成功
    // if (!isLock) {
    //     // 获取锁失败,返回错误和重试
    //     log.error("不允许重复下单~");
    // }
    // try {
    //     // 获取代理对象(只有通过代理对象调用方法,事务才会生效)
    //     // 注意:这里直接通过以下方式获取肯定是不行的。因为方法底层也是基于ThreadLocal获取的,子线程是无法获取父线程ThreadLocal中的对象的
    //     // 解决办法:在seckillVoucher中提前获取,然后通过消息队列传入或者声明成全局变量,从而就可以使用了
    //     // IVoucherOrderService proxy = (IVoucherOrderService) AopContext.currentProxy();
    //     proxy.createVoucherOrder(voucherOrder.getVoucherId());
    // } finally {
    //     lock.unlock();
    // }

    // 方式二:直接创建订单
    proxy.createVoucherOrder(voucherOrder);
}

// RedisScript需要加载seckill.lua文件,为了避免每次释放锁时都加载,我们可以提前加载好。否则每次读取文件就会产生IO,效率很低
static {
    SECKILL_SCRIPT = new DefaultRedisScript <>();
    SECKILL_SCRIPT.setLocation(new ClassPathResource("seckill.lua"));
    SECKILL_SCRIPT.setResultType(Long.class);
}
 
/**
 * 使用Lua脚本+消息队列实现秒杀下单
 *
 * @param voucherId
 * @return
 */
@Override
public Result seckillVoucher(Long voucherId) {
    // 获取用户id
    Long userId = UserHolder.getUser().getId();
    // 1.执行Lua脚本
    Long result = stringRedisTemplate.execute(
            SECKILL_SCRIPT,
            Collections.emptyList(),
            voucherId.toString(),
            userId.toString()
    );

    // 2.判断结果是否为0
    if (result != 0) {
        // 2.1 不为0,代表没有购买资格
        return Result.fail(result == 1 ? "库存不足" : "不能重复下单");
    }
    // 2.2 为0,有购买资格,把下单信息保存到消息队列
    // 2.3 创建订单
    VoucherOrder voucherOrder = new VoucherOrder();
    // 2.4 订单id
    long orderId = redisIdWorker.nextId("order");
    voucherOrder.setId(orderId);
    // 2.5 用户id
    voucherOrder.setUserId(userId);
    // 2.6代金券id
    voucherOrder.setVoucherId(voucherId);
    // 2.7放入阻塞队列【理论上只要放入消息队列就有购买资格】
    orderTasks.add(voucherOrder);

    // 3.获取代理对象
    proxy = (IVoucherOrderService) AopContext.currentProxy();

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

@Override public void createVoucherOrder(VoucherOrder voucherOrder) {

    //注意:因为我们在Lua中已经校验过库存和一人一单了,这里就不需要校验拉~
    // 1.扣减库存
    boolean success = seckillVoucherService.update().setSql("stock = stock - 1").
            eq("voucher_id", voucherOrder.getVoucherId())
            .gt("stock", 0)
            .update();
    //这里其实不判断也是OK的,因为Lua脚本中校验过了,所以一定是充足的
    if (!success) {
        log.error("库存不足!");
    }

    // 2.保存订单
    this.save(voucherOrder);
}

1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 并发测试:

可以看出平均每个请求40ms,并发达到1000/sec,速度非常快。

小总结:

秒杀业务的优化思路是什么?

先利用Redis完成库存余量、一人一单判断,完成抢单业务 再将下单业务放入阻塞队列,利用独立线程异步下单 基于阻塞队列的异步秒杀存在哪些问题? 内存限制问题:因为我们使用的是JDK的阻塞队列,它使用的是内存。不加以限制的时候,在高并发的情况下,无数订单进入队列,可能导致内存溢出。所以我们在创建队列的时候设置了上限。另外如果此时队列已经存满了,又有新的任务忘里面塞,就放不进去了。 数据安全问题:目前是基于内存来保存这些订单信息的, ①如果内存突然宕机,那么内存中所有的订单信息都丢失了。从而就可能出现用户下单成功但是数据库里面并没有订单记录,造成数据不一致的问题。 ②如果有一个线程从队列中取出了下单的任务,即将执行的时候发生了严重的事故(异常等),那么这个任务就没有执行,而且因为这个任务已经取出队列了,以后就再也不会执行了。从而这个任务就丢失了,再次出现数据不一致的问题。 ———————————————— 原文链接:blog.csdn.net/LXYDSF/arti…