数据库设计
商品表
create table goods
(
id bigint auto_increment comment '商品主键id'
primary key,
good_name varchar(1024) default '0' null comment '商品名称',
good_intro varchar(1024) not null comment '商品介绍',
good_type bigint not null comment '商品类型',
good_status bigint not null comment '商品状态',
stock bigint not null comment '商品库存',
unit_price bigint not null comment '单价',
goods_pic varchar(1024) not null comment '图片url',
owner bigint not null comment '发布的单位 商家名称?',
collects bigint default 0 null comment '收藏数',
comment bigint default 0 null comment '评论数',
sell_time timestamp default CURRENT_TIMESTAMP not null comment '上架时间',
create_time timestamp default CURRENT_TIMESTAMP not null comment '创建时间',
update_time timestamp default CURRENT_TIMESTAMP not null on update CURRENT_TIMESTAMP comment '修改时间'
)
comment '商品表' charset = utf8mb4;
秒杀商品表
create table seckill_good
(
id bigint auto_increment
primary key,
goods_id bigint null comment '商品id',
seckill_price bigint not null comment '秒杀价格',
seckill_count bigint not null comment '秒杀数量',
begin_time datetime null comment '开始时间',
end_time datetime null comment '结束时间'
)
comment '秒杀商品表';
订单表
create table order_info
(
id bigint auto_increment
primary key,
user_id bigint null comment '用户id',
goods_id bigint null comment '商品id',
addr_id bigint null comment '收货地址id',
goods_name varchar(1024) null comment '冗余过来的商品名称',
goods_count int null comment '商品数量',
goods_price decimal(10, 2) null comment '商品价格',
order_channel int default 0 null comment '支付通道:1 PC、2 Android、3 ios',
goods_status int null comment '订单状态:0 未支付,1已支付,2 已发货,3 已收货,4 已退款,‘5 已完成',
create_time timestamp default CURRENT_TIMESTAMP not null comment '创建时间',
pay_date datetime null comment '支付时间'
)
comment '订单表';
秒杀订单表
create table seckill_order
(
id bigint auto_increment
primary key,
user_id bigint null comment '用户id',
goods_id bigint null comment '商品id',
order_id bigint null comment '订单id'
)
comment '秒杀订单表';
技术栈
Springboot + Mysql + Redis + RabbitMQ
秒杀流程
存在问题
- 超卖问题💚
解决方案:
- 使用乐观锁
int update = seckillGoodMapper.descSeckillCount(id, seckillCount);
<update id="descSeckillCount">
update seckill_good
set seckill_count = seckill_count - 1
where id = #{id} and seckill_count = #{seckillCount};
</update>
- 使用redis预先把库存存入,每次请求都扣减一个即可,该redis操作是原子性的,不用担心线程安全问题
// todo 为了减少 redis的次数,可以使用内存 map来缓冲,减少redis的压力
if (!hashMap.get(seckillGood.getGoodsId())) {
throw new BusinessException(ErrorCode.SECKILL_OVERSOLD);
}
// todo 解决超卖问题:在redis存储指定商品的缓存,每次有线程来都要递减,如果递减为0,则结束秒杀即可
String key = RedisConstant.SECKILL_KEY + seckillGood.getGoodsId();
Long decrement = redisTemplate.opsForValue().decrement(key);
if (decrement == null || decrement < 0L) {
// 表示现在不能被秒杀了
hashMap.put(seckillGood.getGoodsId(), false);
throw new BusinessException(ErrorCode.SECKILL_BE_OVERDUE);
// 可以使用rabbitmq通知 用户秒杀失败
}
- 实现一人一单💚
在加入订单时查询是否存在该订单,如果是则直接返回错误,如果不是,则加入订单表即可
但是对于同一个用户要实现并发的处理,要加synchronize锁,而不能使用乐观锁,因为查询订单的时候不涉及查询的操作
synchronized (userId.toString().intern()) { // 加锁实现同一个用户 为什么不用乐观锁?
// 3、实现一人一单
LambdaQueryWrapper<SeckillOrder> seckillOrderLambdaQueryWrapper = new LambdaQueryWrapper<>();
seckillOrderLambdaQueryWrapper.eq(SeckillOrder::getUserId, userId);
seckillOrderLambdaQueryWrapper.eq(SeckillOrder::getGoodsId, seckillGood.getGoodsId());
Integer count = seckillOrderMapper.selectCount(seckillOrderLambdaQueryWrapper);
if (count > 0) {
throw new BusinessException(ErrorCode.SECKILL_ON_ORDER, "您已经下单了");
}
// todo 为了减少 redis的次数,可以使用内存 map来缓冲,减少redis的压力
if (!hashMap.get(seckillGood.getGoodsId())) {
throw new BusinessException(ErrorCode.SECKILL_OVERSOLD);
}
// todo 解决超卖问题:在redis存储指定商品的缓存,每次有线程来都要递减,如果递减为0,则结束秒杀即可
String key = RedisConstant.SECKILL_KEY + seckillGood.getGoodsId();
Long decrement = redisTemplate.opsForValue().decrement(key);
if (decrement == null || decrement < 0L) {
// 表示现在不能被秒杀了
hashMap.put(seckillGood.getGoodsId(), false);
throw new BusinessException(ErrorCode.SECKILL_BE_OVERDUE);
// 可以使用rabbitmq通知 用户秒杀失败
}
// 4、成功就减库存数据库的update 自身带缓存
int update = seckillGoodMapper.descSeckillCount(id, seckillCount);
if (update < 0) {
throw new BusinessException(ErrorCode.UPDATE_ERROR);
}
// 获取商品的具体信息
Long goodsId = seckillGood.getGoodsId();
Goods goods = goodsMapper.selectById(goodsId);
// 异步下单 为什么要异步下单?
SeckillMessage seckillMessage = new SeckillMessage();
seckillMessage.setGoods(goods);
seckillMessage.setUserId(userId);
seckillMessage.setSeckillId(Long.getLong(id));
seckillMessage.setSeckillCounts(seckillCount);
String seckillMessageStr = GsonUtils.gson.toJson(seckillMessage);
seckillSender.sendSeckillMessage(seckillMessageStr);
// 返回等待的消息
return ResultUtils.success(SuccessCode.SECKILL_LINEUP);
}
😐因为是异步下单,这里处理库存的时候可能会被多扣减了库存,但最终只生成了一个订单,暂时不知道怎么解决
👌解决方法:在异步下单的时候才更新库存,不要在将信息发送给mq之前就更新缓冲,会出现问题
- 实现秒杀接口的隐藏(接口防刷)💚
- 按下按钮的时候先请求获取秒杀路径的接口
/**
* 获取秒杀的接口
* @return
*/
@GetMapping("/path")
public BaseResponse getSeckillPath(HttpServletRequest request) {
String token = request.getHeader("Authorization");
// 判断其没有登录
if (StringUtils.isBlank(token)) {
return ResultUtils.error(ErrorCode.NOT_LOGIN);
}
return seckillGoodService.getPath(token);
}
/**
* 根据token获取秒杀的路径
*
* @param token
* @return
*/
@Override
public BaseResponse getPath(String token) {
Long useId = UserHolder.getId();
// 对token进行加密
String path = token + useId;
// 存入redis
String key = SECKILL_PATH_KEY + useId + ":" + path;
// 存在时常为1分钟
redisTemplate.opsForValue().set(key, path, SECKILL_PATH_KEY_TTL, TimeUnit.MINUTES);
return ResultUtils.success(path);
}
- 然后根据获取的路径的接口获取请求秒杀的接口,开始请求秒杀
/**
* 检查秒杀的路径
*
* @param path
* @return
*/
@Override
public boolean checkSeckillPath(String path) {
// 获取用户id
Long userId = UserHolder.getId();
// 获取key
String key = SECKILL_PATH_KEY + userId + ":" + path;
String oldPath = (String) redisTemplate.opsForValue().get(key);
// 判断是否过期
if (StringUtils.isBlank(oldPath)) {
return false;
}
// 判断是否合法
if (!oldPath.equals(path)) {
return false;
}
return true;
}
将请求的路径存储在redis中即可,并且判断其是否过期或者相同即可
- 保证订单的幂等性,只能有一位用户只能下一次单💚
注意:幂等性返回的结果要是一样的
- 添加唯一索引
- 下订单前先判断是否已经存在订单了(insert 前先 select)注意:因为我这里只有一台mq所以下单的时候不会有并发问题,但是有了多台mq之后,就会存在并发的问题,此时要配合其他方法进行解决,我这里在update的时候使用了乐观锁的模式进行解决。 也就是在使用这种方法的时候,要配合上其他的手段进行解决,大部分情况下不是单独使用的
// 1、先查是否已经存在了
LambdaQueryWrapper<OrderInfo> orderInfoLambdaQueryWrapper = new LambdaQueryWrapper<>();
orderInfoLambdaQueryWrapper.eq(OrderInfo::getGoodsId, goods.getId());
orderInfoLambdaQueryWrapper.eq(OrderInfo::getUserId, userId);
Integer count = orderInfoService.count(orderInfoLambdaQueryWrapper);
if (count > 0) {
return;
}
- 加锁
-
- 乐观锁:CAS法即可,或者添加版本号
- 悲观锁:使用select ... for update 锁住这一行,然后判断业务的要求,最后再进行更改即可
- 异步下单的好处💚
如果不使用MQ异步下单,那么就会有很多线程去请求数据库,数据库的链接就会开很多链接,就会对数据库产生很大的压力。
但是我们如果使用MQ异步下单,将请求一个一个地写入数据库中,比起多线程同步修改数据库的操作,大大缓解了数据库的链接压力
- 同步方式:大量请求快速占满数据库框架开启的数据库连接池,同时修改数据库,导致数据库读写性能骤减。
- 异步方式:一条条消息以顺序的方式写入数据库,连接数几乎不变(当然,也取决于消息队列消费者的数量)。