ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

Spring Boot + Redis + MySQL 高并发扫码抽奖系统设计与实战

Spring Boot + Redis + MySQL 高并发扫码抽奖系统设计与实战

最近在开发一个扫码抽奖活动时,遇到了一个典型的并发问题:在高并发场景下,如何保证用户扫码、抽奖、中奖记录和奖品发放的原子性与一致性?这不仅仅是“扫码-抽奖”的简单流程,更涉及到库存扣减、防超发、防重复、事务回滚等一系列后端工程难题。本文将从一个真实的“扫码抽奖”业务需求出发,拆解其背后的技术架构,并提供一个基于 Spring Boot + Redis + MySQL 的完整、可落地的解决方案。无论你是正在处理类似活动的开发者,还是希望深入理解高并发业务设计的同学,都能从中获得可直接复用的代码和设计思路。

1. 背景与核心概念:扫码抽奖的业务与技术挑战

“扫码抽奖”是现代营销活动中非常常见的一种形式。用户扫描二维码后,跳转至 H5 页面,点击按钮参与抽奖。这个过程看似简单,但对后端系统提出了严峻的挑战。

核心业务流程如下:

  1. 用户扫码:获取活动唯一标识(activity_id)和用户标识(user_id)。
  2. 资格校验:检查活动是否有效、用户是否已参与、活动库存是否充足。
  3. 执行抽奖:根据预设的中奖概率算法,决定用户是否中奖以及中何种奖品。
  4. 结果处理:如果中奖,需要原子性地完成“记录中奖信息”、“扣减奖品库存”等操作;如果未中奖,则记录参与记录。
  5. 结果返回:将抽奖结果(中奖/未中奖、奖品信息)返回给前端页面。

面临的主要技术挑战:

  • 高并发:活动上线瞬间可能涌入数万甚至数十万请求。
  • 超卖问题:奖品库存有限,必须保证不会被多发、超发。
  • 重复抽奖:同一用户在同一活动中只能参与一次,需要防重。
  • 性能要求:抽奖接口响应必须快速,用户体验要流畅。
  • 数据一致性:用户参与记录、中奖记录、库存扣减等多个数据操作必须保持一致性,不能出现“中了奖但没库存”或“扣了库存但没记录”的情况。

为了解决这些问题,我们需要一个结合缓存、数据库、分布式锁和事务机制的综合方案。接下来,我们将从环境搭建开始,一步步构建这个系统。

2. 环境准备与版本说明

为了完整演示,我们需要准备以下开发环境。请注意,版本号是本文撰写时采用的稳定版本,你可以根据实际项目情况调整。

  • JDK: 1.8 或 11(推荐 11)
  • Spring Boot: 2.7.x
  • 项目管理: Maven 3.6+
  • 数据库: MySQL 5.7+ 或 8.0
  • 缓存/中间件: Redis 6.x
  • IDE: IntelliJ IDEA 或 Eclipse
  • 测试工具: Postman 或 curl

项目依赖 (pom.xml核心部分):我们将使用 Spring Boot 的 Web、Data JPA、Redis 和 MySQL 驱动。

<dependencies> <!-- Web 支持 --> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-web</artifactId> </dependency> <!-- Redis 支持 --> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-data-redis</artifactId> </dependency> <!-- 数据库 JPA --> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-data-jpa</artifactId> </dependency> <!-- MySQL 驱动 --> <dependency> <groupId>mysql</groupId> <artifactId>mysql-connector-java</artifactId> <scope>runtime</scope> </dependency> <!-- 连接池 --> <dependency> <groupId>com.zaxxer</groupId> <artifactId>HikariCP</artifactId> </dependency> <!-- 工具包 --> <dependency> <groupId>org.apache.commons</groupId> <artifactId>commons-lang3</artifactId> </dependency> <!-- 测试 --> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-test</artifactId> <scope>test</scope> </dependency> </dependencies>

项目结构预览:

src/main/java/com/example/lottery/ ├── LotteryApplication.java // 启动类 ├── config/ │ └── RedisConfig.java // Redis 配置 ├── controller/ │ └── LotteryController.java // 抽奖接口 ├── service/ │ ├── LotteryService.java // 抽奖业务接口 │ └── impl/ │ └── LotteryServiceImpl.java // 抽奖业务实现 ├── repository/ │ ├── ActivityRepository.java // 活动数据访问 │ ├── AwardRecordRepository.java // 中奖记录数据访问 │ └── UserParticipationRepository.java // 用户参与记录 └── entity/ ├── Activity.java // 活动实体 ├── AwardRecord.java // 中奖记录实体 └── UserParticipation.java // 用户参与记录实体

3. 核心原理与架构设计拆解

在动手编码之前,理解核心设计原理至关重要。我们的方案核心是“缓存校验 + 数据库事务 + 分布式锁”的三层防护。

3.1 利用 Redis 进行高速校验与库存扣减

Redis 的高性能特性非常适合承担高并发下的“读校验”和“库存扣减”任务。

  • 用户参与记录缓存:使用SET结构存储每个活动已参与的用户ID,key设计为lottery:participated:{activityId}。利用SADDSISMEMBER命令实现快速防重判断。
  • 奖品库存缓存:使用StringHash结构存储奖品库存,key设计为lottery:stock:{activityId}:{awardId}。抽奖前先通过DECRHINCRBY命令进行原子性扣减,如果结果小于0,则说明库存不足,立即返回。
  • 分布式锁:对于“检查并扣减”这类复合操作,虽然 Redis 命令是原子的,但为了在极端情况下保证整个抽奖流程的串行化(例如防止同一用户瞬间发起两次请求),我们可能需要对用户+活动这个维度加锁。可以使用Redisson客户端或基于SETNX实现简单的分布式锁。

3.2 数据库作为最终一致性保障

Redis 是缓存,可能存在数据丢失(尽管可以持久化)。因此,所有最终的业务状态(如中奖记录、最终库存)必须以数据库为准。

  • 事务管理:在LotteryService中,使用@Transactional注解确保“写入中奖记录”和“更新数据库库存”这两个操作在一个数据库事务中,要么都成功,要么都失败回滚。
  • 最终同步:定期任务或通过监听 Redis 键空间事件,将 Redis 中的库存消耗数据同步回数据库,确保数据最终一致。

3.3 抽奖算法设计

抽奖本质是一个概率事件。我们采用经典的“概率区间法”

  1. 为活动配置奖品列表,每个奖品有库存、中奖概率等属性。
  2. 计算每个奖品的中奖概率区间。例如,奖品A概率1%,奖品B概率5%,未中奖概率94%。
  3. 使用ThreadLocalRandom.current().nextDouble(100)生成一个0-100的随机数。
  4. 判断该随机数落在哪个奖品的概率区间内,即决定中奖结果。

流程概览图(文字描述):

用户请求 -> [1. Redis锁: 防用户重复提交] -> [2. Redis校验: 用户是否已参与?] -> 是 -> 返回“已参与” -> [3. Redis原子扣减: 总库存/奖品库存是否>0?] -> 否 -> 返回“库存不足” -> [4. 执行概率抽奖算法] -> 得到奖品ID (或未中奖) -> [5. 数据库事务: 记录参与记录 & 若中奖则记录中奖信息 & 更新DB库存] -> [6. 返回抽奖结果] -> [7. 释放Redis锁]

4. 完整实战案例:从建表到接口开发

4.1 数据库表设计

首先,我们在 MySQL 中创建三张核心表。

-- 活动表 CREATE TABLE `t_activity` ( `id` bigint(20) NOT NULL AUTO_INCREMENT COMMENT '主键', `activity_code` varchar(64) NOT NULL COMMENT '活动编码,唯一', `activity_name` varchar(255) NOT NULL COMMENT '活动名称', `start_time` datetime NOT NULL COMMENT '开始时间', `end_time` datetime NOT NULL COMMENT '结束时间', `total_stock` int(11) NOT NULL DEFAULT '0' COMMENT '总参与名额/库存', `used_stock` int(11) NOT NULL DEFAULT '0' COMMENT '已用名额', `status` tinyint(4) NOT NULL DEFAULT '0' COMMENT '状态:0-未开始,1-进行中,2-已结束', `award_config` json DEFAULT NULL COMMENT '奖品配置JSON,包含奖品列表和概率', PRIMARY KEY (`id`), UNIQUE KEY `uk_activity_code` (`activity_code`), KEY `idx_time_status` (`start_time`,`end_time`,`status`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='抽奖活动表'; -- 用户参与记录表 CREATE TABLE `t_user_participation` ( `id` bigint(20) NOT NULL AUTO_INCREMENT, `activity_id` bigint(20) NOT NULL, `user_id` varchar(128) NOT NULL COMMENT '用户ID', `participate_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP, `ip_address` varchar(64) DEFAULT NULL, PRIMARY KEY (`id`), UNIQUE KEY `uk_activity_user` (`activity_id`,`user_id`), -- 唯一约束,数据库层面防重 KEY `idx_activity_id` (`activity_id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='用户参与记录表'; -- 中奖记录表 CREATE TABLE `t_award_record` ( `id` bigint(20) NOT NULL AUTO_INCREMENT, `activity_id` bigint(20) NOT NULL, `user_id` varchar(128) NOT NULL, `award_id` int(11) NOT NULL COMMENT '奖品ID,对应配置中的奖品', `award_name` varchar(255) NOT NULL COMMENT '奖品名称', `award_type` tinyint(4) NOT NULL COMMENT '奖品类型', `status` tinyint(4) NOT NULL DEFAULT '0' COMMENT '发放状态:0-未发放,1-已发放', `create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (`id`), KEY `idx_activity_user` (`activity_id`,`user_id`), KEY `idx_create_time` (`create_time`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='中奖记录表';

4.2 核心实体类与 Repository

根据表结构,创建 JPA 实体类和对应的 Repository 接口。

// 文件路径:src/main/java/com/example/lottery/entity/Activity.java @Entity @Table(name = "t_activity") @Data // 使用 Lombok 简化 getter/setter public class Activity { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id; private String activityCode; private String activityName; private Date startTime; private Date endTime; private Integer totalStock; private Integer usedStock; private Integer status; // 使用 JPA 的 @Column 注解,定义 columnDefinition 为 json 类型 (MySQL 5.7+) @Column(columnDefinition = "json") private String awardConfig; // 存储JSON字符串,如 [{"awardId":1, "name":"手机", "probability":0.01, "stock":10}, ...] }
// 文件路径:src/main/java/com/example/lottery/repository/ActivityRepository.java @Repository public interface ActivityRepository extends JpaRepository<Activity, Long> { // 根据活动编码查找 Optional<Activity> findByActivityCode(String activityCode); }

UserParticipationAwardRecord实体及其 Repository 的创建方式类似,此处省略。

4.3 抽奖业务逻辑实现

这是最核心的部分。我们实现LotteryServiceImpl

// 文件路径:src/main/java/com/example/lottery/service/impl/LotteryServiceImpl.java @Service @Slf4j public class LotteryServiceImpl implements LotteryService { @Autowired private ActivityRepository activityRepository; @Autowired private UserParticipationRepository participationRepository; @Autowired private AwardRecordRepository awardRecordRepository; @Autowired private StringRedisTemplate redisTemplate; // Redis Key 前缀常量 private static final String PARTICIPATED_KEY_PREFIX = "lottery:participated:"; private static final String STOCK_KEY_PREFIX = "lottery:stock:"; private static final String LOCK_KEY_PREFIX = "lottery:lock:"; @Override @Transactional(rollbackFor = Exception.class) public LotteryResult draw(String activityCode, String userId, String ip) { // 1. 基本参数校验 if (StringUtils.isAnyBlank(activityCode, userId)) { return LotteryResult.fail("参数错误"); } // 2. 查询活动信息 Activity activity = activityRepository.findByActivityCode(activityCode) .orElseThrow(() -> new RuntimeException("活动不存在")); // 校验活动状态、时间等... if (activity.getStatus() != 1) { return LotteryResult.fail("活动未开始或已结束"); } if (activity.getStartTime().after(new Date()) || activity.getEndTime().before(new Date())) { return LotteryResult.fail("不在活动时间内"); } // 3. 构建用户维度的分布式锁 Key,防止同一用户极短时间内重复请求 String userLockKey = LOCK_KEY_PREFIX + activityCode + ":" + userId; // 这里使用简单的 setIfAbsent 实现锁,生产环境建议用 Redisson Boolean lockAcquired = redisTemplate.opsForValue().setIfAbsent(userLockKey, "1", Duration.ofSeconds(3)); if (Boolean.FALSE.equals(lockAcquired)) { log.warn("用户重复请求,activityCode:{}, userId:{}", activityCode, userId); return LotteryResult.fail("请求过于频繁,请稍后再试"); } try { // 4. Redis 校验用户是否已参与 (防重) String participatedKey = PARTICIPATED_KEY_PREFIX + activityCode; Boolean isMember = redisTemplate.opsForSet().isMember(participatedKey, userId); if (Boolean.TRUE.equals(isMember)) { // 可以进一步查询数据库确认,防止缓存穿透 if (participationRepository.existsByActivityIdAndUserId(activity.getId(), userId)) { return LotteryResult.fail("您已参与过本活动"); } } // 5. Redis 原子扣减总库存 String totalStockKey = STOCK_KEY_PREFIX + activityCode + ":total"; Long remainingStock = redisTemplate.opsForValue().decrement(totalStockKey); // 初始化库存到Redis (首次扣减时可能为null) if (remainingStock == null) { redisTemplate.opsForValue().set(totalStockKey, String.valueOf(activity.getTotalStock() - activity.getUsedStock())); remainingStock = redisTemplate.opsForValue().decrement(totalStockKey); } if (remainingStock < 0) { // 库存不足,回滚刚才的 decrement 操作 redisTemplate.opsForValue().increment(totalStockKey); return LotteryResult.fail("活动名额已抢光"); } // 6. 执行抽奖算法 Award award = doDrawAlgorithm(activity); // 7. 如果中奖,扣减对应奖品库存 if (award != null) { String awardStockKey = STOCK_KEY_PREFIX + activityCode + ":award:" + award.getAwardId(); Long awardRemaining = redisTemplate.opsForValue().decrement(awardStockKey); if (awardRemaining != null && awardRemaining < 0) { // 奖品库存不足,回滚总库存,并返回未中奖(或特定提示) redisTemplate.opsForValue().increment(totalStockKey); redisTemplate.opsForValue().increment(awardStockKey); award = null; // 视为未中奖 log.info("奖品库存不足,降级为未中奖,activityCode:{}, awardId:{}", activityCode, award.getAwardId()); } } // 8. 数据库事务操作 // 8.1 保存用户参与记录 UserParticipation participation = new UserParticipation(); participation.setActivityId(activity.getId()); participation.setUserId(userId); participation.setIpAddress(ip); participationRepository.save(participation); // 8.2 如果中奖,保存中奖记录 AwardRecord awardRecord = null; if (award != null) { awardRecord = new AwardRecord(); awardRecord.setActivityId(activity.getId()); awardRecord.setUserId(userId); awardRecord.setAwardId(award.getAwardId()); awardRecord.setAwardName(award.getAwardName()); awardRecord.setAwardType(award.getType()); awardRecord.setStatus(0); awardRecordRepository.save(awardRecord); // 更新数据库中的活动已用库存(可选,也可异步更新) activity.setUsedStock(activity.getUsedStock() + 1); activityRepository.save(activity); } // 9. 将用户ID加入已参与集合(防重缓存) redisTemplate.opsForSet().add(participatedKey, userId); // 10. 构建返回结果 LotteryResult result = new LotteryResult(); result.setSuccess(true); result.setAward(award); result.setMessage(award != null ? "恭喜中奖!" : "很遗憾,未中奖"); return result; } catch (Exception e) { log.error("抽奖过程异常, activityCode:{}, userId:{}", activityCode, userId, e); // 事务注解会回滚数据库操作,但需要手动处理Redis操作的补偿(如库存回滚) // 这里可以设计更严谨的补偿机制,例如将失败操作放入队列重试或记录日志人工处理 throw new RuntimeException("抽奖系统异常,请重试"); } finally { // 11. 释放用户锁 redisTemplate.delete(userLockKey); } } /** * 核心抽奖算法:概率区间法 */ private Award doDrawAlgorithm(Activity activity) { // 解析活动配置中的奖品列表 List<Award> awardList = parseAwardConfig(activity.getAwardConfig()); if (CollectionUtils.isEmpty(awardList)) { return null; } // 计算总概率(可能小于100,剩余部分为“未中奖”) double totalProbability = awardList.stream().mapToDouble(Award::getProbability).sum(); if (totalProbability > 100) { log.error("活动奖品总概率超过100%, activityCode:{}", activity.getActivityCode()); totalProbability = 100; } // 生成随机数 double randomPoint = ThreadLocalRandom.current().nextDouble(100); // 遍历奖品,判断随机数落在哪个区间 double rangeStart = 0; for (Award award : awardList) { double rangeEnd = rangeStart + award.getProbability(); if (randomPoint >= rangeStart && randomPoint < rangeEnd) { return award; } rangeStart = rangeEnd; } // 随机数落在所有奖品区间之外,即为未中奖 return null; } private List<Award> parseAwardConfig(String awardConfigJson) { // 使用 Jackson 或 Gson 解析 JSON 字符串为 Award 对象列表 // 此处为示例,省略具体解析代码 return new ArrayList<>(); } }

4.4 控制器层与结果封装

// 文件路径:src/main/java/com/example/lottery/controller/LotteryController.java @RestController @RequestMapping("/api/lottery") public class LotteryController { @Autowired private LotteryService lotteryService; @PostMapping("/draw") public ApiResponse<LotteryResult> draw(@RequestParam String activityCode, @RequestParam String userId, HttpServletRequest request) { String ip = getClientIp(request); try { LotteryResult result = lotteryService.draw(activityCode, userId, ip); return ApiResponse.success(result); } catch (RuntimeException e) { return ApiResponse.fail(e.getMessage()); } } private String getClientIp(HttpServletRequest request) { // 简化实现,实际应从 X-Forwarded-For 等头部获取 return request.getRemoteAddr(); } }
// 文件路径:src/main/java/com/example/lottery/vo/ApiResponse.java @Data public class ApiResponse<T> { private boolean success; private String code; private String message; private T data; public static <T> ApiResponse<T> success(T data) { ApiResponse<T> response = new ApiResponse<>(); response.setSuccess(true); response.setCode("200"); response.setMessage("success"); response.setData(data); return response; } public static <T> ApiResponse<T> fail(String message) { ApiResponse<T> response = new ApiResponse<>(); response.setSuccess(false); response.setCode("500"); response.setMessage(message); return response; } }

4.5 配置与运行

1. 应用配置文件 (application.yml):

spring: datasource: url: jdbc:mysql://localhost:3306/lottery_db?useUnicode=true&characterEncoding=utf8&useSSL=false&serverTimezone=Asia/Shanghai username: root password: yourpassword driver-class-name: com.mysql.cj.jdbc.Driver hikari: connection-timeout: 30000 maximum-pool-size: 10 jpa: hibernate: ddl-auto: update # 首次启动可设为update创建表,生产环境用none或validate show-sql: true properties: hibernate: format_sql: true redis: host: localhost port: 6379 password: # 如果有密码则填写 database: 0 lettuce: pool: max-active: 8 max-wait: -1ms max-idle: 8 min-idle: 0 server: port: 8080

2. 初始化活动数据和 Redis 库存在项目启动或通过管理后台创建活动后,需要将活动的总库存和奖品库存初始化到 Redis。可以编写一个CommandLineRunner或监听应用启动事件来执行。

@Component @Slf4j public class RedisStockInitializer implements CommandLineRunner { @Autowired private ActivityRepository activityRepository; @Autowired private StringRedisTemplate redisTemplate; @Override public void run(String... args) { List<Activity> activeActivities = activityRepository.findByStatus(1); // 查找进行中的活动 for (Activity activity : activeActivities) { // 初始化总库存:总名额 - 已用名额 String totalStockKey = "lottery:stock:" + activity.getActivityCode() + ":total"; int initStock = activity.getTotalStock() - activity.getUsedStock(); redisTemplate.opsForValue().set(totalStockKey, String.valueOf(initStock)); log.info("初始化活动总库存, activityCode:{}, stock:{}", activity.getActivityCode(), initStock); // 初始化各奖品库存(从 awardConfig 解析) // ... 解析并设置 STOCK_KEY_PREFIX + activityCode + ":award:" + awardId } } }

3. 运行与测试启动 Spring Boot 应用后,使用 Postman 调用接口进行测试。

POST http://localhost:8080/api/lottery/draw?activityCode=ACT2024001&userId=U10001

预期返回:

{ "success": true, "code": "200", "message": "success", "data": { "success": true, "message": "恭喜中奖!", "award": { "awardId": 1, "awardName": "华为手机", "type": 1 } } }

5. 常见问题与排查思路

在高并发抽奖系统中,以下几个问题是排查的重点。

问题现象可能原因排查思路与解决方案
库存超卖1. Redis 库存扣减后,数据库事务失败未回滚 Redis。
2. 极端高并发下,多个请求同时判断库存>0后都通过。
1.确保原子性:使用 Redis 的DECR命令扣减,判断返回值。这是核心。
2.引入分布式锁:对“用户+活动”或“活动库存扣减”加锁,确保串行化。
3.补偿机制:数据库事务失败后,发送消息或记录日志,异步补偿回滚 Redis 库存。
用户重复中奖1. 防重校验(Redis Set)在并发下失效。
2. 用户端恶意重放请求。
1.防重键唯一性:使用SADD返回值判断是否首次添加,结合数据库唯一索引。
2.请求幂等性:前端按钮防重复点击,后端使用唯一请求ID(如雪花ID)实现幂等接口。
接口响应慢1. 数据库查询慢。
2. Redis 慢查询或网络延迟。
3. 同步的数据库事务耗时。
1.优化查询:为activity_code,(activity_id, user_id)等字段加索引。
2.缓存预热:活动开始前将关键数据加载到 Redis。
3.异步化:将中奖后的发奖、通知等非核心逻辑异步处理。
Redis 与 MySQL 数据不一致1. Redis 宕机数据丢失。
2. 同步更新失败。
1.Redis 持久化:开启 AOF 和 RDB。
2.最终一致性:定期跑任务,对比 Redis 库存消耗与 DB 已用库存,进行校准。
3.双写兜底:关键操作(如最终库存)以数据库为准,Redis 作为高速缓存。
“活动太火爆”提示,但实际有库存1. 缓存击穿:大量请求同时查询一个刚过期的库存 Key。
2. 初始化库存未成功。
1.永不过期+主动更新:活动库存 Key 不设过期时间,活动结束后由程序删除。
2.互斥锁重建:缓存失效时,使用分布式锁控制只有一个线程去数据库加载数据。
3.做好监控:监控 Redis 关键指标和库存数量。

6. 最佳实践与工程建议

将系统投入生产环境前,请务必考虑以下工程化实践。

1. 监控与告警

  • 业务监控:实时监控核心指标,如活动参与人数、中奖人数、各奖品消耗速度、接口 QPS/RT、错误率。
  • 资源监控:监控 Redis 内存使用率、连接数、CPU;监控数据库连接池、慢 SQL。
  • 设置阈值告警:当库存低于10%、接口错误率超过1%、Redis 内存超过80%时,及时告警。

2. 压力测试与容量规划

  • 在上线前,使用 JMeter 或 LoadRunner 模拟真实用户场景进行全链路压测。
  • 根据压测结果,评估并规划好应用服务器、Redis、数据库的资源配置。
  • 明确系统的极限容量,并设置流控和降级策略。

3. 代码健壮性

  • 异常处理LotteryService中的try-catch要细致,区分业务异常(如库存不足)和系统异常(如网络超时),并做好补偿。
  • 日志规范:在关键步骤(开始抽奖、库存扣减、中奖、异常)打印结构化日志,方便问题追踪。使用traceId串联一次请求的所有日志。
  • 参数校验:接口层要对activityCode,userId进行非空、格式校验,防止无效请求穿透到核心逻辑。

4. 安全与防刷

  • 频率限制:除了用户锁,还应在网关或应用层对 IP 或 UserID 进行限流(如每秒最多1次请求)。
  • 风险控制:识别异常 IP(如短时间内大量请求)、异常用户(新注册即参与),将其引入二次验证或直接限制。
  • 数据脱敏:返回给前端的用户ID、奖品信息等敏感数据要进行脱敏处理。

5. 可扩展性设计

  • 抽奖算法插件化:将doDrawAlgorithm方法抽象成接口,未来可以轻松切换为其他算法(如权重抽奖、时间段概率调整等)。
  • 库存管理服务化:将库存的扣减、回滚、查询操作抽象成独立的StockService,便于统一管理和维护。
  • 结果异步处理:中奖后,将发奖、发送短信通知等操作放入消息队列(如 RocketMQ/Kafka),由消费者异步处理,提升主接口性能。

6. 数据一致性保障

  • 定期对账:每天凌晨运行对账任务,比较 Redis 中的库存消耗记录与数据库中的中奖记录、参与记录是否一致,并生成对账报告。
  • 人工干预后台:开发运营后台,支持手动调整库存、补发奖品、使特定用户中奖等操作,以应对紧急情况。

扫码抽奖系统是典型的高并发、高一致性要求的业务场景。本文从需求分析、架构设计、代码实现到生产实践,提供了一套完整的解决方案。核心在于利用 Redis 应对高并发读和原子写,利用数据库事务保证核心数据最终一致,并通过分布式锁、防重校验、异步处理等手段提升系统的稳定性和公平性。在实际项目中,还需要根据具体的业务规模、团队技术栈和运维能力进行细节调整。建议你先在本地环境跑通整个流程,理解每一行代码的作用,再逐步应用到生产环境中。

返回列表