Loading

领取优惠券

需求介绍

  • 优惠券业务需求介绍

    • 新用户注册-发放后端配置的新人优惠券
    • 用户可以主动领取优惠券
    • 下单可以选择对应的优惠券抵扣
    • 支持满减优惠券-无门槛优惠券两种
    • 多种元数据配置
      • 类型:无门槛、满减等
      • 每人领劵次数限制
      • 发券总量控制
      • 优惠券开始时间和结束时间
      • 优惠券状态配置
  • 核心知识:

    • 高并发下扣减劵库存
      • 超发
      • 单人超领取
  • 原生分布式锁+redisson框架分布锁使用

    • 分布式锁+最佳实践
  • 数据库表介绍

#优惠券表
CREATE TABLE `coupon` (
  `id` bigint(20) NOT NULL AUTO_INCREMENT COMMENT 'id',
  `category` varchar(11) DEFAULT NULL COMMENT '优惠卷类型[NEW_USER注册赠券,TASK任务卷,PROMOTION促销劵]',
  `publish` varchar(11) DEFAULT NULL COMMENT '发布状态, PUBLISH发布,DRAFT草稿,OFFLINE下线',
  `coupon_img` varchar(524) DEFAULT NULL COMMENT '优惠券图片',
  `coupon_title` varchar(128) DEFAULT NULL COMMENT '优惠券标题',
  `price` decimal(16,2) DEFAULT NULL COMMENT '抵扣价格',
  `user_limit` int(11) DEFAULT NULL COMMENT '每人限制张数',
  `start_time` datetime DEFAULT NULL COMMENT '优惠券开始有效时间',
  `end_time` datetime DEFAULT NULL COMMENT '优惠券失效时间',
  `publish_count` int(11) DEFAULT NULL COMMENT '优惠券总量',
  `stock` int(11) DEFAULT '0' COMMENT '库存',
  `create_time` datetime DEFAULT NULL,
  `condition_price` decimal(16,2) DEFAULT NULL COMMENT '满多少才可以使用',
  PRIMARY KEY (`id`)
) ENGINE=InnoDB AUTO_INCREMENT=23 DEFAULT CHARSET=utf8mb4;


#优惠券领劵记录
CREATE TABLE `coupon_record` (
  `id` bigint(11) unsigned NOT NULL AUTO_INCREMENT,
  `coupon_id` bigint(11) DEFAULT NULL COMMENT '优惠券id',
  `create_time` datetime DEFAULT NULL COMMENT '创建时间获得时间',
  `use_state` varchar(32) DEFAULT NULL COMMENT '使用状态  可用 NEW,已使用USED,过期 EXPIRED;',
  `user_id` bigint(11) DEFAULT NULL COMMENT '用户id',
  `user_name` varchar(128) DEFAULT NULL COMMENT '用户昵称',
  `coupon_title` varchar(128) DEFAULT NULL COMMENT '优惠券标题',
  `start_time` datetime DEFAULT NULL COMMENT '开始时间',
  `end_time` datetime DEFAULT NULL COMMENT '结束时间',
  `order_id` bigint(11) DEFAULT NULL COMMENT '订单id',
  `price` decimal(16,2) DEFAULT NULL COMMENT '抵扣价格',
  `condition_price` decimal(16,2) DEFAULT NULL COMMENT '满多少才可以使用',
  PRIMARY KEY (`id`)
) ENGINE=InnoDB AUTO_INCREMENT=141 DEFAULT CHARSET=utf8mb4;

分页查询优惠券

思路:查询为发布状态并且是促销优惠券类型的优惠券信息

@Override
public Map<String, Object> pageCouponActivity(int page, int size) {

    Page<CouponDO> pageInfo = new Page<>(page,size);

    IPage<CouponDO> couponDOIPage =  couponMapper.selectPage(pageInfo, new QueryWrapper<CouponDO>()
                                                             .eq("publish",CouponPublishEnum.PUBLISH)
                                                             .eq("category", CouponCategoryEnum.PROMOTION)
                                                             .orderByDesc("create_time"));

    Map<String,Object> pageMap = new HashMap<>(3);
    //总条数
    pageMap.put("total_record", couponDOIPage.getTotal());
    //总页数
    pageMap.put("total_page",couponDOIPage.getPages());

    pageMap.put("current_data",couponDOIPage.getRecords().stream().map(obj->beanProcess(obj)).collect(Collectors.toList()));


    return pageMap;
}

private CouponVO beanProcess(CouponDO couponDO) {
    CouponVO couponVO = new CouponVO();
    BeanUtils.copyProperties(couponDO,couponVO);
    return couponVO;
}

问题:HashMap初始化时为什么建议使用HashMap(int initialCapacity)

领取优惠券

思路:

* 领劵接口
* 1、获取优惠券是否存在
* 2、校验优惠券是否可以领取:时间、库存、超过限制
* 3、扣减库存
* 4、保存领劵记录
@Override
public JsonData addCoupon(long couponId, CouponCategoryEnum category) {

    LoginUser loginUser = LoginInterceptor.threadLocal.get();

    CouponDO couponDO = couponMapper.selectOne(new QueryWrapper<CouponDO>()
                                               .eq("id",couponId)
                                               .eq("category",category.name()));


    //优惠券是否可以领取
    this.checkCoupon(couponDO,loginUser.getId());


    //构建领劵记录
    CouponRecordDO couponRecordDO = new CouponRecordDO();
    BeanUtils.copyProperties(couponDO,couponRecordDO);
    couponRecordDO.setCreateTime(new Date());
    couponRecordDO.setUseState(CouponStateEnum.NEW.name());
    couponRecordDO.setUserId(loginUser.getId());
    couponRecordDO.setUserName(loginUser.getName());
    couponRecordDO.setCouponId(couponId);
    couponRecordDO.setId(null);

    //扣减库存  TODO
    int rows = couponMapper.reduceStock(couponId);
    // int rows = couponMapper.reduceStock(couponId,couponDO.getStock());
    if(rows==1){
        //库存扣减成功才保存记录
        couponRecordMapper.insert(couponRecordDO);
    }else {
        log.warn("发放优惠券失败:{},用户:{}",couponDO,loginUser);
        throw  new BizException(BizCodeEnum.COUPON_NO_STOCK);
    }
    return JsonData.buildSuccess();
}


private void checkCoupon(CouponDO couponDO, Long userId) {

    //优惠券不存在
    if(couponDO==null){
        throw new BizException(BizCodeEnum.COUPON_NO_EXITS);
    }

    //库存是否足够
    if(couponDO.getStock()<=0){
        throw new BizException(BizCodeEnum.COUPON_NO_STOCK);
    }

    //判断是否是否发布状态
    if(!couponDO.getPublish().equals(CouponPublishEnum.PUBLISH.name())){
        throw new BizException(BizCodeEnum.COUPON_GET_FAIL);
    }

    //是否在领取时间范围
    long time = CommonUtil.getCurrentTimestamp();
    long start = couponDO.getStartTime().getTime();
    long end = couponDO.getEndTime().getTime();
    if(time<start || time>end){
        throw new BizException(BizCodeEnum.COUPON_OUT_OF_TIME);
    }

    //用户是否超过限制
    int recordNum =  couponRecordMapper.selectCount(new QueryWrapper<CouponRecordDO>()
                                                    .eq("coupon_id",couponDO.getId())
                                                    .eq("user_id",userId));

    if(recordNum >= couponDO.getUserLimit()){
        throw new BizException(BizCodeEnum.COUPON_OUT_OF_LIMIT);
    }
}

控制超发

<!--扣减库存-->
<update id="reduceStock">
    update coupon set stock=stock-1 where id = #{couponId} and stock > 0
</update>

分布式锁

image-20221024225607053

  • 设计分布式锁应该考虑的东西
    • 排他性
      • 在分布式应用集群中,同一个方法在同一时间只能被一台机器上的一个线程执行
    • 容错性
      • 分布式锁一定能得到释放,比如客户端奔溃或者网络中断
    • 满足可重入、高性能、高可用
    • 注意分布式锁的开销、锁粒度

基于Redis实现分布式锁的几种坑

  • 实现分布式锁 可以用 Redis、Zookeeper、Mysql数据库这几种 , 性能最好的是Redis且是最容易理解

    • 分布式锁离不开 key - value 设置
    key 是锁的唯一标识,一般按业务来决定命名,比如想要给一种商品的秒杀活动加锁,key 命名为 “seckill_商品ID” 。value就可以使用固定值,比如设置成1
  • 基于redis实现分布式锁,文档:http://www.redis.cn/commands.html#string

    • 加锁 SETNX key value
    setnx 的含义就是 SET if Not Exists,有两个参数 setnx(key, value),该方法是原子性操作
    
    如果 key 不存在,则设置当前 key 成功,返回 1;
    
    如果当前 key 已经存在,则设置当前 key 失败,返回 0
    
    • 解锁 del (key)
    得到锁的线程执行完任务,需要释放锁,以便其他线程可以进入,调用 del(key)
    
    • 配置锁超时 expire (key,30s)
    客户端奔溃或者网络中断,资源将会永远被锁住,即死锁,因此需要给key配置过期时间,以保证即使没有被显式释放,这把锁也要在一定时间后自动释放
    
    • 综合伪代码
    methodA(){
      String key = "coupon_66"
    
      if(setnx(key,1) == 1){
          expire(key,30,TimeUnit.MILLISECONDS)
          try {
              //做对应的业务逻辑
              //查询用户是否已经领券
              //如果没有则扣减库存
              //新增领劵记录
          } finally {
              del(key)
          }
      }else{
    
        //睡眠100毫秒,然后自旋调用本方法
    		methodA()
      }
    }
    
  • 存在问题
    • 多个命令之间不是原子性操作,如setnxexpire之间,如果setnx成功,但是expire失败,且宕机了,则这个资源就是死锁
使用原子命令:设置和配置过期时间  setnx / setex
如: set key 1 ex 30 nx
java里面 redisTemplate.opsForValue().setIfAbsent("seckill_1",1,30,TimeUnit.MILLISECONDS)

image-20221024225756895

  • 业务超时,存在其他线程勿删,key 30秒过期,假如线程A执行很慢超过30秒,则key就被释放了,其他线程B就得到了锁,这个时候线程A执行完成,而B还没执行完成,结果就是线程A删除了线程B加的锁
可以在 del 释放锁之前做一个判断,验证当前的锁是不是自己加的锁, 那 value 应该是存当前线程的标识或者uuid

String key = "coupon_66"
String value = Thread.currentThread().getId()

if(setnx(key,value) == 1){
    expire(key,30,TimeUnit.MILLISECONDS)
    try {
        //做对应的业务逻辑
    } finally {
    	//删除锁,判断是否是当前线程加的
    	if(get(key).equals(value)){
					//还存在时间间隔
					del(key)
        }
    }
}else{
	
	//睡眠100毫秒,然后自旋调用本方法

}
  • 进一步细化误删
    • 当线程A获取到正常值时,返回带代码中判断期间锁过期了,线程B刚好重新设置了新值,线程A那边有判断value是自己的标识,然后调用del方法,结果就是删除了新设置的线程B的值
    • 核心还是判断和删除命令 不是原子性操作导致

lua脚本

  • redis做分布式锁存在的问题

    • 核心是保证多个指令原子性,加锁使用setnx setex 可以保证原子性,那解锁使用 判断和删除怎么保证原子性
    • 文档:http://www.redis.cn/commands/set.html
    • 多个命令的原子性:采用 lua脚本+redis, 由于【判断和删除】是lua脚本执行,所以要么全成功,要么全失败
    //获取lock的值和传递的值一样,调用删除操作返回1,否则返回0
    
    String script = "if redis.call('get',KEYS[1]) == ARGV[1] then return redis.call('del',KEYS[1]) else return 0 end";
    
    //Arrays.asList(lockKey)是key列表,uuid是参数
    Integer result = redisTemplate.execute(new DefaultRedisScript<>(script, Integer.class), Arrays.asList(lockKey), uuid);
    
    • 全部代码
    /**
    * 原生分布式锁 开始
    * 1、原子加锁 设置过期时间,防止宕机死锁
    * 2、原子解锁:需要判断是不是自己的锁
    */
    String uuid = CommonUtil.generateUUID();
    String lockKey = "lock:coupon:"+couponId;
    Boolean nativeLock=redisTemplate.opsForValue().setIfAbsent(lockKey,uuid,Duration.ofSeconds(30));
        if(nativeLock){
          //加锁成功
          log.info("加锁:{}",nativeLock);
          try {
               //执行业务  TODO
            }finally {
               String script = "if redis.call('get',KEYS[1]) == ARGV[1] then return redis.call('del',KEYS[1]) else return 0 end";
    
                    Integer result = redisTemplate.execute(new DefaultRedisScript<>(script, Integer.class), Arrays.asList(lockKey), uuid);
                    log.info("解锁:{}",result);
                }
    
            }else {
                //加锁失败,睡眠100毫秒,自旋重试
                try {
                    TimeUnit.MILLISECONDS.sleep(100L);
                } catch (InterruptedException e) { }
                return addCoupon( couponId, couponCategory);
            }
            //原生分布式锁 结束
    
    • 遗留一个问题,锁的过期时间,如何实现锁的自动续期 或者 避免业务执行时间过长,锁过期了?
      • 原生方式的话,一般把锁的过期时间设置久一点,比如10分钟时间

Redisson

<!--分布式锁-->
<dependency>
      <groupId>org.redisson</groupId>
      <artifactId>redisson</artifactId>
      <version>3.10.1</version>
</dependency>
  • 创建redisson客户端
@Value("${spring.redis.host}")
private String redisHost;

@Value("${spring.redis.port}")
private String redisPort;

@Value("${spring.redis.password}")
private String redisPwd;

/**
     * 配置分布式锁
     * @return
     */
@Bean
public RedissonClient redissonClient() {
    Config config = new Config();

    //单机模式
    //config.useSingleServer().setPassword("123456").setAddress("redis://8.129.113.233:3308");
    config.useSingleServer().setPassword(redisPwd).setAddress("redis://"+redisHost+":"+redisPort);

    //集群模式
    //config.useClusterServers()
    //.setScanInterval(2000)
    //.addNodeAddress("redis://10.0.29.30:6379", "redis://10.0.29.95:6379")
    // .addNodeAddress("redis://127.0.0.1:6379");

    RedissonClient redisson = Redisson.create(config);

    return redisson;
}
Lock lock = redisson.getLock("lock:coupon:"+couponId);
//阻塞式等待,一个线程获取锁后,其他线程只能等待,和原生的方式循环调用不一样
lock.lock();
        try {
            CouponDO couponDO = couponMapper.selectOne(new QueryWrapper<CouponDO>().eq("id", couponId)
                    .eq("category", couponCategory)
                    .eq("publish", CouponPublishEnum.PUBLISH));

            this.couponCheck(couponDO,loginUser.getId());

            CouponRecordDO couponRecordDO = new CouponRecordDO();
            BeanUtils.copyProperties(couponDO,couponRecordDO);
            couponRecordDO.setCreateTime(new Date());
            couponRecordDO.setUseState(CouponStateEnum.NEW.name());
            couponRecordDO.setUserId(loginUser.getId());
            couponRecordDO.setUserName(loginUser.getName());
            couponRecordDO.setCouponId(couponId);
            couponRecordDO.setId(null);
            //高并发下扣减劵库存,采用乐观锁,当前stock做版本号,一次只能领取1张
            int rows = couponMapper.reduceStock(couponId);

            if(rows == 1){
                //库存扣减成功才保存
                couponRecordMapper.insert(couponRecordDO);
            }else {
                log.warn("发放优惠券失败:{},用户:{}",couponDO,loginUser);
                throw new BizException(BizCodeEnum.COUPON_NO_STOCK);
            }

        }finally {
            lock.unlock();
        }

Redisson解决分布式锁里面的坑

  • Redis锁的过期时间小于业务的执行时间该如何续期?

    • watch dog看门狗机制
    负责储存这个分布式锁的Redisson节点宕机以后,而且这个锁正好处于锁住的状态时,这个锁会出现锁死的状态。或者业务执行时间过长导致锁过期,
    
    为了避免这种情况的发生,Redisson内部提供了一个监控锁的看门狗,它的作用是在Redisson实例被关闭前,不断的延长锁的有效期。
    
    Redisson中客户端一旦加锁成功,就会启动一个watch dog看门狗。watch dog是一个后台线程,会每隔10秒检查一下,如果客户端还持有锁key,那么就会不断的延长锁key的生存时间
    
    默认情况下,看门狗的检查锁的超时时间是30秒钟,也可以通过修改Config.lockWatchdogTimeout来另行指定
    
    • 指定加锁时间
    // 加锁以后10秒钟自动解锁
    // 无需调用unlock方法手动解锁
    lock.lock(10, TimeUnit.SECONDS);
    
    // 尝试加锁,最多等待100秒,上锁以后10秒自动解锁
    boolean res = lock.tryLock(100, 10, TimeUnit.SECONDS);
    
    if (res) {
       try {
         ...
       } finally {
           lock.unlock();
       }
    }
    

事务

Spring常见的事务管理

  • 事务:多个操作,要么同时成功,要么失败后一起回滚

    • 具备ACID四种特性
      • Atomic(原子性)
      • Consistency(一致性)
      • Isolation(隔离性)
      • Durability(持久性)
  • 常见的Spring事务管理方式

    • 编程式事务管理
    代码中调用beginTransaction()、commit()、rollback()等事务管理相关的方法,通过TransactionTempalte手动管理事务(用的少)
    
    • 声明式事务管理
    通过AOP实现,可配置文件方式或者注解方式实现事务的管理控制(用的多)
    
  • 声明式事务管理本质:

    • 本质是对方法前后进行拦截,底层是建立在 AOP 的基础之上

    • 在目标方法开始之前创建或者加入一个事务,在执行完目标方法之后根据执行情况提交或者回滚事务

Spring事务的传播属性和隔离级别

  • 事物传播行为介绍:

    • 如果在开始当前事务之前,一个事务上下文已经存在,此时有若干选项可以指定一个事务性方法的执行行为
    @Transactional(propagation=Propagation.REQUIRED) 如果有事务, 那么加入事务, 没有的话新建一个(默认情况下)
    
    @Transactional(propagation=Propagation.NOT_SUPPORTED) 不为这个方法开启事务
    
    @Transactional(propagation=Propagation.REQUIRES_NEW) 不管是否存在事务,都创建一个新的事务,原来的挂起,新的执行完毕,继续执行老的事务
    
    @Transactional(propagation=Propagation.MANDATORY) 必须在一个已有的事务中执行,否则抛出异常
    
    @Transactional(propagation=Propagation.NEVER) 必须在一个没有的事务中执行,否则抛出异常(与Propagation.MANDATORY相反)
    
    @Transactional(propagation=Propagation.SUPPORTS) 如果其他bean调用这个方法,在其他bean中声明事务,那就用事务.如果其他bean没有声明事务,那就不用事务.
    
    @Transactional(propagation=Propagation.NESTED) 如果当前存在事务,则创建一个事务作为当前事务的嵌套事务来运行; 如果当前没有事务,则该取值等价于Propagation.REQUIRED。
    
  • 事务隔离级别

    • 是指若干个并发的事务之间的隔离程度
    @Transactional(isolation = Isolation.READ_UNCOMMITTED) 读取未提交数据(会出现脏读, 不可重复读) 基本不使用
    
    @Transactional(isolation = Isolation.READ_COMMITTED) 读取已提交数据(会出现不可重复读和幻读)
    
    @Transactional(isolation = Isolation.REPEATABLE_READ) 可重复读(会出现幻读)
    
    @Transactional(isolation = Isolation.SERIALIZABLE) 串行化
    
  • MYSQL: 默认为REPEATABLE_READ级别

领劵接口微服务本地事务配置

  • 领劵接口配置事务+测试

    • 启动类增加注解 @EnableTransactionManagement
    • 方法增加注解 @Transactional(rollbackFor=Exception.class,propagation=Propagation.REQUIRED)
    • 指定方法需要事务再添加,不能全局都使用
  • 分布式锁测试锁续期的时候

    • 不能使用debug模式,不然看到不到自动续期
@Transactional(rollbackFor = Exception.class, propagation = Propagation.REQUIRED)
@Override
public JsonData addCoupon(long couponId, CouponCategoryEnum category) {

    LoginUser loginUser = LoginInterceptor.threadLocal.get();
    String uuid = CommonUtil.generateUUID();
    String lockKey = "lock:coupon:" + couponId;
    RLock rLock = redissonClient.getLock(lockKey);
    rLock.lock();
    log.info("领券接口加锁成功:{}", Thread.currentThread().getId());

    try {
        CouponDO couponDO = couponMapper.selectOne(new QueryWrapper<CouponDO>().eq("id", couponId).eq("category", category.name()));
        this.checkCoupon(couponDO, loginUser.getId());

        CouponRecordDO couponRecordDO = new CouponRecordDO();
        BeanUtils.copyProperties(couponDO, couponRecordDO);

        couponRecordDO.setCreateTime(new Date());
        couponRecordDO.setUseState(CouponStateEnum.NEW.name());
        couponRecordDO.setUserId(loginUser.getId());
        couponRecordDO.setUserName(loginUser.getName());
        couponRecordDO.setCouponId(couponId);
        couponRecordDO.setId(null);

        int row = couponMapper.reduceStock(couponId);

        if (row == 1) {
            couponRecordMapper.insert(couponRecordDO);
        } else {
            log.info("发放优惠券失败:{},用户:{}", couponDO, loginUser);
            throw new BizException(BizCodeEnum.COUPON_NO_STOCK);
        }
    } finally {
        rLock.unlock();
        log.info("解锁成功");
    }
    return JsonData.buildSuccess();
}
posted @ 2022-10-19 23:01  yonugleesin  阅读(216)  评论(0编辑  收藏  举报