给自己复盘用的tjxt笔记day11第三部分

基于LUA脚本的异步领券

在兑换资格校验的时候,或者领券资格校验的时候,会有多次与Redis的交互,每一次交互都需要发起一次网络请求。在并发较高的时候可能导致网络拥堵,甚至导致业务变慢。

我们能不能在一次请求Redis中完成所有校验呢?

普通的Redis命令做不到,不过Redis提供了一种脚本语法可以在脚本中编写复杂业务判断。我们只需要向Redis发起一次请求,就可以完成对脚本调用,即可实现复杂业务校验。这个脚本就是LUA脚本。

SpringDataRedis调用LUA脚本的API

领券脚本 

if(redis.call('GETBIT', KEYS[1], ARGV[1]) == 1) then
    return "1"
end
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
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
redis.call('SETBIT', KEYS[1], ARGV[1], "1")
return cid

改造业务

首先,将LUA脚本保存在项目的resources目录;

然后,在com.tianji.promotion.service.impl.UserCouponServiceImpl中通过静态代码块加载脚本;

@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脚本后无需加锁也是线程安全的
@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);
}

// 使用LUA脚本后无需加锁也是线程安全的
@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);
}

@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();
    }
}

面试题

1.超发问题

面试官:你做的优惠券功能如何解决券超发的问题?

答:券超发问题常见的有两种场景:

  • 券库存不足导致超发

  • 发券时超过了每个用户限领数量

这两种问题产生的原因都是高并发下的线程安全问题。往往需要通过加锁来保证线程安全。不过在处理细节上,会有一些差别。

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

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

2.锁实现的问题

面试官:那你这里聊到悲观锁,是用什么来实现的呢?

由于在我们项目中,优惠券服务是多实例部署形成的负载均衡集群。因此考虑到分布式下JVM锁失效问题,我们采用了基于Redisson的分布式锁

(此处面试官可能会追问怎么实现的呢?如果没有追问就自己往下说,不要停)

不过Redisson分布式锁的加锁和释放锁逻辑对业务侵入比较多,因此就对其做了二次封装,利用自定义注解AOP,以及SPEL表达式实现了基于注解的分布式锁。(面试官可能会问SPEL用来做什么,没问的话就自己说)

我在封装的时候用了工厂模式来选择不同的锁类型利用了策略模式来选择锁失败重试策略利用SPEL表达式来实现动态锁名称

(面试官可能追问锁失败重试的具体策略,没有就自己往下说)

因为获取锁可能会失败嘛,失败后可以重试,也可以不重试。如果重试结束可以直接报错,也可以快速结束。综合来说可能包含5种不同失败重试策略。例如:失败后直接结束、失败后直接抛异常、失败后重试一段时间然后结束、失败后重试一段时间然后抛异常、失败后一直重试。

(面试官如果追问Redisson原理,可以参考黑马的Redis视频中对于Redisson的讲解

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

3.性能问题

面试官:加锁以后性能会比较差,有什么好的办法吗?

答:解决性能问题的办法有很多,针对领券问题,我们可以采用MQ来做异步领券,起到一个流量削峰和整型的作用,降低数据库压力。

具体来说,我们可以将优惠券的关键信息缓存到Redis中,用户请求进入后先读取Redis缓存,做好优惠券库存、领取数量的校验如果校验不通过直接返回失败结果如果校验通过则通过MQ发送消息异步去写数据库,然后告诉用户领取成功即可。

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

  • 14
    点赞
  • 20
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值