redisson分布式锁处理秒杀与sentinel限流

1,017 阅读6分钟

1 数据库设计

秒杀商品表:t_seckill_goods

CREATE TABLE `t_seckill_goods` (
  `id` bigint NOT NULL,
  `goods_name` varchar(255) COLLATE utf8mb4_general_ci DEFAULT NULL,
  `seckill_num` int DEFAULT NULL,
  `price` decimal(10,2) DEFAULT NULL,
  PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci;

秒杀订单表:t_seckill_order

CREATE TABLE `t_seckill_order` (
  `id` bigint NOT NULL,
  `seckill_goods_id` bigint DEFAULT NULL,
  `user_id` bigint DEFAULT NULL,
  `seckill_goods_name` varchar(255) COLLATE utf8mb4_general_ci DEFAULT NULL,
  `seckill_goods_price` decimal(10,2) DEFAULT NULL,
  PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci;	

初始数据:

一个商品,库存10

image.png

2 架构介绍

演示代码的结构使用的是: springboot redis redisson sentinel 其中使用了自定义异常,全局异常拦截,统一返回对象等

3 jmeter使用

3.1 修改配置

下载jmeter,修改配置文件 jmeter.properties

language=zh_CN # 把语言改为中文
sampleresult.default.encoding=UTF-8 # 默认编码改为utf-8

3.2 添加测试线程组

image.png

3.2.1 设置参数从CSV文件中来->添加配置元件->CSV data set config

image.png

image.png

3.2.2 添加取样器->http请求

image.png

3.2.3 添加配置元件->http信息头管理器

image.png

3.2.4 添加监听器->查看结果树

可以查看每次请求的响应结果

image.png

4 秒杀代码

4.1 version 1

@Service
public class TSeckillGoodsServiceImpl extends ServiceImpl<TSeckillGoodsMapper, TSeckillGoods> implements TSeckillGoodsService {

    @Autowired
    private TSeckillGoodsMapper seckillGoodsMapper;
    @Autowired
    private TSeckillOrderMapper seckillOrderMapper;

    @Override
    @Transactional
    public void seckill(SeckillDto seckillDto) {

        //1 判断该商品是否存在
        TSeckillGoods goods = seckillGoodsMapper.selectOne(new LambdaQueryWrapper<TSeckillGoods>()
                .eq(TSeckillGoods::getId, seckillDto.getGoodsId()));
        if (goods == null) {
            throw new BusinessException(202, "商品不存在");
        }


        //2 判断该用户是否已经购买过
        Integer boughtNum = seckillOrderMapper.selectCount(new LambdaQueryWrapper<TSeckillOrder>()
                .eq(TSeckillOrder::getSeckillGoodsId, seckillDto.getGoodsId())
                .eq(TSeckillOrder::getUserId, seckillDto.getUserId()));
        if (boughtNum > 0) {
            throw new BusinessException(201, "您已购买过该商品");
        }

        //3 减库存操作
        int i = 0;
        if (goods.getSeckillNum() > 0) {
            goods.setSeckillNum(goods.getSeckillNum() - 1);
            i =seckillGoodsMapper.updateById(goods);
        }

        if (i > 0) {
            //4 插入订单
            TSeckillOrder order = TSeckillOrder.builder()
                    .id(SnowIdUtils.nextId())
                    .seckillGoodsId(seckillDto.getGoodsId())
                    .userId(seckillDto.getUserId())
                    .seckillGoodsName(goods.getGoodsName())
                    .seckillGoodsPrice(goods.getPrice()).build();
            int insertNum = seckillOrderMapper.insert(order);
            if (insertNum > 0) {
                System.out.println("发送mq,告诉用户尽快付款");
            }
        } else {
            throw new BusinessException(203, "存库不足,请抢购其他商品");
        }

    }
}

输出:

image.png

结论:

商品表没有超卖但是订单多了很多而且有重复购买现象,所以问题出现的位置在:

        //2 判断该用户是否已经购买过
        Integer boughtNum = seckillOrderMapper.selectCount(new LambdaQueryWrapper<TSeckillOrder>()
                .eq(TSeckillOrder::getSeckillGoodsId, seckillDto.getGoodsId())
                .eq(TSeckillOrder::getUserId, seckillDto.getUserId()));
        if (boughtNum > 0) {
            throw new BusinessException(201, "您已购买过该商品");
        }

        //3 减库存操作
        int i = 0;
        if (goods.getSeckillNum() > 0) {
            goods.setSeckillNum(goods.getSeckillNum() - 1);
            i =seckillGoodsMapper.updateById(goods);
        }

        if (i > 0) {
            //4 插入订单
            TSeckillOrder order = TSeckillOrder.builder()
                    .id(SnowIdUtils.nextId())
                    .seckillGoodsId(seckillDto.getGoodsId())
                    .userId(seckillDto.getUserId())
                    .seckillGoodsName(goods.getGoodsName())
                    .seckillGoodsPrice(goods.getPrice()).build();
            int insertNum = seckillOrderMapper.insert(order);
            if (insertNum > 0) {
                System.out.println("发送mq,告诉用户尽快付款");
            }
        } else {
            throw new BusinessException(203, "存库不足,请抢购其他商品");
        }

多个线程同时判断当前用户没有购买过商品,然后数量>0,进而插入了订单

4.2 version 2

@Override
@Transactional
public void seckill(SeckillDto seckillDto) {


    //1 判断该商品是否存在
    TSeckillGoods goods = seckillGoodsMapper.selectOne(new LambdaQueryWrapper<TSeckillGoods>()
            .eq(TSeckillGoods::getId, seckillDto.getGoodsId()));
    if (goods == null) {
        throw new BusinessException(202, "商品不存在");
    }

    //2 判断该用户是否已经购买过
    Integer boughtNum = seckillOrderMapper.selectCount(new LambdaQueryWrapper<TSeckillOrder>()
            .eq(TSeckillOrder::getSeckillGoodsId, seckillDto.getGoodsId())
            .eq(TSeckillOrder::getUserId, seckillDto.getUserId()));
    if (boughtNum > 0) {
        throw new BusinessException(201, "您已购买过该商品或商品不存在");
    }

    //3 减库存操作
    //update t_seckill_goods set seckill_num = seckill_num -1 where seckill_num > 0 and id = #{id}
    int i = seckillGoodsMapper.updateInventory(seckillDto.getGoodsId());

    if (i > 0) {
        //4 插入订单
        TSeckillOrder order = TSeckillOrder.builder()
                .id(SnowIdUtils.nextId())
                .seckillGoodsId(seckillDto.getGoodsId())
                .userId(seckillDto.getUserId())
                .seckillGoodsName(goods.getGoodsName())
                .seckillGoodsPrice(goods.getPrice()).build();
        int insertNum = seckillOrderMapper.insert(order);
        if (insertNum > 0) {
            System.out.println("发送mq,告诉用户尽快付款");
        }
    } else {
        throw new BusinessException(203, "存库不足,请抢购其他商品");
    }

}

输出:

image.png

结论:

修改了第三个步骤,把多步操作改为一条sql原子操作后,没有了超卖现象;但是任有重复购买问题,主要就是第二步"判断该用户是否已经购买过"有线程安全问题

4.3 version 3

@Override
@Transactional
public void seckill(SeckillDto seckillDto) {


    //1 判断该商品是否存在
    TSeckillGoods goods = seckillGoodsMapper.selectOne(new LambdaQueryWrapper<TSeckillGoods>()
            .eq(TSeckillGoods::getId, seckillDto.getGoodsId()));
    if (goods == null) {
        throw new BusinessException(202, "商品不存在");
    }


    //2 判断该用户是否已经购买过
    Integer boughtNum = seckillOrderMapper.selectCount(new LambdaQueryWrapper<TSeckillOrder>()
            .eq(TSeckillOrder::getSeckillGoodsId, seckillDto.getGoodsId())
            .eq(TSeckillOrder::getUserId, seckillDto.getUserId()));
    if (boughtNum > 0) {
        throw new BusinessException(201, "您已购买过该商品或商品不存在");
    }


    //3 减库存操作
    //update t_seckill_goods set seckill_num = seckill_num -1 where seckill_num > 0 and id = #{id}
    int i = seckillGoodsMapper.updateInventory(seckillDto.getGoodsId());

    //4 插入订单
    if (i > 0) {
        //模拟volatile单例设计模式,双重校验;这里的冗余代码就懒得改了
        boughtNum = seckillOrderMapper.selectCount(new LambdaQueryWrapper<TSeckillOrder>()
                .eq(TSeckillOrder::getSeckillGoodsId, seckillDto.getGoodsId())
                .eq(TSeckillOrder::getUserId, seckillDto.getUserId()));
        if (boughtNum > 0) {
            throw new BusinessException(201, "您已购买过该商品或商品不存在");
        }
        
        TSeckillOrder order = TSeckillOrder.builder()
                .id(SnowIdUtils.nextId())
                .seckillGoodsId(seckillDto.getGoodsId())
                .userId(seckillDto.getUserId())
                .seckillGoodsName(goods.getGoodsName())
                .seckillGoodsPrice(goods.getPrice()).build();
        int insertNum = seckillOrderMapper.insert(order);
        if (insertNum > 0) {
            System.out.println("发送mq,告诉用户尽快付款");
        }
    } else {
        throw new BusinessException(203, "存库不足,请抢购其他商品");
    }

}

输出:

为了增加出错率,我调大了库存容量为20

image.png

结论:

这一版改动是在第四步,模拟volatile单例设计模式的双重校验;可以看得出来重复购买几率下降了很多,但是任然有,所以并不能完全的解决问题

4.4 version 4

    @Override
    @Transactional
    public void seckill(SeckillDto seckillDto) {

        //1 判断该商品是否存在
        TSeckillGoods goods = seckillGoodsMapper.selectOne(new LambdaQueryWrapper<TSeckillGoods>()
                .eq(TSeckillGoods::getId, seckillDto.getGoodsId()));
        if (goods == null) {
            throw new BusinessException(202, "商品不存在");
        }

        final String key = "lock:" + seckillDto.getUserId() + "-" + seckillDto.getGoodsId();
        RLock lock = redissonClient.getLock(key);

        try {
            //默认30s的redis过期时间
            lock.lock();
            //2 判断该用户是否已经购买过
            Integer boughtNum = seckillOrderMapper.selectCount(new LambdaQueryWrapper<TSeckillOrder>()
                    .eq(TSeckillOrder::getSeckillGoodsId, seckillDto.getGoodsId())
                    .eq(TSeckillOrder::getUserId, seckillDto.getUserId()));
            if (boughtNum > 0) {
                throw new BusinessException(201, "您已购买过该商品或商品不存在");
            }


            //3 减库存操作
            if (seckillGoodsMapper.updateInventory(seckillDto.getGoodsId()) > 0) {
                //4 插入订单
                TSeckillOrder order = TSeckillOrder.builder()
                        .id(SnowIdUtils.nextId())
                        .seckillGoodsId(seckillDto.getGoodsId())
                        .userId(seckillDto.getUserId())
                        .seckillGoodsName(goods.getGoodsName())
                        .seckillGoodsPrice(goods.getPrice()).build();
                int insertNum = seckillOrderMapper.insert(order);
                if (insertNum > 0) {
                    System.out.println("发送mq,告诉用户尽快付款");
                }
            } else {
                throw new BusinessException(203, "存库不足,请抢购其他商品");
            }
        } finally {
            lock.unlock();
        }
    }

输出:

image.png

结论:

这一版,使用了redisson框架来做分布式锁,测试了好些次没有问题;

  1. 为什么不适用redis来做分布式锁呢?

    原因主要还是redis没有智能处理过期时间的功能,依旧会引发线程安全甚至死锁问题;那redisson就没有

  2. 难道redisson就没有问题了吗?

    在redis主从架构下,如果master宕机时没有同步数据到salve中,依旧还是会出现问题,但是几率非常小,所以redisson只能保证ap(partition tolerance分区容错性),无法保证consistency(一致性);使用zookeeper可以解决数据一致性问题,但是avaliability(可用性)会差一些

补充:

为什么不把锁的范围只控制在第二步呢?问题的发生不就是插入了重复数据吗?

image.png

5 sentinel限流

5.1 引入

我们先回顾如上秒杀中,聚合报告显示:1秒1万次请求中的平均响应时间达到了20多秒,如果是在生产中,对用户而言无疑是奔溃的,甚至前端直接会报超时。

image.png

所以在如此并发下,我们必然会选择分布式部署,该弄集群的弄集群,分库分表,冷热数据分离等等一系列操作。这里我们引入sentinel来做限流,降低服务器压力,提高用户体验

补充: 聚合报告字段含义

image.png

5.2 实操

pom.xml 引入 alibaba sentinel

        <dependency>
            <groupId>com.alibaba.cloud</groupId>
            <artifactId>spring-cloud-starter-alibaba-sentinel</artifactId>
            <version>2.2.6.RELEASE</version>
        </dependency>	

Controller加入@Resource注解

@RestController
@RequestMapping("/seckill")
public class TSeckillGoodsController {

    @Autowired
    private TSeckillGoodsService seckillGoodsService;

    @PostMapping("goods")
    @SentinelResource(value = "seckill",blockHandler = "seckillHandler")
    public String goods(@RequestBody SeckillDto seckillDto) {
        seckillGoodsService.seckill(seckillDto);
        return "下单成功,请尽快付款";
    }

    public static String seckillHandler(SeckillDto seckillDto, BlockException e) {
        return "系统繁忙,请稍后重试";
    }
}

sentinel dashboard加入流控规则,这里设置为 一秒钟允许10个qps

image.png

5.3 结果

还是1秒1万个线程,聚合报告显示平均响应时间不足1秒,感觉还可以了;亲们可以自己调调qps阈值感受一下

image.png

大家还可以看看jemeter的查看结果树,我们自己定义的限流异常也生效了

image.png

带我看源码