异步领券
把优惠券相关数据缓存到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脚本后无需加锁也是线程安全的
- 执行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…]:传递给脚本的参数列表。
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发一次请求即可完成校验。

被折叠的 条评论
为什么被折叠?



