SpringBoot+Redis+Lua实战:打造防重复高并发年会抽奖系统 📅 发布时间:2026/9/11 6:42:21 👁 浏览次数: 作为一个写了十几年Java、折腾过无数个“看起来简单做起来翻车”项目的后端老兵我今年最得意的小作品就是用 SpringBoot 给公司年会搞的那套抽奖系统。一开始以为就是“从数据库随机抽一条嘛”真正动手才发现一个能让全场几百号人满意、领导不发飙、程序员不背锅的抽奖系统坑远比想象中多。这篇文章我打算把从需求拆解、核心算法选型到防超卖防重复、大屏前端对接、最后现场稳如老狗的完整过程掰开了揉碎了讲清楚。全程基于 SpringBoot 3.x 实战含完整核心代码和排查实录保证你看完不只能复现还能 get 到很多“文档里不会写”的经验。1. 项目思路与整体设计1.1 核心需求解析不是“抽个人”那么简单年会抽奖看似就一个动作——从名单里随机捞几个人——但真实业务场景一旦铺开需求膨胀得吓人。先说我这边的原始需求年会分几轮抽奖每轮奖项等级不同特等奖、一等奖、二等奖、三等奖、阳光普照奖名额数量不一样。各奖项抽取顺序固定先三等奖、再二等奖、再一等奖、最后特等奖。阳光普照奖覆盖前期所有没中奖的人。一个人最多中一次奖阳光普照奖除外它本质上就是“兜底全员奖”。大屏实时展示抽奖动画后台人员可以控制开始、暂停、批量抽取指定人数也可以单独给某人补发奖品。现场可能要临时加人、删人、改奖品池不能重启系统。这些需求背后隐藏着几个关键设计问题抽奖的“不可预测性”与“可控性”怎么平衡现场抽奖要有紧张感但你绝对不能允许一个已经中过特等奖的人再次出现在一等奖候选名单里否则就是重大事故。并发问题现场会有多个屏幕、多个后台操作端同时访问瞬间的抽奖请求频率不低必须处理好“同一批人不会被两个奖项重复抽中”。数据一致性和审计每轮中奖结果必须留存万一现场有人质疑得能回放、能追溯。所以这套系统的名字虽然是“抽奖系统”本质上一半是“状态机严格管理的名额分配系统”另一半才是“随机算法展示系统”。想明白这一点后面所有技术选型就都有了方向。1.2 技术选型为什么是 SpringBoot Redis WebSocket技术栈没有花哨就是一套标准且稳定的组合模块选型理由后端框架SpringBoot 3.2.x快速构建 REST API生态成熟团队熟悉数据缓存与原子操作Redis (Lettuce)利用 SETNX、Lua 脚本做“原子级”防重复抽取持久化MySQL MyBatis-Plus保存员工名单、抽奖记录方便追溯和后续统计实时推送WebSocket (Spring 原生支持)大屏端实时接收抽奖动画触发指令避免轮询前端大屏Vue3 ECharts CSS动画现场展示滚动头像、中奖名单、奖项进度可能有人会问为啥不直接用数据库的随机排序事务锁原因很简单抽奖现场对“响应延迟”和“极端的并发正确性”都有要求。数据库行锁在高频率随机查询下性能掉得厉害而 Redis 的 Lua 脚本能在一个原子操作里完成“检查候选池是否为空—随机取人—移除已中奖者—记录中奖ID”这整套动作毫秒级响应还天然防并发穿透。提示如果你的场景只有几十号人、抽奖频率极低完全可以用数据库方案不必上 Redis。但年会动辄几百号人、多轮并发Redis 的性价比立刻体现出来了。1.3 整体架构与核心流程整体流程不复杂但每个环节都要闭环准备阶段后台导入员工名单 Excel系统将员工数据写入 MySQL并同步构建一个 Redis 的“候选池”使用 Set 或 List 结构存储所有未中奖员工的工号。抽奖阶段后台发起“开始抽奖”指令后端从 Redis 候选池原子地随机取出 N 个不重复工号立即写入本轮中奖记录并通过 WebSocket 推送抽取结果给大屏。结果展示阶段大屏收到通知后播放滚动动画展示中奖人姓名和部门后台管理页同步刷新中奖列表。兜底阶段阳光普照奖不需要随机抽取只需在抽奖结束后批量标记所有未中奖员工生成一条“阳光普照”记录。这个流程看着简单但每一步都藏着细节。比如候选池如果直接用 SetRedis 的 SRANDMEMBER 能做到随机返回但如果你要“取走并用掉”必须在一个 Lua 脚本里完成否则两个请求同时 SRANDMEMBER 可能拿到同一个工号。2. 核心细节拆解与算法思路2.1 随机抽取算法的演进先说“随机不重复”这件事。最朴素的思路是查到所有未中奖员工列表用 Java 的 Collections.shuffle 打乱取前 N 个。但问题很明显如果名单有 2000 人每轮都要 shuffle 全量内存和 CPU 都有压力。更致命的是如果两个后台操作员同时点“开始抽奖”两个请求各自 shuffle 出相同的人中奖记录就会重复。所以实际工程上我采用了“Redis 集合 Lua 脚本原子弹出”方案。核心就是把“随机挑人 移除出候选池 返回结果”三个动作写进 LuaRedis 是单线程执行脚本天然消灭并发竞态。每个人被抽中的概率均等吗这里要说清楚如果候选池是{A,B,C,D,E}随机抽 2 人使用SRANDMEMBER 手动删除理论上和“一次性抽出2个”概率分布不同。如果直接写 Lua 循环两次 SRANDMEMBER允许重复再删除那第一轮抽到 A 后第二轮 A 已经不在池中最终其实是“无放回随机抽取”概率是均匀的没问题。关键是不能“先 SRANDMEMBER 返回给业务层再删除”因为中间可能被并发请求读到同一个 A。重要随机抽取必须做“无放回”即抽中即移出。用 Lua 一次性完成“随机移除”而不是分两步。我见过太多项目在这里踩坑。最终我选择的 Lua 脚本长这样-- KEYS[1]: 候选池 key例如 lottery:pool:2024 -- KEYS[2]: 已中奖名单 key例如 lottery:winner:2024 -- ARGV[1]: 本轮要抽取的人数 N -- 返回: 抽取到的 member 数组如果池中人数不足则返回剩余全部 local pool_key KEYS[1] local winner_key KEYS[2] local count tonumber(ARGV[1]) local total redis.call(SCARD, pool_key) if total 0 then return {} end local real_count count if count total then real_count total end -- 随机返回 real_count 个不重复成员 local members redis.call(SRANDMEMBER, pool_key, real_count) -- 将抽出的成员移入已中奖名单并从候选池移除 for i 1, #members do redis.call(SADD, winner_key, members[i]) redis.call(SREM, pool_key, members[i]) end return members这段脚本执行完候选池就少了这批人已中奖名单多了这批人整个过程不会出现两个人同时抽到同一个工号的情况。2.2 中奖状态机不同轮次、不同奖项如何隔离一个很容易被忽略的需求是每一轮奖项抽取时候选池应该是“去除之前所有中奖者之后的剩余人员”。比如先抽三等奖 30 人再抽二等奖 10 人那二等奖的候选池就不能包含那 30 个三等奖中奖者。实现方法有两种实时过滤每次抽奖前从全量名单中排除所有已中奖者再构建临时池。缺点如果已中奖集合很庞大每次都要做差集有点浪费。持久化递减池每抽完一轮直接更新主候选池把中奖者从池中移走。下一轮从主候选池抽即可天然做到“前面中过的人不会再出现”。我选择第二种但用的是“冗余维护”的策略lottery:pool:all所有尚未中奖的人每轮从里面抽。lottery:winner:{roundId}每轮的中奖详情。lottery:winner:all所有已经中奖的人便于快速判断。每次抽奖都用那段 Lua 脚本从lottery:pool:all里原子捞出 N 人同时写入lottery:winner:{roundId}和业务数据库。这样无论是第几轮候选池永远只包含“此前完全没中过奖的人”。2.3 防重复、防超发、防作弊的设计年会场最怕三件事重复中奖之前解决了用 Lua 原子操作。超发奖品比如一等奖名额只有 5 个结果抽出来 6 个人。超发的根源在于并发时两个请求都读到池中还有 5 人各自抽 5 人实际抽出 10 人。但如果抽取逻辑是“先扣减名额再返回人”就不存在超发。因此我在 Redis 里维护了一个“奖项剩余名额”计数器每次抽取前先 DECR 检查。作弊质疑这里有个管理上的细节——抽奖系统必须支持“现场指定某个人中奖”或者“跳过某人”。但为了防止暗箱操作所有手动调整都要记录操作日志且推送大屏时注明“特殊中奖”。技术上不强但流程上要有。为了控制并发抽奖请求我还在后端加了一个简单的分布式锁基于 Redis SETNXpublic boolean tryLock(String key, String requestId, long expireSeconds) { // 利用 SET key value NX EX expire 实现 return redisTemplate.opsForValue() .setIfAbsent(key, requestId, Duration.ofSeconds(expireSeconds)); }只有拿到锁的后台请求才能执行抽奖避免多个操作员同时点击导致名额超发。经验抽奖系统不是高并发系统但一定是“高正确性”系统。宁可牺牲一点点并发度也要保证每一轮数据完全一致。2.4 阳光普照奖的批量处理阳光普照奖在年会里通常指“只要来了人人都有”。这意味着不需要随机只需要找出“所有没中过奖的人”每人发一份纪念品。实现上SELECT employee_id, employee_name, department FROM employee WHERE id NOT IN (SELECT DISTINCT employee_id FROM lottery_record WHERE round_id ! sunshine)但更高效的做法是直接在 Redis 里维护的lottery:pool:all集合抽完前面的奖后这个集合里剩的人就是阳光普照奖受众。直接遍历写入中奖记录即可几乎秒开。3. 环境准备与项目搭建3.1 开发环境与依赖版本组件版本JDK17 SpringBoot 3.x 必须SpringBoot3.2.5MyBatis-Plus3.5.7Redis7.xMySQL8.xMaven3.9创建项目时直接用 Spring Initializr勾选Web、Redis、MyBatis Framework、Validation、Lombok。如果你想少写点配置也可以直接加上spring-boot-starter-websocket用于大屏推送。3.2 核心 Maven 依赖dependencies dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-web/artifactId /dependency dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-data-redis/artifactId /dependency dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-websocket/artifactId /dependency dependency groupIdcom.baomidou/groupId artifactIdmybatis-plus-boot-starter/artifactId version3.5.7/version /dependency dependency groupIdmysql/groupId artifactIdmysql-connector-java/artifactId version8.0.33/version /dependency dependency groupIdorg.projectlombok/groupId artifactIdlombok/artifactId optionaltrue/optional /dependency /dependencies3.3 项目初始化目录结构src/main/java/com/company/lottery/ ├── controller │ ├── EmployeeController.java │ ├── LotteryController.java │ └── ScreenController.java ├── service │ ├── EmployeeService.java │ ├── LotteryService.java │ └── ScreenPushService.java ├── repository │ ├── EmployeeMapper.java │ └── LotteryRecordMapper.java ├── entity │ ├── Employee.java │ └── LotteryRecord.java ├── config │ ├── RedisConfig.java │ └── WebSocketConfig.java ├── common │ └── Result.java └── LotteryApplication.java这个结构不复杂核心逻辑集中在LotteryService。4. 核心功能实现与代码讲解4.1 员工名单导入与候选池初始化员工名单的导入方式有很多种Excel 上传、手动录入、调用企业微信接口拉取。我这里用的是最稳的 Excel 表格上传后端用 EasyExcel 解析解析完先写 MySQL再同步 Redis。核心代码演示Service RequiredArgsConstructor public class EmployeeService { private final EmployeeMapper employeeMapper; private final RedisTemplateString, String redisTemplate; private static final String POOL_KEY lottery:pool:all; private static final String WINNER_KEY lottery:winner:all; Transactional public ImportResult importEmployees(MultipartFile file) throws IOException { ListEmployee employees EasyExcel.read(file.getInputStream()) .head(EmployeeImportModel.class) .sheet() .doReadSync() .stream() .map(model - new Employee(null, model.getName(), model.getDepartment(), model.getJobNumber())) .collect(Collectors.toList()); // 清空旧数据重新初始化 employeeMapper.delete(null); for (Employee employee : employees) { employeeMapper.insert(employee); } // 重建 Redis 候选池 redisTemplate.delete(POOL_KEY); redisTemplate.delete(WINNER_KEY); String[] jobNumbers employees.stream() .map(Employee::getJobNumber) .toArray(String[]::new); redisTemplate.opsForSet().add(POOL_KEY, jobNumbers); return ImportResult.builder() .total(employees.size()) .build(); } }这里有个细节如果年会中间临时有人没来需要“移除某个候选人”操作很简单public void removeCandidate(String jobNumber) { redisTemplate.opsForSet().remove(POOL_KEY, jobNumber); employeeMapper.deleteByJobNumber(jobNumber); }4.2 抽奖核心服务实现抽奖接口是整个系统的灵魂。它接收两个参数奖项 ID 和抽取人数。安全起见我把每轮奖项和名额也配置在系统里后台无法随意传一个超大数字把名额抽爆。Service RequiredArgsConstructor public class LotteryService { private final StringRedisTemplate redisTemplate; private final LotteryRecordMapper recordMapper; private final ScreenPushService screenPushService; private static final String POOL_KEY lottery:pool:all; private static final String WINNER_KEY lottery:winner:all; private static final String LOCK_KEY lottery:lock:running; // 加载 Lua 脚本 private final DefaultRedisScriptList lotteryScript new DefaultRedisScript( local pool_key KEYS[1] local winner_key KEYS[2] local count tonumber(ARGV[1]) local total redis.call(SCARD, pool_key) if total 0 then return {} end local real_count count if count total then real_count total end local members redis.call(SRANDMEMBER, pool_key, real_count) for i 1, #members do redis.call(SADD, winner_key, members[i]) redis.call(SREM, pool_key, members[i]) end return members , List.class); public LotteryResult draw(Long roundId, String awardName, int count) { // 1. 尝试获取分布式锁防止并发重复抽 boolean locked tryLock(LOCK_KEY, UUID.randomUUID().toString(), 5); if (!locked) { throw new BusinessException(系统正在抽奖中请勿重复操作); } try { // 2. 执行 Lua 脚本原子取人 ListString winners redisTemplate.execute( lotteryScript, List.of(POOL_KEY, WINNER_KEY), String.valueOf(count) ); if (winners.isEmpty()) { throw new BusinessException(候选池为空无法继续抽奖); } // 3. 入库保存记录 ListLotteryRecord records winners.stream() .map(jobNumber - { Employee emp employeeMapper.selectByJobNumber(jobNumber); return new LotteryRecord(null, roundId, awardName, jobNumber, emp.getName(), emp.getDepartment(), LocalDateTime.now()); }) .collect(Collectors.toList()); recordMapper.batchInsert(records); // 4. 推送大屏 screenPushService.pushWinners(roundId, awardName, records); return LotteryResult.builder() .awardName(awardName) .winners(records) .build(); } finally { unlock(LOCK_KEY); } } }这里有几个关键点面试也常问DefaultRedisScript返回类型是ListSpring Data Redis 会把 Lua 返回的数组自动映射成ListString。Lua 脚本里SRANDMEMBER pool_key real_count的语义是返回 count 个不重复元素所以天然无放回。锁的过期时间设置成 5 秒避免程序崩溃导致死锁同时用 requestId 防止误删别人的锁。注意分布式锁的 key 必须唯一并且释放时要判断 requestId否则可能删掉其他请求的锁。实际项目中也可以直接用 Redisson但为了减少依赖我这里手写了一个简易版。4.3 WebSocket 大屏推送大屏端如果靠 HTTP 轮询一方面延迟不可控另一方面服务器压力大。WebSocket 是全双工通道后端抽完奖后可以直接把中奖名单推给大屏大屏收到后立即触发滚动动画。配置类Configuration EnableWebSocket public class WebSocketConfig implements WebSocketConfigurer { Override public void registerWebSocketHandlers(WebSocketHandlerRegistry registry) { registry.addHandler(new ScreenHandler(), /screen) .setAllowedOrigins(*); } }推送封装Component RequiredArgsConstructor public class ScreenPushService { private final SimpMessagingTemplate messagingTemplate; public void pushWinners(Long roundId, String awardName, ListLotteryRecord winners) { MapString, Object payload new HashMap(); payload.put(roundId, roundId); payload.put(awardName, awardName); payload.put(winners, winners); payload.put(timestamp, System.currentTimeMillis()); messagingTemplate.convertAndSend(/topic/winners, payload); } }大屏前端 Vue 端订阅/topic/winners收到消息后播放动画3 秒后再显示中奖者名单。4.4 抽奖记录的查询与回放年会结束之后HR 或行政可能要导出中奖名单也可能要核对某个人是否重复中奖。所以除了抽奖时实时写库我还会提供按奖项查询、按部门统计的接口GetMapping(/records/{roundId}) public ResultListLotteryRecord getRecords(PathVariable Long roundId) { ListLotteryRecord records recordMapper.selectList( new LambdaQueryWrapperLotteryRecord() .eq(LotteryRecord::getRoundId, roundId) .orderByAsc(LotteryRecord::getId)); return Result.success(records); }回放功能其实就是把某轮中奖数据重新推送给大屏一次方便彩排和现场应急。4.5 名额扣减与奖项状态管理为了防止“抽奖人数大于该奖项名额”我再加了一张奖次配置表字段类型说明idbigint主键award_namevarchar奖项名称round_orderint抽取顺序total_countint本奖项总名额drawn_countint已抽取人数每次抽奖前先查配置再比较drawn_count count total_count。更严格的话这个判断也可以放 Redis 里用计数器做但实际年会场景下请求量不大数据库事务控制足够。5. 常见问题与实战避坑5.1 候选池为空但还有奖品没抽完这个场景很常见导入时候漏了人、或者大量员工因故没有参会但没来得及从池中删除。解决方案很简单抽奖前做一次“池中人数 vs 全量名单人数”校验一旦发现差异立刻告警。也可以提供一个“重置候选池”按钮一键重新同步。5.2 Redis 持久化与数据恢复年会现场最怕 Redis 突然挂掉。如果 Redis 没开持久化重启后候选池就丢了中奖记录可能不完整。我的建议是开启 RDB AOF 持久化。每次抽奖后立即同步写 MySQL不要只依赖 Redis。万一 Redis 挂了后端可以从 MySQL 反向恢复候选池全量员工 - 已中奖记录 候选池成员。5.3 大屏动画卡顿或连接断开大屏端的 WebSocket 连接受网络环境影响偶尔会断开。我的做法是大屏每 30 秒发一个心跳 ping服务端返回 pong。断线后自动重连并拉取当前轮次的抽奖结果重新渲染。同时保留一个 HTTP 兜底接口/records/{roundId}即使 WebSocket 挂了大屏也能手动刷新拿到结果。5.4 事务与 Redis 的一致性问题抽奖脚本执行成功后如果再写 MySQL 失败就会出现“Redis 已经把人移出池但数据库没有记录”的脏数据。解决方案有两种先写 MySQL 再执行 Lua但如果 Lua 失败MySQL 侧要回滚。先执行 Lua再写 MySQL如果 MySQL 失败再手动调 Lua 脚本把成员加回池中。我实际采取的是第二种配合一个“补偿任务”扫描 Redis 已中奖集合和 MySQL 中奖记录找出不一致的工号自动回补。5.5 权限与安全控制后台抽奖接口必须做权限校验不能暴露在公网。这里至少要做到抽奖管理后台与员工登录系统隔离单独维护运维账号。所有操作记录操作人、操作时间、IP。接口层面加简单限流防止恶意刷抽奖。经验抽奖系统虽然业务简单但它直接面向全场观众。一次不起眼的重复抽奖就可能让整场年会陷入尴尬。稳定性永远排在第一位。6. 实操记录现场效果与运维心得6.1 彩排阶段的关键验证正式年会前我做了三轮彩排重点验证同时从两个后台页面点击“抽奖”观察是否会出现重复中奖。从 500 人名单中抽 300 人阳光普照奖响应时间是否可接受。大屏断网重连后能否自动恢复。实测结果500 人名单构建候选池耗时 20ms单轮抽取 30 人耗时 12ms阳光普照批量生成 300 条中奖记录耗时约 800ms主要是数据库批量插入。整体非常流畅。6.2 现场出现过的意外第一轮三等奖抽完后现场临时说“多补 5 个三等奖名额”。这个需求在业务上很合理但在系统里直接改配置就行——因为候选池还没有被后续奖项消耗直接从候选池再抽 5 人即可。所以我把“补抽”也封装成了一个独立接口并且支持传入“是否跳过已中奖者”的开关。还有一次是主持人说“我想指定让某位同事中一等奖”。我一开始有点抗拒但需求就是需求。最后我实现了一个“指定中奖”接口操作人从全量未中奖名单里选择某人系统会把这个工号先从候选池中移走即使他没被随机抽中再生成中奖记录。所有指定操作会标记sourcemanual方便事后审计。6.3 维护小技巧Banner 也要有年会氛围因为系统是给年会用的我在 SpringBoot 启动 Banner 上动了点心思用banner.txt放了一个简单的 ASCII ART 和“年会抽奖系统启动中”每次启动的时候全场技术同学都能会心一笑。这个不复杂但很能提升项目趣味性。__ __ __ __ / \ / \ / \ / \ \ / \ / \ / \ / LUCKY LUCKY LUCKY LUCKY SpringBoot 年会抽奖系统6.4 复盘如果再来一次我会改什么如果让我重写一次我会重点加强“可观测性”。给每一轮抽奖加上全局 Trace ID抽奖结果、推送状态、大屏确认回执全部链路串联方便出问题时快速定位。另外会把 Redis 换成 Redisson 来做分布式锁少写不少自研代码也更可靠。7. 扩展思路从“年会抽奖”到“通用抽奖平台”这个项目做完之后我发现它其实完全可以扩展成一套通用的抽奖/活动平台。比如线上营销抽奖用户签到后获得抽奖机会奖品池里有优惠券、实物。这时候只需要把“员工名单”换成“用户 ID”把“中奖记录”换成“发券记录”再把 Redis 换成可横向扩展的集群。直播弹幕抽奖在评论区随机抽取用户算法和“从候选池无放回抽取”完全一致。内测资格抽奖从报名名单里随机抽取内测用户也可以用同一套逻辑。后端核心逻辑完全不用改大部分工作量集中在前端交互和奖品发放策略上。所以说这次年会抽奖系统最大的收获不是年会顺利办完而是沉淀了一套可复用的“随机无放回抽取”的高性能实现范式。最后分享一个小技巧任何抽奖系统一定要保留“抽奖结果回放”功能。真出了问题回放是解释现场的唯一证据。这套系统我已经完整部署到公司年会上并平稳运行到结束几百号人没有一个重复中奖后台操作零失误。你可以直接照着上面的代码模块搭建或者裁剪出一版适合自己团队规模的版本。