业务背景
当用户预约了一个或多个优惠券抢购提醒后,如果不再需要提醒,可以取消预约通知。不过,虽然用户可以取消提醒,但已经发送到 MQ 的消息不会被撤回,消费者在时间点到达时依然会收到消息。此时,我们不应该再向用户发出提醒。因此,我们需要开发一个方法来判断用户是否取消了预约。同时,还需支持用户查询其已预约的优惠券列表信息,以便用户管理其预约状态。
取消预约提醒
1. 取消用户预约优惠券提醒
有这样一种情况,用户预约了优惠券提醒后不想再预约场景,那我们就需要把这个提醒删除。
代码如下所示:
@Service
@RequiredArgsConstructor
public class CouponTemplateServiceRemindImpl extends ServiceImpl<CouponTemplateRemindMapper, CouponTemplateRemindDO> implements CouponTemplateRemindService {// ......
@Overridepublic void cancelCouponRemind(CouponTemplateRemindCancelReqDTO requestParam) {// 验证优惠券是否存在,避免缓存穿透问题并获取优惠券开抢时间CouponTemplateQueryRespDTO couponTemplate = couponTemplateService.findCouponTemplate(new CouponTemplateQueryReqDTO(requestParam.getShopNumber(), requestParam.getCouponTemplateId()));if (couponTemplate.getValidStartTime().before(new Date())) {throw new ClientException("无法取消已开始领取的优惠券预约");}
LambdaQueryWrapper<CouponTemplateRemindDO> queryWrapper = Wrappers.lambdaQuery(CouponTemplateRemindDO.class).eq(CouponTemplateRemindDO::getUserId, UserContext.getUserId()).eq(CouponTemplateRemindDO::getCouponTemplateId, requestParam.getCouponTemplateId());CouponTemplateRemindDO couponTemplateRemindDO = couponTemplateRemindMapper.selectOne(queryWrapper);if (couponTemplateRemindDO == null) {throw new ClientException("优惠券模板预约信息不存在");}// 计算 BitMap 信息Long bitMap = CouponTemplateRemindUtil.calculateBitMap(requestParam.getRemindTime(), requestParam.getType());if ((bitMap & couponTemplateRemindDO.getInformation()) == 0L) {throw new ClientException("您没有预约该时间点的提醒");}bitMap ^= couponTemplateRemindDO.getInformation();queryWrapper.eq(CouponTemplateRemindDO::getInformation, couponTemplateRemindDO.getInformation());if (bitMap.equals(0L)) {// 如果新 BitMap 信息是 0,说明已经没有预约提醒了,可以直接删除if (couponTemplateRemindMapper.delete(queryWrapper) == 0) {// MySQL 乐观锁进行删除,如果删除失败,说明用户可能同时正在进行删除、新增提醒操作throw new ClientException("取消提醒失败,请刷新页面后重试");}} else {// 虽然删除了这个预约提醒,但还有其它提醒,那就更新数据库couponTemplateRemindDO.setInformation(bitMap);if (couponTemplateRemindMapper.update(couponTemplateRemindDO, queryWrapper) == 0) {// MySQL 乐观锁进行更新,如果更新失败,说明用户可能同时正在进行删除、新增提醒操作throw new ClientException("取消提醒失败,请刷新页面后重试");}}}
}
流程图如下:
业务流程如下所示:
- 验证优惠券:根据查询优惠券模板方法避免缓存击穿和穿透,并且获取到优惠券模板详情后判断优惠券是否已开始领取,如果是的话抛出异常。
- 查询预约提醒记录:系统使用
userId
和couponTemplateId
在数据库中查找对应的提醒记录。如果找不到该记录,则抛出异常,提示“优惠券模板预约信息不存在”。如果找到记录,继续执行后续操作。
- 计算用户想要取消的提醒对应的 BitMap:使用
CouponTemplateRemindUtil.calculateBitMap()
方法,根据用户的remindTime
和type
计算出该提醒对应的bitMap
(位图)。
- 检查用户是否已经预约该提醒:通过
bitMap & couponTemplateRemindDO.getInformation()
检查数据库中的预约提醒信息是否包含该时间点的提醒。如果结果为0
,说明用户没有预约该时间点的提醒,抛出异常提示“您没有预约该时间点的提醒”。
- 更新 BitMap 信息:使用异或操作
bitMap ^= couponTemplateRemindDO.getInformation()
取消该时间点的提醒位。此时,bitMap
会去除用户想要取消的提醒对应的位。
- 判断更新后的 BitMap:如果
bitMap
为0
,说明用户取消了所有提醒,删除该预约提醒记录。如果bitMap
不为0
:说明用户取消了部分提醒,仍有其他提醒存在。系统更新数据库中的information
字段,保存剩余的提醒信息。
需要注意的是 validStartTime
不能小于当前时间。创建完成后,模板信息会被复制到创建优惠券预约提醒的入参中,并在 t_coupon_template_remind
表中生成一条预约提醒记录。
当用户通过取消预约提醒接口进行操作时,传入的参数依然是上述的模板信息。执行取消操作后,查看数据库时,可以发现对应的预约提醒记录会被逐步修改。如果用户创建了多个时间段的提醒,每次取消会修改记录中的提醒信息,直到最后一个预约时间被取消,才最终删除该记录。
2. 消息队列判断是否已取消预约
虽然用户可以取消提醒,但已经发送到 MQ 的消息不会被撤回,消费者在时间点到达时依然会收到消息。这时我们不应该再向用户发出提醒。所以,我们需要开发一个方法,那就是判断用户是否取消了预约。
@Service
@RequiredArgsConstructor
public class CouponTemplateServiceRemindImpl extends ServiceImpl<CouponTemplateRemindMapper, CouponTemplateRemindDO> implements CouponTemplateRemindService {
// ......
@Overridepublic boolean isCancelRemind(CouponTemplateRemindDTO requestParam) {LambdaQueryWrapper<CouponTemplateRemindDO> queryWrapper = Wrappers.lambdaQuery(CouponTemplateRemindDO.class).eq(CouponTemplateRemindDO::getUserId, requestParam.getUserId()).eq(CouponTemplateRemindDO::getCouponTemplateId, requestParam.getCouponTemplateId());CouponTemplateRemindDO couponTemplateRemindDO = couponTemplateRemindMapper.selectOne(queryWrapper);if (couponTemplateRemindDO == null) {// 数据库中没该条预约提醒,说明被取消return true;}
// 即使存在数据,也要检查该类型的该时间点是否有提醒Long information = couponTemplateRemindDO.getInformation();Long bitMap = CouponTemplateRemindUtil.calculateBitMap(requestParam.getRemindTime(), requestParam.getType());
// 按位与等于 0 说明用户取消了预约return (bitMap & information) == 0L;}
}
通过该方法,我们在消息队列的消费者执行前加入判断,如果已取消则打印一行日志即可。
代码如下所示:
@Slf4j
@Component
@RequiredArgsConstructor
public class CouponTemplateRemindExecutor {
private final CouponTemplateRemindService couponTemplateRemindService;
/*** 执行提醒** @param couponTemplateRemindDTO 用户预约提醒请求信息*/public void executeRemindCouponTemplate(CouponTemplateRemindDTO couponTemplateRemindDTO) {// 用户没取消预约,则发出提醒if (couponTemplateRemindService.isCancelRemind(couponTemplateRemindDTO)) {log.info("用户已取消优惠券预约提醒,参数:{}", JSON.toJSONString(couponTemplateRemindDTO));return;}
// ......}
}
3. 布隆过滤器优化性能
是否需要每次消息消费时都查询数据库来检查用户是否取消了提醒呢?如果对每条消息都进行数据库查询,消息消费的效率就会受到数据库的瓶颈影响。
为了解决这个问题,可以使用布隆过滤器进行初步判断。当用户取消提醒时,我们根据(用户ID、券ID、提醒时间点、提醒类型)的四元组计算哈希值,并将其存入布隆过滤器。消息消费时,如果布隆过滤器中不存在该哈希值,则说明用户没有取消提醒,可以直接发送提醒。如果存在该哈希值,则有两种可能:
- 用户确实取消了提醒。
- 布隆过滤器发生了误判。
由于存在误判的可能性,我们必须进一步查询数据库,确认用户是否真的取消了提醒。不过这种情况很少出现,大部分请求已经被布隆过滤器过滤,剩下需要查询数据库的请求量很小。
3.1 创建布隆过滤器
在优惠券查询布隆过滤器的基础上,添加防止取消提醒缓存穿透布隆过滤器。
代码如下所示:
@Configuration
public class RBloomFilterConfiguration {
/*** 优惠券查询缓存穿透布隆过滤器*/@Beanpublic RBloomFilter<String> couponTemplateQueryBloomFilter(RedissonClient redissonClient, @Value("${framework.cache.redis.prefix:}") String cachePrefix) {RBloomFilter<String> bloomFilter = redissonClient.getBloomFilter(cachePrefix + "couponTemplateQueryBloomFilter");bloomFilter.tryInit(640L, 0.001);return bloomFilter;}
/*** 防止取消提醒缓存穿透的布隆过滤器*/@Beanpublic RBloomFilter<String> cancelRemindBloomFilter(RedissonClient redissonClient, @Value("${framework.cache.redis.prefix:}") String cachePrefix) {RBloomFilter<String> bloomFilter = redissonClient.getBloomFilter(cachePrefix + "cancelRemindBloomFilter");bloomFilter.tryInit(640L, 0.001);return bloomFilter;}
}
3.2 取消预约提醒加入布隆过滤器
在我们取消优惠券提醒方法的最后,将优惠券模板 ID、用户 ID、预约时间、预约类型获取 Hash 加入布隆过滤器。
代码如下所示:
@Service
@RequiredArgsConstructor
public class CouponTemplateServiceRemindImpl extends ServiceImpl<CouponTemplateRemindMapper, CouponTemplateRemindDO> implements CouponTemplateRemindService {// ......
private final RBloomFilter<String> cancelRemindBloomFilter;@Overridepublic void cancelCouponRemind(CouponTemplateRemindCancelReqDTO requestParam) {// ......
// 取消提醒这个信息添加到布隆过滤器中cancelRemindBloomFilter.add(String.valueOf(Objects.hash(requestParam.getCouponTemplateId(), UserContext.getUserId(), requestParam.getRemindTime(), requestParam.getType())));}
}
3.3 判断取消优惠券提醒
代码如下所示:
@Service
@RequiredArgsConstructor
public class CouponTemplateServiceRemindImpl extends ServiceImpl<CouponTemplateRemindMapper, CouponTemplateRemindDO> implements CouponTemplateRemindService {// ......
private final RBloomFilter<String> cancelRemindBloomFilter;
@Overridepublic boolean isCancelRemind(CouponTemplateRemindDTO requestParam) {if (!cancelRemindBloomFilter.contains(String.valueOf(Objects.hash(requestParam.getCouponTemplateId(), requestParam.getUserId(), requestParam.getRemindTime(), requestParam.getType())))) {// 布隆过滤器中不存在,说明没取消提醒,此时已经能挡下大部分请求return false;}
// 对于少部分的“取消了预约”,可能是误判,此时需要去数据库中查找LambdaQueryWrapper<CouponTemplateRemindDO> queryWrapper = Wrappers.lambdaQuery(CouponTemplateRemindDO.class).eq(CouponTemplateRemindDO::getUserId, requestParam.getUserId()).eq(CouponTemplateRemindDO::getCouponTemplateId, requestParam.getCouponTemplateId());CouponTemplateRemindDO couponTemplateRemindDO = couponTemplateRemindMapper.selectOne(queryWrapper);if (couponTemplateRemindDO == null) {// 数据库中没该条预约提醒,说明被取消return true;}
// 即使存在数据,也要检查该类型的该时间点是否有提醒Long information = couponTemplateRemindDO.getInformation();Long bitMap = CouponTemplateRemindUtil.calculateBitMap(requestParam.getRemindTime(), requestParam.getType());
// 按位与等于 0 说明用户取消了预约return (bitMap & information) == 0L;}
}
查询预约提醒列表
1. 查询用户优惠券预约提醒列表
代码如下所示:
@Service
@RequiredArgsConstructor
public class CouponTemplateServiceRemindImpl extends ServiceImpl<CouponTemplateRemindMapper, CouponTemplateRemindDO> implements CouponTemplateRemindService {// ......
@Overridepublic List<CouponTemplateRemindQueryRespDTO> listCouponRemind(CouponTemplateRemindQueryReqDTO requestParam) {String value = stringRedisTemplate.opsForValue().get(String.format(USER_COUPON_TEMPLATE_REMIND_INFORMATION, requestParam.getUserId()));if (value != null) {return JSON.parseArray(value, CouponTemplateRemindQueryRespDTO.class);}
// 查出用户预约的信息LambdaQueryWrapper<CouponTemplateRemindDO> queryWrapper = Wrappers.lambdaQuery(CouponTemplateRemindDO.class).eq(CouponTemplateRemindDO::getUserId, requestParam.getUserId());List<CouponTemplateRemindDO> couponTemplateRemindDOlist = couponTemplateRemindMapper.selectList(queryWrapper);if (CollUtil.isEmpty(couponTemplateRemindDOlist))return new ArrayList<>();
// 根据优惠券 ID 查询优惠券信息List<Long> couponIds = couponTemplateRemindDOlist.stream().map(CouponTemplateRemindDO::getCouponTemplateId).toList();List<Long> shopNumbers = couponTemplateRemindDOlist.stream().map(CouponTemplateRemindDO::getShopNumber).toList();List<CouponTemplateDO> couponTemplateDOList = couponTemplateService.listCouponTemplateByIds(couponIds, shopNumbers);List<CouponTemplateRemindQueryRespDTO> actualResult = BeanUtil.copyToList(couponTemplateDOList, CouponTemplateRemindQueryRespDTO.class);
// 填充响应结果的其它信息actualResult.forEach(each -> {// 找到当前优惠券对应的预约提醒信息couponTemplateRemindDOlist.stream().filter(i -> i.getCouponTemplateId().equals(each.getId())).findFirst().ifPresent(i -> {// 解析并填充预约提醒信息CouponTemplateRemindUtil.fillRemindInformation(each, i.getInformation());});});
stringRedisTemplate.opsForValue().set(String.format(USER_COUPON_TEMPLATE_REMIND_INFORMATION, requestParam.getUserId()), JSON.toJSONString(actualResult), 1, TimeUnit.MINUTES);return actualResult;}
}
逻辑整体来说比较简单,但是有两个难点:
-
如何将位图中的信息解析为正常的预约记录?
-
因为用户预约的优惠券可能是跨多个库的,如何完成跨库查询?
因为我们取消了用户的优惠券模板预约提醒,对应添加的缓存也需要删除,我们这里采用更新数据库删除缓存策略保障数据库和缓存一致性。
@Service
@RequiredArgsConstructor
public class CouponTemplateServiceRemindImpl extends ServiceImpl<CouponTemplateRemindMapper, CouponTemplateRemindDO> implements CouponTemplateRemindService {// ......
private final RBloomFilter<String> cancelRemindBloomFilter;@Overridepublic void cancelCouponRemind(CouponTemplateRemindCancelReqDTO requestParam) {// ......
// 取消提醒这个信息添加到布隆过滤器中cancelRemindBloomFilter.add(String.valueOf(Objects.hash(requestParam.getCouponTemplateId(), UserContext.getUserId(), requestParam.getRemindTime(), requestParam.getType())));// 删除用户预约提醒的缓存信息,通过更新数据库删除缓存策略保障数据库和缓存一致性stringRedisTemplate.delete(String.format(USER_COUPON_TEMPLATE_REMIND_INFORMATION, UserContext.getUserId()));}
}
对应的流程图为: