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
2 架构介绍
演示代码的结构使用的是: springboot redis redisson sentinel 其中使用了自定义异常,全局异常拦截,统一返回对象等
3 jmeter使用
3.1 修改配置
下载jmeter,修改配置文件 jmeter.properties
language=zh_CN # 把语言改为中文
sampleresult.default.encoding=UTF-8 # 默认编码改为utf-8
3.2 添加测试线程组
3.2.1 设置参数从CSV文件中来->添加配置元件->CSV data set config
3.2.2 添加取样器->http请求
3.2.3 添加配置元件->http信息头管理器
3.2.4 添加监听器->查看结果树
可以查看每次请求的响应结果
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, "存库不足,请抢购其他商品");
}
}
}
输出:
结论:
商品表没有超卖但是订单多了很多而且有重复购买现象,所以问题出现的位置在:
//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, "存库不足,请抢购其他商品");
}
}
输出:
结论:
修改了第三个步骤,把多步操作改为一条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
结论:
这一版改动是在第四步,模拟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();
}
}
输出:
结论:
这一版,使用了redisson框架来做分布式锁,测试了好些次没有问题;
-
为什么不适用redis来做分布式锁呢?
原因主要还是redis没有智能处理过期时间的功能,依旧会引发线程安全甚至死锁问题;那redisson就没有
-
难道redisson就没有问题了吗?
在redis主从架构下,如果master宕机时没有同步数据到salve中,依旧还是会出现问题,但是几率非常小,所以redisson只能保证ap(partition tolerance分区容错性),无法保证consistency(一致性);使用zookeeper可以解决数据一致性问题,但是avaliability(可用性)会差一些
补充:
为什么不把锁的范围只控制在第二步呢?问题的发生不就是插入了重复数据吗?
5 sentinel限流
5.1 引入
我们先回顾如上秒杀中,聚合报告显示:1秒1万次请求中的平均响应时间达到了20多秒,如果是在生产中,对用户而言无疑是奔溃的,甚至前端直接会报超时。
所以在如此并发下,我们必然会选择分布式部署,该弄集群的弄集群,分库分表,冷热数据分离等等一系列操作。这里我们引入sentinel来做限流,降低服务器压力,提高用户体验
补充: 聚合报告字段含义
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
5.3 结果
还是1秒1万个线程,聚合报告显示平均响应时间不足1秒,感觉还可以了;亲们可以自己调调qps阈值感受一下
大家还可以看看jemeter的查看结果树,我们自己定义的限流异常也生效了