一、前言
涉及业务:微信登录 - 发放优惠券
技术点:
MQ异步化Redis幂等MQ事务消息
登录逻辑业务流程,如下:
- 如果是第一次登录:会给对应的客户发放优惠券,同时客户可以在小程序中查询到优惠券的信息。
- 如果不是第一次登录:跳过发放优惠券的流程进入下面验证登录信息的处理。
- 无论是否是第一次登录,最后流程都会对用户进行登录验证的操作,最后返回登录结果给客户
其实主要做这三件事:
-
判断是否第一次登录:客户每次登录都在数据库中记录一个状态,下次登录的时候与这个状态进行比较就可以知道是否第一次登录。更加细致一点其实是两个操作:检查登录状态和更新登录状态。
-
发放优惠券:向用户发放优惠券。在这个场景中是第一次登录就发放。
当然这个功能也可以用到其他场景中,例如:消费多少金额,发放优惠券。
- 验证登录信息:登录信息。
为了服务的高内聚、低耦合,可通过分布式提高服务高并发能力,将服务进行划分。
这里将服务拆分为 登录模块 和 优惠券模块。
引入 MQ,异步化登录流程:
二、代码实战
代码部分,也可以分为:
- 登录服务:登录,发送登录消息
- 优惠券服务:消费登录消息
对应 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)优惠券重复消费
会产生重复有两种场景:
- 生产者:生产者先送一条消息,由于网络波动,迟迟没有响应,于是重试又发送了一条。
- 消费者:消费者消费了一条数据,由于网络波动,消息队列没有收到对应的响应(即,消费者提交
offset失败),而这条消息就一直在,当网络恢复了消费者又消费一次。
生产者重复消息,如图:
消费者重复消息,如图:
实际上除了上面两种场景应该还有其他的可能性造成消息重发,或者消息处理多次。
无法针对每种情况想出对策,不过可以根据幂等性原则来思考这个问题。
总之:无论消息被发送多少次,或者消息被反复处理多少次,最后只对同一条消息进行一次处理。
幂等机制,解决方案:
Redis幂等机制:利用setnx函数(key的过期时间可以为 1小时,主要用于瞬时请求)- 数据库唯一键蔸底
解决方案在消费者侧,步骤如下:
消费者每次处理消息的时候都会将这个消息存放到
Redis上进行缓存
- 利用
Redis的setnx,来判断是否存在对应的key - 如果否,那就发放优惠券
- 如果是,那就跳过
为什么要用 Redis 呢?
- 为了保护
MySQL被瞬时打挂,当然最好有MySQL唯一键蔸底。 - 公司技术栈中就有,现成维护好的。
对应实现如下:
@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;
}
}