基于Redis实现简单的商品秒杀服务端

简介

这是一个简单的商品秒杀服务端,通过redis实现快速读写库存,实现以商品粒度的分布式锁来防止并发操作库存的问题。秒杀成功的订单以消息的模式发送到mq上,然后可以在商品服务中消费这个mq来创建订单落库,消费者这一块这个项目中没有实现。

秒杀流程

miaosha.png

  1. 通过拦截器每秒放一部分请求进来进行抢购,过滤的请求直接返回抢购失败,
  2. 进行抢购数量,活动,商品是否售罄等基本的校验,
  3. 尝试扣库存:
    1. 尝试请求获取该商品的锁,
    2. 获取该商品的库存,如果库存为0,则添加到售罄队列,返回抢购失败,
    3. 扣减库存,
    4. 生成订单发送mq,
    5. 抢购成功,返回订单号。

主要逻辑

利用分布式锁对同一个抢购商品加锁,进而扣减库存

  1. 由于可能存在多个商品的描述活动,所以需要按商品获取锁。先定义一个自定义注解表示加锁:
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
@Documented
public @interface OrderLock {
    // 商品编码
    String value() default "";
}
复制代码
  1. 扣减库存的方法加上OrderLock注解,并通过el表达式设置value值,也就是后面加锁的对象:
@Override
@OrderLock("#activity.itemCode")
public long stockReduce(String userId, int quantity, SeckillActivityInfo activity) {
    ...
}
复制代码
  1. 通过环绕切面尝试对抢购的商品库存加锁,处理成功后释放锁:
@Around("@annotation(orderLock)")
public Object invoke(ProceedingJoinPoint pjp, OrderLock orderLock) throws Throwable {
    Object lockObject = getLockObject(pjp, orderLock.value());
    if (lockObject == null) {
        throw new LockFailException("无法获取加锁对象");
    }

    RLock lock = redissonClient.getLock(lockObject.toString());
    boolean isLock = false;
    try {
        // 尝试在1000ms内获取锁,5000ms锁失效时间
        isLock = lock.tryLock(1000, 5000, TimeUnit.MILLISECONDS);
        if (isLock) {
            return pjp.proceed();
        }

        throw new LockFailException("对象"" + lockObject + ""加锁失败");
    } finally {
        if (isLock) {
            lock.unlock();
        }
    }
}
复制代码

使用RLocalCachedMap来缓存活动信息数据

实际上,在每个抢购请求处理之前,都需要查询抢购的活动信息,来获取活动商品信息,活动价格等数据。这是一个高度频繁的读取操作,而且活动信息数据在这期间不会更新,所以不需要查询远端数据,网络通信将会是瓶颈。Redisson提供了一个带有本地缓存功能的分布式缓存映射RLocalCachedMap刚好适用我们这个场景。
RLocalCachedMap继承自RMap,实现了java.util.concurrent.ConcurrentMap和java.util.Map两个接口,本地缓存功能充分的利用了JVM的自身内存空间,对部分常用的元素实行就地缓存,按照官方的说法是读取操作的性能比分布式映射提高最多45倍。还等什么,拿来用吧。

扣减库存具体步骤

  1. 获取抢购的商品库存,
RBucket<ItemStockBase> bucket = redissonClient.getBucket(key);
ItemStockBase stock = bucket.get();
复制代码
  1. 判断库存是否等于0,如果库存等于0,将该商品添加到售罄集合里,返回抢购失败,
if (stockQuantity == 0) {
    // 售罄
    RSet<String> itemSaleOut = redissonClient.getSet(ITEM_SALE_OUT);
    itemSaleOut.add(itemCode);
    return -1;
}
复制代码
  1. 抢购数量是否大于库存,库存不足,抢购失败,
  2. 更新库存,
stock.setQuantity(stockQuantity - quantity);
bucket.set(stock);
复制代码
  1. 生成订单发送mq,
// 生成订单发送mq
SeckillOrder order = createSeckillOrder(userId, activity, quantity);
SendResult sr = orderProducerBean.send(order, seckillOrderTopic);

if (sr.isSuccess()) {
    return order.getOrderNo();
} else {
    return -1;
}
复制代码

这里需要注意下,发送mq可能失败,所以还需要根据send的结果来判断是否抢购成功,这一步应该在扣减redis库存后面,确保不会超卖,但是有可能会少卖。

测试

可以通过jmeter并发测试秒杀接口,同时参与2个商品的抢购活动。

  • /seckill/init 初始化秒杀活动信息。预设活动信息,商品库存信息。建议每次压测前都删除redis数据,重新调用这个接口初始化数据,
  • /seckill/order 秒杀接口。

总结

秒杀系统是非常复杂的,这里只是模拟了服务端的工作流程,并没有涉及到前端、负载均衡等的部分。高并发的思想就是避免频繁的读写数据库IO操作,利用mq来异步生成订单,并且要保证不能超卖。

  • 以商品维度生成分布式锁,可以支持多个不同商品的抢购活动,
  • 请求过滤(接口限流)。抢购本来就是一个概率事件,1000个商品,100万人抢购,千分之一的成功率,总不能把这100万个请求都去redis库存查一遍吧,那样redis也扛不住,所以随机丢弃部分请求是巧妙且合理的。这里就是为秒杀接口配置一个限流的拦截器来过滤请求,
  • 预防超卖。关于扣库存和发送mq,应该是先扣库存,后发送mq,因为发送mq有可能会失败,mq里面的订单一定是扣减过库存的,不会超卖。有人说这里通过mq的结果来判断扣减redis库存,或者发送mq失败再把库存加回去,其实没必要,少买总比超卖好。其实mq的可靠性还是很高的,我测试了很多次百万级别的并发请求,还没有出现少买的情况。

完整的源码已经上传:github.com/Phantom0103… 请多提意见帮我改进,🙏 !