【MQ】酒店项目实战 - 优惠券

464 阅读4分钟

一、前言

涉及业务:微信登录 - 发放优惠券

技术点:

  • MQ 异步化
  • Redis 幂等
  • MQ 事务消息

登录逻辑业务流程,如下:

  • 如果是第一次登录:会给对应的客户发放优惠券,同时客户可以在小程序中查询到优惠券的信息。
  • 如果不是第一次登录:跳过发放优惠券的流程进入下面验证登录信息的处理。
  • 无论是否是第一次登录,最后流程都会对用户进行登录验证的操作,最后返回登录结果给客户

coupon-登录.png

其实主要做这三件事:

  1. 判断是否第一次登录:客户每次登录都在数据库中记录一个状态,下次登录的时候与这个状态进行比较就可以知道是否第一次登录。更加细致一点其实是两个操作:检查登录状态和更新登录状态。

  2. 发放优惠券:向用户发放优惠券。在这个场景中是第一次登录就发放。

当然这个功能也可以用到其他场景中,例如:消费多少金额,发放优惠券。

  1. 验证登录信息:登录信息。

为了服务的高内聚、低耦合,可通过分布式提高服务高并发能力,将服务进行划分。

这里将服务拆分为 登录模块优惠券模块

引入 MQ,异步化登录流程:

coupon-异步化登录.png



二、代码实战

代码部分,也可以分为:

  • 登录服务:登录,发送登录消息
  • 优惠券服务:消费登录消息

对应 application.properties 配置如下:

# 登录消息的topic
rocketmq.login.topic=login_notify_topic
rocketmq.login.producer.group=login_notify_producer_group
rocketmq.login.consumer.group=login_notify_consumer_group

# rocketmq
rocketmq.namesry.address=106.15.236.88:9876

(1)登录服务

对应配置类如下:

@Configuration
public class LoginProducerConfiguration {

    @Value("${rocketmq.namesrv.address}")
    private String namesrvAddress;

    @Value("${rocketmq.login.producer.group}")
    private String loginProducerGroup;

    /**
     * 登录生产者
     *
     * @return 登录消息rocketmq的生产者对象
     */
    @Bean(value = "loginMqProducer")
    public DefaultMQProducer loginMqProducer() throws MQClientException {
        DefaultMQProducer producer = new DefaultMQProducer(loginProducerGroup);
        producer.setNamesrvAddr(namesrvAddress);
        producer.start();
        return producer;
    }
}

不用看 Controller,直接看下 Service 里对应实现:

@Service
public class LoginServiceImpl implements LoginService {

    @Autowired
    @Qualifier(value = "loginMqProducer")
    private DefaultMQProducer loginMqProducer;

    @Value("${rocketmq.login.topic}")
    private String loginTopic;
    
    @Override
    public void firstLoginDistributeCoupon(LoginRequestDTO loginRequestDTO) {
        // 实现省略
        if (!isFirstLogin(loginRequestDTO)) {
            // 不是第一次登陆 返回
            LOGGER.info("userId:{} not first login", loginRequestDTO.getUserId());
            return;
        }
        // 更新第一次登陆的标识位,实现省略
        this.updateFirstLoginStatus(loginRequestDTO.getPhoneNumber(),
                                    FirstLoginStatusEnum.NO);

        // 重点:发送第一次登陆成功的消息
        this.sendFirstLoginMessage(loginRequestDTO);
    }
    
    private void sendFirstLoginMessage(LoginRequestDTO loginRequestDTO) {
        // 场景一:性能提升  异步发送一个登录成功的消息到mq中
        Message message = new Message();
        message.setTopic(loginTopic);
        // 消息内容用户id
        message.setBody(JSON.toJSONString(loginRequestDTO)
                        .getBytes(StandardCharsets.UTF_8));
        try {
            LOGGER.info("start send login success notify message");
            SendResult sendResult = loginMqProducer.send(message);
            LOGGER.info("end send login success notify message, sendResult:{}", 
                        JSON.toJSONString(sendResult));
        } catch (Exception e) {
            LOGGER.error("send login success notify message fail, error message:{}", e);
        }
    }
}

那么简单优惠券发送 demo 到目前就完成了。


(2)优惠券服务

定义的配置类:

@Configuration
public class CouponConsumerConfiguration {
    /**
     * namesrv address
     */
    @Value("${rocketmq.namesrv.address}")
    private String namesrvAddress;
    /**
     * 登录topic
     */
    @Value("${rocketmq.login.topic}")
    private String loginTopic;
    /**
     * 登录消息consumerGroup
     */
    @Value("${rocketmq.login.consumer.group}")
    private String loginConsumerGroup;
    /**
     * 退房订单topic
     */
    @Value("${rocketmq.order.finished.topic}")
    private String orderFinishedTopic;
    /**
     * 退房订单consumerGroup
     */
    @Value("${rocketmq.order.finished.consumer.group}")
    private String orderFinishedConsumerGroup;
    /**
     * 登录消息的consumer bean
     *
     * @return 登录消息的consumer bean
     */
    @Bean(value = "loginConsumer")
    public DefaultMQPushConsumer loginConsumer(
        @Qualifier(value = "firstLoginMessageListener") 
        FirstLoginMessageListener firstLoginMessageListener)
        throws MQClientException {
        DefaultMQPushConsumer consumer = new DefaultMQPushConsumer(loginConsumerGroup);
        consumer.setNamesrvAddr(namesrvAddress);
        consumer.subscribe(loginTopic, "*");
        consumer.setMessageListener(firstLoginMessageListener);
        consumer.start();
        return consumer;
    }
}

消息监听:

@Component
public class FirstLoginMessageListener implements MessageListenerConcurrently {
    /**
     * 优惠券服务service组件
     */
    @Autowired
    private CouponService couponService;
    /**
     * 第一次登陆下发的优惠券id
     */
    @Value("${first.login.couponId}")
    private Integer firstLoginCouponId;
    /**
     * 第一次登陆优惠券有效天数
     */
    @Value("${first.login.coupon.day}")
    private Integer firstLoginCouponDay;

    @Override
    public ConsumeConcurrentlyStatus consumeMessage(List<MessageExt> msgs, 
                                                    ConsumeConcurrentlyContext context) {
        Integer userId = null;
        String phoneNumber = null;
        for (MessageExt msg : msgs) {
            String body = new String(msg.getBody(), StandardCharsets.UTF_8);
            try {
                // 第一次登陆消息内容
                FirstLoginMessageDTO firstLoginMessageDTO = JSON.parseObject(body, 
                              FirstLoginMessageDTO.class);
                // 用户id
                userId = firstLoginMessageDTO.getUserId();
                // 手机号
                phoneNumber = firstLoginMessageDTO.getPhoneNumber();
                // 分发优惠券
                couponService.distributeCoupon(firstLoginMessageDTO.getBeid(),
                                                   firstLoginMessageDTO.getUserId(),
                                                   firstLoginCouponId,
                                                   firstLoginCouponDay,
                                                   0,
                                                   phoneNumber);
            } catch (Exception e) {
                // 消费失败
                // Failure consumption,later try to consume 消费失败,以后尝试消费
                return ConsumeConcurrentlyStatus.RECONSUME_LATER;
            }
        }
        return ConsumeConcurrentlyStatus.CONSUME_SUCCESS;
    }
}

最后,分发优惠券 couponService.distributeCoupon()

  • 保存用户优惠券



三、问题

这里需要思考事务一致性问题:

  • 优惠券重复消费: 基于 Redis 幂等机制

(1)优惠券重复消费

会产生重复有两种场景:

  1. 生产者:生产者先送一条消息,由于网络波动,迟迟没有响应,于是重试又发送了一条。
  2. 消费者:消费者消费了一条数据,由于网络波动,消息队列没有收到对应的响应(即,消费者提交 offset 失败),而这条消息就一直在,当网络恢复了消费者又消费一次。

生产者重复消息,如图:

coupon-生产者重复.png

消费者重复消息,如图:

coupon-消费者重复.png

实际上除了上面两种场景应该还有其他的可能性造成消息重发,或者消息处理多次。

无法针对每种情况想出对策,不过可以根据幂等性原则来思考这个问题。

总之:无论消息被发送多少次,或者消息被反复处理多少次,最后只对同一条消息进行一次处理。

幂等机制,解决方案:

  • Redis 幂等机制:利用 setnx 函数(key 的过期时间可以为 1小时,主要用于瞬时请求)
  • 数据库唯一键蔸底

解决方案在消费者侧,步骤如下:

消费者每次处理消息的时候都会将这个消息存放到 Redis 上进行缓存

  1. 利用 Redissetnx,来判断是否存在对应的 key
  2. 如果否,那就发放优惠券
  3. 如果是,那就跳过

为什么要用 Redis 呢?

  1. 为了保护 MySQL 被瞬时打挂,当然最好有 MySQL 唯一键蔸底。
  2. 公司技术栈中就有,现成维护好的。

对应实现如下:

@Component
public class FirstLoginMessageListener implements MessageListenerConcurrently {
    /**
     * redis dubbo服务
     */
    @Reference(version = "1.0.0",
            interfaceClass = RedisApi.class,
            cluster = "failfast")
    private RedisApi redisApi;

    /**
     * 优惠券服务service组件
     */
    @Autowired
    private CouponService couponService;
    /**
     * 第一次登陆下发的优惠券id
     */
    @Value("${first.login.couponId}")
    private Integer firstLoginCouponId;
    /**
     * 第一次登陆优惠券有效天数
     */
    @Value("${first.login.coupon.day}")
    private Integer firstLoginCouponDay;

    @Override
    public ConsumeConcurrentlyStatus consumeMessage(List<MessageExt> msgs, ConsumeConcurrentlyContext context) {
        Integer userId = null;
        String phoneNumber = null;
        for (MessageExt msg : msgs) {
            String body = new String(msg.getBody(), StandardCharsets.UTF_8);
            try {
                // 第一次登陆消息内容
                FirstLoginMessageDTO firstLoginMessageDTO = JSON.parseObject(body, FirstLoginMessageDTO.class);
                // 用户id
                userId = firstLoginMessageDTO.getUserId();
                // 手机号
                phoneNumber = firstLoginMessageDTO.getPhoneNumber();

                // 通过redis保证幂等
                CommonResponse<Boolean> response = redisApi.setnx(RedisKeyConstant.FIRST_LOGIN_DUPLICATION_KEY_PREFIX + userId,
                                                                  String.valueOf(userId),
                                                                  phoneNumber,
                                                                  LittleProjectTypeEnum.ROCKETMQ);
                if (Objects.equals(response.getCode(), ErrorCodeEnum.FAIL.getCode())) {
                    // 请求redis dubbo接口失败
                    return ConsumeConcurrentlyStatus.RECONSUME_LATER;
                }

                // redis操作成功
                if (Objects.equals(response.getCode(), ErrorCodeEnum.SUCCESS.getCode())
                        && Objects.equals(response.getData(), Boolean.FALSE)) {
                    // 重复消费登录消息 返回

                } else {
                    // 未重复消费 分发权益
                    couponService.distributeCoupon(firstLoginMessageDTO.getBeid(),
                                                   firstLoginMessageDTO.getUserId(),
                                                   firstLoginCouponId,
                                                   firstLoginCouponDay,
                                                   0,
                                                   phoneNumber);
                }
            } catch (Exception e) {
                // 消费失败,删除redis中幂等key
                if (userId != null) {
                    redisApi.del(RedisKeyConstant.FIRST_LOGIN_DUPLICATION_KEY_PREFIX + userId,
                                 phoneNumber,
                                 LittleProjectTypeEnum.ROCKETMQ);
                }
                // 消费失败
                // Failure consumption,later try to consume 消费失败,以后尝试消费
                return ConsumeConcurrentlyStatus.RECONSUME_LATER;
            }
        }
        return ConsumeConcurrentlyStatus.CONSUME_SUCCESS;
    }
}