秒杀高并发解决方案
1.秒杀/高并发方案-介绍
- 秒杀/高并发 其实主要解决两个问题,一个是并发读,一个是并发写
- 并发读的核心优化理念是尽量减少用户到 DB 来"读"数据,或者让他们读更少的数据, 并
发写的处理原则也一样 - 针对秒杀系统需要做一些保护,针对意料之外的情况设计兜底方案,以防止最坏的情况
发生。 - 系统架构要满足高可用: 流量符合预期时要稳定,要保证秒杀活动顺利完成,即秒杀商品
顺利地卖出去,这个是最基本的前提 - 系统保证数据的一致性: 就是秒杀 10 个 商品 ,那就只能成交 10 个商品,多一个少一
个都不行。一旦库存不对,就要承担损失 - 系统要满足高性能: 也就是系统的性能要足够高,需要支撑大流量, 不光是服务端要做极
致的性能优化,而且在整个请求链路上都要做协同的优化,每个地方快一点, 整个系统就"快
"了 - 秒杀涉及大量的并发读和并发写,因此支持高并发访问这点非常关键, 对应的方案比如页
面缓存方案、Redis 预减库存/内存标记与隔离、请求的削峰(RabbitMQ/异步请求)、分布式
Session 共享等
2.秒杀场景模拟
这里模拟每秒1000个请求,循环10次即总共发起10000次请求,请求/seckill/doSeckill
接口
3.秒杀解决方案
3.1 v1.0-原始版本
SeckillController
@Controller
@RequestMapping(value = "/seckill")
public class SeckillController {
@Resource
private GoodsService goodsService;
@Resource
private SeckillOrderService seckillOrderService;
@Resource
private OrderService orderService;
@RequestMapping(value = "/doSeckill")
public String doSeckill(Model model, User user, Long goodsId) {
System.out.println("-----秒杀 V1.0--------");
//===================秒杀 v1.0 start =========================
if (user == null) {
return "login";
}
model.addAttribute("user", user);
GoodsVo goodsVo = goodsService.findGoodsVoByGoodsId(goodsId);
//判断库存
if (goodsVo.getStockCount() < 1) {
model.addAttribute("errmsg", RespBeanEnum.ENTRY_STOCK.getMessage());
return "secKillFail";
}
//解决重复抢购
LambdaQueryWrapper<SeckillOrder> seckillOrderLambdaQueryWrapper = new QueryWrapper<SeckillOrder>().lambda()
.eq(SeckillOrder::getGoodsId, goodsId).eq(SeckillOrder::getUserId, user.getId());
SeckillOrder seckillOrder = seckillOrderService.getOne(seckillOrderLambdaQueryWrapper);
if (seckillOrder != null) {
model.addAttribute("errmsg", RespBeanEnum.REPEATE_ERROR.getMessage());
return "secKillFail";
}
//抢购
Order order = orderService.seckill(user, goodsVo);
if (order == null) {
model.addAttribute("errmsg", RespBeanEnum.ENTRY_STOCK.getMessage());
return "secKillFail";
}
model.addAttribute("order", order);
model.addAttribute("goods", goodsVo);
return "orderDetail";
//===================秒杀 v1.0 end... =========================
}
}
OrderServiceImpl
@Service
public class OrderServiceImpl extends ServiceImpl<OrderMapper, Order> implements OrderService {
@Resource
private SecKillGoodsService secKillGoodsService;
@Resource
private OrderMapper orderMapper;
@Resource
private SeckillOrderMapper seckillOrderMapper;
/**
* 秒杀商品,减少库存
* @param user
* @param goodsVo
* @return
*/
@Override
public Order seckill(User user, GoodsVo goodsVo) {
//1.根据商品id获取秒杀商品信息
LambdaQueryWrapper<SecKillGoods> lambdaQueryWrapper = new QueryWrapper<SecKillGoods>().lambda().eq(SecKillGoods::getGoodsId,goodsVo.getId());
SecKillGoods seckillGoods = secKillGoodsService.getOne(lambdaQueryWrapper);
//2.秒杀商品库存-1
seckillGoods.setStockCount(seckillGoods.getStockCount() - 1);
secKillGoodsService.updateById(seckillGoods);
//3.生成普通订单
Order order = new Order();
order.setUserId(user.getId());
order.setGoodsId(goodsVo.getId());
order.setDeliveryAddrId(0L);
order.setGoodsName(goodsVo.getGoodsName());
order.setGoodsCount(1);
order.setGoodsPrice(seckillGoods.getSeckillPrice());
order.setOrderChannel(1);
order.setStatus(0);
order.setCreateDate(new Date());
orderMapper.insert(order);
//4.生成秒杀订单
SeckillOrder seckillOrder = new SeckillOrder();
seckillOrder.setGoodsId(goodsVo.getId());
seckillOrder.setUserId(user.getId());
seckillOrder.setOrderId(order.getId());
seckillOrderMapper.insert(seckillOrder);
return order;
}
}
说明
非高并发情况下,程序执行流程分析
-
程序首先校验用户是否登录
-
然后判断商品库存是充足
-
在判断是否存在重复抢购
-
最后执行抢购生成订单及抢购订单数据
高并发情况下,程序执行流程分析
- 判断商品库存是充足、否存在重复抢购的程序是不具备原子性
- 秒杀商品的库存为负数,但具体的值无法确定,因为获取秒杀商品库存也不具备原子性
- 当有多少个请求冲破了前面库存和抢购的过滤,就会去生成多少个订单
3.2 v2.0-秒杀-复购、超卖处理
SeckillController
@RequestMapping(value = "/doSeckill")
public String doSeckill(Model model, User user, Long goodsId) {
System.out.println("-----秒杀 V2.0--------");
//===================秒杀 v2.0 start =========================
if (user == null) {
return "login";
}
model.addAttribute("user", user);
GoodsVo goodsVo = goodsService.findGoodsVoByGoodsId(goodsId);
//判断库存
if (goodsVo.getStockCount() < 1) {
model.addAttribute("errmsg", RespBeanEnum.ENTRY_STOCK.getMessage());
return "secKillFail";
}
//解决重复抢购,直接到redis中获取对应的秒杀订单,如果有则说明已经抢购了,不能继续抢购
SeckillOrder seckillOrder = (SeckillOrder) redisTemplate.opsForValue().get("order:" + user.getId() + ":" + goodsId);
if (seckillOrder != null) {
model.addAttribute("errmsg", RespBeanEnum.REPEATE_ERROR.getMessage());
return "secKillFail";
}
//抢购
Order order = orderService.seckill(user, goodsVo);
if (order == null) {
model.addAttribute("errmsg", RespBeanEnum.ENTRY_STOCK.getMessage());
return "secKillFail";
}
model.addAttribute("order", order);
model.addAttribute("goods", goodsVo);
return "orderDetail";
//===================秒杀 v2.0 end... =========================
}
OrderServiceImpl
/**
* 秒杀商品,减少库存
*
* @param user
* @param goodsVo
* @return
*/
@Override
@Transactional(rollbackFor = {Exception.class})
public Order seckill(User user, GoodsVo goodsVo) {
//1.根据商品id获取秒杀商品信息
LambdaQueryWrapper<SecKillGoods> lambdaQueryWrapper = new QueryWrapper<SecKillGoods>().lambda().eq(SecKillGoods::getGoodsId, goodsVo.getId());
SecKillGoods seckillGoods = secKillGoodsService.getOne(lambdaQueryWrapper);
//2.秒杀商品库存-1
// seckillGoods.setStockCount(seckillGoods.getStockCount() - 1);
// secKillGoodsService.updateById(seckillGoods);
//1. Mysql在默认的事务隔离级别[REPEATABLE-READ]下
//2. 执行update语句时,会在事务中锁定要更新的行
//3. 这样可以防止其它会话在同一行执行update,delete
LambdaUpdateWrapper<SecKillGoods> updateWrapper = new UpdateWrapper<SecKillGoods>().lambda().setSql("stock_count=stock_count-1")
.eq(SecKillGoods::getGoodsId, goodsVo.getId())
.gt(SecKillGoods::getStockCount, 0);
boolean update = secKillGoodsService.update(updateWrapper);
//如果更新失败说明已经没有库存了
if (!update) {
return null;
}
//3.生成普通订单
Order order = new Order();
order.setUserId(user.getId());
order.setGoodsId(goodsVo.getId());
order.setDeliveryAddrId(0L);
order.setGoodsName(goodsVo.getGoodsName());
order.setGoodsCount(1);
order.setGoodsPrice(seckillGoods.getSeckillPrice());
order.setOrderChannel(1);
order.setStatus(0);
order.setCreateDate(new Date());
orderMapper.insert(order);
//4.生成秒杀订单
SeckillOrder seckillOrder = new SeckillOrder();
seckillOrder.setGoodsId(goodsVo.getId());
seckillOrder.setUserId(user.getId());
seckillOrder.setOrderId(order.getId());
seckillOrderMapper.insert(seckillOrder);
//设置秒杀订单的key --> order:用户id:商品id
redisTemplate.opsForValue().set("order:" + user.getId() + ":" + goodsVo.getId(), seckillOrder);
return order;
}
说明
超卖问题处理
前面出现超卖的主要原因在于,更新库存时不具备原子性,这里利用mysql默认的事务隔离级别
[REPEATABLE-READ]
在事务中执行update语句获取锁定更新的行,这个机制进行处理
复购(生成了多个订单)问题处理
,则在生成订单之前对库存更新进行判断,如果更新失败则不在生成订单
//如果更新失败说明已经没有库存了
if (!update) {
return null;
}
优化复购判断
在生成秒杀订单之后将订单存储到redis中,在判断复购时直接从redis中获取,避免频繁查询数据库,提升执行效率
//解决重复抢购,直接到redis中获取对应的秒杀订单,如果有则说明已经抢购了,不能继续抢购
SeckillOrder seckillOrder = (SeckillOrder) redisTemplate.opsForValue().get("order:" + user.getId() + ":" + goodsId);
if (seckillOrder != null) {
model.addAttribute("errmsg", RespBeanEnum.REPEATE_ERROR.getMessage());
return "secKillFail";
}
测试结果
3.3 v3.0 优化秒杀,Redis 预减库存
- 前面我们防止超卖 是通过到数据库查询和到数据库抢购,来完成的, 代码如下:
- 如果在短时间内,大量抢购冲击 DB, 造成洪峰, 容易压垮数据库
- 解决方案: 使用 Redis 完成预减库存,如果没有库存了, 直接返回, 减小对 DB 的压力
- 示意图:
代码实现
@Controller
@RequestMapping(value = "/seckill")
public class SeckillController implements InitializingBean {
@Resource
private GoodsService goodsService;
@Resource
private SeckillOrderService seckillOrderService;
@Resource
private OrderService orderService;
@Resource
private RedisTemplate redisTemplate;
@RequestMapping(value = "/doSeckill")
public String doSeckill(Model model, User user, Long goodsId) {
System.out.println("-----秒杀 V3.0--------");
//===================秒杀 v3.0 start =========================
if (user == null) {
return "login";
}
model.addAttribute("user", user);
GoodsVo goodsVo = goodsService.findGoodsVoByGoodsId(goodsId);
//判断库存
if (goodsVo.getStockCount() < 1) {
model.addAttribute("errmsg", RespBeanEnum.ENTRY_STOCK.getMessage());
return "secKillFail";
}
//解决重复抢购,直接到redis中获取对应的秒杀订单,如果有则说明已经抢购了,不能继续抢购
SeckillOrder seckillOrder = (SeckillOrder) redisTemplate.opsForValue().get("order:" + user.getId() + ":" + goodsId);
if (seckillOrder != null) {
model.addAttribute("errmsg", RespBeanEnum.REPEATE_ERROR.getMessage());
return "secKillFail";
}
//库存预减,如果在redis中预减库存,发现秒杀商品中已经没有库存了,直接返回
//从而减少这个方法请求 orderService.seckill防止大量请求打到数据,优化秒杀
//这个方法具有原子性!!!
Long decrement = redisTemplate.opsForValue().decrement("seckillGoods:" + goodsId);
//说明这个商品已经没有库存了
if (decrement < 0) {
//恢复redis库存为0,对业务没有影响只是为了好看
redisTemplate.opsForValue().increment("seckillGoods:" + goodsId);
model.addAttribute("errmsg", RespBeanEnum.REPEATE_ERROR.getMessage());
return "secKillFail";
}
//抢购
Order order = orderService.seckill(user, goodsVo);
if (order == null) {
model.addAttribute("errmsg", RespBeanEnum.ENTRY_STOCK.getMessage());
return "secKillFail";
}
model.addAttribute("order", order);
model.addAttribute("goods", goodsVo);
return "orderDetail";
//===================秒杀 v3.0 end... =========================
}
//该方法实在类的所有属性都初始化后,自动执行
//这里将所有秒杀商品的库存量加载到redis中
@Override
public void afterPropertiesSet() throws Exception {
//1.查询所有的秒杀商品
List<GoodsVo> list = goodsService.findGoodsVo();
if (CollectionUtils.isEmpty(list)) {
return;
}
//遍历list,将秒杀商品的库存量放入到redis中
//秒杀商品库存量对应的key seckillGoods:商品id
for (GoodsVo goodsVo : list) {
redisTemplate.opsForValue().set("seckillGoods:" + goodsVo.getId(), goodsVo.getStockCount());
}
}
}
大体思路:
-
项目启动时加载秒杀商品库存到redis中,库存量随着每次启动都会更新
-
在进行抢购之前先到redis中进行库存预减,利用
redis的decrement具备原子性的特点
-
如果redis预减库存小于0则直接返回,避免了多余的请求打到数据库
3.4 v4.0 优化秒杀:加入内存标记,避免总到 Reids 查询库存
- 需求分析/图解
- 如果某个商品库存已经为空了, 我们仍然是到 Redis 去查询的, 还可以进行优化
- 解决方案: 给商品进行内存标记, 如果库存为空, 直接返回, 避免总是到 Redis 查询库存
- 分析思路-示意图
@Controller
@RequestMapping(value = "/seckill")
public class SeckillController implements InitializingBean {
@Resource
private GoodsService goodsService;
@Resource
private SeckillOrderService seckillOrderService;
@Resource
private OrderService orderService;
@Resource
private RedisTemplate redisTemplate;
@RequestMapping(value = "/doSeckill")
public String doSeckill(Model model, User user, Long goodsId) {
System.out.println("-----秒杀 V4.0--------");
//===================秒杀 v4.0 start =========================
if (user == null) {
return "login";
}
model.addAttribute("user", user);
GoodsVo goodsVo = goodsService.findGoodsVoByGoodsId(goodsId);
//判断库存
if (goodsVo.getStockCount() < 1) {
model.addAttribute("errmsg", RespBeanEnum.ENTRY_STOCK.getMessage());
return "secKillFail";
}
//解决重复抢购,直接到redis中获取对应的秒杀订单,如果有则说明已经抢购了,不能继续抢购
SeckillOrder seckillOrder = (SeckillOrder) redisTemplate.opsForValue().get("order:" + user.getId() + ":" + goodsId);
if (seckillOrder != null) {
model.addAttribute("errmsg", RespBeanEnum.REPEATE_ERROR.getMessage());
return "secKillFail";
}
//如果库存为空,避免总是到 reids 去查询库存,给 redis 增加负担(内存标记)
if (entryStockMap.get(goodsId)) {
//如果当前这个秒杀商品已经是空库存,则直接返回.
model.addAttribute("errmsg", RespBeanEnum.ENTRY_STOCK.getMessage());
return "secKillFail";
}
//库存预减,如果在redis中预减库存,发现秒杀商品中已经没有库存了,直接返回
//从而减少这个方法请求 orderService.seckill防止大量请求打到数据,优化秒杀
//这个方法具有原子性!!!
Long decrement = redisTemplate.opsForValue().decrement("seckillGoods:" + goodsId);
//说明这个商品已经没有库存了
if (decrement < 0) {
//这里使用内存标记,避免多次操作 redis, true 表示空库存了.
entryStockMap.put(goodsId, true);
//恢复redis库存为0,对业务没有影响只是为了好看
redisTemplate.opsForValue().increment("seckillGoods:" + goodsId);
model.addAttribute("errmsg", RespBeanEnum.REPEATE_ERROR.getMessage());
return "secKillFail";
}
//抢购
Order order = orderService.seckill(user, goodsVo);
if (order == null) {
model.addAttribute("errmsg", RespBeanEnum.ENTRY_STOCK.getMessage());
return "secKillFail";
}
model.addAttribute("order", order);
model.addAttribute("goods", goodsVo);
return "orderDetail";
//===================秒杀 v4.0 end... =========================
}
//该方法实在类的所有属性都初始化后,自动执行
//这里将所有秒杀商品的库存量加载到redis中
@Override
public void afterPropertiesSet() throws Exception {
//1.查询所有的秒杀商品
List<GoodsVo> list = goodsService.findGoodsVo();
if (CollectionUtils.isEmpty(list)) {
return;
}
//遍历list,将秒杀商品的库存量放入到redis中
//秒杀商品库存量对应的key seckillGoods:商品id
for (GoodsVo goodsVo : list) {
redisTemplate.opsForValue().set("seckillGoods:" + goodsVo.getId(), goodsVo.getStockCount());
//当有库存为 false,当无库存为 true。防止库存没有了,还会到 Redis 进行判断操作
entryStockMap.put(goodsVo.getId(), false);
}
}
}
3.4 v5.0 优化秒杀: 加入消息队列,实现秒杀的异步请求
需求分析/图解
- 问题分析
前面秒杀, 没有实现异步机制, 是完成下订单后, 再返回的, 当有大并发请求下订单操作时, 数据库来不及响应, 容易造成线程堆积
- 解决方案
-
加入消息队列,实现秒杀的异步请求
-
接收到客户端秒杀请求后,服务器立即返回 正在秒杀中…, 有利于流量削峰
-
客户端进行轮询秒杀结果, 接收到秒杀结果后,在客户端页面显示即可
-
秒杀消息发送设计 SeckillMessage - String
代码实现
秒杀消息类
@Data
@NoArgsConstructor
@AllArgsConstructor
public class SeckillMessage {
private User user;
private Long goodsId;
}
定义消息队列、交换机
@Configuration
public class RabbitMQSecKillConfig {
private static final String QUEUE = "seckillQueue";
private static final String EXCHANGE = "seckillExchange";
@Bean
public Queue queue_seckill() {
return new Queue(QUEUE);
}
@Bean
public TopicExchange topicExchange_seckill() {
return new TopicExchange(EXCHANGE);
}
@Bean
public Binding binding_seckill() {
return BindingBuilder.bind(queue_seckill())
.to(topicExchange_seckill()).with("seckill.#");
}
}
发送秒杀消息
@Service
@Slf4j
public class MQSenderMessage {
@Resource
private RabbitTemplate rabbitTemplate;
//发送秒杀信息
public void senderMessage(String message) {
System.out.println("发送消息了=" + message);
log.info("发送消息:" + message);
rabbitTemplate.convertAndSend("seckillExchange", "seckill.message", message);
}
}
消息接收者
@Service
@Slf4j
public class MQReceiverConsumer {
@Resource
private GoodsService goodsService;
@Resource
private OrderService orderService;
//下单操作
@RabbitListener(queues = "seckillQueue")
public void queue(String message) {
SeckillMessage seckillMessage = JSONUtil.toBean(message, SeckillMessage.class);
Long goodId = seckillMessage.getGoodsId();
User user = seckillMessage.getUser();
//获取抢购的商品信息
GoodsVo goodsVo = goodsService.findGoodsVoByGoodsId(goodId);
//下单操作
orderService.seckill(user, goodsVo);
}
}
SeckillController
@Controller
@RequestMapping(value = "/seckill")
public class SeckillController implements InitializingBean {
@Resource
private GoodsService goodsService;
@Resource
private RedisTemplate redisTemplate;
@Resource
private MQSenderMessage mqSenderMessage;
//如果某个商品库存已经为空, 则标记到 entryStockMap
private HashMap<Long, Boolean> entryStockMap = new HashMap<>();
@RequestMapping(value = "/doSeckill")
public String doSeckill(Model model, User user, Long goodsId) {
System.out.println("-----秒杀 V5.0--------");
//===================秒杀 v5.0 start =========================
if (user == null) {
return "login";
}
model.addAttribute("user", user);
GoodsVo goodsVo = goodsService.findGoodsVoByGoodsId(goodsId);
//判断库存
if (goodsVo.getStockCount() < 1) {
model.addAttribute("errmsg", RespBeanEnum.ENTRY_STOCK.getMessage());
return "secKillFail";
}
//解决重复抢购,直接到redis中获取对应的秒杀订单,如果有则说明已经抢购了,不能继续抢购
SeckillOrder seckillOrder = (SeckillOrder) redisTemplate.opsForValue().get("order:" + user.getId() + ":" + goodsId);
if (seckillOrder != null) {
model.addAttribute("errmsg", RespBeanEnum.REPEATE_ERROR.getMessage());
return "secKillFail";
}
//如果库存为空,避免总是到 reids 去查询库存,给 redis 增加负担(内存标记)
if (entryStockMap.get(goodsId)) {
//如果当前这个秒杀商品已经是空库存,则直接返回.
model.addAttribute("errmsg", RespBeanEnum.ENTRY_STOCK.getMessage());
return "secKillFail";
}
//库存预减,如果在redis中预减库存,发现秒杀商品中已经没有库存了,直接返回
//从而减少这个方法请求 orderService.seckill防止大量请求打到数据,优化秒杀
//这个方法具有原子性!!!
Long decrement = redisTemplate.opsForValue().decrement("seckillGoods:" + goodsId);
//说明这个商品已经没有库存了
if (decrement < 0) {
//这里使用内存标记,避免多次操作 redis, true 表示空库存了.
entryStockMap.put(goodsId, true);
//恢复redis库存为0,对业务没有影响只是为了好看
redisTemplate.opsForValue().increment("seckillGoods:" + goodsId);
model.addAttribute("errmsg", RespBeanEnum.REPEATE_ERROR.getMessage());
return "secKillFail";
}
//抢购
// Order order = orderService.seckill(user, goodsVo);
// if (order == null) {
// model.addAttribute("errmsg", RespBeanEnum.ENTRY_STOCK.getMessage());
// return "secKillFail";
// }
// model.addAttribute("order", order);
// model.addAttribute("goods", goodsVo);
//抢购, 向 MQ 发消息, 因为不知道是否成功, 客户端需要轮询
//errmsg 为排队中.... , 暂时返回 "secKillFaill" 页面
SeckillMessage seckillMessage =
new SeckillMessage(user, goodsId);
mqSenderMessage.senderMessage
(JSONUtil.toJsonStr(seckillMessage));
model.addAttribute("errmsg", "排队中.....");
return "orderDetail";
//===================秒杀 v5.0 end... =========================
}
//该方法实在类的所有属性都初始化后,自动执行
//这里将所有秒杀商品的库存量加载到redis中
@Override
public void afterPropertiesSet() throws Exception {
//1.查询所有的秒杀商品
List<GoodsVo> list = goodsService.findGoodsVo();
if (CollectionUtils.isEmpty(list)) {
return;
}
//遍历list,将秒杀商品的库存量放入到redis中
//秒杀商品库存量对应的key seckillGoods:商品id
for (GoodsVo goodsVo : list) {
redisTemplate.opsForValue().set("seckillGoods:" + goodsVo.getId(), goodsVo.getStockCount());
//当有库存为 false,当无库存为 true。防止库存没有了,还会到 Redis 进行判断操作
entryStockMap.put(goodsVo.getId(), false);
}
}
}
- 客户端轮询秒杀结果-思路分析示意图