天机学堂8--优惠券发放MQ+Lua分布式锁

异步领券

把优惠券相关数据缓存到Redis中,基于Redis完成资格校验在这里插入图片描述
最终我们要做的有:

  • 生成兑换码时,将优惠券及对应兑换码序列号的最大值缓存到Redis中
  • 改造兑换优惠券的功能,利用Redis完成资格校验,然后发送MQ消息(消息体中要增加传递兑换码的序列号)
  • 改造领取优惠券的MQ监听器,添加标记兑换码状态为已兑换的功能

在这里插入图片描述
弊端:

  • MQ挂掉
  • 后面的用户券更新 失败,但是前面 已经记录用户领取成功了

redisTemplate.opsForHash().increment(key, userId.toString(), 1); 最终返回的是递增操作完成后该字段的新值。例如,若该字段原本存储的值为 5,执行此操作后,该字段的值会更新为 6,并且该方法会返回 6。
第一步,改造领取逻辑,实现基于Redis的领取资格校验。校验完成后不是立刻领取,而是发送MQ消息:

package com.tianji.promotion.service.impl;
// ...略

import static com.tianji.promotion.constants.PromotionConstants.COUPON_CODE_MAP_KEY;
import static com.tianji.promotion.constants.PromotionConstants.COUPON_RANGE_KEY;

/**
 * <p>
 * 用户领取优惠券的记录,是真正使用的优惠券信息 服务实现类
 * </p>
 */
@Service
@RequiredArgsConstructor
public class UserCouponServiceImpl extends ServiceImpl<UserCouponMapper, UserCoupon> implements IUserCouponService {

    private final CouponMapper couponMapper;

    private final IExchangeCodeService codeService;

    private final StringRedisTemplate redisTemplate;

    private final RabbitMqHelper mqHelper;

    @Override
    @Lock(name = "lock:coupon:#{couponId}")
    public void receiveCoupon(Long couponId) {
        // 1.查询优惠券
        Coupon coupon = queryCouponByCache(couponId);
        if (coupon == null) {
            throw new BadRequestException("优惠券不存在");
        }
        // 2.校验发放时间
        LocalDateTime now = LocalDateTime.now();
        if (now.isBefore(coupon.getIssueBeginTime()) || now.isAfter(coupon.getIssueEndTime())) {
            throw new BadRequestException("优惠券发放已经结束或尚未开始");
        }
        // 3.校验库存
        if (coupon.getIssueNum() >= coupon.getTotalNum()) {
            throw new BadRequestException("优惠券库存不足");
        }
        Long userId = UserContext.getUser();
        // 4.校验每人限领数量
        // 4.1.查询领取数量
        String key = PromotionConstants.USER_COUPON_CACHE_KEY_PREFIX + couponId;
        Long count = redisTemplate.opsForHash().increment(key, userId.toString(), 1);
        // 4.2.校验限领数量
        if(count > coupon.getUserLimit()){
            throw new BadRequestException("超出领取数量");
        }
        // 5.扣减优惠券库存
        redisTemplate.opsForHash().increment(
                PromotionConstants.COUPON_CACHE_KEY_PREFIX + couponId, "totalNum", -1);

        // 6.发送MQ消息
        UserCouponDTO uc = new UserCouponDTO();
        uc.setUserId(userId);
        uc.setCouponId(couponId);
        mqHelper.send(MqConstants.Exchange.PROMOTION_EXCHANGE, MqConstants.Key.COUPON_RECEIVE, uc);
    }

    private Coupon queryCouponByCache(Long couponId) {
        // 1.准备KEY
        String key = PromotionConstants.COUPON_CACHE_KEY_PREFIX + couponId;
        // 2.查询
        Map<Object, Object> objMap = redisTemplate.opsForHash().entries(key);
        if (objMap.isEmpty()) {
            return null;
        }
        // 3.数据反序列化
        return BeanUtils.mapToBean(objMap, Coupon.class, false, CopyOptions.create());
    }
    // ...略
}

第二步:监听MQ并领券

编写一个MQ监听器,监听领券的消息:

package com.tianji.promotion.handler;

import com.tianji.promotion.domain.dto.UserCouponDTO;
import com.tianji.promotion.service.IUserCouponService;
import lombok.RequiredArgsConstructor;
import org.springframework.amqp.core.ExchangeTypes;
import org.springframework.amqp.rabbit.annotation.Exchange;
import org.springframework.amqp.rabbit.annotation.Queue;
import org.springframework.amqp.rabbit.annotation.QueueBinding;
import org.springframework.amqp.rabbit.annotation.RabbitListener;
import org.springframework.stereotype.Component;

import static com.tianji.common.constants.MqConstants.Exchange.PROMOTION_EXCHANGE;
import static com.tianji.common.constants.MqConstants.Key.COUPON_RECEIVE;

@RequiredArgsConstructor
@Component
public class PromotionMqHandler {

    private final IUserCouponService userCouponService;

    @RabbitListener(bindings = @QueueBinding(
            value = @Queue(name = "coupon.receive.queue", durable = "true"),
            exchange = @Exchange(name = PROMOTION_EXCHANGE, type = ExchangeTypes.TOPIC),
            key = COUPON_RECEIVE
    ))
    public void listenCouponReceiveMessage(UserCouponDTO uc){
        userCouponService.checkAndCreateUserCoupon(uc);
    }
}

直接调用了userCouponService.checkAndCreateUserCoupon()这个方法。
这个方法之前我们定义过,但是现在参数声明做了修改,必须重构该方法:

// 移除了锁,这里不需要加锁了
@Transactional
@Override
public void checkAndCreateUserCoupon(UserCouponDTO uc) {
    // 1.查询优惠券
    Coupon coupon = couponMapper.selectById(uc.getCouponId());
    if (coupon == null) {
        throw new BizIllegalException("优惠券不存在!");
    }
    // 2.更新优惠券的已经发放的数量 + 1
    int r = couponMapper.incrIssueNum(coupon.getId());
    if (r == 0) {
        throw new BizIllegalException("优惠券库存不足!");
    }
    // 3.新增一个用户券
    saveUserCoupon(coupon, uc.getUserId());
    // 4.更新兑换码状态
    if (uc.getSerialNum()!= null) {
        codeService.lambdaUpdate()
                .set(ExchangeCode::getUserId, uc.getUserId())
                .set(ExchangeCode::getStatus, ExchangeCodeStatus.USED)
                .eq(ExchangeCode::getId, uc.getSerialNum())
                .update();
    }
}

异步兑换码领卷

结合视频中讲解的实现思路,最终我们要做的有:

  • 生成兑换码时,将优惠券及对应兑换码序列号的最大值缓存到Redis中
  • 改造兑换优惠券的功能,利用Redis完成资格校验,然后发送MQ消息(消息体中要增加传递兑换码的序列号)
  • 改造领取优惠券的MQ监听器,添加标记兑换码状态为已兑换的功能

由于监听到MQ消息后要更新兑换码状态,因此,需要给MQ消息体中添加一个序列号字段:
在这里插入图片描述

缓存兑换码:

为啥要用zset缓存最大序列号呢?
写入Redis缓存,member:couponId,score:兑换码的最大序列号
redisTemplate.opsForZSet().add(COUPON_RANGE_KEY, coupon.getId().toString(), maxSerialNum);
修改ExchangeCodeServiceImpl中的生成兑换码功能:

@Override
@Async("generateExchangeCodeExecutor")
public void asyncGenerateCode(Coupon coupon) {
    // 发放数量  我要发放多少
    Integer totalNum = coupon.getTotalNum();
    // 1.获取Redis自增序列号  要发放到的最大的redis序列号
    Long result = serialOps.increment(totalNum);
    if (result == null) {
        return;
    }
    int maxSerialNum = result.intValue();
    List<ExchangeCode> list = new ArrayList<>(totalNum);
    //起始=要发放到的最大的redis序列号-我要发放多少
    for (int serialNum = maxSerialNum - totalNum + 1; serialNum <= maxSerialNum; serialNum++) {
        // 2.生成兑换码
        String code = CodeUtil.generateCode(serialNum, coupon.getId());
        ExchangeCode e = new ExchangeCode();
        e.setCode(code);
        e.setId(serialNum);
        e.setExchangeTargetId(coupon.getId());
        e.setExpiredTime(coupon.getIssueEndTime());
        list.add(e);
    }
    // 3.保存数据库
    saveBatch(list);

    // 4.写入Redis缓存,member:couponId,score:兑换码的最大序列号
    redisTemplate.opsForZSet().add(COUPON_RANGE_KEY, coupon.getId().toString(), maxSerialNum);
}

领券功能

根据兑换码序列号,查询优惠券id的逻辑封装到了ExchangeCodeService中:
查询score值比当前序列号大的第一个优惠券
因为存的时候是:写入Redis缓存,member:couponId,score:兑换码的最大序列号
在com.tianji.promotion.service.impl.ExchangeCodeServiceImpl中:

@Override
public Long exchangeTargetId(long serialNum) {
    // 1.查询score值比当前序列号大的第一个优惠券
    Set<String> results = redisTemplate.opsForZSet().rangeByScore(
            COUPON_RANGE_KEY, serialNum, serialNum + 5000, 0L, 1L);
    if (CollUtils.isEmpty(results)) {
        return null;
    }
    // 2.数据转换
    String next = results.iterator().next();
    return Long.parseLong(next);
}

改造com.tianji.promotion.service.impl.UserCouponServiceImpl中的exchangeCoupon方法:

@Override
@Lock(name = "lock:coupon:#{T(com.tianji.common.utils.UserContext).getUser()}")
public void exchangeCoupon(String code) {
    // 1.校验并解析兑换码
    long serialNum = CodeUtil.parseCode(code);
    // 2.校验是否已经兑换 SETBIT KEY 4 1
    boolean exchanged = codeService.updateExchangeMark(serialNum, true);
    if (exchanged) {
        throw new BizIllegalException("兑换码已经被兑换过了");
    }
    try {
        // 3.查询兑换码对应的优惠券id
        Long couponId = codeService.exchangeTargetId(serialNum);
        if (couponId == null) {
            throw new BizIllegalException("兑换码不存在!");
        }
        Coupon coupon = queryCouponByCache(couponId);
        // 4.是否过期
        LocalDateTime now = LocalDateTime.now();
        if (now.isAfter(coupon.getIssueEndTime()) || now.isBefore(coupon.getIssueBeginTime())) {
            throw new BizIllegalException("优惠券活动未开始或已经结束");
        }

        // 5.校验每人限领数量
        Long userId = UserContext.getUser();
        // 5.1.查询领取数量
        String key = PromotionConstants.USER_COUPON_CACHE_KEY_PREFIX + couponId;
        Long count = redisTemplate.opsForHash().increment(key, userId.toString(), 1);
        // 5.2.校验限领数量
        if(count > coupon.getUserLimit()){
            throw new BadRequestException("超出领取数量");
        }

        // 6.发送MQ消息通知
        UserCouponDTO uc = new UserCouponDTO();
        uc.setUserId(userId);
        uc.setCouponId(couponId);
        uc.setSerialNum((int) serialNum);
        mqHelper.send(MqConstants.Exchange.PROMOTION_EXCHANGE, MqConstants.Key.COUPON_RECEIVE, uc);
    } catch (Exception e) {
        // 重置兑换的标记 0
        codeService.updateExchangeMark(serialNum, false);
        throw e;
    }
}

redisTemplate.opsForZSet().rangeByScore 的返回值是一个 Set<V>。
其中:
V 表示存储在有序集合中的元素的类型,它可以是 String、Integer 等,具体取决于你存储元素时使用的数据类型。
该集合包含了有序集合中分数在指定的最小分数 min 和最大分数 max 范围内的元素。元素的顺序是按照它们在有序集合中的顺序排列的。

基于LUA脚本的异步领券

在兑换资格校验的时候,或者领券资格校验的时候,会有多次与Redis的交互,每一次交互都需要发起一次网络请求。在并发较高的时候可能导致网络拥堵,甚至导致业务变慢。
我们能不能在一次请求Redis中完成所有校验呢?
普通的Redis命令做不到,不过Redis提供了一种脚本语法,可以在脚本中编写复杂业务判断。我们只需要向Redis发起一次请求,就可以完成对脚本调用,即可实现复杂业务校验。这个脚本就是LUA脚本。

我们通过不同的返回值标记表示不同的校验结果:
在这里插入图片描述

兑换资格校验的最终脚本exchange_coupon.lua
redis.call('HGET', _k1, 'userLimit'):从 _k1 键对应的哈希表中获取 userLimit 字段的值。
redis.call('HINCRBY', _k2, ARGV[3], 1):将 _k2 键对应的哈希表中 ARGV[3] 字段的值加 1,并返回结果。
if(tonumber(redis.call('HGET', _k1, 'userLimit')) < redis.call('HINCRBY', _k2, ARGV[3], 1)) 如果用户领取的数量超过了 userLimit,返回 “5”。

--兑换码是否兑换过
if(redis.call('GETBIT', KEYS[1], ARGV[1]) == 1) then
    return "1"
end
--如果 arr 的长度为 0,意味着在指定分数范围内没有找到元素,返回 "2"
local arr = redis.call('ZRANGEBYSCORE', KEYS[2], ARGV[1], ARGV[2], 'LIMIT', 0, 1);
if(#arr == 0) then
    return "2"
end
local cid = arr[1]
local _k1 = "prs:coupon:" .. cid
local _k2 = "prs:user:coupon:" .. cid
if(redis.call('EXISTS', _k1) == 0) then
    return "3"
end
--将当前时间戳与 issueEndTime 进行比较,如果当前时间大于 issueEndTime,说明优惠券已过期,返回 "4"。
if(tonumber(redis.call('time')[1]) > tonumber(redis.call('HGET', _k1, 'issueEndTime'))) then
    return "4"
end
if(tonumber(redis.call('HGET', _k1, 'userLimit')) < redis.call('HINCRBY', _k2, ARGV[3], 1)) then
    return "5"
end
--将 KEYS[1] 键的 ARGV[1] 位置的位设置为 1, 标记用户已领取优惠券。
redis.call('SETBIT', KEYS[1], ARGV[1], "1")
--如果以上条件都满足,最终返回优惠券的 cid。
return cid

将LUA脚本保存在项目的resources目录:在com.tianji.promotion.service.impl.UserCouponServiceImpl中通过静态代码块加载脚本:

package com.tianji.promotion.service.impl;

import lombok.RequiredArgsConstructor;
import org.springframework.core.io.ClassPathResource;
import org.springframework.data.redis.core.script.RedisScript;
import org.springframework.stereotype.Service;

/**
 * <p>
 * 用户领取优惠券的记录,是真正使用的优惠券信息 服务实现类
 * </p>
 */
@Service
@RequiredArgsConstructor
public class UserCouponServiceImpl extends ServiceImpl<UserCouponMapper, UserCoupon> implements IUserCouponService {

    // ... 略

    private static final RedisScript<Long> RECEIVE_COUPON_SCRIPT;
    private static final RedisScript<String> EXCHANGE_COUPON_SCRIPT;

    static {
        RECEIVE_COUPON_SCRIPT = RedisScript.of(new ClassPathResource("lua/receive_coupon.lua"), Long.class);
        EXCHANGE_COUPON_SCRIPT = RedisScript.of(new ClassPathResource("lua/exchange_coupon.lua"), String.class);
    }
    
    // ... 略
}

改造领券、兑换券业务:使用LUA脚本后无需加锁也是线程安全的

  1. 执行Lua进行资格检验,往MQ发送 发放优惠券消息
@Override
public void receiveCoupon(Long couponId) {
    // 1.执行LUA脚本,判断结果
    // 1.1.准备参数
    String key1 = PromotionConstants.COUPON_CACHE_KEY_PREFIX + couponId;
    String key2 = PromotionConstants.USER_COUPON_CACHE_KEY_PREFIX + couponId;
    Long userId = UserContext.getUser();
    // 1.2.执行脚本
    Long r = redisTemplate.execute(RECEIVE_COUPON_SCRIPT, List.of(key1, key2), userId.toString());
    int result = NumberUtils.null2Zero(r).intValue();
    if (result != 0) {
        // 结果大于0,说明出现异常
        throw new BizIllegalException(PromotionConstants.RECEIVE_COUPON_ERROR_MSG[result - 1]);
    }
    // 2.发送MQ消息
    UserCouponDTO uc = new UserCouponDTO();
    uc.setUserId(userId);
    uc.setCouponId(couponId);
    mqHelper.send(MqConstants.Exchange.PROMOTION_EXCHANGE, MqConstants.Key.COUPON_RECEIVE, uc);
}
@Override
public void exchangeCoupon(String code) {
    // 1.校验并解析兑换码
    long serialNum = CodeUtil.parseCode(code);
    // 2.执行LUA脚本
    Long userId = UserContext.getUser();
    String result = redisTemplate.execute(
            EXCHANGE_COUPON_SCRIPT,
            List.of(COUPON_CODE_MAP_KEY, COUPON_RANGE_KEY),
            String.valueOf(serialNum), String.valueOf(serialNum + 5000), userId.toString());
    long r = NumberUtils.parseLong(result);
    if (r < 10) {
        // 异常结果应该是在1~5之间
        throw new BizIllegalException(PromotionConstants.EXCHANGE_COUPON_ERROR_MSG[(int) (r - 1)]);
    }
    // 3.发送MQ消息通知
    UserCouponDTO uc = new UserCouponDTO();
    uc.setUserId(userId);
    uc.setCouponId(r);
    uc.setSerialNum((int) serialNum);
    mqHelper.send(MqConstants.Exchange.PROMOTION_EXCHANGE, MqConstants.Key.COUPON_RECEIVE, uc);
}

对应监听MQ并领券:

@Transactional
@Override
public void checkAndCreateUserCoupon(UserCouponDTO uc) {
    // 1.查询优惠券
    Coupon coupon = couponMapper.selectById(uc.getCouponId());
    if (coupon == null) {
        throw new BizIllegalException("优惠券不存在!");
    }
    // 2.更新优惠券的已经发放的数量 + 1
    int r = couponMapper.incrIssueNum(coupon.getId());
    if (r == 0) {
        throw new BizIllegalException("优惠券库存不足!");
    }
    // 3.新增一个用户券
    saveUserCoupon(coupon, uc.getUserId());

    // 4.更新兑换码状态
    if (uc.getSerialNum() != null) {
        codeService.lambdaUpdate()
                .set(ExchangeCode::getUserId, uc.getUserId())
                .set(ExchangeCode::getStatus, ExchangeCodeStatus.USED)
                .eq(ExchangeCode::getId, uc.getSerialNum())
                .update();
    }
}

使用 Redis 的 EVAL 命令在 Redis 服务器上直接执行 Lua 脚本。EVAL 命令的基本语法如下:
EVAL script numkeys key [key...] arg [arg...]
其中:
script:要执行的 Lua 脚本。
numkeys:脚本中使用的键的数量。
key [key…]:传递给脚本的键列表。
arg [arg…]:传递给脚本的参数列表。

Redisson+Lua参考黑马点评

4.1.超发问题
面试官:你做的优惠券功能如何解决券超发的问题?
答:券超发问题常见的有两种场景:

  • 券库存不足导致超发
  • 发券时超过了每个用户限领数量
    这两种问题产生的原因都是高并发下的线程安全问题。往往需要通过加锁来保证线程安全。不过在处理细节上,会有一些差别。

首先,针对库存不足导致的超发问题,也就是典型的库存超卖问题,我们可以通过乐观锁来解决。也就是在库存扣减的SQL语句中添加对于库存余量的判断。当然这里不必要求必须与查询到的库存一致,因为这样可能导致库存扣减失败率太高。而是判断库存是否大于0即可,这样既保证了安全,也提高了库存扣减的成功率。

其次,对于用户限领数量超出的问题,我们无法采用乐观锁。因为要判断是否超发,需要先查询用户已领取数量,然后判断有没有超过限领数量,没有超过才会新增一条领取记录。这就导致后续的新增操作会影响超发的判断,只能利用悲观锁将查询已领数量、判断超发、新增领取记录几个操作封装为原子操作。这样才能保证线程的安全。

4.2.锁实现的问题
面试官:那你这里聊到悲观锁,是用什么来实现的呢?
由于在我们项目中,优惠券服务是多实例部署形成的负载均衡集群。因此考虑到分布式下JVM锁失效问题,我们采用了基于Redisson的分布式锁。
(此处面试官可能会追问怎么实现的呢?如果没有追问就自己往下说,不要停)
不过Redisson分布式锁的加锁和释放锁逻辑对业务侵入比较多,因此我就对其做了二次封装(强调是自己做的),利用自定义注解,AOP,以及SPEL表达式实现了基于注解的分布式锁。
我在封装的时候用了工厂模式来选择不同的锁类型,利用了策略模式来选择锁失败重试策略,利用SPEL表达式来实现动态锁名称。
(面试官可能追问锁失败重试的具体策略,没有就自己往下说)
因为获取锁可能会失败嘛,失败后可以重试,也可以不重试。如果重试结束可以直接报错,也可以快速结束。综合来说可能包含5种不同失败重试策略。例如:失败后直接结束、失败后直接抛异常、失败后重试一段时间然后结束、失败后重试一段时间然后抛异常、失败后一直重试。
(面试官如果追问Redisson原理,可以参考黑马的Redis视频中对于Redisson的讲解)

注意,这个回答也可以用作这个面试题:你在项目中用过什么设计模式啊?要学会举一反三。

4.3.性能问题
面试官:加锁以后性能会比较差,有什么好的办法吗?
答:解决性能问题的办法有很多,针对领券问题,我们可以采用MQ来做异步领券,起到一个流量削峰和整型的作用,降低数据库压力。
具体来说,我们可以将优惠券的关键信息缓存到Redis中,用户请求进入后先读取Redis缓存,做好优惠券库存、领取数量的校验,如果校验不通过直接返回失败结果。如果校验通过则通过MQ发送消息,异步去写数据库,然后告诉用户领取成功即可。

当然,前面说的这种办法也存在一个问题,就是可能需要多次与Redis交互。因此还有一种思路就是利用Redis的LUA脚本来编写校验逻辑来代替java编写的校验逻辑。这样就只需要向Redis发一次请求即可完成校验。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值