目录
优化思路分析
缓存数据结构
异步领券
优化思路分析
高并发写优化的几种思路
合并写请求比较适合应用在写频率较高,写数据比较简单的场景。而异步写则更适合应用在业务比较复杂,业务链较长的场景。
显然,领券业务更适合使用异步写方案。
当用户请求来领券时,不是直接领券,而是通过MQ发送一个领券消息。有一个监听器监听消息,完成领券动作
判断用户是否具有领劵资格的校验必须前置,只要发送mq 就表明肯定有领劵资格
但是,校验领券资格的部分依然会有多次数据库查询,还需要加锁。效率提升并不明显,怎么办?
为了进一步提高效率,我们可以把优惠券相关数据缓存到Redis中,这样就可以基于Redis完成资格校验,不用访问数据库,效率自然会进一步提高了。
缓存数据结构
优惠券资格校验需要校验的内容包括:
-
优惠券发放时间
-
优惠券库存
-
用户限领数量
因此,为了减少对Redis内存的消耗,在构建优惠券缓存的时候,我们并不需要把所有优惠券信息写入缓存,而是只保存上述字段即可。
为了便于我们修改缓存中的库存数据,这里建议采用Hash结构,将库存作为Hash的一个字段
时间存进去只是为了取的时候方便,所以时间转换为毫秒值
String.valueOf(DateUtils.toEpochMilli(LocalDateTime.now())) 使用DateUtils.toEpochMilli转换localdatetime为毫秒值,然后使用string.valueof转化为string 存起来,因是hash,所以可以直接存入map,这样可以一次性存进去
上述结构中记录了券的每人限领数量:userLimit , 但是用户已经领取的数量并没有记录。因此,我们还需要一个数据结构,来记录某张券,每个用户领取的数量。
一个券可能被多个用户领取,每个用户的已领取数量都需要记录。显然,还是Hash结构更加适合:
优惠券的缓存该何时添加呢?
优惠券一旦发放,就可能有用户来领券,因此应该在发放优惠券的同时直接添加优惠券缓存。而暂停发放时则应该将优惠券的缓存删除,下次再次发放时重新添加。
移除缓存
redisTemplate.delete(PromotionConstants.COUPON_CACHE_KEY_PREFIX + id);
至于过期移除缓存,大家需要编写一个定时任务,定期扫描优惠券并判断是否到达过期时间。如果到达则需要将优惠券状态置为发放结束,并移除Redis缓存。
异步领券
接下来我们就可以开始实现异步领券了。分为两步:
-
改造领券逻辑,实现基于Redis的领取资格校验,然后发送MQ消息
-
编写MQ监听器,监听到消息后执行领券逻辑
领劵前先查询缓存,看卷是否存在,查看时间是否可以领取查看库存是否充足,然后查看是否超过个人领劵限制(让个人领劵数量+1,加一后判断是否超过了领劵限制),如果校验都通过,然后让总数量-1 increment(key,userid,-1),发送消息到队列,最后消息队列监听到更新即可(mq队列名叫什么无所谓只要交换机和key对应上就行),消费者消费消息的时候出现异常,看是否需要抛异常,因为配置文件中是否配置了重试机制?mq的stateless 一般开启事务的话 要设置为false
public void receiveCoupon(Long couponId) {
Long userId = UserContext.getUser();
String redissionkey = "lock:coupon:uid:"+userId;
RLock lock = redissonClient.getLock(redissionkey);
try {
boolean isLock = lock.tryLock();
if (!isLock) {
throw new BizIllegalException("操作太频繁了");
}
// 1.查询优惠券
// Coupon coupon = couponMapper.selectById(couponId);
// 从redis中获取优惠卷信息
Coupon coupon = queryCacheByCache(couponId);
if (coupon == null) {
throw new BadRequestException("优惠券不存在");
}
// 2.校验发放时间
LocalDateTime now = LocalDateTime.now();
if (now.isBefore(coupon.getIssueBeginTime()) || now.isAfter(coupon.getIssueEndTime())) {
throw new BadRequestException("优惠券发放已经结束或尚未开始");
}
if (coupon.getTotalNum()<=0){
throw new BadRequestException("库存不足");
}
// 4.校验每人限领数量
// 4.1.查询领取数量
String key = PromotionConstants.USER_COUPON_CACHE_KEY_PREFIX + couponId;
// increment代表领取后的 已领数量
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消息
UserCoupon uc = new UserCoupon();
uc.setUserId(userId);
uc.setCouponId(couponId);
mqHelper.send(MqConstants.Exchange.PROMOTION_EXCHANGE, MqConstants.Key.COUPON_RECEIVE, uc);
}finally {
lock.unlock();
}
}
private Coupon queryCacheByCache(Long couponId) {
// 1.准备KEY
String key = PromotionConstants.COUPON_CACHE_KEY_PREFIX + couponId;
Map<Object, Object> map = redisTemplate.opsForHash().entries(key);
if (map.isEmpty()){
return null;
}
//
return BeanUtils.mapToBean(map,Coupon.class,false, CopyOptions.create());
}
重点:
maptobean,第三个参数是否是驼峰来着??反正选false,从redis取出来的没有驼峰
redis中hgetall 对应 java中 entries
maptobean,
rabbitmq的nacos配置
spring:
rabbitmq:
host: ${tj.mq.host:192.168.150.101}
port: ${tj.mq.port:5672}
virtual-host: ${tj.mq.vhost:/tjxt}
username: ${tj.mq.username:tjxt}
password: ${tj.mq.password:123321}
listener:
simple:
retry:
enabled: ${tj.mq.listener.retry.enable:true} # 开启消费者失败重试
initial-interval: ${tj.mq.listener.retry.interval:1000ms} # 初始的失败等待时长为1秒
multiplier: ${tj.mq.listener.retry.multiplier:2} # 失败的等待时长倍数,下次等待时长 = multiplier * last-interval
max-attempts: ${tj.mq.listener.retry.max-attempts:3} # 最大重试次数
stateless: ${tj.mq.listener.retry.stateless:true} # true无状态;false有状态。如果业务中包含事务,这里改为false