1. 抽奖设计
抽奖过程是抽奖系统中最重要的核心环节,它需要确保公平、透明且高效
以下是抽奖过程设计:
1. 参与者注册与奖品建立
- 参与者注册:管理员通过管理端新增用户,填写必要的信息,如姓名、联系方式等。
- 奖品建立:奖品需要提前建立好
2. 抽奖活动设置
- 活动创建:管理员在系统中创建抽奖活动,输入活动名称、描述、奖品列表等信息。
- 圈选人员:关联该抽奖活动的参与者。
- 圈选奖品:圈选该抽奖活动的奖品,设置奖品等级、个数等。
- 活动发布:活动信息发布后,系统通过管理端界面展示活动列表。
3. 抽奖请求处理(重要)
- 随机抽取:前端随机选择后端提供的参与者,确保每次抽取的结果是公平的。
- 请求提交:在活动进行时,管理员可发起抽奖请求。请求包含活动ID、奖品ID和中奖人员等附加信息。
- 消息队列通知:有效的抽奖请求被发送至MQ队列中,等待MQ消费者真正处理抽奖逻辑。
- 请求返回:抽奖的请求处理接口将不再完成任何的事情,直接返回。
4. 抽奖结果公布
- 前端展示:中奖名单通过前端随机抽取的人员,公布展示出来。
5. 抽奖逻辑执行(重要)
- 消息消费:MQ消费者收到异步消息,系统开始执行以下抽奖逻辑。
6. 中奖结果处理(重要)
- 请求验证:
- 系统验证抽奖请求的有效性,如是否满足系统根据设定的规则(如奖品数量、每人中奖次数限制等)等;
- 幂等性:若消息多发,已抽取的内容不能再次抽取
- 状态扭转:根据中奖结果扭转活动/奖品/参与者状态,如奖品是否已被抽取,人员是否已中奖等。
- 结果记录:中奖结果被记录在数据库中,并同步更新Redis缓存。
7. 中奖者通知
- 通知中奖者:通知中奖者和其他相关系统(如邮件发送服务)。
- 奖品领取:中奖者根据通知中的指引领取奖品。
8. 抽奖异常处理
- 回滚处理:当抽奖过程中发生异常,需要保证事务一致性。
- 补救措施:抽奖行为是一次性的,因此异步处理抽奖任务必须保证成功,若过程异常,需采取补救措施
技术实现细节
- 异步处理:提高抽奖性能,不影响抽奖流程,将抽奖处理放入队列中进行异步处理,且保证了幂等性。
- 活动状态扭转处理:状态扭转会涉及活动及奖品等多横向维度扭转,不能避免未来不会有其他内容牵扯进活动中,因此对于状态扭转处理,需要高扩展性(设计模式)与维护性。
- 并发处理:中奖者通知,可能要通知多系统,但相互解耦,可以设计为并发处理,加快抽奖效率作用。
- 事务处理:在抽奖逻辑执行时,如若发生异常,需要确保数据库表原子性、事务一致性,因此要做好事务处理。
通过以上流程,抽奖系统能够确保抽奖过程的公平性和高效性,同时提供良好的用户体验。而且还整合了Redis和MQ,进一步提高系统的性能。
2. RabbitMQ 的配置与使用
2.1 什么是 MQ
MQ(Message queue),从字面意思上看,本质是个队列,FIFO 先进先出,只不过队列中存放的内容是消息(message)而已。消息可以非常简单,比如只包含文本字符串、JSON 等,也可以很复杂,比如内嵌对象
MQ 多用于分布式系统之间的通信
系统之间的调用通常有两种方式:
1) 同步通信
直接调用对方的服务,数据从一端发出后立即就可以达到另一端
2) 异步通信
数据从一端发出后,先进入一个容器进行临时存储,当达到某种条件后,再由这个容器发送给另一端。容器的一个具体实现就是 MQ(Message queue)
RabbitMQ 就是 MQ 的一种实现
2.2 MQ 的作用
MQ 主要工作是接受并转发消息,在不同的应用场景下可以展现不同的作用
可以把 MQ 想象成一个仓库,采购部门进货之后把零件放进仓库里,生产部门从仓库中取出零件并加工成产品。MQ 和仓库的区别是,仓库里放的是物品,MQ 里放的是消息。仓库负责存储物品并转发物品,MQ 负责存储和转发消息。
- 异步解耦: 在业务流程中,一些操作可能非常耗时,但并不需要即时返回结果.可以借助 MQ 把这些操作异步化,比如 用户注册后发送注册短信或邮件通知,可以作为异步任务处理,而不必等待这些操作完成后才告知用户注册成功.
- 流量削峰: 在访问量剧增的情况下,应用仍然需要继续发挥作用,但是是这样的突发流量并不常见.如果以能处理这类峰值为标准而投入资源,无疑是巨大的浪费.使用 MQ 能够使关键组件支撑突发访问压力,不会因为突发流量而崩溃.比如秒杀或者促销活动,可以使用 MQ 来控制流量,将请求排队,然后系统根据自己的处理能力逐步处理这些请求.
- 异步通信: 在很多时候应用不需要立即处理消息,MQ 提供了异步处理机制,允许应用把一些消息放入 MQ 中,但并不立即处理它,在需要的时候再慢慢处理.
- 消息分发: 当多个系统需要对同一数据做出响应时,可以使用 MQ 进行消息分发.比如支付成功后,支付系统可以向 MQ 发送消息,其他系统订阅该消息,而无需轮询数据库.
- 延迟通知: 在需要特定时间后发送通知的场景中,可以使用 MQ 的延迟消息功能,比如在电子商务平台中,如果用户下单后一定时间内未支付,可以使用延迟队列在超时后自动取消订单
2.3 RabbitMQ 下载及代码配置
云服务器下载安装 RabbitMQ 参考:在云服务器上安装 RabbitMQ:从零到一的最佳实践_云服务器安装rabbitmq-CSDN博客
pom.xml
<!-- RabbitMQ -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-amqp</artifactId>
</dependency>
application.properties
## mq ##
spring.rabbitmq.host=云服务器公网IP
spring.rabbitmq.port=5672
spring.rabbitmq.username=admin
spring.rabbitmq.password=admin
#消息确认机制,默认auto
spring.rabbitmq.listener.simple.acknowledge-mode=auto
#设置失败重试 5次
spring.rabbitmq.listener.simple.retry.enabled=true
spring.rabbitmq.listener.simple.retry.max-attempts=5
DirectRabbitConfig 配置类
package com.example.lotterysystem.common.config;
import org.springframework.amqp.core.*;
import org.springframework.amqp.support.converter.Jackson2JsonMessageConverter;
import org.springframework.amqp.support.converter.MessageConverter;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
@Configuration
public class DirectRabbitConfig {
public static final String QUEUE_NAME = "DirectQueue";
public static final String EXCHANGE_NAME = "DirectExchange";
public static final String ROUTING = "DirectRouting";
/**
* 队列 起名: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);
}
/**
* 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);
}
@Bean
public MessageConverter jsonMessageConverter() {
return new Jackson2JsonMessageConverter();
}
}
3. 抽奖请求处理
时序图
约定前后端交互接口
[请求] /draw-prize POST
{}
[响应]
{}
Controller 层接口设计
package com.example.lotterysystem.controller;
import com.example.lotterysystem.common.pojo.CommonResult;
import com.example.lotterysystem.controller.param.DrawPrizeParam;
import com.example.lotterysystem.service.DrawPrizeService;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
@RestController
public class DrawPrizeController {
private static final Logger logger = LoggerFactory.getLogger(DrawPrizeController.class);
@Autowired
private DrawPrizeService drawPrizeService;
@RequestMapping("/draw-prize")
public CommonResult<Boolean> drawPrize(@Validated @RequestBody DrawPrizeParam param) {
logger.info("drawPrize DrawPrizeParam:{}", param);
// service
drawPrizeService.drawPrize(param);
return CommonResult.success(true);
}
}
DrawPrizeParam
package com.example.lotterysystem.controller.param;
import jakarta.validation.Valid;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotEmpty;
import jakarta.validation.constraints.NotNull;
import lombok.Data;
import java.util.Date;
import java.util.List;
@Data
public class DrawPrizeParam {
// 活动id
@NotNull(message = "活动 id 不能为空!")
private Long activityId;
// 奖品id
@NotNull(message = "奖品 id 不能为空!")
private Long prizeId;
// 奖品等级
@NotBlank(message = "奖品等级不能为空!")
private String prizeTiers;
// 中奖时间
@NotNull(message = "中奖时间不能为空!")
private Date winningTime;
// 中奖者列表
@NotEmpty(message = "中奖者列表不能为空!")
@Valid
private List<Winner> winnerList;
@Data
public static class Winner {
// 中奖者id
@NotNull(message = "中奖者 id 不能为空!")
private Long userId;
// 中奖者姓名
@NotBlank(message = "中奖者姓名不能为空!")
private String userName;
}
}
Service 层接口设计
package com.example.lotterysystem.service;
import com.example.lotterysystem.controller.param.DrawPrizeParam;
public interface DrawPrizeService {
// 异步抽奖接口
void drawPrize(DrawPrizeParam param);
}
接口实现
package com.example.lotterysystem.service.impl;
import com.example.lotterysystem.common.utils.JacksonUtil;
import com.example.lotterysystem.controller.param.DrawPrizeParam;
import com.example.lotterysystem.service.DrawPrizeService;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.amqp.rabbit.core.RabbitTemplate;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import java.util.HashMap;
import java.util.Map;
import java.util.UUID;
import static com.example.lotterysystem.common.config.DirectRabbitConfig.EXCHANGE_NAME;
import static com.example.lotterysystem.common.config.DirectRabbitConfig.ROUTING;
@Service
public class DrawPrizeServiceImpl implements DrawPrizeService {
private static final Logger logger = LoggerFactory.getLogger(DrawPrizeServiceImpl.class);
@Autowired
private RabbitTemplate rabbitTemplate;
@Override
public void drawPrize(DrawPrizeParam param) {
Map<String, String> map = new HashMap<>();
map.put("messageId", String.valueOf(UUID.randomUUID()));
map.put("messageData", JacksonUtil.writeValueAsString(param));
// 发消息,需要传入参数:交换机、交换机与队列绑定的 key、消息体(上面定义的 map)
rabbitTemplate.convertAndSend(EXCHANGE_NAME, ROUTING, map);
logger.info("MQ 消息发送成功:map={}", JacksonUtil.writeValueAsString(map));
}
}
测试 DrawPrizeTest
package com.example.lotterysystem;
import com.example.lotterysystem.controller.param.DrawPrizeParam;
import com.example.lotterysystem.service.DrawPrizeService;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import java.util.ArrayList;
import java.util.Date;
import java.util.List;
@SpringBootTest
public class DrawPrizeTest {
@Autowired
private DrawPrizeService drawPrizeService;
@Test
void drawPrize() {
DrawPrizeParam param = new DrawPrizeParam();
param.setActivityId(1L);
param.setPrizeId(1L);
param.setPrizeTiers("FIRST_PRIZE");
param.setWinningTime(new Date());
List<DrawPrizeParam.Winner> winnerList = new ArrayList<>();
DrawPrizeParam.Winner winner = new DrawPrizeParam.Winner();
winner.setUserId(1L);
winner.setUserName("xxx");
winnerList.add(winner);
param.setWinnerList(winnerList);
drawPrizeService.drawPrize(param);
}
}
运行结果:
4. MQ 异步抽奖逻辑执行
时序图
4.1 消费 MQ 消息
消费者类 实现
package com.example.lotterysystem.service.mq;
import com.example.lotterysystem.common.exception.ServiceException;
import com.example.lotterysystem.common.utils.JacksonUtil;
import com.example.lotterysystem.controller.param.DrawPrizeParam;
import com.example.lotterysystem.service.DrawPrizeService;
import com.example.lotterysystem.service.activitystatus.ActivityStatusManager;
import com.example.lotterysystem.service.dto.ConvertActivityStatusDTO;
import com.example.lotterysystem.service.enums.ActivityPrizeStatusEnum;
import com.example.lotterysystem.service.enums.ActivityStatusEnum;
import com.example.lotterysystem.service.enums.ActivityUserStatusEnum;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.amqp.rabbit.annotation.RabbitHandler;
import org.springframework.amqp.rabbit.annotation.RabbitListener;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component;
import java.util.Map;
import java.util.stream.Collectors;
import static com.example.lotterysystem.common.config.DirectRabbitConfig.QUEUE_NAME;
@Component
@RabbitListener(queues = QUEUE_NAME)
public class MqReceive {
private static final Logger logger = LoggerFactory.getLogger(MqReceive.class);
@Autowired
private DrawPrizeService drawPrizeService;
@Autowired
private ActivityStatusManager activityStatusMapper;
@RabbitHandler
public void process(Map<String, String> message) {
// 成功接收到队列中的消息
logger.info("MQ 成功接收到消息,message:{}", JacksonUtil.writeValueAsString(message));
String paramString = message.get("messageData");
DrawPrizeParam param = JacksonUtil.readValue(paramString, DrawPrizeParam.class);
// 处理抽奖的流程
try {
// 校验抽奖请求是否有效
// 1. 特殊场景:可能前端发起两个一样的抽奖请求,对于 param 来说也是一样的两个请求
// 2. 若此时 param 是最后一个奖项,就有以下两种操作
// 处理 param1:活动完成、奖品完成
// 处理 param2:回滚活动状态、奖品状态
if (!drawPrizeService.checkDrawPrizeParam(param)) {
return;
}
// 状态扭转处理(重要!这里有设计模式,代码的扩展性、灵活性在这里体现)
statusConvert(param);
// 保存中奖者名单
// 通知中奖者(邮箱、短信)
} catch (ServiceException e) {
logger.error("处理 MQ 消息异常!{}:{}", e.getCode(), e.getMessage(), e);
// 如果异常,需要保证事务一致性(回滚),抛出异常
} catch (Exception e) {
logger.error("处理 MQ 消息异常!", e);
// 如果异常,需要保证事务一致性(回滚),抛出异常
}
}
// 状态扭转优化后方法
private void statusConvert(DrawPrizeParam param) {
ConvertActivityStatusDTO convertActivityStatusDTO = new ConvertActivityStatusDTO();
convertActivityStatusDTO.setActivityId(param.getActivityId());
convertActivityStatusDTO.setTargetActivityStatus(ActivityStatusEnum.COMPLETED);
convertActivityStatusDTO.setPrizeId(param.getPrizeId());
convertActivityStatusDTO.setTargetPrizeStatus(ActivityPrizeStatusEnum.COMPLETED);
convertActivityStatusDTO.setUserIds(
param.getWinnerList().stream()
.map(DrawPrizeParam.Winner::getUserId)
.collect(Collectors.toList())
);
convertActivityStatusDTO.setTargetUserStatus(ActivityUserStatusEnum.COMPLETED);
activityStatusMapper.handlerEvent(convertActivityStatusDTO);
}
// 状态扭转优化前方法
// private void statusConvert(DrawPrizeParam param) {
//
// /**
// * 下面这种写法的问题:
// * 1. 活动状态扭转有依赖性(扭转活动状态依赖于扭转奖品状态),导致代码维护性差
// * 2. 状态扭转条件可能会扩展(当前代码中扭转活动状态依赖于扭转奖品状态,未来还可能依赖于其他状态),导致代码扩展性差、维护性差
// * 3. 代码的灵活性、扩展性、维护性极差
// * 解决方案:设计模式(责任链模式、策略模式)
// */
//
// // 1. 扭转奖品状态
// // 查询活动关联的奖品信息
// // 条件判断是否符合扭转奖品状态:判断当前状态是否是 INIT,如果是:
// // 奖品:INIT-->COMPLETED
//
// // 2. 扭转人员状态
// // 查询活动关联的人员信息
// // 条件判断是否符合扭转人员状态:判断当前状态是否是 INIT,如果是:
// // 人员列表:INIT-->COMPLETED
//
// // 3. 扭转活动状态(此操作必须在扭转奖品状态之后进行)
// // 查询活动信息
// // 条件判断是否符合扭转活动状态:判断当前状态是否是 RUNNING,如果是,且当前全部奖品以抽取完毕后:
// // 活动:RUNNING-->COMPLETED
//
// // 4. 更新完整活动信息的缓存
//
// }
}
4.2 请求验证(校验抽奖信息有效性)
- 校验是否存在活动奖品
- 校验奖品数量是否等于中奖人数
- 校验活动有效性
- 校验抽取的奖品有效性
- ...
DrawPrizeService 新增核对抽奖信息有效性接口
// package com.example.lotterysystem.service;
// 校验抽奖请求
Boolean checkDrawPrizeParam(DrawPrizeParam param);
接口实现
// package com.example.lotterysystem.service.impl;
@Override
public Boolean checkDrawPrizeParam(DrawPrizeParam param) {
ActivityDO activityDO = activityMapper.selectById(param.getActivityId());
// 奖品是否存在可以从 activity_prize 表中查,原因是保存 activity 时做了本地事务,保证了数据一致性
ActivityPrizeDO activityPrizeDO = activityPrizeMapper.selectByAPId(param.getActivityId(), param.getPrizeId());
// 活动或奖品是否存在
if (null == activityDO || null == activityPrizeDO) {
// throw new ServiceException(ServiceErrorCodeConstants.ACTIVITY_OR_PRIZE_IS_EMPTY);
logger.info("校验抽奖请求失败!失败原因:{}",ServiceErrorCodeConstants.ACTIVITY_OR_PRIZE_IS_EMPTY.getMsg());
return false;
}
// 活动是否有效
if (activityDO.getStatus().equalsIgnoreCase(ActivityStatusEnum.COMPLETED.name())) {
// throw new ServiceException(ServiceErrorCodeConstants.ACTIVITY_COMPLETED);
logger.info("校验抽奖请求失败!失败原因:{}",ServiceErrorCodeConstants.ACTIVITY_COMPLETED.getMsg());
return false;
}
// 奖品是否有效
if (activityPrizeDO.getStatus().equalsIgnoreCase(ActivityPrizeStatusEnum.COMPLETED.name())) {
// throw new ServiceException(ServiceErrorCodeConstants.ACTIVITY_PRIZE_COMPLETED);
logger.info("校验抽奖请求失败!失败原因:{}",ServiceErrorCodeConstants.ACTIVITY_PRIZE_COMPLETED.getMsg());
return false;
}
// 中奖者人数是否和设置奖品数量一致
if (activityPrizeDO.getPrizeAmount() != param.getWinnerList().size()) {
// throw new ServiceException(ServiceErrorCodeConstants.WINNER_PRIZE_AMOUNT_ERROR);
logger.info("校验抽奖请求失败!失败原因:{}",ServiceErrorCodeConstants.WINNER_PRIZE_AMOUNT_ERROR.getMsg());
return false;
}
return true;
}
ActivityPrizeMapper 新增selectByActivityAndPrizeId 方法
// package com.example.lotterysystem.dao.mapper;
@Select("select * from activity_prize where activity_id = #{activityId} and prize_id = #{prizeId}")
ActivityPrizeDO selectByAPId(@Param("activityId") Long activityId,
@Param("prizeId") Long prizeId);
@Select("select count(1) from activity_prize where activity_id = #{activityId} and status = #{status}")
int countPrize(@Param("activityId") Long activityId,
@Param("status") String status);
@Update("update activity_prize set status = #{status} where activity_id = #{activityId} and prize_id = #{prizeId}")
void updateStatus(@Param("activityId") Long activityId, @Param("prizeId") Long pri