业务背景
可以类比 12306 购票预约功能。当购买紧张列车的车票时,我们通常会提前设置预约提醒,以便在开票时及时购买。类似地,优惠券预约提醒也具有很强的时效性,同时需要支持海量用户的提醒需求。为了实现这一点,我们选择了 RocketMQ 5.x 的任意延时消息功能,并通过线程池来并行提醒用户。
需要注意的是在实现过程中,我们采用了位图(bitmap)思想,巧妙地利用单一字段实现了多个时间段的预约提醒功能。通过这种方式,我们不仅满足了海量提醒的需求,还确保了系统的高效和时效性。
总体的流程图如下:
数据库表设计
用户预约表 SQL
CREATE TABLE `t_coupon_template_remind` (
`user_id` bigint(20) NOT NULL COMMENT '用户ID',
`coupon_template_id` bigint(20) NOT NULL COMMENT '券ID',
`information` bigint(20) DEFAULT NULL COMMENT '存储信息',
`shop_number` bigint(20) DEFAULT NULL COMMENT '店铺编号',
`start_time` datetime DEFAULT NULL COMMENT '优惠券开抢时间',
PRIMARY KEY (`user_id`,`coupon_template_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='用户预约提醒信息存储表';
分库分表思考
在三层 B+ 树结构下,最大可存储的记录数为:744(第一层)× 744(第二层)× 410(叶子节点)≈ 226,492,160 条记录。
即大约 2.26 亿条记录。考虑到实际情况中页内可能存在的其他开销,这个数值可能会略少,但至少可以支持 2 亿条记录。
假设用户量为 5000 万,那么平均每个用户可以预约 4 张券,完全满足需求。
通过设置定时任务,定期删除已过期的记录,可以有效控制数据量。因此,单表结构已经足够承载当前业务需求,无需进行分库分表。
创建优惠券预约提醒
1. 优惠券预约抢购提醒请求参数
请求参数的字段都有如下所示:
@Data
@Schema(description = "优惠券预约抢券提醒请求参数实体")
public class CouponTemplateRemindCreateReqDTO {
/**
* 优惠券模板id
*/
@Schema(description = "优惠券模板id", example = "xxxxxx", required = true)
private String couponTemplateId;
/**
* 店铺编号
*/
@Schema(description = "店铺编号", example = "1810714735922956666", required = true)
private String shopNumber;
/**
* 提醒方式
*/
@Schema(description = "提醒方式", example = "0", required = true)
private Integer type;
/**
* 提醒时间,比如五分钟,十分钟,十五分钟
*/
@Schema(description = "提醒时间", example = "5", required = true)
private Integer remindTime;
}
字段解释如下所示:
-
优惠券模板 ID:建议咱们创建新的优惠券模板,创建的时候
validStartTime
需要个性化设置,比如你想设置提前 5、10 分钟提醒,假设当前时间 10:00,那可以设置 10:20,这样都能涵盖住 5、10 分钟的预约提醒测试。
-
店铺编号:使用示例店铺编号即可。
-
提醒方式:比如手机 APP 弹框提醒、邮件提醒等,目前主流是前者,我们这里 0 默认就是这个。
-
提醒时间:提前多少分钟提醒,以 5 分钟为单位,最多支持提前一小时。
2. 验证优惠券是否存在
首先通过我们优惠券模板 Service 中的查询模板信息接口进行判断,看模板是否存在,通过这个可以判断缓存击穿和穿透问题。
就是之前:布隆过滤+分布式锁+双重判定查询那个实现
@Override
@Transactional
public void createCouponRemind(CouponTemplateRemindCreateReqDTO requestParam) {
// 验证优惠券是否存在,避免缓存穿透问题并获取优惠券开抢时间
CouponTemplateQueryRespDTO couponTemplate = couponTemplateService
.findCouponTemplate(new CouponTemplateQueryReqDTO(requestParam.getShopNumber(), requestParam.getCouponTemplateId()));
// ......
}
3. 创建或者更新优惠券提醒
逻辑简单梳理就是,查询数据库是否存在,不存在创建优惠券提醒,存在则将提醒时间更新到已有的记录中。
代码如下:
@Override
@Transactional
public void createCouponRemind(CouponTemplateRemindCreateReqDTO requestParam) {
// ......
// 查询用户是否已经预约过优惠券的提醒信息
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) {
couponTemplateRemindDO = BeanUtil.toBean(requestParam, CouponTemplateRemindDO.class);
// 设置优惠券开抢时间信息
couponTemplateRemindDO.setStartTime(couponTemplate.getValidStartTime());
couponTemplateRemindDO.setInformation(CouponTemplateRemindUtil.calculateBitMap(requestParam.getRemindTime(), requestParam.getType()));
couponTemplateRemindDO.setUserId(Long.parseLong(UserContext.getUserId()));
couponTemplateRemindMapper.insert(couponTemplateRemindDO);
} else {
Long information = couponTemplateRemindDO.getInformation();
Long bitMap = CouponTemplateRemindUtil.calculateBitMap(requestParam.getRemindTime(), requestParam.getType());
if ((information & bitMap) != 0L) {
throw new ClientException("已经创建过该提醒了");
}
couponTemplateRemindDO.setInformation(information ^ bitMap);
couponTemplateRemindMapper.update(couponTemplateRemindDO, queryWrapper);
}
// ......
}
4. 位图参数讲解
如何通过创建时间和提醒类型进行位图运算为一个字段。
存放预约信息的巧妙之处在于使用了64个比特位,能够表示5种提醒信息和每种60min的信息,仅用1位就可以表示某一种提醒规则
1. 每一类占12位,每一位表示5min,所以最高可以在前60min提醒用户
2. 提醒类别从右到左依次是0,1,2,3,4类,这样可以节省位数,所以最前面的4位是无效的
如下图所示
仅用1位就可以表示一种规则,所以只要两种规则进行&与运算=1就表明规则冲突,理论上用户可以创建60种不同规则,确实高效
5. 修改优惠券抢购提醒
如果优惠券提醒已存在,我们需要判断提醒是否重复,如果重复存在,直接抛出异常,如果说两个提醒不存在重叠,则使用 information ^ bitMap
运算进行合并结果,修改放入数据库中。
information & bitMap
用于检查当前的提醒信息是否已经包含了用户设置的提醒,如果相同位置上的位都是 1
,则 if ((information & bitMap) != 0L)
会为 true
,系统抛出异常,提示用户已经创建过该提醒。
Long information = couponTemplateRemindDO.getInformation();
Long bitMap = CouponTemplateRemindUtil.calculateBitMap(requestParam.getRemindTime(), requestParam.getType());
if ((information & bitMap) != 0L) {
throw new ClientException("已经创建过该提醒了");
}
couponTemplateRemindDO.setInformation(information ^ bitMap);
推送用户预约提醒
刚才我们讨论了优惠券预约的设计,重点介绍了如何将预约提醒发送到 RocketMQ 延时队列中。继续详细说明如何将消息推送给用户,确保用户能够及时接收到预约提醒。
1. 预约消息消费者
开发 RocketMQ 消息队列消费者,相关的用户提醒代码进行了抽象,放在了一个执行器中。
代码如下所示:
@Component
@RequiredArgsConstructor
@RocketMQMessageListener(
topic = "one-coupon_engine-service_coupon-remind_topic${unique-name:}",
consumerGroup = "one-coupon_engine-service_coupon-remind_cg${unique-name:}"
)
@Slf4j(topic = "CouponTemplateRemindDelayConsumer")
public class CouponTemplateRemindDelayConsumer implements RocketMQListener<MessageWrapper<CouponTemplateRemindDelayEvent>> {
private final CouponTemplateRemindExecutor couponTemplateRemindExecutor;
@Override
public void onMessage(MessageWrapper<CouponTemplateRemindDelayEvent> messageWrapper) {
// 开头打印日志,平常可 Debug 看任务参数,线上可报平安(比如消息是否消费,重新投递时获取参数等)
log.info("[消费者] 提醒用户抢券 - 执行消费逻辑,消息体:{}", JSON.toJSONString(messageWrapper));
CouponTemplateRemindDelayEvent event = messageWrapper.getMessage();
CouponTemplateRemindDTO couponTemplateRemindDTO = BeanUtil.toBean(event, CouponTemplateRemindDTO.class);
// 根据不同策略向用户发送消息提醒
couponTemplateRemindExecutor.executeRemindCouponTemplate(couponTemplateRemindDTO);
}
}
向用户发送消息提醒的逻辑在 CouponTemplateRemindExecutor
执行器,分为几个版本演进。
代码如下所示:
public void executeRemindCouponTemplate(CouponTemplateRemindDTO couponTemplateRemindDTO) {
// 向用户发起消息提醒
switch (Objects.requireNonNull(CouponRemindTypeEnum.getByType(couponTemplateRemindDTO.getType()))) {
case APP -> sendAppMessageRemindCouponTemplate.remind(couponTemplateRemindDTO);
case EMAIL -> sendEmailRemindCouponTemplate.remind(couponTemplateRemindDTO);
default -> {
}
}
}
2. 线程池并行发送提醒
通过第一版代码,我们可以明显发现一个问题:消息队列的消费速度较慢,因为是逐条处理每条消息后才消费下一条。而我们希望尽快将这些预约信息发送给用户。基于这一前提,我们可以采用线程池进行并行发送,以提高消息处理和发送的效率。
代码如下所示:
// 提醒用户属于 IO 密集型任务
private final ExecutorService executorService = new ThreadPoolExecutor(
Runtime.getRuntime().availableProcessors() << 1,
Runtime.getRuntime().availableProcessors() << 2,
60,
TimeUnit.SECONDS,
new SynchronousQueue<>(),
new ThreadPoolExecutor.CallerRunsPolicy()
);
/**
* 执行提醒
*
* @param couponTemplateRemindDTO 用户预约提醒请求信息
*/
public void executeRemindCouponTemplate(CouponTemplateRemindDTO couponTemplateRemindDTO) {
executorService.execute(() -> {
// 向用户发起消息提醒
switch (Objects.requireNonNull(CouponRemindTypeEnum.getByType(couponTemplateRemindDTO.getType()))) {
case APP -> sendAppMessageRemindCouponTemplate.remind(couponTemplateRemindDTO);
case EMAIL -> sendEmailRemindCouponTemplate.remind(couponTemplateRemindDTO);
default -> {
}
}
});
}
线程池参数解析如下:
-
核心线程数:
Runtime.getRuntime().availableProcessors() << 1
CPU 核数 * 2,因为是 IO 密集型线程数可以多些。
-
最大线程数:
Runtime.getRuntime().availableProcessors() << 2
CPU 核数 * 4,因为是 IO 密集型线程数可以多些。
-
阻塞队列:SynchronousQueue,不缓冲任务。
-
拒绝策略:CallerRunsPolicy,通过提交任务线程运行被拒绝的任务。
我们通过线程池来加快预约任务的通知提醒处理。当线程池达到其处理能力的瓶颈时,采用任务拒绝策略,将被拒绝的任务交由提交任务的线程自行执行,以确保通知任务不会被丢弃并尽可能提高系统的处理效率。
3. 通过 Redis 延迟队列兜底任务
为了提升消息消费的速度,我们将任务投递到线程池后立即返回消息投递成功给 MQ。然而,如果此时发生了断电,线程池中的任务将会丢失。为了解决这个问题,我们需要引入标记或持久化操作,并在后续通过扫描检测任务状态,确保任务未丢失。
当收到消息时,使用 Redisson 的延时队列,发送一条延时 10 秒的消息。10 秒后,系统检查任务状态。如果消费成功,流程结束;如果消费失败,则重新投递消息,从而保证消息不会丢失。
需要考虑的几个问题:
- 为什么使用 Redis 的延时队列,而不是继续使用 RocketMQ 的延时消息? Redis 的延时队列适用于 10 秒的短时任务,数据在 10 秒后投递完成即删除。
- 是否有横向对比其它方案? 我们也考虑了 Redis 的过期监听机制。虽然实现简单,但由于过期监听的时间不够精准,且过期消息只发送一次,可靠性较差。如果消息未成功接收或者发生异常,无法再次收到,因此不够稳妥。
- 如果“检测状态”的机器也挂了怎么办? 由于我们使用的是 Redis 的延时队列,只要有一台机器存活,就能继续收到消息。除非所有机器同时宕机,否则消息仍可被消费。对于我们这个场景,偶尔丢失少量消息仅意味着少通知几个用户,可以接受,并不构成重大影响。
这种方案确保了在保证消息消费速度的同时,降低了消息丢失的风险。所以,我们要参考之前优惠券分发任务的 Redis Stream 延迟任务进行兜底。
代码如下所示:
public void executeRemindCouponTemplate(CouponTemplateRemindDTO couponTemplateRemindDTO) {
// 假设刚把消息提交到线程池,突然应用宕机了,我们通过延迟队列进行兜底 Refresh
RBlockingDeque<String> blockingDeque = redissonClient.getBlockingDeque(REDIS_BLOCKING_DEQUE);
RDelayedQueue<String> delayedQueue = redissonClient.getDelayedQueue(blockingDeque);
String key = String.format(COUPON_REMIND_CHECK_KEY, couponTemplateRemindDTO.getUserId(), couponTemplateRemindDTO.getCouponTemplateId(), couponTemplateRemindDTO.getRemindTime(), couponTemplateRemindDTO.getType());
stringRedisTemplate.opsForValue().set(key, JSON.toJSONString(couponTemplateRemindDTO));
delayedQueue.offer(key, 10, TimeUnit.SECONDS);
executorService.execute(() -> {
// 向用户发起消息提醒
switch (Objects.requireNonNull(CouponRemindTypeEnum.getByType(couponTemplateRemindDTO.getType()))) {
case APP -> sendAppMessageRemindCouponTemplate.remind(couponTemplateRemindDTO);
case EMAIL -> sendEmailRemindCouponTemplate.remind(couponTemplateRemindDTO);
default -> {
}
}
// 提醒用户后删除 Key
stringRedisTemplate.delete(key);
});
}
我们将任务暂存到 Redis String,并通过延迟队列进行兜底处理。首先检查 Redis String 是否存在,如果不存在,表示任务已成功消费;如果存在,则需要重新投递消息,确保任务被正确处理。代码如下所示:
@Slf4j
@Component
@RequiredArgsConstructor
static class RefreshCouponRemindDelayQueueRunner implements CommandLineRunner {
private final CouponTemplateRemindDelayProducer couponRemindProducer;
private final RedissonClient redissonClient;
private final StringRedisTemplate stringRedisTemplate;
@Override
public void run(String... args) {
Executors.newSingleThreadExecutor(
runnable -> {
Thread thread = new Thread(runnable);
thread.setName("delay_coupon-remind_consumer");
thread.setDaemon(Boolean.TRUE);
return thread;
})
.execute(() -> {
RBlockingDeque<String> blockingDeque = redissonClient.getBlockingDeque(REDIS_BLOCKING_DEQUE);
for (; ; ) {
try {
// 获取延迟队列待消费 Key
String key = blockingDeque.take();
if (stringRedisTemplate.hasKey(key)) {
log.info("检查用户发送的通知消息Key:{} 未消费完成,开启重新投递", key);
// Redis 中还存在该 Key,说明任务没被消费完,则可能是消费机器宕机了,重新投递消息
CouponTemplateRemindDelayEvent couponTemplateRemindDelayEvent = JSONUtil.toBean(stringRedisTemplate.opsForValue().get(key), CouponTemplateRemindDelayEvent.class);
couponRemindProducer.sendMessage(couponTemplateRemindDelayEvent);
// 提醒用户后删除 Key
stringRedisTemplate.delete(key);
}
} catch (Throwable ignored) {
}
}
});
}
}
本章总结
本章流程图如下
部分图片和内容引用知识星球《拿个offer》牛券项目-https://nageoffer.com/onecoupon/