基于Redis实现的优惠券秒杀业务

全局唯一Id生成器

为实现唯一性、递增性、安全性、高可用、高性能,能支持未来大量订单业务的订单id的快速生成,给出一种id生成的方法。

代码实现

package com.hmdp.utils;

import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.stereotype.Component;

import javax.annotation.Resource;
import java.time.LocalDateTime;
import java.time.ZoneOffset;
import java.time.format.DateTimeFormatter;

/**
 * @Description TODO
 * @Author ygw
 * @Date 2022/10/18 9:13
 * @Version 1.0
 */

@Component
public class RedisIdWorker {
    /**
     * 开始的时间戳
     */
    private static final long BEGIN_TIMESTAMP = 1640995200L;

    @Resource
    StringRedisTemplate stringRedisTemplate;

    public long nextId(String key){
        //1、生成时间戳
        LocalDateTime time = LocalDateTime.now();
        long nowTimestamp = time.toEpochSecond(ZoneOffset.UTC);
        long timestamp = nowTimestamp - BEGIN_TIMESTAMP;

        //2、生成序列号,由于给递增序列号设置为32位,需要在生成序列号时给出限制
        DateTimeFormatter timeFormatter = DateTimeFormatter.ofPattern("yyyy:MM:dd");
        String date = time.format(timeFormatter);

        /**
         * 重点关注此处redis数据结构的设置,inc:deal:20221018 - value,也就是说一天允许生成2^32个订单
         */
        Long count = stringRedisTemplate.opsForValue().increment("inc:" + key + ":" + date);

        //3、合并生成id
        long id = (timestamp << 32) | count;
        return id;
    }

}

秒杀业务流程

业务流程

说明

1、抢购前,需要判断当前时间活动是否开始或结束

2、如果当前为活动进行时间段,对库存进行判断,存量减一(需要考虑并发)

3、抢到了消费券之后,就直接创建对应的id

注意

需要开启事务,Spring默认使用数据库的隔离级别,mysql的默认隔离级别为可重复读(Repeated Read)(可重复读。在同一个事务内的查询都是事务开始时刻一致的,InnoDB默认级别。在SQL标准中,该隔离级别消除了不可重复读,但是还存在幻象读,但是innoDB解决了幻读),也就是说不用考虑脏读、不可重复读的问题。

代码实现

SeckillVoucherServiceImpl.seckillVoucher()

@Service
@Slf4j
public class SeckillVoucherServiceImpl extends ServiceImpl<SeckillVoucherMapper, SeckillVoucher> implements ISeckillVoucherService {

    @Resource
    private IVoucherOrderService voucherOrderService;

    @Resource
    private RedisIdWorker redisIdWorker;

    @Override
    @Transactional
    public Result seckillVoucher(Long voucherId) {
        SeckillVoucher voucher = query().eq("voucher_id", voucherId).one();

        LocalDateTime now = LocalDateTime.now();

        //1、判断当前时间是否处在活动时间段内
        if (now.isBefore(voucher.getBeginTime()) || now.isAfter(voucher.getEndTime())) {
            //如果不再活动的时间段内,之间返回
            return Result.fail("请等待活动开放后重试");
        }

        //2、如果处在活动时间段内,对库存进行判断
        if(voucher.getStock() < 1){
            return Result.fail("优惠券已经抢完了");
        }

        //3、如果库存大于0,优惠券数量减一并创建订单

        boolean success = update().setSql("stock = stock - 1").eq("voucher_id", voucherId).gt("stock", 0).update();
        if(!success){
            return Result.fail("优惠券被抢完了");
        }

        //4、创建订单返回订单的id
        long orderId = redisIdWorker.nextId("order");
        Long userId = UserHolder.getUser().getId();

        VoucherOrder voucherOrder = new VoucherOrder();
        voucherOrder.setUserId(userId);
        voucherOrder.setId(orderId);
        voucherOrder.setVoucherId(voucherId);

        voucherOrderService.save(voucherOrder);

        return Result.ok(orderId);
    }
}

乐观锁解决超卖问题(并发)

也可以利用串行化的事务隔离级别来解决超卖的问题,但是效率很低

超卖问题可以看成是一种略高于不可重复读的问题,需要在可重复读的事务隔离级别上加锁

问题分析

说明:版本号法就是在select的时候获取版本号,此时如果有多个线程获取到同样的数据;那么最先修改数据的线程在修改数据的同时修改版本号,其他线程在修改数据的时候应该先比较版本号;如果版本号发生改变则无法修改数据,事务回滚。

说明:由版本号法的图中可以发现,仅在版本号为1时进行修改;利用这个特性刚好可以卡住使用stock为1时的状态,即在修改时对stock的值进行判断

代码实现

根据以上分析,可以就用stock的状态来作为一个乐观锁,代码部分只改变更新时的操作

boolean success = update()
    				.setSql("stock = stock - 1")
    				.eq("voucher_id", voucherId)
    				.gt("stock", 0)  //增加比较条件,即stock > 0
    				.update();

悲观锁解决一人一单的问题(并发)

问题分析

说明

在基本秒杀业务的基础上,增加了一个判断优惠券订单是否存在的分支,如果该用户已经抢到了优惠券就直接返回

代码实现

SeckillVoucherServiceImpl.java

@Service
@Slf4j
public class SeckillVoucherServiceImpl extends ServiceImpl<SeckillVoucherMapper, SeckillVoucher> implements ISeckillVoucherService {

    @Resource
    private IVoucherOrderService voucherOrderService;

    @Resource
    private RedisIdWorker redisIdWorker;

    @Override
    public Result seckillVoucher(Long voucherId) {
        SeckillVoucher voucher = query().eq("voucher_id", voucherId).one();

        LocalDateTime now = LocalDateTime.now();

        //1、判断当前时间是否处在活动时间段内
        if (now.isBefore(voucher.getBeginTime()) || now.isAfter(voucher.getEndTime())) {
            //如果不再活动的时间段内,之间返回
            return Result.fail("请等待活动开放后重试");
        }

        //2、如果处在活动时间段内,对库存进行判断
        if(voucher.getStock() < 1){
            return Result.fail("优惠券已经抢完了");
        }

        //3、如果库存大于0,优惠券数量减一并创建订单
        Long userId = UserHolder.getUser().getId();

        //使用intern()是为了确保锁住的是toString后常量池中的值,而不是引用
        synchronized (userId.toString().intern()){
            /**
             * 关于事务失效的说明
             * 在spring中我们将SeckillVoucherServiceImpl交由proxy来进行代理
             * 也就是说SeckillVoucherServiceImpl中的事务实际上由proxy来完成
             * 直接调用createVoucherOrder()事务实际上调用的是SeckillVoucherServiceImpl.createVoucherOrder()
             * 会引起事务的失效,因此下面的操作是为了防止事务失效
             */
            //需要加上aspectjweaver依赖,并在启动程序上开启@EnableAspectJAutoProxy(exposeProxy = true)
            ISeckillVoucherService seckillVoucherService = (ISeckillVoucherService) AopContext.currentProxy();

            return seckillVoucherService.createVoucherOrder(voucherId);
        }
    }

    /**
     * 我所理解的此处的事务为:
     * 事务确保的要么都成功,要么都失败然后进行回滚->是对事务的完整性进行保证
     * 锁是锁住变量然后针对这个变量进行的一系列操作->是对并发安全进行保证
     * @param voucherId
     * @return
     */
    @Transactional
    public Result createVoucherOrder(Long voucherId){
        Long userId = UserHolder.getUser().getId();
        /**
         * TODO 需要判断该用户是否已经抢到了优惠券
         * 并发情况下,可能有多个线程同时进入查询,获得相同的数据,同时满足了更新条件
         * 因此我们在查询时,需要对用户的id进行加锁
         * 此种方法选用的是悲观锁,直接加synchronized即可
         */
        Integer count = voucherOrderService.query().eq("user_id", userId).eq("voucher_id", voucherId).count();
        if(count > 0){
            return Result.fail("优惠券每人限领一张");
        }

        boolean success = update().setSql("stock = stock - 1").eq("voucher_id", voucherId).gt("stock", 0).update();
        if(!success){
            return Result.fail("优惠券被抢完了");
        }

        //4、创建订单返回订单的id
        long orderId = redisIdWorker.nextId("order");

        VoucherOrder voucherOrder = new VoucherOrder();
        voucherOrder.setUserId(userId);
        voucherOrder.setId(orderId);
        voucherOrder.setVoucherId(voucherId);

        voucherOrderService.save(voucherOrder);

        return Result.ok(orderId);
    }
}

posted @ 2022-10-18 20:48  CDUT的一只小菜鸡  阅读(200)  评论(0编辑  收藏  举报