9. 抽奖模块
9.1 抽奖设计
抽奖过程是抽奖系统中最重要的核⼼环节,它需要确保公平、透明且⾼效。以下是详细的抽奖过程设计:
对于前端来说,负责控制抽奖的流程,确定中奖的人员
对于后端来说:
接口1:查询完整的活动信息
接口2: 前端确定好中奖的人员之后奖人员信息发送给后端,后端完整的保存好中奖信息。(扭转活动,奖品,人员状态,有的奖品抽完了,有的人员中奖了就不能参加下一个等级的抽奖了,这个活动是不是已经抽奖结束了)(完成中奖人员通知的操作)
接口三:查询中奖列表。
9.2 查询活动完整信息接口
activityDetailDTO类里面包含抽奖活动所需要的所有参数。
前端:向后端发起请求,从redis中查询相关的数据(如果没有存储,或者redis中的缓存过期,就会查询相关的数据库表,整合所需要的所有数据,再一次存放到redis中),最后奖数据返回给前端。
controller:
java
@RequestMapping("/activity-detail/find")
public CommonResult<GetActivityDetailResult> getActivityDetail(Long activityId) {
logger.info("ActivityController getActivityDetail activityId:{}", activityId);
ActivityDetailDTO detailDTO = activityService.getActivityDetail(activityId);
return CommonResult.success(convertToGetActivityDetailResult(detailDTO));
}
private GetActivityDetailResult convertToGetActivityDetailResult(ActivityDetailDTO detailDTO) {
if (detailDTO == null) {
throw new ControllerException(ControllerErrorCodeConstants.GET_ACTIVITY_DETAIL_ERROR);
}
GetActivityDetailResult result = new GetActivityDetailResult();
result.setActivityId(detailDTO.getActivityId());
result.setActivityName(detailDTO.getActivityName());
result.setDescription(detailDTO.getDesc());
result.setValid(detailDTO.valid());
// 抽奖顺序:一等奖、二、三
result.setPrizes(
detailDTO.getPrizeDTOList().stream()
.sorted(Comparator.comparingInt(prizeDTO -> prizeDTO.getTiers().getCode()))
//按照奖品code1,2,3等奖来进行从小到大的排序
.map(prizeDTO -> {
GetActivityDetailResult.Prize prize = new GetActivityDetailResult.Prize();
prize.setPrizeId(prizeDTO.getPrizeId());
prize.setName(prizeDTO.getName());
prize.setImageUrl(prizeDTO.getImageUrl());
prize.setPrice(prizeDTO.getPrice());
prize.setDescription(prizeDTO.getDescription());
prize.setPrizeTierName(prizeDTO.getTiers().getMessage());
prize.setPrizeAmount(prizeDTO.getPrizeAmount());
prize.setValid(prizeDTO.valid());
return prize;
}).collect(Collectors.toList())
);
result.setUsers(
detailDTO.getUserDTOList().stream()
.map(userDTO -> {
GetActivityDetailResult.User user = new GetActivityDetailResult.User();
user.setUserId(userDTO.getUserId());
user.setUserName(userDTO.getUserName());
user.setValid(userDTO.valid());
return user;
}).collect(Collectors.toList())
);
return result;
}
service:
java
ActivityDetailDTO getActivityDetail(Long activityId);
@Override
public ActivityDetailDTO getActivityDetail(Long activityId) {
if (activityId == null) {
logger.warn("查询活动详细信息失败,activityId为空!");
return null;
}
// 查询 redis
ActivityDetailDTO detailDTO = getActivityFromCache(activityId);
if (detailDTO != null) {
logger.info("查询活动详细信息成功!detailDTO={}",
JacksonUtil.writeValueAsString(detailDTO));
return detailDTO;
}
// 如果redis不存在,查表
// 活动表
ActivityDO aDO = activityMapper.selectById(activityId);
// 活动奖品表
List<ActivityPrizeDO> apDOList = activityPrizeMapper.selectByActivityId(activityId);
// 活动人员表
List<ActivityUserDO> auDOList = activityUserMapper.selectByActivityId(activityId);
// 奖品表: 先获取要查询的奖品id
List<Long> prizeIds = apDOList.stream()
.map(ActivityPrizeDO::getPrizeId)
.collect(Collectors.toList());
List<PrizeDO> pDOList = prizeMapper.batchSelectByIds(prizeIds);
// 整合活动详细信息,存放redis
detailDTO = convertToActivityDetailDTO(aDO, auDOList, pDOList, apDOList);
cacheActivity(detailDTO);
// 返回
return detailDTO;
}
dao:
java
@Select("select * from activity_prize where activity_id = #{activityId}")
List<ActivityPrizeDO> selectByActivityId(@Param("activityId") Long activityId);
@Select("select * from activity_user where activity_id = #{activityId}")
List<ActivityUserDO> selectByActivityId(@Param("activityId") Long activityId);
postman进行测试:
返回结果如下:
java
{
"code": 200,
"data": {
"activityId": 26,
"activityName": "测试抽奖活动2.0",
"description": "测试抽奖活动2.0",
"valid": true,
"prizes": [
{
"prizeId": 23,
"name": "snh48演唱会门票",
"imageUrl": "bc10a8f2-eb31-4e1e-8a72-2883b3968f0c.jpg",
"price": 498.00,
"description": "演唱会门票",
"prizeTierName": "一等奖",
"prizeAmount": 1,
"valid": true
},
{
"prizeId": 21,
"name": "奥迪a6",
"imageUrl": "a1452970-b709-4a51-8343-e969f5ac5c64.jpg",
"price": 260000.00,
"description": "一个四轮车子",
"prizeTierName": "二等奖",
"prizeAmount": 1,
"valid": true
}
],
"users": [
{
"userId": 44,
"userName": "王奕",
"valid": true
},
{
"userId": 45,
"userName": "周诗雨",
"valid": true
}
]
},
"msg": ""
}
9.3 抽奖接口分析
抽奖场景:当确定中奖人员信息之后,处于用户体验,需要当前页面马上显示出相关的人员信息,但是前端将相关中奖人确定的信息传入到后端之后,后端要进行(保存中奖信息,扭转对协应的转态,完成通知),之后才会将先关数据发送到前端显示的页面上,这样会极大地消耗时间,这就是同步接口,该接口完全不符合当前的抽奖接口。
异步接口:
前端:确定中间人之后前往后端。
后端:前端来请求之后直接返回数据,对上面三个步骤进行异步处理。
异步处理流程,会另找时间完成后端所要完成的三个步骤。
9.3.1 异步处理业务流程图
9.3.2 业务逻辑分析
1、抽奖请求处理(重要)
随机抽取:前端随机选择后端提供的参与者,确保每次抽取的结果是公平的。
请求提交:在活动进⾏时,管理员可发起抽奖请求。请求包含活动ID、奖品ID和中奖⼈员等附加 信息。
消息队列通知:有效的抽奖请求被发送⾄MQ队列中,等待MQ消费者真正处理抽奖逻辑。
请求返回:抽奖的请求处理接⼝将不再完成任何的事情,直接返回。
2、 抽奖结果公布
前端展⽰:中奖名单通过前端随机抽取的⼈员,公布展⽰出来。
3、 抽奖逻辑执⾏(重要)
消 息消费:MQ消费者收到异步消息,系统开始执⾏以下抽奖逻辑。
4、 中奖结果处理(重要)
请求验证:
◦ 系统验证抽奖请求的有效性,如是否满⾜系统根据设定的规则(如奖品数量、每⼈中奖次数 限制等)等;
◦ 幂等性:若消息多发,已抽取的内容不能再次抽取
状态扭转:根据中奖结果扭转活动/奖品/参与者状态,如奖品是否已被抽取,⼈员是否已中奖等。
结果记录:中奖结果被记录在数据库中,并同步更新Redis缓存。
5、 中奖者通知
通知中奖者:通知中奖者和其他相关系统(如邮件发送服务)。
奖品领取:中奖者根据通知中的指引领取奖品。
6、 抽奖异常处理
回滚处理:当抽奖过程中发⽣异常,需要保证事务⼀致性。
补救措施:抽奖⾏为是⼀次性的,因此异步处理抽奖任务必须保证成功,若过程异常,需采取补 救措施
技术实现细节:
异步处理:提⾼抽奖性能,不影响抽奖流程,将抽奖处理放⼊队列中进⾏异步处理,且保证了幂 等性。
活动状态扭转处理:状态扭转会涉及活动及奖品等多横向维度扭转,不能避免未来不会有其他内 容牵扯进活动中,因此对于状态扭转处理,需要⾼扩展性(设计模式)与维护性。 并发处理:中奖者通知,可能要通知多系统(邮箱和手机两种方式),但相互解耦,可以设计为并发处理,加快抽奖效率 作⽤。
事务处理:在抽奖逻辑执⾏时,如若发⽣异常,需要确保数据库表原⼦性、事务⼀致性,因此要 做好事务处理。
本次的异步处理操作使用rabbitmq工具来实现。
9.4 mq
MQ( Message queue ),从字⾯意思上看,本质是个队列,FIFO先⼊先出,只不过队列中存放的内容 是消息(message)⽽已.消息可以⾮常简单,⽐如只包含⽂本字符串,JSON等,也可以很复杂,⽐如内嵌对象. MQ多⽤于分布式系统之间进⾏通信,系统之间的调⽤通常有两种⽅式:
- 同步通信
直接调⽤对⽅的服务,数据从⼀端发出后⽴即就可以达到另⼀端.
- 异步通信
数据从⼀端发出后,先进⼊⼀个容器进⾏临时存储,当达到某种条件后,再由这个容器发送给另⼀端. 容器的⼀个具体实现就是MQ( message queue)
RabbitMQ 就是MQ的⼀种实现
9.4.1 MQ主要作用
MQ主要⼯作是接收并转发消息,在不同的应⽤场景下可以展现不同的作⽤:
- 异步解耦:
在业务流程中,⼀些操作可能⾮常耗时,但并不需要即时返回结果.可以借助MQ把这些操 作异步化,⽐如⽤⼾注册后发送注册短信或邮件通知,可以作为异步任务处理,⽽不必等待这些操作 完成后才告知⽤⼾注册成功.
- 流量削峰:
在访问量剧增的情况下,应⽤仍然需要继续发挥作⽤,但是是这样的突发流量并不常⻅.如 果以能处理这类峰值为标准⽽投⼊资源,⽆疑是巨⼤的浪费.使⽤MQ能够使关键组件⽀撑突发访问压 ⼒,不会因为突发流量⽽崩溃.⽐如秒杀或者促销活动,可以使⽤MQ来控制流量,将请求排队,然后系 统根据⾃⼰的处理能⼒逐步处理这些请求.
- 异步通信:
在很多时候应⽤不需要⽴即处理消息,MQ提供了异步处理机制,允许应⽤把⼀些消息放⼊ MQ中,但并不⽴即处理它,在需要的时候再慢慢处理.
- 消息分发:
当多个系统需要对同⼀数据做出响应时,可以使⽤MQ进⾏消息分发.⽐如⽀付成功后,⽀ 付系统可以向MQ发送消息,其他系统订阅该消息,⽽⽆需轮询数据库.
- 延迟通知:
在需要在特定时间后发送通知的场景中,可以使⽤MQ的延迟消息功能,⽐如在电⼦商务平 台中,如果⽤⼾下单后⼀定时间内未⽀付,可以使⽤延迟队列在超时后⾃动取消订单
9.4.2 rabbitmq核心概念
如下图所示,生产者和消费者是通过网络进行连接的,网络之中是有信道的,发送的消息是存放在队列中遵循先进先出的规则,交换机的作用是用来路由消息的,消息由生产者发送到网络中,由交换机经过路由将消息传递给队列(交换机绑定的队列)。
消费者消费消息,就是去相应对的队列里面进行拿取相应的消息。
多个消费者可以订阅同一个队列。
9.4.3 . web界⾯操作
RabbitMQ 介绍.pdf 基本步骤见文件
9.4.4 rabbitmq的配置与使用
1、引入依赖:
java
<!-- RabbitMQ -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-amqp</artifactId>
</dependency>
2、application.properties 配置 MQ
## mq ##
spring.rabbitmq.host=49
spring.rabbitmq.port=5672
spring.rabbitmq.username=admin
spring.rabbitmq.password=111111
#消息确认机制,默认auto
spring.rabbitmq.listener.simple.acknowledge-mode=auto
#设置失败重试 5次
spring.rabbitmq.listener.simple.retry.enabled=true
spring.rabbitmq.listener.simple.retry.max-attempts=5
3、DirectRabbitConfig 配置类
java
@Configuration
public class DirectRabbitConfig {
public static final String QUEUE_NAME = "DirectQueue";
public static final String EXCHANGE_NAME = "DirectExchange";
public static final String ROUTING = "DirectRouting";
public static final String DLX_QUEUE_NAME = "DlxDirectQueue";
public static final String DLX_EXCHANGE_NAME = "DlxDirectExchange";
public static final String DLX_ROUTING = "DlxDirectRouting";
/**
* 队列 起名:DirectQueue
*
* @return
*/
@Bean
public Queue directQueue() {
// durable:是否持久化,默认是false,持久化队列:会被存储在磁盘上,当消息代理重启时仍然存在,暂存队列:当前连接有效
// exclusive:默认也是false,只能被当前创建的连接使用,而且当连接关闭后队列即被删除。此参考优先级高于durable
// autoDelete:是否自动删除,当没有生产者或者消费者使用此队列,该队列会自动删除。
// return new Queue("DirectQueue",true,true,false);
// 一般设置一下队列的持久化就好,其余两个就是默认false
return new Queue(QUEUE_NAME,true);
// 普通队列绑定死信交换机
// return QueueBuilder.durable(QUEUE_NAME)
// .deadLetterExchange(DLX_EXCHANGE_NAME)
// .deadLetterRoutingKey(DLX_ROUTING).build();
}
/**
* Direct交换机 起名:DirectExchange
*
* @return
*/
@Bean
DirectExchange directExchange() {
return new DirectExchange(EXCHANGE_NAME,true,false);
}
/**
* 绑定 将队列和交换机绑定, 并设置用于匹配键:DirectRouting
*
* @return
*/
@Bean
Binding bindingDirect() {
return BindingBuilder.bind(directQueue())
.to(directExchange())
.with(ROUTING);
}
/**
* 死信队列
*
* @return
*/
@Bean
public Queue dlxQueue() {
// durable:是否持久化,默认是false,持久化队列:会被存储在磁盘上,当消息代理重启时仍然存在,暂存队列:当前连接有效
// exclusive:默认也是false,只能被当前创建的连接使用,而且当连接关闭后队列即被删除。此参考优先级高于durable
// autoDelete:是否自动删除,当没有生产者或者消费者使用此队列,该队列会自动删除。
// return new Queue("DirectQueue",true,true,false);
// 一般设置一下队列的持久化就好,其余两个就是默认false
return new Queue(DLX_QUEUE_NAME,true);
}
/**
* 死信交换机
*
* @return
*/
@Bean
DirectExchange dlxExchange() {
return new DirectExchange(DLX_EXCHANGE_NAME,true,false);
}
/**
* 绑定死信队列与交换机
*
* @return
*/
@Bean
Binding bindingDlx() {
return BindingBuilder.bind(dlxQueue())
.to(dlxExchange())
.with(DLX_ROUTING);
}
@Bean
public MessageConverter jsonMessageConverter(){
return new Jackson2JsonMessageConverter();
}
}
在当前生产消费的模块中,生产者是前端的抽奖接口,消费者是处理抽奖流程的方法。