公司动态
Spring Boot + Redis + MySQL 高并发扫码抽奖系统设计与实战
最近在开发一个扫码抽奖活动时遇到了一个典型的并发问题在高并发场景下如何保证用户扫码、抽奖、中奖记录和奖品发放的原子性与一致性这不仅仅是“扫码-抽奖”的简单流程更涉及到库存扣减、防超发、防重复、事务回滚等一系列后端工程难题。本文将从一个真实的“扫码抽奖”业务需求出发拆解其背后的技术架构并提供一个基于 Spring Boot Redis MySQL 的完整、可落地的解决方案。无论你是正在处理类似活动的开发者还是希望深入理解高并发业务设计的同学都能从中获得可直接复用的代码和设计思路。1. 背景与核心概念扫码抽奖的业务与技术挑战“扫码抽奖”是现代营销活动中非常常见的一种形式。用户扫描二维码后跳转至 H5 页面点击按钮参与抽奖。这个过程看似简单但对后端系统提出了严峻的挑战。核心业务流程如下用户扫码获取活动唯一标识activity_id和用户标识user_id。资格校验检查活动是否有效、用户是否已参与、活动库存是否充足。执行抽奖根据预设的中奖概率算法决定用户是否中奖以及中何种奖品。结果处理如果中奖需要原子性地完成“记录中奖信息”、“扣减奖品库存”等操作如果未中奖则记录参与记录。结果返回将抽奖结果中奖/未中奖、奖品信息返回给前端页面。面临的主要技术挑战高并发活动上线瞬间可能涌入数万甚至数十万请求。超卖问题奖品库存有限必须保证不会被多发、超发。重复抽奖同一用户在同一活动中只能参与一次需要防重。性能要求抽奖接口响应必须快速用户体验要流畅。数据一致性用户参与记录、中奖记录、库存扣减等多个数据操作必须保持一致性不能出现“中了奖但没库存”或“扣了库存但没记录”的情况。为了解决这些问题我们需要一个结合缓存、数据库、分布式锁和事务机制的综合方案。接下来我们将从环境搭建开始一步步构建这个系统。2. 环境准备与版本说明为了完整演示我们需要准备以下开发环境。请注意版本号是本文撰写时采用的稳定版本你可以根据实际项目情况调整。JDK: 1.8 或 11推荐 11Spring Boot: 2.7.x项目管理: Maven 3.6数据库: MySQL 5.7 或 8.0缓存/中间件: Redis 6.xIDE: IntelliJ IDEA 或 Eclipse测试工具: Postman 或 curl项目依赖 (pom.xml核心部分):我们将使用 Spring Boot 的 Web、Data JPA、Redis 和 MySQL 驱动。dependencies !-- Web 支持 -- dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-web/artifactId /dependency !-- Redis 支持 -- dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-data-redis/artifactId /dependency !-- 数据库 JPA -- dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-data-jpa/artifactId /dependency !-- MySQL 驱动 -- dependency groupIdmysql/groupId artifactIdmysql-connector-java/artifactId scoperuntime/scope /dependency !-- 连接池 -- dependency groupIdcom.zaxxer/groupId artifactIdHikariCP/artifactId /dependency !-- 工具包 -- dependency groupIdorg.apache.commons/groupId artifactIdcommons-lang3/artifactId /dependency !-- 测试 -- dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-test/artifactId scopetest/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结构存储每个活动已参与的用户IDkey设计为lottery:participated:{activityId}。利用SADD和SISMEMBER命令实现快速防重判断。奖品库存缓存使用String或Hash结构存储奖品库存key设计为lottery:stock:{activityId}:{awardId}。抽奖前先通过DECR或HINCRBY命令进行原子性扣减如果结果小于0则说明库存不足立即返回。分布式锁对于“检查并扣减”这类复合操作虽然 Redis 命令是原子的但为了在极端情况下保证整个抽奖流程的串行化例如防止同一用户瞬间发起两次请求我们可能需要对用户活动这个维度加锁。可以使用Redisson客户端或基于SETNX实现简单的分布式锁。3.2 数据库作为最终一致性保障Redis 是缓存可能存在数据丢失尽管可以持久化。因此所有最终的业务状态如中奖记录、最终库存必须以数据库为准。事务管理在LotteryService中使用Transactional注解确保“写入中奖记录”和“更新数据库库存”这两个操作在一个数据库事务中要么都成功要么都失败回滚。最终同步定期任务或通过监听 Redis 键空间事件将 Redis 中的库存消耗数据同步回数据库确保数据最终一致。3.3 抽奖算法设计抽奖本质是一个概率事件。我们采用经典的“概率区间法”。为活动配置奖品列表每个奖品有库存、中奖概率等属性。计算每个奖品的中奖概率区间。例如奖品A概率1%奖品B概率5%未中奖概率94%。使用ThreadLocalRandom.current().nextDouble(100)生成一个0-100的随机数。判断该随机数落在哪个奖品的概率区间内即决定中奖结果。流程概览图文字描述用户请求 - [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) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 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) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 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) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 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 JpaRepositoryActivity, Long { // 根据活动编码查找 OptionalActivity findByActivityCode(String activityCode); }UserParticipation和AwardRecord实体及其 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) { // 解析活动配置中的奖品列表 ListAward 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 ListAward 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 ApiResponseLotteryResult 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 ApiResponseT { private boolean success; private String code; private String message; private T data; public static T ApiResponseT success(T data) { ApiResponseT response new ApiResponse(); response.setSuccess(true); response.setCode(200); response.setMessage(success); response.setData(data); return response; } public static T ApiResponseT fail(String message) { ApiResponseT 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?useUnicodetruecharacterEncodingutf8useSSLfalseserverTimezoneAsia/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: 80802. 初始化活动数据和 Redis 库存在项目启动或通过管理后台创建活动后需要将活动的总库存和奖品库存初始化到 Redis。可以编写一个CommandLineRunner或监听应用启动事件来执行。Component Slf4j public class RedisStockInitializer implements CommandLineRunner { Autowired private ActivityRepository activityRepository; Autowired private StringRedisTemplate redisTemplate; Override public void run(String... args) { ListActivity 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?activityCodeACT2024001userIdU10001预期返回{ 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 应对高并发读和原子写利用数据库事务保证核心数据最终一致并通过分布式锁、防重校验、异步处理等手段提升系统的稳定性和公平性。在实际项目中还需要根据具体的业务规模、团队技术栈和运维能力进行细节调整。建议你先在本地环境跑通整个流程理解每一行代码的作用再逐步应用到生产环境中。