Spring Boot 抽奖模块设计:MQ 异步处理、缓存与状态管理
基于 Spring Boot 的抽奖系统设计与实现。核心采用 RabbitMQ 进行异步处理,解耦请求与业务逻辑。通过策略模式与责任链模式优化活动、奖品及参与者的状态转换,确保逻辑可扩展。利用 Redis 缓存中奖名单提升查询性能。设计了事务回滚机制与死信队列,保障数据一致性与消息可靠性。同时包含前后端交互接口设计及前端抽奖页交互逻辑,实现了公平、透明、高效的抽奖流程。

基于 Spring Boot 的抽奖系统设计与实现。核心采用 RabbitMQ 进行异步处理,解耦请求与业务逻辑。通过策略模式与责任链模式优化活动、奖品及参与者的状态转换,确保逻辑可扩展。利用 Redis 缓存中奖名单提升查询性能。设计了事务回滚机制与死信队列,保障数据一致性与消息可靠性。同时包含前后端交互接口设计及前端抽奖页交互逻辑,实现了公平、透明、高效的抽奖流程。

抽奖过程是系统的核心环节,设计目标为公平、透明、高效。整体流程分为以下阶段:
技术实现细节:
# mq
spring.rabbitmq.host=ip
spring.rabbitmq.port=端口
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
时序图:
约定前后端交互接口:
请求:
请求地址:/draw-prize
请求方法:POST
请求体:
{"winnerList":[{"userId":15,"userName":"胡一博"},{"userId":21,"userName":"范闲"}],"activityId":23,"prizeId":13,"prizeTiers":"FIRST_PRIZE","winningTime":"2024-05-21T11:55:10.000Z"}
响应体:
{"code":200,"data":true,"msg":""}
Controller 层接口设计:
DrawPrizeController 提供 /draw-prize 接口,接收 @Validated 的 DrawPrizeParam 参数。DrawPrizeService.drawPrize() 方法,返回 CommonResult.success(true)。请求参数封装:DrawPrizeParam:
List<Winner>)。@NotNull 等注解进行参数校验。Service 层接口设计:
DrawPrizeService 定义 drawPrize(DrawPrizeParam param) 方法。接口实现:
DrawPrizeServiceImpl 中注入 RabbitTemplate。DrawPrizeParam 序列化为 JSON,与消息 ID、创建时间一起封装为 Map,通过 rabbitTemplate.convertAndSend() 发送到指定交换机与路由键。接口实现示例:
@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,哪个队列,消息体
rabbitTemplate.convertAndSend(EXCHANGE_NAME, ROUTING, map);
log.info("mq 消息发送成功,map: {}", JacksonUtil.writeValueAsString(map));
}
时序图
MqReceiver 使用 @RabbitListener 监听队列 DirectQueue。DrawPrizeParam,按顺序执行以下步骤:
消费者类示例:
@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 {
// 校验抽奖请求是否有效
// 如果有两个一样的抽奖请求,
if (!drawPrizeService.checkDrawPrizeParam(param)) {
return;
}
// 状态扭转处理
statusConvert(param);
// 保存中奖者名单
List<WinningRecordDO> winningRecordDOList = drawPrizeService.saveWinnerRecords(param);
// 通知中奖者
syncExecute(winningRecordDOList);
} catch (ServiceException e) {
logger.error("处理 MQ 消息异常:{} : {}", e.getCode(), e.getMessage(), e);
// 如果异常,需要保证事务一致性,需要回滚;并且抛出异常
rollback(param);
throw e;
} catch (Exception e) {
logger.error("处理 MQ 消息异常:", e);
rollback(param);
throw e;
}
}
checkDrawPrizeValid(DrawPrizeParam param)。ActivityPrizeMapper 和 ActivityMapper 查询对应记录,比对状态与数量,不满足则抛出 ServiceException。验证代码示例:
@Override
public Boolean checkDrawPrizeParam(DrawPrizeParam param) {
ActivityDO activityDO = activityMapper.selectById(param.getActivityId());
ActivityPrizeDO activityPrizeDO = activityPrizeMapper.selectByAPId(param.getActivityId(), param.getPrizeId());
// 活动或奖品是否存在
if (null == activityDO || null == activityPrizeDO) {
log.info("校验抽奖请求失败!失败原因:{}", ServiceErrorCodeConstants.ACTIVITY_OR_PRIZE_IS_EMPTY.getMsg());
// throw new ServiceException(ServiceErrorCodeConstants.ACTIVITY_OR_PRIZE_IS_EMPTY);
return false;
}
// 活动是否有效
if (activityDO.getStatus().equals(ActivityStatusEnum.COMPLETED.name())) {
// throw new ServiceException(ServiceErrorCodeConstants.ACTIVITY_COMPLETED);
log.info("校验抽奖请求失败!失败原因:{}", ServiceErrorCodeConstants.ACTIVITY_COMPLETED.getMsg());
return false;
}
// 奖品是否有效
if (activityPrizeDO.getStatus().equals(ActivityPrizeStatusEnum.COMPLETED.name())) {
// throw new ServiceException(ServiceErrorCodeConstants.ACTIVITY_PRIZE_COMPLETED);
log.info("校验抽奖请求失败!失败原因:{}", ServiceErrorCodeConstants.ACTIVITY_PRIZE_COMPLETED.getMsg());
return false;
}
// 中奖者列表和奖品数量
if (activityPrizeDO.getPrizeAmount() != param.getWinnerList().size()) {
// throw new ServiceException(ServiceErrorCodeConstants.WINNER_PRIZE_AMOUNT_ERROR);
log.info("校验抽奖请求失败!失败原因:{}", ServiceErrorCodeConstants.WINNER_PRIZE_AMOUNT_ERROR.getMsg());
return false;
}
;
}
AbstractActivityOperator 抽象类,子类实现具体状态转换逻辑(PrizeOperator、UserOperator、ActivityOperator)。ActivityStatusManager 管理所有操作符,按指定顺序(sequence)遍历,每个操作符判断是否需要处理(needConvert),若需要则执行转换(convertStatus),并从链中移除,避免重复处理。MqReceiver 中,构造 ActivityStatusConvertDTO(包含活动 ID、奖品 ID、中奖用户 ID 列表及目标状态),调用 activityStatusManager.handleEvent()。Map<String, AbstractActivityOperator>(Spring 自动注入所有操作符)。handleEvent 方法中,按 sequence 分两次遍历操作符(先执行 sequence=1 的奖品和人员状态转换,再执行 sequence=2 的活动状态转换),每次调用 processStatusConversion 方法。activityService.cacheActivity() 刷新 Redis)。PrizeOperator:sequence=1,判断奖品状态是否需要更新,调用 activityPrizeMapper.updateStatus()。UserOperator:sequence=1,批量更新中奖用户状态。ActivityOperator:sequence=2,判断所有奖品是否抽完,若是则更新活动状态。状态扭转示例:
@Override
@Transactional(rollbackFor = Exception.class)
public void handlerEvent(ConvertActivityStatusDTO convertActivityStatusDTO) {
// map<String, AbstractActivityOperator>
if (CollectionUtils.isEmpty(operatorMap)) {
logger.warn("operatorMap 为空");
return;
}
Map<String, AbstractActivityOperator> currMap = new HashMap<>(operatorMap);
Boolean update = false;
// 先处理:人员,奖品
update = processConvertStatus(convertActivityStatusDTO, currMap, PROCESS_TYPE_USER_PRIZE);
// 后处理:活动
update = processConvertStatus(convertActivityStatusDTO, currMap, PROCESS_TYPE_ACTIVITY) || update;
if (update) {
// 更新缓存
activityService.cacheActivity(convertActivityStatusDTO.getActivityId());
}
}
/**
* 扭转状态
*
* @param convertActivityStatusDTO
* @param currMap
* @param sequence
* @return
*/
private Boolean processConvertStatus(ConvertActivityStatusDTO convertActivityStatusDTO,
Map<String, AbstractActivityOperator> currMap,
int sequence) {
Boolean update = false;
// 遍历 currMap
Iterator<Map.Entry<String, AbstractActivityOperator>> iterator = currMap.entrySet().iterator();
while (iterator.hasNext()) {
AbstractActivityOperator operator = iterator.next().getValue();
// Operatior 是否需要转换
(operator.sequence() != sequence || !operator.needConvert(convertActivityStatusDTO)) {
;
}
(!operator.convert(convertActivityStatusDTO)) {
logger.error(, operator.getClass().getName());
(ServiceErrorCodeConstants.ACTIVITY_STATUS_CONVERT_ERROR);
}
iterator.remove();
update = ;
}
update;
}
时序图:
saveWinningRecords(DrawPrizeParam param) 返回中奖记录列表。WinningRecordDO 列表(包含中奖者 ID、姓名、邮箱、电话,活动名称,奖品名称、等级,中奖时间)。WINNING_RECORD_PREFIX + activityId + "_" + prizeId 为 key,存入本轮中奖记录,过期时间 1 天。WINNING_RECORD_PREFIX + activityId 为 key 存入 Redis,过期时间 2 天。保存结果示例:
@Override
public List<WinningRecordDO> saveWinnerRecords(DrawPrizeParam param) {
// 查询相关信息,活动,人员,奖品,活动关联奖品......
ActivityDO activityDO = activityMapper.selectById(param.getActivityId());
List<UserDO> userDOList = userMapper.batchSelectByIds(
param.getWinnerList().stream().map(DrawPrizeParam.Winner::getUserId).collect(Collectors.toList()));
PrizeDO prizeDO = prizeMapper.selectById(param.getPrizeId());
ActivityPrizeDO activityPrizeDO = activityPrizeMapper.selectByAPId(param.getActivityId(), param.getPrizeId());
// 构造中奖者记录
List<WinningRecordDO> winningRecordDOList = userDOList.stream()
.map(userDO -> {
WinningRecordDO winningRecordDO = new WinningRecordDO();
winningRecordDO.setActivityId(activityDO.getId());
winningRecordDO.setActivityName(activityDO.getActivityName());
winningRecordDO.setPrizeId(prizeDO.getId());
winningRecordDO.setPrizeName(prizeDO.getName());
winningRecordDO.setPrizeTier(activityPrizeDO.getPrizeTiers());
winningRecordDO.setWinnerId(userDO.getId());
winningRecordDO.setWinnerName(userDO.getUserName());
winningRecordDO.setWinnerEmail(userDO.getEmail());
winningRecordDO.setWinnerPhoneNumber(userDO.getPhoneNumber());
winningRecordDO.setWinningTime(param.getWinningTime());
return winningRecordDO;
}).collect(Collectors.toList());
// 保存中奖记录
winningRecordMapper.batchInsert(winningRecordDOList);
// 缓存中奖者记录
// 缓存奖品的中奖信息(key:前缀 + activityId + prizeId, winningRecordDOList(奖品维度))
cacheWinningRecords(param.getActivityId() + "_" + param.getPrizeId(), winningRecordDOList, WINNING_RECORDS_TIMEOUT);
// 缓存活动维度的中奖记录(key:前缀 + activityId, winningRecordDOList(活动维度中奖名单))
// 当活动已完成去存放活动维度中奖记录
if (activityDO.getStatus().equalsIgnoreCase(ActivityStatusEnum.COMPLETED.name())) {
List<WinningRecordDO> allList = winningRecordMapper.selectByActivityId(param.getActivityId());
cacheWinningRecords(String.valueOf(param.getActivityId()), allList, WINNING_RECORDS_TIMEOUT);
}
winningRecordDOList;
}
spring-boot-starter-mail,配置 QQ 邮箱 SMTP。MailUtil 封装 sendSampleMail 方法,发送简单文本邮件。SMSUtil 中配置 AccessKey,调用 API 发送短信。application.properties 中配置核心线程数、最大线程数、队列容量等。ExecutorConfig 创建 ThreadPoolTaskExecutor bean,用于并发执行通知任务。MqReceiver 的 syncExecute 方法中,使用线程池分别执行 pushWinningList(邮件)和 sendMessage(短信)任务。邮件服务示例:
/**
* 发邮件
*
* @param to: 目标邮箱地址
* @param subject:标题
* @param context:正文
* @return
*/
public Boolean sendSampleMail(String to, String subject, String context) {
SimpleMailMessage message = new SimpleMailMessage();
message.setFrom(from);
message.setTo(to);
message.setSubject(subject);
message.setText(context);
try {
mailSender.send(message);
} catch (Exception e) {
logger.error("向{}发送邮件失败!", to, e);
return false;
}
return true;
}
MqReceiver.process() 中捕获 ServiceException 和通用 Exception,调用 rollbackWinning 方法。rollbackWinning 步骤:
activityStatusManager.rollbackHandleEvent(),将状态回滚至初始(活动->RUNNING,奖品->INIT,人员->INIT),并刷新缓存。drawPrizeService.removeRecords() 删除数据库记录及对应缓存。ActivityStatusManager 新增 rollbackHandleEvent,遍历所有操作符,构造回滚目标状态(与正向相反),执行 convertStatus,并更新缓存。回滚实现代码:
/**
* 恢复状态
*
* @param param
*/
private void rollbackStatus(DrawPrizeParam param) {
// 涉及状态的恢复工作
ConvertActivityStatusDTO convertActivityStatusDTO = new ConvertActivityStatusDTO();
convertActivityStatusDTO.setActivityId(param.getActivityId());
convertActivityStatusDTO.setTargetActivityStatus(ActivityStatusEnum.RUNNING);
convertActivityStatusDTO.setPrizeId(param.getPrizeId());
convertActivityStatusDTO.setTargetPrizeStatus(ActivityPrizeStatusEnum.INIT);
convertActivityStatusDTO.setUserIds(
param.getWinnerList().stream().map(DrawPrizeParam.Winner::getUserId).collect(Collectors.toList()));
convertActivityStatusDTO.setTargetUserStatus(ActivityUserStatusEnum.INIT);
activityStatusManager.rollbackHandlerEvent(convertActivityStatusDTO);
}
@Override
public void rollbackHandlerEvent(ConvertActivityStatusDTO convertActivityStatusDTO) {
for (AbstractActivityOperator operator : operatorMap.values()) {
operator.convert(convertActivityStatusDTO);
}
// 缓存更新
activityService.cacheActivity(convertActivityStatusDTO.getActivityId());
}
DirectRabbitConfig 中声明死信队列 DLX_QUEUE、死信交换机 DLX_EXCHANGE 及绑定。DirectQueue 绑定死信交换机:通过 QueueBuilder.durable().deadLetterExchange().deadLetterRoutingKey() 设置。MqReceiver.process() 中,发生任何异常并完成回滚后,重新抛出异常,使消息消费失败,自动转入死信队列。DLxReceiver 监听死信队列,收到消息后,可将其重新发送到正常队列,或记录到数据库待后续处理。时序图:
约定前后端交互接口:
请求:
请求地址:/winning-records/show
请求方法:POST
请求体:
{"activityId":23}
响应体:
{"code":200,"data":[{"winnerId":15,"winnerName":"胡一博","prizeName":"华为手机","prizeTier":"一等奖","winningTime":"2024-05-21T11:55:10.000+00:00"},{"winnerId":21,"winnerName":"范闲","prizeName":"华为手机","prizeTier":"一等奖","winningTime":"2024-05-21T11:55:10.000+00:00"}],"msg":""}
Controller 层接口设计:
DrawPrizeController 提供 /winning-records/show 接口,接收 ShowWinningRecordsParam,调用 drawPrizeService.showWinningRecords(),返回 CommonResult<List<WinningRecordResult>>。Service 层接口设计:
DrawPrizeService 已有 showWinningRecords 方法(之前用于回滚判断),直接复用。实现(DrawPrizeServiceImpl):
WINNING_RECORD_PREFIX + activityId + "_" + prizeId,否则为 WINNING_RECORD_PREFIX + activityId。接口实现示例:
@Override
public List<WinningRecordDTO> getRecords(ShowWinningRecordsParam param) {
// 查询 redis: 奖品,活动
String key = null == param.getPrizeId() ? String.valueOf(param.getActivityId()) : param.getActivityId() + "_" + param.getPrizeId();
List<WinningRecordDO> winningRecordDOList = getWinningRecords(key);
if (CollectionUtils.isEmpty(winningRecordDOList)) {
return convetToWinningRecordDTOList(winningRecordDOList);
}
// 如果 redis 不存在,查库
winningRecordDOList = winningRecordMapper.selectByActivityIdOrPrizeId(param.getActivityId(), param.getPrizeId());
// 整合存放记录到 redis 中
if (CollectionUtils.isEmpty(winningRecordDOList)) {
log.info("查询的中奖记录为空,param: {}", JacksonUtil.writeValueAsString(param));
return Arrays.asList();
}
cacheWinningRecords(key, winningRecordDOList, WINNING_RECORDS_TIMEOUT);
// 构造返回
return convetToWinningRecordDTOList(getWinningRecords(key));
}
时序图:
约定前后端交互接口:
请求:
请求地址:/activity-detail/find?activityId=24
请求方法:GET
响应体:
{"code":200,"data":{"activityId":24,"activityName":"测试抽奖活动","description":"测试抽奖活动","valid":true,"prizes":[{"prizeId":18,"name":"手机","description":"手机","price":5000.00,"imageUrl":"e606c8db-218a-40c2-8946-0d9f8570626d.jpg","prizeAmount":1,"prizeTierName":"一等奖","valid":true}
Controller 层接口设计:
ActivityController 新增 /activity-detail/find 接口,调用 activityService.getActivityDetail(activityId),返回 FindActivityDetailResult。Service 层接口设计:
ActivityService 新增 getActivityDetail(Long activityId),返回 ActivityDetailDTO。实现(ActivityServiceImpl):
ACTIVITY_PREFIX + activityId),若存在直接返回。ActivityMapper、ActivityPrizeMapper、PrizeMapper、ActivityUserMapper 组装完整详情,调用 cacheActivity 存入 Redis 后返回。接口实现示例:
/**
* 根据活动 ID 从缓存中获取活动详细信息
* @param activityId
* @return
*/
private ActivityDetailDTO getActivityFromCache(Long activityId) {
if (null == activityId) {
log.warn("获取缓存活动数据 activityId 为空");
return null;
}
try {
String str = redisUtil.get(Constants.ACTIVITY_PREFIS + activityId);
if (!StringUtils.hasLength(str)) {
log.warn("获取缓存活动数据为空!key: {}", Constants.ACTIVITY_PREFIS);
return null;
}
return JacksonUtil.readValue(str, ActivityDetailDTO.class);
} catch (Exception e) {
log.error("从缓存中获取活动信息异常,key: {}", Constants.ACTIVITY_PREFIS);
return null;
}
}
抽奖页面示例展示:
抽奖结束中奖名单展示:
reloadConf 获取活动详情,初始化奖品列表(steps)和人员列表(names),并进入第一个奖品的状态(state='showPic')。showBlink 状态,人名随机闪烁。data.list,并调用 /draw-prize 接口异步提交中奖结果,同时更新 data.valid=false。showList 状态展示本轮中奖名单,按钮变为'已抽完,下一步'。valid 为 false,直接展示中奖名单(通过 showListByBackEnd 从后端查询),防止重复抽取。showRecords 展示全量中奖名单,并生成'分享结果'按钮,点击复制带参数(隐藏按钮)的链接。
微信公众号「极客日志」,在微信中扫描左侧二维码关注。展示文案:极客日志 zeeklog
查找任何按下的键的javascript键代码、代码、位置和修饰符。 在线工具,Keycode 信息在线工具,online
JavaScript 字符串转义/反转义;Java 风格 \uXXXX(Native2Ascii)编码与解码。 在线工具,Escape 与 Native 编解码在线工具,online
使用 Prettier 在浏览器内格式化 JavaScript 或 HTML 片段。 在线工具,JavaScript / HTML 格式化在线工具,online
Terser 压缩、变量名混淆,或 javascript-obfuscator 高强度混淆(体积会增大)。 在线工具,JavaScript 压缩与混淆在线工具,online
将字符串编码和解码为其 Base64 格式表示形式即可。 在线工具,Base64 字符串编码/解码在线工具,online
将字符串、文件或图像转换为其 Base64 表示形式。 在线工具,Base64 文件转换器在线工具,online