优惠券平台(十六):结合位图实现优惠券预约通知功能

业务背景

可以类比 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/  

        评论
        添加红包

        请填写红包祝福语或标题

        红包个数最小为10个

        红包金额最低5元

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

        抵扣说明:

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

        余额充值