欢迎光临
我们一直在努力

15分钟理解一个秒杀方案

        本篇文章是解读一个github上的基于SpringBoot开发的高并发限时抢购秒杀系统,github仓库链接:https://github.com/zaiyunduan123/springboot-seckill.git

        该秒杀系统围绕高并发防护、数据库保护、性能优化核心目标设计,通过多级拦截、缓存、异步、限流等手段,将秒杀请求尽可能拦截在系统上游,最终实现从优化前 QPS 423 到优化后 QPS 2501 的性能提升。

思路设计

        秒杀场景的核心痛点是高并发请求冲击数据库(读多写少、瞬时流量大、易超卖 / 重复秒杀),因此方案的核心逻辑是:层层拦截请求,最大化减少对数据库的直接访问,整体遵循 “本地→缓存→消息队列→数据库” 的请求处理链路。

秒杀全流程

在秒杀开始之前,系统要提前完成数据加载,避免秒杀时瞬时查询压力。

一、前置准备

(1)商品/库存数据预热

@Override
public void afterPropertiesSet() {
List<GoodsVo> goodsVoList = goodsService.listGoodsVo();
if (goodsVoList == null) {
return;
}
for (GoodsVo goods : goodsVoList) {
redisService.set(GoodsKey.getGoodsStock, "" + goods.getId(), goods.getStockCount());
//初始化商品都是没有处理过的
localOverMap.put(goods.getId(), false);
}
}

启动时将所有秒杀商品的库存同步到redis,秒杀初期的库存扣减直接在Redis中完成

(2)本地标识初始化

private HashMap<Long, Boolean> localOverMap = new HashMap<Long, Boolean>();

将所有商品标记为“未售罄”,避免秒杀时频繁访问Redis判断库存状态

二、三板斧

(1)限流

当用户点击“立即秒杀”提交请求时,系统可以通过多级拦截过滤掉无效请求

//基于令牌桶算法的限流实现类
RateLimiter rateLimiter = RateLimiter.create(10);

if (!rateLimiter.tryAcquire(1000, TimeUnit.MILLISECONDS)) {
return Result.error(CodeMsg.ACCESS_LIMIT_REACHED);
}

实现:基于Guava的RateLimiter(配置RateLimiter.create(10),每秒生成10个令牌),秒杀接口通过rateLimiter.tryAcquire(1000, TimeUnit.MILLISECONDS)判断是否获取令牌;直接拦截了超出系统处理能力的请求,返回“访问受限”,避免无效请求占用下游资源

同时使用本地内存校验,减少redis访问:

//内存标记,减少redis访问
boolean over = localOverMap.get(goodsId);
if (over) {
return Result.error(CodeMsg.SECKILL_OVER);
}

通过localOverMap判断商品是否已售罄,若标记为“已售罄”,直接返回,无需访问Redis,可以过滤掉90%以上的无效库存查询请求

(2)Redis层操作

通过本地校验之后,请求进入Redis层处理库存,避免直接操作数据库。

  • Redis库存预扣减:调用redisService.decr()扣减Redis中的库存,接着进行库存判断若小于0,则调用afterPropertiesSet()重新将库存同步到Redis作为补偿机制,同时做双重校验,做第二次库存扣减,若仍小于0,则将本地内存标识为"已售罄"

//预减库存
long stock = redisService.decr(GoodsKey.getGoodsStock, "" + goodsId);//10
if (stock < 0) {
afterPropertiesSet();
long stock2 = redisService.decr(GoodsKey.getGoodsStock, "" + goodsId);//10
if(stock2 < 0){
localOverMap.put(goodsId, true);
return Result.error(CodeMsg.SECKILL_OVER);
}
}

  • Redis防重:通过调用redisService.get()获查询Redis,判断该用户有没有秒杀过该商品

//判断重复秒杀
SeckillOrder order = orderService.getOrderByUserIdGoodsId(user.getId(), goodsId);
if (order != null) {
return Result.error(CodeMsg.REPEATE_SECKILL);
}
public SeckillOrder getOrderByUserIdGoodsId(long userId, long goodsId) {
return redisService.get(OrderKey.getSeckillOrderByUidGid, "" + userId + "_" + goodsId, SeckillOrder.class);
}

(3)消息队列

通过了Redis检验的有效请求,不直接操作数据库,而是通过消息队列异步处理,削峰填谷,将瞬间高并发请求转化为队列的串行消费,避免数据库被瞬间请求压垮

  • 封装消息:

public class SeckillMessage {

private User user;
private long goodsId;

public User getUser() {
return user;
}

public void setUser(User user) {
this.user = user;
}

public long getGoodsId() {
return goodsId;
}

public void setGoodsId(long goodsId) {
this.goodsId = goodsId;
}
}

  • 发送消息:

public void sendSeckillMessage(SeckillMessage message){
String msg = RedisService.beanToString(message);
log.info("send message:"+msg);
amqpTemplate.convertAndSend(MQConfig.QUEUE, msg);

}

  • 消费端处理:

@RabbitListener(queues=MQConfig.QUEUE)
public void receive(String message){
log.info("receive message:"+message);
SeckillMessage m = RedisService.stringToBean(message, SeckillMessage.class);
User user = m.getUser();
long goodsId = m.getGoodsId();

GoodsVo goodsVo = goodsService.getGoodsVoByGoodsId(goodsId);
int stock = goodsVo.getStockCount();
if(stock <= 0){
return;
}

//判断重复秒杀
SeckillOrder order = orderService.getOrderByUserIdGoodsId(user.getId(), goodsId);
if(order != null) {
return;
}

//减库存 下订单 写入秒杀订单
seckillService.seckill(user, goodsVo);
}

三、保底策略

在消费端处理消息的代码中,可以看出又做了一次库存校验和防重,利用的是数据库在秒杀前做了库存判断,利用userId+goodsId建立唯一键索引,即使Redis缓存失效,也可以利用数据库约束判断

消费端处理消息时,最终落地数据库,核心解决“超卖”和“数据一致性”问题:

(1)乐观锁扣减库存:防止超卖

/**
* 减少库存,每次减一
*
* @return
*/
public boolean reduceStock(GoodsVo goods) {
int numAttempts = 0;
int ret = 0;
SeckillGoods sg = new SeckillGoods();
sg.setGoodsId(goods.getId());
sg.setVersion(goods.getVersion());
do {
numAttempts++;
try {
sg.setVersion(goodsMapper.getVersionByGoodsId(goods.getId()));
ret = goodsMapper.reduceStockByVersion(sg);
} catch (Exception e) {
e.printStackTrace();
}
if (ret != 0)
break;
} while (numAttempts < DEFAULT_MAX_RETRIES);

return ret > 0;
}

通过 版本号 (version) + do-while 循环重试 的方式实现了乐观锁。它允许并发访问,只有在真正发生数据冲突(多个线程同时尝试更新同一条记录)时,才会让部分线程重试。这种方法避免了悲观锁(如 SELECT … FOR UPDATE)带来的阻塞,提高了并发性能,是处理高并发库存扣减的常用且有效的方法。

(2)事务化下单:订单与秒杀单一致

//保证这三个操作,减库存 下订单 写入秒杀订单是一个事物
@Transactional
public OrderInfo seckill(User user, GoodsVo goods){
//减库存
boolean success = goodsService.reduceStock(goods);
if (success){
//下订单 写入秒杀订单
return orderService.createOrder(user, goods);
}else {
setGoodsOver(goods.getId());
return null;
}
}
/**
* 因为要同时分别在订单详情表和秒杀订单表都新增一条数据,所以要保证两个操作是一个事物
*/
@Transactional
public OrderInfo createOrder(User user, GoodsVo goods) {
OrderInfo orderInfo = new OrderInfo();
orderInfo.setCreateDate(new Date());
orderInfo.setDeliveryAddrId(0L);
orderInfo.setGoodsCount(1);
orderInfo.setGoodsId(goods.getId());
orderInfo.setGoodsName(goods.getGoodsName());
orderInfo.setGoodsPrice(goods.getGoodsPrice());
orderInfo.setOrderChannel(1);
orderInfo.setStatus(0);
orderInfo.setUserId(user.getId());
orderMapper.insert(orderInfo);

SeckillOrder seckillOrder = new SeckillOrder();
seckillOrder.setGoodsId(goods.getId());
seckillOrder.setOrderId(orderInfo.getId());
seckillOrder.setUserId(user.getId());
orderMapper.insertSeckillOrder(seckillOrder);

redisService.set(OrderKey.getSeckillOrderByUidGid, "" + user.getId() + "_" + goods.getId(), seckillOrder);

return orderInfo;
}

在完成数据库操作之后,将订单信息写入到redis中,用于前面的校验

秒杀时序图

总结

        该秒杀系统核心围绕“层层拦截请求、减少数据库直访”设计,通过前置将商品库存预热至Redis并初始化本地售罄标记,结合RateLimiter令牌桶限流、Redis原子扣减库存+防重校验、RabbitMQ异步削峰的三级拦截策略,把绝大多数无效请求挡在数据库之外;最终通过数据库乐观锁防超卖、事务化下单、唯一索引防重复秒杀的兜底策略,既将QPS从423提升至2501,又保障了数据一致性,有效解决了高并发秒杀场景下的核心痛点。

         这套方案以“本地→缓存→消息队列→数据库”的请求链路为核心,用内存标记减少Redis访问、用Redis预扣减少数据库操作、用消息队列削峰填谷,兼顾了高性能与数据安全性,是中小规模秒杀场景的经典实现,能在保护数据库不被瞬时流量冲垮的前提下,最大化处理有效秒杀请求。

赞(0)
未经允许不得转载:171主机测评 » 15分钟理解一个秒杀方案
分享到: 更多 (0)

评论 抢沙发

  • 昵称 (必填)
  • 邮箱 (必填)
  • 网址