Skip to content
DAILY QUOTE

“ ”

1.秒杀业务性能测试

  • 在测试类中添加如下方法,根据1000条用户信息生成1000条token
java
/**
 * 执行generateToken相关业务逻辑。
 *
 * 作用:
 * 1.数据库查询1000个用户信息;
 * 2.创建字符输出流准备写入token到文件;
 * 3.随即生成Token作为登录令牌;
 * 4.隐藏User对象信息;
 * 5.将User对象转换为HashMap存储;
 *
 * @return无返回值
 */
@Test
public void generateToken() throws IOException {
    //数据库查询1000个用户信息
    List<User> userList= userService.list(new QueryWrapper<User>().last("limit 1000"));
    //创建字符输出流准备写入token到文件
    BufferedWriter br=new BufferedWriter(new FileWriter("D:\\Work_develop\\idea_project\\hm-dianping\\tokens.txt"));
    for (User user:userList){
        //随即生成Token作为登录令牌
        String token= UUID.randomUUID().toString();
        //隐藏User对象信息
        UserDTO userDTO= BeanUtil.copyProperties(user,UserDTO.class);
        //将User对象转换为HashMap存储
        Map<String,Object> map=BeanUtil.beanToMap(userDTO, new HashMap<>(),
                CopyOptions.create()
                        .setIgnoreNullValue(true)
                        .setFieldValueEditor((fieldName,fieldValue)->fieldValue.toString()));
        //保存用户token到Redis,设置token有效期
        String tokenKey= RedisConstants.LOGIN_USER_KEY+token;
        stringRedisTemplate.opsForHash().putAll(tokenKey,map);
        stringRedisTemplate.expire(tokenKey,RedisConstants.LOGIN_USER_TTL, TimeUnit.MINUTES);
        //写入token到文件
        br.write(token);
        br.newLine();
        br.flush();
    }
}

执行结果如下,生成了1000条用户登录token到tokens.txt,方便JMeter读取

清空订单表,更改秒杀库存为200

Jmeter中线程数设为1000,执行时间为1秒,模拟1000个用户高并发访问秒杀业务

在CSV数据文件设置中进行如下设置

在HTTP信息头管理器中进行如下设置

测试秒杀id为11的优惠券接口

  • 测试结果

  • 数据库200条数据正常扣减库存

2.异步秒杀业务流程

之前的秒杀业务流程:

  1. 查询优惠卷,是否在秒杀时间段,判断秒杀卷库存是否充足
  2. 查询订单,校验一人一单
  3. 扣减优惠卷库存,判断库存是否充足,防止超卖
  4. 将用户抢购的优惠卷信息写入订单,返回订单id

查询优惠卷,查询订单,扣减库存,创建订单,都是数据库操作,前两部对用户秒杀资格判断,后两步进行数据库写操作,耗时较久。

异步思路

(1)判断秒杀卷库存和校验一人一单逻辑放到Redis中完成,在Redis中预先扣减库存

  • 优化:将数据库查询操作改为Redis查询,将秒杀资格的判断分离,缩短业务流程 (2)保存优惠卷id、用户id、订单id到阻塞队列,将耗时较久的减库存、创建订单这两部去开启独立线程异步写入数据库
  • 优化:异步读取队列中的订单信息,完成下单

  • 那如何在Redis判断库存是否充足和一人一单呢?

把优惠卷库存信息和优惠卷被哪些用户买过的信息缓存在Redis中,我们应该选择什么养的数据结构保存这两个信息呐?

  • 对于优惠卷库存信息,使用Stirng类型,Key为优惠卷库存key前缀+优惠卷id,value为库存

  • 对于订单购买信息,使用Set类型,去重判断一人一单,key为优惠卷订单key前缀+优惠卷id,value为购买过该优惠卷的用户id集合

  • 异步秒杀业务流程如下

  1. 使用lua脚本保证操作原子性:判断用户秒杀资格,根据不同情况返回不同标识(0:满足条件、1:库存不足、2:该优惠卷用户已下过单),如果满足秒杀条件,预先扣减Redis库存,数据库库存后面异步扣减,将用户id存入当前优惠卷的set集合,作为一人一单的依据。
  2. 在tomcat中,首先执行lua脚本,判断返回结果是否为0,如果没有购买资格返回提示信息,如果由购买资格,将优惠卷id、用户id以及订单id存入阻塞队列,方便线程异步读信息,完成下单,最后返回订单id,用户就可以拿订单id完成支付操作了。
  3. 开启异步线程读取阻塞队列中的信息,完成数据库下单和扣减库存动作,这一步对时效性要求就没有那么高了。

3.改进秒杀业务,提高并发性能

需求:

  1. 新增秒杀优惠卷同时,将优惠卷库存信息存入Redis
  2. 基于lua脚本,判断秒杀库存、一人一单、决定用户是否抢购成功
  3. 如果抢购成功,将优惠卷id和用户id封装后存入阻塞队列
  4. 开启线程任务,不断从阻塞队列中获取信息,实现异步下单功能
  • 新增秒杀优惠券的同时,将优惠券库存信息保存到Redis中。
java
/**
 * 新增秒杀优惠券。
 *
 * 作用:
 * 1.保存优惠券;
 * 2.保存秒杀信息;
 * 3.保存秒杀库存到Redis;
 *
 * @param voucher优惠券对象
 * @return无返回值
 */
@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(RedisConstants.SECKILL_STOCK_KEY +voucher.getId(),voucher.getStock().toString());
}

测试新增秒杀券,返回的秒杀券id为15

json
{
    "shopId": 1,
    "title": "100元代金券",
    "subTitle": "周一至周五均可使用",
    "rules": "全场通用\\n无需预约\\n可无限叠加\\不兑现、不找零\\n仅限堂食",
    "payValue": 8000,
    "actualValue": 10000,
    "type": 1,
    "stock": 100,
    "beginTime": "2022-01-25T10:09:17",
    "endTime": "2037-01-26T12:09:04"
}

新增的秒杀券成功添加到数据库和Redis中

  • 基于Lua脚本,判断秒杀库存、一人一单,决定用户是否抢购成功。
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.2.库存不足,返回1
    return 1
end
--3.2.判断用户是否下单SISMEMBER orderKey userId    //SISMEMBER:判断Set集合中是否存在某个元素,存在返回1,不存在放回0
if(redis.call('sismember',orderKey,userId)==1) then
    --3.3.存在,说明是重复订单,返回2
    return 2
end
--3.4.扣库存  incrby stockKey -1
redis.call('incrby',stockKey,-1)
--3.5.下单(保存用户) sadd orderKey userId
redis.call('sadd',orderKey,userId)
return 0

在Java代码中调用seckill.lua脚本

  • 如果抢购成功,将优惠券id和用户id封装后存入阻塞队列,开启线程任务,不断从阻塞队列中获取信息,实现异步下单功能。
  • IVoucherOrderService
java
public interface IVoucherOrderService extends IService<VoucherOrder> {
    Result seckillVoucher(Long voucherId);

    void createVoucherOrder(VoucherOrder voucherOrder);
}
  • 改造VoucherOrderServiceImpl
java
@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;
    //伴随着类的加载而执行,且只执行一次
    static {
        //装载Lua脚本的“容器”对象
        SECKILL_SCRIPT = new DefaultRedisScript<>();
        //去当前项目的classpath(通常也就是resources文件夹)的根目录下找文件
        SECKILL_SCRIPT.setLocation(new ClassPathResource("seckill.lua"));
        //告诉Spring框架,等Redis执行完这段Lua脚本后,把Redis返回的结果转换成Java里的什么数据类型
        SECKILL_SCRIPT.setResultType(Long.class);
    }

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

    /**
     * 初始化异步处理任务。
     *
     * @return无返回值
     */
    @PostConstruct
    public void init(){
        SECKILL_ORDER_EXECUTOR.submit(new VoucherOrderHandler());
    }

    private class VoucherOrderHandler implements Runnable {
        /**
         * 循环处理异步任务。
         *
         * 作用:
         * 1.获取队列中的订单信息;
         * 2.//take()方法:从阻塞队列中获取元素,如果队列为空,线程会被阻塞,直到队列中有;
         * 3.创建订单;
         *
         * @return无返回值
         */
        public void run() {
            while(true){
                try {
                    //1.获取队列中的订单信息
                    //// take()方法:从阻塞队列中获取元素,如果队列为空,线程会被阻塞,直到队列中有元素,线程才会被唤醒,并去获取元素
                    VoucherOrder voucherOrder=orderTasks.take();
                    //2.创建订单
                    handleVoucherOrder(voucherOrder);
                } catch (InterruptedException e) {
                    log.error("处理订单异常",e);
                }
            }
        }
    }

    /**
     * 执行handleVoucherOrder相关业务逻辑。
     *
     * 作用:
     * 1.获取用户;
     * 2.创建锁对象;
     * 3.获取锁;
     * 4.判断是否获取锁成功;
     * 5.获取锁失败,返回错误或重试;
     *
     * @param voucherOrder voucherOrder参数
     * @return无返回值
     */
    private void handleVoucherOrder(VoucherOrder voucherOrder) {
        //获取用户  //子线程不能通过UserHolder获取用户,必须从voucherOrder对象里拿用户ID,因为ThreadLocal是不跨线程的。
        Long userId=voucherOrder.getUserId();
        //创建锁对象
        RLock lock = redissonClient.getLock("lock:order" + userId);
        //获取锁
        boolean isLock = lock.tryLock();
        //判断是否获取锁成功
        if (!isLock) {
            //获取锁失败,返回错误或重试
            log.error("不允许重复下单");
            return ;
        }
        try {
            //获取代理对象
            proxy.createVoucherOrder(voucherOrder);
        } catch (IllegalStateException e) {
            throw new RuntimeException(e);
        } finally {
            lock.unlock();
        }
    }

    private IVoucherOrderService proxy;
    /**
     * 处理优惠券秒杀下单请求。
     *
     * 作用:
     * 1.获取用户;
     * 2.执行lua脚本;
     * 3.判断结果是否为0;
     * 4.不为0,代表没有购买资格;
     * 5.为0,有购买资格,把下单信息保存到阻塞队列;
     *
     * @param voucherId优惠券id
     * @return处理结果
     */
    @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
        int r =result.intValue();
        if(r!=0){
            //2.1.不为0,代表没有购买资格
            return Result.fail(r==1?"库存不足":"不能重复下单");
        }
        //2.2.为0,有购买资格,把下单信息保存到阻塞队列
        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.获取代理对象
        IVoucherOrderService proxy= (IVoucherOrderService) AopContext.currentProxy();


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



    /**
     * 创建优惠券订单。
     *
     * 作用:
     * 1.一人一单;
     * 2.查询订单;
     * 3.判断是否村在;
     * 4.用户已经购买过了;
     * 5.扣减库存;
     *
     * @param voucherOrder voucherOrder参数
     * @return无返回值
     */
    @Transactional
    public void createVoucherOrder(VoucherOrder voucherOrder){
            //5.一人一单
            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", voucherOrder.getVoucherId())
                        //.eq("stock", voucher.getStock()) //where id=? and stock=?
                        .gt("stock", 0)
                        .update();
                if(!success){
                    log.error("库存不足");
                    return ;
                }
                //7.创建订单
                save(voucherOrder);
        }
}
  • 接口测试 将数据库和Redis优惠劵库存恢复成200 测试一人一单功能

获取用户第一次下单返回的订单id并且不允许重复下单

再来用JMeter做性能测试,结果发现,由于异步线程写入数据库,耗时减少,吞吐量大幅增加,提高了秒杀系统的并发性能!

总结

秒杀业务思路: 先用Redis完成库存余量,一人一单判断,完成抢单业务 再将下单业务放入阻塞队列,利用独立线程异步下单 基于阻塞队列列的异步秒杀存在哪些问题? 内存限制问题:JDK阻塞队列使用的是JVM内存,高并发订单量可能导致内存溢出,队列大小由我们自己指定,可能超出阻塞队列上限 数据安全问题:JVM没有持久化机制,服务器重启或者宕机,阻塞队列任务都会丢失;从阻塞队列拿到任务未处理,此时发生异常,任务也会丢失,就没有机会再次处理了,造成数据不一致