黑马点评:秒杀优化

秒杀优化

秒杀优化-异步秒杀思路

原始方案

回顾下单流程

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

  1. 查询优惠卷

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

  3. 查询订单

  4. 校验是否是一人一单

  5. 扣减库存

  6. 创建订单

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

在这里给大家分享一下课程内没有的思路,看看有没有小伙伴这么想,比如,我们可以不可以使用异步编排来做,或者说我开启N多线程,N多个线程,一个线程执行查询优惠卷,一个执行判断扣减库存,一个去创建订单等等,然后再统一做返回,这种做法和课程中有哪种好呢?

答案是课程中的好,因为如果你采用我刚说的方式,如果访问的人很多,那么线程池中的线程可能一下子就被消耗完了,而且你使用上述方案,最大的特点在于,你觉得时效性会非常重要,但是你想想是吗?并不是,比如我只要确定他能做这件事,然后我后边慢慢做就可以了,我并不需要他一口气做完这件事,所以我们应当采用的是课程中,类似消息队列的方式来完成我们的需求,而不是使用线程池或者是异步编排的方式来完成这个需求

原先方案

1653560986599

对此方案进行jmeter压力测试:

  • 前置准备:

    • 用户1000个:

      image-20240101143131139

    • 秒杀优惠券:

      image-20240101143735458

    • 订单表清空,库存改为200和课程保持一致

      图省略

生成1000个token保存到Redis并写入tokens.txt文件

方法:根据之前的用户登录生成token保存到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 org.junit.jupiter.api.Test;
    import org.springframework.boot.test.context.SpringBootTest;
    import org.springframework.data.redis.core.StringRedisTemplate;
    import javax.annotation.Resource;
    import java.io.*;
    import java.util.HashMap;
    import java.util.List;
    import java.util.Map;
    import java.util.concurrent.TimeUnit;
    
    import static com.hmdp.utils.RedisConstants.*;
    
    @SpringBootTest
    public class GetTokensAndWriteFiles {
    
        @Resource
        private IUserService userService;
    
        @Resource
        private StringRedisTemplate stringRedisTemplate;
    
        @Test
        void getTokensAndWriteFiles() throws IOException {
            List<User> users = userService.lambdaQuery().last("limit 1000").list();
            FileWriter fr = null;
            try {
                fr = new FileWriter("C:\\百度网盘\\itheima\\Redis\\02-实战篇\\资料\\tokens.txt");//文件位置
                for (User user : users) {
                    // 7.保存用户信息到 Redis 中
                    // 7.1。随机生成 token 作为登录令牌
                    String token = UUID.randomUUID().toString(true);
                    // 7.2.将 user 对象 转化为 hashmap 存储
                    UserDTO userDTO = BeanUtil.copyProperties(user, UserDTO.class);
                    Map<String, Object> userMap = BeanUtil.beanToMap(userDTO, new HashMap<>(),
                            CopyOptions.create()
                                    .setIgnoreNullValue(true)
                                    .setFieldValueEditor((fieldName, fieldValue) -> fieldValue.toString()));
                    // 7.3.存储
                    String tokenKey = LOGIN_USER_KEY + token;
                    stringRedisTemplate.opsForHash().putAll(tokenKey, userMap);
                    // 7.4.设置 token 有效期
                    stringRedisTemplate.expire(tokenKey, LOGIN_USER_TTL, TimeUnit.MINUTES);
    
                    //将Redis中token写入文件中
                    fr.append(token);
                    fr.append("\n");
                }
            }catch(IOException e){
                System.out.println(e.getMessage());
            }finally{
                fr.close();
            }
        }
    }
    
    
  • 启动测试

    Redis和token.txt文件

    image-20240101144645386

    成功!

jmeter压测
  1. 根据课程配置好jmeter

    • 登录状态头
    • tokens文件
    • http请求秒杀优惠券路径
  2. 启动测试

    • jmeter

      image-20240101150801228

    • mysql

      image-20240101150856522

    • 页面也是正常显示

      image-20240101151001736

  3. 测试通过,没有超卖。就是性能较差

优化方案

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

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

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

image-20240101151436734

我们现在来看看整体思路:

当用户下单之后,判断库存是否充足只需要导redis中去根据key找对应的value是否大于0即可,

  • 如果不充足,则直接结束,

  • 如果充足,继续在redis中判断用户是否可以下单,

  • 如果set集合中没有这条数据,说明他可以下单,

  • 如果set集合中没有这条记录,则将userId和优惠卷存入到redis中,并且返回0,

整个过程需要保证是原子性的,我们可以使用lua来操作

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

图解

image-20240101151717510

秒杀优化-Redis完成秒杀资格判断

需求

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

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

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

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

image-20240101175232989

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

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中
        //SECKILL_STOCK_KEY 这个变量定义在RedisConstans中
        //private static final String SECKILL_STOCK_KEY ="seckill:stock:"
        stringRedisTemplate.opsForValue().set(SECKILL_STOCK_KEY+voucher.getId().toString(),voucher.getStock().toString());
    }

测试:

  • postman

    image-20240101175559292

  • 数据库√

  • Redis

    image-20240101175739877

  • 成功

基于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
local stockKey = 'seckill:stock:'..voucherId  -- ..相当于+,拼接key
-- 2.2.订单key
local orderKey = 'seckill:order:'..voucherId
-- 3.脚本业务
-- 3.1.判断库存是否充足 get stockKey
if(tonumber(redis.call('get',stockKey)) <= 0) then  --tonumber:将字符串转化为数字
    -- 库存不足,返回1
    return 1
end
-- 3.2.判断用户是否下单
if(redis.call('sismember',orderKey,userId) == 1) then
    -- 存在,说明是重复下单,返回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.发送消息到队列中

return 0

当以上lua表达式执行完毕后,剩下的就是根据步骤3,4来执行我们接下来的任务了

VoucherOrderServiceImpl

private static final DefaultRedisScript<Long> SECKILL_SCRIPT;
    //SECKILL_SCRIPT 同步修改快捷键 shift+F6
    static{
        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();
        long orderId = redisIdWorker.nextId("order");
        //1.执行lua脚本
        Long result = stringRedisTemplate.execute(
                SECKILL_SCRIPT,
                Collections.emptyList(),
                voucherId.toString(), userId.toString()
        );
        int r = result.intValue();
        //2.判断结果是否为0
        if(r != 0){
            // 2.1.不为0 ,代表没有购买资格
            return Result.fail(r == 1 ? "库存不足" : "不能重复下单");
        }
        //todo:保存到阻塞队列


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

测试

手动测试

postman:

请求方式:post
请求地址:http://localhost:8888/api/voucher-order/seckill/13
请求头:
Authorization - token

第一次成功

  • postmanimage-20240101181043006

  • Redis

    库存:

    image-20240101181633081

    用户:

    image-20240101181713909

第二次失败:

  • postman:

    image-20240101181820803

  • Redis库存没变

    image-20240101181907368

jmeter压测
  1. 修改Redis中秒杀券的库存为200

  2. 修改jmeter中http请求地址(秒杀券id改为13)

  3. 启动jmeter

    image-20240101182745327

    比之前快了一倍(电脑开了nginx,Redis,postman,Navicat等软件,CPU和内存占用较高,效果不那么明显)

    Redis

    image-20240101183023006

    测试成功!

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

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

VoucherOrderServiceImpl

private IVoucherOrderService proxy;

    @Override
    public Result seckillVoucher(Long voucherId) {

        Long userId = UserHolder.getUser().getId();
//        long orderId = redisIdWorker.nextId("order");
        //1.执行lua脚本
        Long result = stringRedisTemplate.execute(
                SECKILL_SCRIPT,
                Collections.emptyList(),
                voucherId.toString(), userId.toString()
        );
        int r = result.intValue();
        //2.判断结果是否为0
        if (r != 0) {
            // 2.1.不为0 ,代表没有购买资格
            return Result.fail(r == 1 ? "库存不足" : "不能重复下单");
        }
        //todo:保存到阻塞队列
        VoucherOrder voucherOrder = new VoucherOrder();
        // 2.3.订单id
        long orderId = redisIdWorker.nextId("order");
        voucherOrder.setId(orderId);
        // 2.4.用户id
        voucherOrder.setUserId(userId);
        // 2.5.代金券id
        voucherOrder.setVoucherId(voucherId);
        // 2.6.放入阻塞队列
        orderTasks.add(voucherOrder);
        //3.获取代理对象
        //获取代理对象 事务
        proxy = (IVoucherOrderService) AopContext.currentProxy();

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

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

VoucherOrderServiceImpl

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

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

    //当前类初始化完毕去执行
    //在类初始化之后执行,因为当这个类初始化好了之后,随时都是有可能要执行的
    @PostConstruct
    private void init() {
        SECKILL_ORDER_EXECUTOR.submit(new VoucherOrderHandler());
    }

    // 用于线程池处理的任务
    // 当初始化完毕后,就会去从对列中去拿信息
    private class VoucherOrderHandler implements Runnable {

        @Override
        public void run() {
            while (true) {

                try {
                    //1. 获取队列中的订单信息
                    VoucherOrder voucherOrder = orderTasks.take();

                    //2. 创建订单
                    handleVoucherOrder(voucherOrder);
                } catch (InterruptedException e) {
                    log.error("处理订单异常", e);
                }

            }
        }
    }

    private void handleVoucherOrder(VoucherOrder voucherOrder) {
        //1.获取用户id,不能从ThreadLocal中取,因为是从线程池获取新线程
        Long userId = voucherOrder.getUserId();
        //2.创建锁对象
        RLock lock = redissonClient.getLock("lock:order:" + userId);
        boolean isLock = lock.tryLock();
        if (!isLock) {
            log.error("不允许重复下单");
            return;
        }
        try {
            proxy.createVoucherOrder(voucherOrder);
        } finally {
            //释放锁
            lock.unlock();
        }
    }

完整代码如下:

package com.hmdp.service.impl;

import com.hmdp.dto.Result;
import com.hmdp.entity.SeckillVoucher;
import com.hmdp.entity.VoucherOrder;
import com.hmdp.mapper.VoucherOrderMapper;
import com.hmdp.service.ISeckillVoucherService;
import com.hmdp.service.IVoucherOrderService;
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import com.hmdp.utils.RedisIdWorker;
import com.hmdp.utils.SimpleRedisLock;
import com.hmdp.utils.UserHolder;
import lombok.extern.slf4j.Slf4j;
import org.redisson.api.RLock;
import org.redisson.api.RedissonClient;
import org.springframework.aop.framework.AopContext;
import org.springframework.cglib.core.Block;
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 org.springframework.transaction.annotation.Transactional;

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

/**
 * <p>
 * 服务实现类
 * </p>
 *
 * @author 虎哥
 * @since 2021-12-22
 */
@Slf4j
@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;

    private static final DefaultRedisScript<Long> SECKILL_SCRIPT;

    //SECKILL_SCRIPT 同步修改快捷键 shift+F6
    static {
        SECKILL_SCRIPT = new DefaultRedisScript<>();
        SECKILL_SCRIPT.setLocation(new ClassPathResource("seckill.lua"));
        SECKILL_SCRIPT.setResultType(Long.class);
    }

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

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

    //当前类初始化完毕去执行
    //在类初始化之后执行,因为当这个类初始化好了之后,随时都是有可能要执行的
    @PostConstruct
    private void init() {
        SECKILL_ORDER_EXECUTOR.submit(new VoucherOrderHandler());
    }

    // 用于线程池处理的任务
    // 当初始化完毕后,就会去从对列中去拿信息
    private class VoucherOrderHandler implements Runnable {

        @Override
        public void run() {
            while (true) {

                try {
                    //1. 获取队列中的订单信息
                    VoucherOrder voucherOrder = orderTasks.take();

                    //2. 创建订单
                    handleVoucherOrder(voucherOrder);
                } catch (InterruptedException e) {
                    log.error("处理订单异常", e);
                }

            }
        }
    }

    private void handleVoucherOrder(VoucherOrder voucherOrder) {
        //1.获取用户id,不能从ThreadLocal中取,因为是从线程池获取新线程
        Long userId = voucherOrder.getUserId();
        //2.创建锁对象
        RLock lock = redissonClient.getLock("lock:order:" + userId);
        boolean isLock = lock.tryLock();
        if (!isLock) {
            log.error("不允许重复下单");
            return;
        }
        try {
            proxy.createVoucherOrder(voucherOrder);
        } finally {
            //释放锁
            lock.unlock();
        }
    }

    private IVoucherOrderService proxy;

    @Override
    public Result seckillVoucher(Long voucherId) {

        Long userId = UserHolder.getUser().getId();
//        long orderId = redisIdWorker.nextId("order");
        //1.执行lua脚本
        Long result = stringRedisTemplate.execute(
                SECKILL_SCRIPT,
                Collections.emptyList(),
                voucherId.toString(), userId.toString()
        );
        int r = result.intValue();
        //2.判断结果是否为0
        if (r != 0) {
            // 2.1.不为0 ,代表没有购买资格
            return Result.fail(r == 1 ? "库存不足" : "不能重复下单");
        }
        //todo:保存到阻塞队列
        VoucherOrder voucherOrder = new VoucherOrder();
        // 2.3.订单id
        long orderId = redisIdWorker.nextId("order");
        voucherOrder.setId(orderId);
        // 2.4.用户id
        voucherOrder.setUserId(userId);
        // 2.5.代金券id
        voucherOrder.setVoucherId(voucherId);
        // 2.6.放入阻塞队列
        orderTasks.add(voucherOrder);
        //3.获取代理对象
        //获取代理对象 事务
        proxy = (IVoucherOrderService) AopContext.currentProxy();

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

//    @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("库存不足!");
//        }
//
//        Long userId = UserHolder.getUser().getId();
//        //创建锁对象
//        //"order:" 所有用户下单都会锁
//        //"order:" + userId 锁的范围使用户,同一个用户才加锁
        SimpleRedisLock lock = new SimpleRedisLock("order:" + userId, stringRedisTemplate);
//        RLock lock = redissonClient.getLock("lock:order:" + userId);
//        boolean isLock = lock.tryLock();
//        if (!isLock) {
//            return Result.fail("不允许重复下单");
//        }
//        try {
//            // 获取代理对象 事务
//            IVoucherOrderService proxy = (IVoucherOrderService) AopContext.currentProxy();
//            return proxy.createVoucherOrder(voucherId);
//        } finally {
//            //释放锁
//            lock.unlock();
//        }
//
//    }

    @Transactional
    public void createVoucherOrder(VoucherOrder voucherOrder) {

        // 5.一人一单逻辑
//        Long userId = UserHolder.getUser().getId();
        Long userId = voucherOrder.getUserId();
        // 5.1.查询订单
        int count = query().eq("user_id", userId).eq("voucher_id", voucherOrder.getVoucherId()).count();
        // 5.2判断是否存在
        if (count > 0) {
            //用户已经购买过了
            log.error("用户已经购买过一次了");
            return;
        }
        // 6.扣减库存
//        boolean success = seckillVoucherService.update()
//                .setSql("stock= stock -1") //set stock = stock -1
//                .eq("voucher_id", voucherId).eq("stock",voucher.getStock()).update(); //where id = ? and stock = ?
        boolean success = seckillVoucherService.update()
                .setSql("stock= stock -1")
                .eq("voucher_id", voucherOrder.getVoucherId()).gt("stock", 0).update(); //where id = ? and stock > 0
        if (!success) {
            //扣减库存
            log.error("库存不足!");
            return;
        }
//        // 7.创建订单
//        VoucherOrder voucherOrder = new VoucherOrder();
//        // 7.1.订单id
//        long orderId = redisIdWorker.nextId("order");
//        voucherOrder.setId(orderId);
//        // 7.2.用户id
//        Long id = UserHolder.getUser().getId();
//        voucherOrder.setUserId(id);
//        //7.3.代金券id
//        voucherOrder.setVoucherId(voucherId);
        // 8.保存订单
        save(voucherOrder);
//        return Result.ok(orderId);
    }
}

测试

手动测试

第一次成功:(记得开nginx)

  1. 数据库秒杀券恢复为200,但测试后发现没变,因为只修改Redis库存

  2. Redis秒杀券库存恢复为200

  3. postman测试

    image-20240102212830344

  4. Redis

    image-20240102212914436

第二次测试:

  • postman+Redis失败

  • image-20240102213102905

jmeter压测
  1. 恢复数据:Redis库存恢复为200

  2. jmeter

    image-20240102214154822

  3. Redis

    image-20240102214325110

异常情况

当jmeter压测后控制台显示大量的空指针异常:

image-20240102220036565

奇怪的是:

  1. Redis库存扣减成功,jmeter显示正常(80%异常)
  2. 当使用postman进行debug时没有空指针异常

经过反复测试,无论是请求经过nginx转发到后端还是直接访问后端接口,并发量大的情况下还是会空指针异常,暂时未找到原因

总结

Redis实现秒杀优化

  • 基于redis实现秒杀减库存
    • 先利用Redis完成库存余量、一人一单判断,完成抢单业务
    • 再将下单业务放入阻塞队列,利用独立线程异步下单
  • 基于redis的一人一单限制
    • 新增秒杀优惠券的同时,将优惠券信息保存到Redis中
    • 基于Lua脚本,判断秒杀库存、一人一单,决定用户是否抢购成功
  • 基于阻塞队列的异步下单
    • 如果抢购成功,将优惠券id和用户id封装后存入阻塞队列
    • 开启线程任务,不断从阻塞队列中获取信息,实现异步下单功能

基于阻塞队列的异步秒杀存在哪些问题?

  • 内存限制问题
  • 数据安全问题
  • 17
    点赞
  • 20
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 1
    评论

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

打赏作者

GaleTeng

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

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

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

打赏作者

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

抵扣说明:

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

余额充值