抽奖功能听起来是件小事但真要在 Java 项目里落地里头的门道比大多数人想象的多。一个简单的“点击抽奖”背后涉及随机算法选型、概率权重设计、并发控制、库存扣减、奖励发放甚至还有数据一致性的坑。我在实际项目里做过好几版抽奖模块从最初“随机一个数然后 if 判断”的玩具代码到后来支撑秒杀级流量的完整方案踩过不少坑也沉淀了一些心得。这篇内容就按我实际做项目的思路来写从最容易理解的基础版本开始逐步过渡到能应对真实业务场景的工程化写法顺便把 Java 面试里经常问到的相关问题也串进去比如数据一致性、容器选择、排序算法这些知识点在抽奖场景里都有非常具体的落点。1. 抽奖功能的整体设计与思路拆解先别急着写代码。做任何功能第一件事都是把需求聊清楚。抽奖看似统一实际上业务形态千差万别我经手过的就有好几种大转盘抽奖、刮刮卡、签到抽奖、积分抽奖、直播弹幕抽奖、会员日活动抽奖。它们的共同点是“用户触发一次抽奖行为系统返回一个奖品结果”但差异点集中在几个关键维度上这些维度直接决定技术方案。第一个维度是概率模式。固定概率是最常见的每个奖品配一个百分比或中奖权重另外还有“奖品池总量固定抽完即止”的模式这种在中奖率之上还要考虑库存余量还有“定时放量”的模式比如某时段内中奖率动态调整。第二个维度是并发量级。内部后台测试用的抽奖几十个人同时操作就差不多了但给 C 端用户做的营销抽奖尤其碰上节假日活动瞬间 QPS 可能冲到几千上万。第三个维度是事务边界。单机单库里的简单事务很简单分布式环境下的抽奖就麻烦了抽奖、扣库存、发奖如果分散在不同的服务或数据库就得考虑分布式事务或最终一致性。针对这些差异我的拆解思路是分层设计。最底层是抽奖引擎负责“随机出结果”核心就是随机算法和概率分配往上一层是业务服务层负责“确认用户有没有资格、有没有次数、奖品还有没有库存”再往上一层是交易与发奖层负责“扣减库存、生成记录、发放奖品”最外层是接口层做限流、幂等、防刷校验。分层的好处是即使你只是做一个简单的单体应用也能把这个思维带进去后期如果并发上来改动成本会小很多。选型方面随机数生成我会优先考虑ThreadLocalRandom而不是裸用Math.random()。原因后面在代码部分细说。概率表的设计我倾向于用“权重列表 二分查找”的方式而不是简单的 if-else 区间判断因为奖品数量一变if-else 的代码就爆炸了。库存控制用数据库乐观锁或者 Redis 原子操作具体看部署环境。如果整个项目还在单体阶段数据库行锁其实也够用别盲目上 Redis多一个中间件多一个故障点。2. 核心细节解析与实操要点2.1 随机源的选择Random 与 ThreadLocalRandomJava 里最简单的随机数是Math.random()它内部其实就是Random.nextDouble()但每次调用都会涉及原子种子更新并发高时会产生竞争。Random类本身是线程安全的但在高并发下有 CAS 竞争性能会下降。ThreadLocalRandom是 JDK 7 开始引入的把种子放到线程本地每个线程独立随机序列不需要竞争性能提升非常明显。我实测过一个简单场景100 个线程各抽 1 万次Random的耗时比ThreadLocalRandom高出好几倍。所以做抽奖这种高频随机操作直接用ThreadLocalRandom.current().nextDouble()或者nextInt()就行。需要注意的一点是ThreadLocalRandom不要通过new ThreadLocalRandom()构造要使用ThreadLocalRandom.current()获取实例这是它的设计约束。如果你的抽奖系统对随机序列的安全性有要求比如开奖号码类场景要考虑使用SecureRandom。不过普通营销抽奖用不到SecureRandom性能差不少没必要杀鸡用牛刀。2.2 概率设计的两种主流模式固定百分比概率是最直观的。假设奖品 A 概率 10%B 概率 20%C 概率 70%直接生成一个 0 到 100 的随机数落到哪个区间就中哪个。实现时要注意概率的精度问题。用整数权重而不是浮点百分比去计算能避开浮点误差。比如 A 权重 1B 权重 2C 权重 7总权重 10其实和 10%、20%、70% 完全等价。代码里用整数权重累加一个总权重然后nextInt(totalWeight)产生一个随机数再遍历奖品列表判断落在哪个区间这种方式简单直观适合奖品数量少的场景。但奖品多了以后每次遍历是 O(n) 的时间复杂度。假设有几百个奖品抽奖频率又高浪费明显。更优雅的方案是用“权重前缀和 二分查找”。先把每个奖品的权重转化为前缀和数组比如[10, 30, 60, 100]现在生成一个nextInt(100)然后二分查找前缀和数组找到第一个大于随机数的位置。时间复杂度从 O(n) 降到 O(log n)。这里有个小细节奖品的顺序会影响二分查找结果。如果把“谢谢参与”这个空奖项放在最后面前缀和数组的最后一个元素必须是总权重二分查找的返回值如果超出奖品数组 index就代表未中奖。我习惯把空奖项作为兜底并保证一个原则所有奖品权重之和为 10000方便按万分之一精度计算这样在配置奖品时可以直接填“中奖率”。2.3 库存与概率的耦合问题很多新手做抽奖只算概率不扣库存结果活动刚开始一分钟A 奖就中出去了 10 个但实际库存只有 5 个。这就是概率模型和库存模型脱钩的典型问题。实际项目里的做法是每次抽奖后判断如果按随机结果命中了某奖品但该奖品库存已经不足不能简单返回“中奖失败”也不能直接把算出来的结果作废。正确做法是进行“降级重抽”或“容错转移”。降级重抽的意思是从剩余奖品中重新按权重抽一次把权重为 0 的奖品剔除。容错转移则是抽一个备选奖品通常是“谢谢参与”或者低价纪念品。策略要提前和业务方约定否则会出现一种情况用户明明看到自己点击抽奖结果永远中不了高价值奖品因为高价值奖品早就被抽完了但你依然把它放在概率表里只靠数据库扣减时报库存不足那对用户来说就是“抽了个寂寞”。我的设计习惯是在初始化抽奖引擎时就加载当前奖品库存快照。如果库存已经归零的奖品直接从奖品池里排除对应权重置为 0然后重新计算总权重。这样用户请求进来的时候根本不会命中已无库存的奖品。注意这只适合低并发场景高并发下库存快照会过期还是需要扣减时校验。2.4 并发控制的几种做法并发抽奖是问题重灾区。先说最容易踩的坑用AtomicInteger当作库存计数器抽奖前decrementAndGet()返回负数就说明超卖。这看起来没问题只适用于单机。一旦应用多实例部署AtomicInteger在各实例之间不共享照样超卖。多实例环境下第一选择是用数据库乐观锁。奖品行包含库存字段stock执行更新时带上条件update prize set stock stock - 1 where prize_id ? and stock 0;受影响行数为 1 表示扣减成功为 0 表示库存不足或竞争失败。这个方案的优点是简单、可靠不需要额外组件缺点是数据库压力大在 QPS 高的时候会成为瓶颈。如果项目已经用了 Redis推荐用 Redis 的 Lua 脚本做原子扣减Lua 脚本内部先检查库存再decr整个过程原子性性能远高于数据库。不过 Redis 扣库存有一个经典问题如果抽奖服务在 Redis 扣减成功但后续发奖失败会出现库存已经扣了但用户没拿到奖品。解决思路是把“扣减”和“发放”放到一个分布式事务里或者做成可补偿的发奖失败时回补库存。如果公司不大我通常的做法是引入一张lottery_record表把抽奖记录作为持久化凭证状态机流转INIT - DRAW_SUCCESS - PRIZE_SENT发奖失败时执行补偿任务从失败记录里恢复库存。这就是最终一致性的原型面试时能把这个讲清楚比背八股文强得多。3. 实操过程从零写一个可运行的抽奖引擎接下来是我在实际项目中会写出的精简版抽奖引擎可以直接参考。我会保持代码段尽量小同时覆盖前面说的几个核心点权重抽选、库存排除、并发扣减、记录落库。3.1 奖品与配置模型先定义奖品实体。字段不必多够用就行public class Prize { private Long id; // 奖品ID private String name; // 奖品名称 private int stock; // 剩余库存 private int weight; // 权重比如 100 表示 1% private int probability; // 供展示用不参与计算 }这里weight是关键字段probability纯粹用来页面展示避免每家公司对“概率”定义不同直接用人能读的 12.5% 展示计算一律走权重。再定义一个奖品池对象public class PrizePool { private ListPrize prizes; // 参与抽奖的奖品列表 private int[] prefixSum; // 权重前缀和 private int totalWeight; // 总权重 public PrizePool(ListPrize prizes) { // 过滤掉库存不足的奖品 ListPrize valid prizes.stream() .filter(p - p.getStock() 0) .collect(Collectors.toList()); // 每个权重累加为前缀和 this.prefixSum new int[valid.size()]; this.totalWeight 0; for (int i 0; i valid.size(); i) { totalWeight valid.get(i).getWeight(); prefixSum[i] totalWeight; } this.prizes valid; } /** * 按权重随机返回奖品下标-1 表示没有可抽奖品 */ public int nextPrizeIndex() { if (prizes.isEmpty()) return -1; int random ThreadLocalRandom.current().nextInt(totalWeight) 1; int index Arrays.binarySearch(prefixSum, random); if (index 0) { index -index - 1; } return index; } }重点解释一下这段代码里的几个细节。第一构造PrizePool时过滤了库存为 0 的奖品这一步保证权重池里没有“无效选项”。第二使用nextInt(totalWeight) 1而不是nextInt(totalWeight)是为了让随机数范围变成[1, totalWeight]配合前缀和数组时可以正确处理边界。第三Arrays.binarySearch如果正好找到了前缀和值比如随机数是 30前缀和里有 30二分查找会返回对应索引如果找不到会返回-(insertionPoint) - 1我们取反再减一得到插入点这个插入点正好是第一个大于该随机值的前缀和下标也就是命中的奖品项。这一段是抽奖算法的核心理解了你就能轻松改造成支持任意数量奖品的通用引擎。3.2 抽奖服务的完整流程有了抽屉引擎接下去是业务层。我习惯把抽奖逻辑拆成三部分前置校验、执行抽奖、后置发奖。Service public class LotteryService { private PrizeRepository prizeRepository; private LotteryRecordRepository recordRepository; Transactional public LotteryResult draw(Long userId, Long activityId) { // 1. 校验用户资格与抽奖次数 boolean eligible checkUserEligible(userId, activityId); if (!eligible) { return LotteryResult.fail(今日抽奖次数已用完); } // 2. 构建当前奖品池过滤无库存奖品 ListPrize prizes prizeRepository.findByActivityId(activityId); PrizePool pool new PrizePool(prizes); int index pool.nextPrizeIndex(); if (index 0) { return LotteryResult.fail(活动奖品已抽完); } Prize hit pool.getPrizes().get(index); // 3. 扣减库存数据库乐观锁方式 int updated prizeRepository.decreaseStock(hit.getId()); if (updated 0) { // 扣减失败说明该奖品刚被其他并发请求抽完重新抽一次 return retryDraw(userId, activityId); } // 4. 保存抽奖记录 LotteryRecord record new LotteryRecord(); record.setUserId(userId); record.setActivityId(activityId); record.setPrizeId(hit.getId()); record.setStatus(CREATED); recordRepository.save(record); // 5. 异步发奖短信、优惠券、实物包裹等 sendPrizeAsync(record); return LotteryResult.success(hit); } private LotteryResult retryDraw(Long userId, Long activityId) { // 最多重试3次若仍失败返回“未中奖” for (int i 0; i 3; i) { ListPrize prizes prizeRepository.findByActivityId(activityId); prizes.removeIf(p - p.getStock() 0); if (prizes.isEmpty()) return LotteryResult.fail(活动奖品已抽完); PrizePool newPool new PrizePool(prizes); int idx newPool.nextPrizeIndex(); if (idx 0) return LotteryResult.fail(活动奖品已抽完); Prize hit newPool.getPrizes().get(idx); int updated prizeRepository.decreaseStock(hit.getId()); if (updated 0) { // 保存记录... return LotteryResult.success(hit); } } return LotteryResult.fail(手气稍差未中奖); } }这段代码隐藏了事务边界。我在这里声明了Transactional意思是整个抽奖、扣库存、写记录在一个数据库事务里完成。如果你希望“并发扣减时库存不够就立即失败”数据库的UPDATE stock 0配合事务已经足够保证不出超卖。但注意如果发奖是调外部接口万万不能把这个外部调用也放进Transactional否则事务会长时间持有数据库连接拖垮连接池。正确做法是事务内只写记录事务提交后异步发奖。3.3 使用 Redis Lua 的高并发改进单体应用的数据库扣减能撑住一般活动但如果你遇到秒杀这种量级还是建议上 Redis。有人会把库存放在 Redis 里然后抽奖流程变成检查用户参与资格。用 Lua 脚本原子扣减库存。扣减成功后再异步写数据库记录。Lua 脚本的原子性是关键。下面是扣减库存的脚本-- KEYS[1] 是库存 key -- ARGV[1] 是扣减数量 local stock tonumber(redis.call(get, KEYS[1])) if not stock then return -1 end if stock tonumber(ARGV[1]) then return 0 end redis.call(decrby, KEYS[1], ARGV[1]) return 1我把这个脚本封装在 Java 中调用public int decreaseStock(String key, int count) { DefaultRedisScriptLong script new DefaultRedisScript(); script.setScriptText(...上面脚本...); script.setResultType(Long.class); Long result redisTemplate.execute(script, Collections.singletonList(key), count); return result null ? -1 : result.intValue(); }Redis 方案有个容易被忽视的问题缓存与数据库的库存数据一致性。如果只扣 Redis 库存数据库库存没有同步活动结束后导出记录对不上。我的做法是每日定时任务将 Redis 剩余库存同步回数据库同时以数据库流水为最终对账依据。而且每次抽奖成功都会在数据库写流水流水数应该等于初始库存减去最终库存对不上就报警人工处理。3.4 数据一致性的取舍与补偿说到数据一致性问题这是 Java 面试中特别高频的一个考点被问烂了。面试官问“Java 怎么保证数据一致性”时如果只回答“用事务”那基本没戏。在抽奖场景里认真做过的同学能讲出几个层次。单库单机阶段用数据库事务保证“抽奖记录 库存扣减”的原子性这是强一致性适合对账要求严格的场景。分布式阶段如果抽奖和发奖在独立服务里就需要分布式事务。但营销抽奖一般不会用强一致方案否则性能和复杂度没法接受。实际项目更倾向于“事务消息 本地消息表 定时对账”的模式抽奖服务写本地消息表发送 MQ 消息到发奖服务发奖服务消费后更新状态。如果发奖失败MQ 重试超过重试次数进入人工补偿队列。我印象很深的一个线上事故当时发奖依赖外部优惠券系统外部系统偶尔超时。最开始我直接同步调用一旦外部系统慢抽奖接口也跟着慢大量用户看到“抽奖中”转圈。后来改为异步发奖用户体验立刻改善。但异步引入了新的问题用户明明看到中了奖半小时后短信还没发客服来问为什么。最后的解决方式是加了状态查询接口用户可以在活动页看到“奖励发放中”并设置失败重发按钮。兜底思路比追求某个万能技术更重要。3.5 排序与容器的应用做抽奖系统的过程中还把 Java 排序和容器知识用得很透。比如奖品列表需要按照价值排序展示可以使用Comparator定制排序prizes.sort(Comparator.comparingInt(Prize::getWeight).reversed());再比如构建一个权重抽奖表可以用TreeMap保存权重累积值利用TreeMap的ceilingEntry()方法直接获取目标区间TreeMapInteger, Prize weightMap new TreeMap(); int total 0; for (Prize p : prizes) { total p.getWeight(); weightMap.put(total, p); } int random ThreadLocalRandom.current().nextInt(total) 1; Prize prize weightMap.ceilingEntry(random).getValue();这个方案比数组前缀和 二分查找更简单天然支持键值查找。但TreeMap是红黑树插入和查询开销略高于数组奖品数量少时无所谓奖品多时还是用数组版本更省内存。面试被问“容器怎么选型”时这是很好的回答素材抽奖场景下数组适合固定奖品池TreeMap 适合频繁增删奖品的动态池ArrayList 用于遍历展示HashMap 用于缓存奖品 ID 到实体的映射。3.6 防刷与限流做面向用户的抽奖防刷比抽奖算法本身更重要。常见的小号批量注册领奖、脚本刷抽奖次数、并发轰炸接口都能把活动预算打穿。我做的防护措施一般包括登录态校验 短信验证码或者人机验证。营销活动里至少接一个行为验证不然脚本很容易模拟请求。用户维度限流。比如同一个用户每天最多抽 3 次这个次数放在 Redis 里INCR加过期时间实现。接口维度限流。全局限流可以使用 Guava 的 RateLimiter 或 Redis 计数器但分布式环境建议用 Redis Lua 实现滑动窗口或令牌桶。Java 面试里问限流算法也是同一个知识点。幂等控制。抽奖接口必须支持幂等比如前端生成一个requestId后端用 RedisSETNX做防重防止用户快速双击按钮发起两个请求结果中两次奖。其中双击问题是真实且高频的。用户在转盘动画还没结束时就疯狂点按钮前端往往会禁用按钮但前端禁用挡不住恶意请求。后端必须做一层幂等校验以用户 ID 活动 ID 时间窗口作为 key一段时间内只允许一次抽奖请求。简单实现是String key lottery:req: userId : activityId : LocalDate.now(); Boolean success redisTemplate.opsForValue().setIfAbsent(key, 1, Duration.ofSeconds(1)); if (!success) { return LotteryResult.fail(抽奖请求过于频繁请稍后再试); }这只是兜底真正的防刷还是要在网关层做风险控制。4. 常见问题与排查技巧实录写这套抽奖功能的过程中我整理了遇到过的几个高频问题以及排查思路按项目实操顺序列出来。4.1 并发抽奖时库存扣超了现象后台最终统计发现奖品发出数量大于库存总量。排查思路检查扣库存 SQL 是否有stock 0条件。很多人写的是UPDATE prize SET stock stock - 1 WHERE id ?并发时两个请求都能拿同一行都更新成负库存。检查是否有读到旧库存后做了库存判断。比如先用SELECT stock查一遍如果stock 0再UPDATE这种“先查后改”天然不具备原子性。确认事务隔离级别。默认的REPEATABLE_READ下UPDATE本身是行锁安全的关键还是 SQL 语句是否自带条件。我建议直接把条件写进UPDATE不要依赖“查出来判断再更新”见上文decreaseStock。如果使用了 Redis 扣减要检查 Lua 脚本是否原子执行。Redis 单线程执行 Lua只要不是调试脚本中有TIME这种非确定性命令基本不会出问题。4.2 抽奖结果总是抽到同一个奖品现象测试环境里发现连续多次抽到同一个奖品概率显示和实际不符。原因分析如果没有过滤库存并且该奖品权重特别大比如 9900而其他奖品权重合计 100随机数大部分都会落到 9900 那个区间看起来就像“一直中它”。这不是随机算法坏了是权重配置问题。另一个常见原因是PrizePool在构造时没有过滤已抽完的奖品库存为 0 的奖品权重还占着池子它抽中后扣库存失败导致“疑似一直抽到 A但 A 又无法发放”。解决办法在抽奖初始化时过滤库存每次重抽时重新构造池子并给每个奖品设置合理的权重上限。建议在配置后台增加一个模拟测试按钮输入抽奖次数返回每个奖品的中奖次数统计和理论概率对一下很直观。4.3 抽奖接口响应慢现象压测时 TPS 上不去平均响应时间超过 1 秒。定位思路用 Arthas 或 jstack 抓线程栈看线程到底阻塞在哪里。如果是数据库连接池等待说明 SQL 慢或者事务太长。检查是否有同步发奖逻辑。我在 3.2 节里提过异步发奖能极大缓解接口压力。实际项目中发奖的准备动作都可能很慢尤其是生成券码、调第三方系统。检查日志是否有大量重复打印可能是异常重试逻辑写得不严谨。比如retryDraw里没有设置最大重试次数库存紧张时陷入死循环。如果是 Redis 扣减检查是否有大 key 问题。库存 key 一般不会有问题但用户防重 key 如果带大量过期时间可能让 Redis 内存佷高需要合理设置 TTL。4.4 抽奖活动开始后奖品提前抽完现象原计划 3 天的活动1 小时奖品全空了用户反馈体验极差。这在营销活动里很常见本质是“中奖率”和“参与量”没挂钩。解决方式有两种一是“总量控制”即把概率计算与剩余库存深度绑定库存越少中奖率越低这个叫“动态概率”二是“拆分时段放量”把奖品池分成多个时段段比如第一天只放 30%第二天放 70%避免一次性被撸光。动态概率的实现很简单// 某个奖品的动态权重 基础权重 * (剩余库存 / 初始库存) int dynamicWeight (int) Math.round(prize.getWeight() * prize.getStock() / prize.getInitStock());然后把动态权重传给PrizePool。这种做法可以在不引入复杂调度任务的前提下平滑控制发放速度。4.5 对账失败记录和库存对不上现象每天跑对账任务时发现中奖记录数和扣减库存数不一致。一般原因在于发奖补偿逻辑。如果发奖失败后回补了库存但忘记把记录状态改为“已回补”那记录数虽然多了一条但对应库存又加回了后台统计错位。我的规范是抽奖记录增加settle_status0 代表未对账1 代表已对账。每日对账任务扫描当日抽奖记录统计按prize_id分组的中奖数量。和奖品表的“初始库存 - 当前库存”做比较。不一致时遍历记录找异常。这种对账机制不复杂但能救命尤其是用了 Redis 之后库存和数据库之间本身就是异步同步的没有对账就是拿着一本糊涂账。5. 经验总结与扩展点这套抽奖模块我后续又扩展了不少功能整理几个值得做的方向供参考。可以用 Redis HyperLogLog 做 UV 统计分析活动整体参与率。可以把奖品配置和管理做成后台化运营可随时调整权重但我一般会在后台加一个“权重变更即时生效”的开关避免正在抽奖时改权重导致概率池混乱。也可以考虑引入消息队列把抽奖和发奖彻底解耦比如 RocketMQ 或者 RabbitMQ阈值低时用线程池 内存队列也能顶一阵。关于动态概率我认为这是抽奖系统和小型电商营销系统的一个分水岭。静态概率适合预算充足的简单活动动态概率适合精细化成本控制。如果领导要求“活动期间成本不能超预算”那基本就得走动态概率配合兜底奖品方案。最后再分享一个小细节抽奖结果页的文案和奖品展示顺序会影响用户体验。即使技术后端支持真实随机用户感知也是接近“套路”。我会建议把权重高的奖品排在一块展示区但真正在抽奖动画里一定要展示完整奖品列表不要只展示高价值奖品而隐藏低价值奖品否则用户抽几次没中高价值就会认为是黑幕。技术上解决不了信任问题透明度可以弥补一部分。抽奖功能这种看似简单的模块实际做深了能牵出大量 Java 核心知识和工程经验。从ThreadLocalRandom的适用边界到容器选型、并发控制、事务边界、消息补偿、限流防刷每一个点都值得单独写些练习。如果你想练手可以从一个单体 Spring Boot 项目开始先跑通数据库乐观锁版本再加 Redis 缓存库存最后接消息队列异步发奖。把这套流程走下来你就不会觉得它只是一个“随机数”问题了。