ARTICLE DETAIL

资讯详情

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

Java抽奖系统实战:从随机算法到高并发防超发

Java抽奖系统实战:从随机算法到高并发防超发 抽奖功能是Java开发里最常见的业务需求之一无论你是面试前刷题、课设凑模块、还是公司营销系统的真实需求都绕不开这套东西。很多新手一上来就写个Math.random()抽个赏看着是跑通了实际上连“概率正确”“并发安全”“库存准确扣减”这三道门槛都没有迈过去。这篇文章就围绕Java实现抽奖功能的核心技术点展开从最基础的算法选型到权重配置、并发控制、幂等防重再复盘真实环境里最容易踩的坑一次性把原理解透。1. 抽奖功能的技术拆解与整体设计思路1.1 抽奖功能到底在解决什么问题先要理解抽奖本质上是“两个维度的业务系统”概率计算和资源分配。概率计算解决“用户抽中什么”资源分配解决“中奖者能不能拿到货”。大多数人写抽奖都只盯着第一个维度结果放在真实场景里并发一上来库存瞬间超发或者同一个奖被同一个人重复抽走活动直接崩盘。从Java工程的角度抽奖功能可以拆成五个核心环节奖池配置奖项、奖品、概率、总数、每人限抽次数的定义。抽奖算法给定用户依据规则从奖池中选中一个结果。库存扣减中奖后扣减奖品库存失败则重抽或返回未中奖。幂等与防重防止并发请求、重复点击导致重复抽奖、超发。发放记录持久化抽奖结果供对账、审计、短信/发货通知使用。所以架构设计必须先想清楚抽奖是纯内存算法还是依赖Redis、数据库是单机部署还是分布式这决定了后续所有代码的写法。如果只是一个后台小活动、并发不高的管理后台单机内存版就够用如果是面向C端用户的大促活动分布式方案还要考虑Redis分布式锁、Lua脚本原子扣减、消息队列削峰。1.2 适合从哪个角度切入这篇文章以“前后端分离场景下后端Java接口如何处理一次抽奖请求”为主线采用经典的预生成奖池 算法抽取 锁保护库存模式。这个方案的好处是不依赖一次性把所有大奖放出来通过奖池配置表灵活配置运营同学后台改数据就能调整概率不用发版。算法层与存储层分离你今天用MySQL明天换Redis改动成本低。抽奖算法本身可以脱离数据库单测方便回归。先别急着写CRUD把思想理顺了代码写起来就是水到渠成的事。2. 抽奖算法的选型与核心实现2.1 等概率抽奖从Random到ThreadLocalRandom最基础的抽奖是从奖池中随机选一个奖项每个奖项的中奖概率相同。很多入门教材会让你用Math.random()但在这个场景里我更推荐使用ThreadLocalRandom.current()。import java.util.List; import java.util.concurrent.ThreadLocalRandom; public class RandomDraw { public static T T drawFrom(ListT pool) { if (pool null || pool.isEmpty()) { return null; } int index ThreadLocalRandom.current().nextInt(pool.size()); return pool.get(index); } }为什么要用ThreadLocalRandom而不是Math.random()Math.random()底层用的是Random类内部有一个AtomicLong种子多线程环境下会因CAS竞争损耗性能。ThreadLocalRandom是每个线程独立的随机数生成器没有竞争并发高时性能明显更好。使用方式几乎零成本迁移nextInt(n)语义和Random一致返回[0, n)的整数。只有一种情况不建议用ThreadLocalRandom你需要可复现的随机序列比如测试环境固定随机种子这时才需要自己new一个Random并传入固定seed。2.2 权重抽奖离散概率分布的本质绝大多数真实抽奖不是等概率而是按权重配置比如一等奖概率1%、二等奖概率3%、三等奖概率10%、谢谢参与86%。这就涉及离散概率分布的采样。实现方法也很朴素把每个奖品的“概率区间”按顺序累加落在哪个区间就返回哪个奖品。import java.util.NavigableMap; import java.util.TreeMap; import java.util.concurrent.ThreadLocalRandom; public class WeightedDraw { private final NavigableMapDouble, Prize weightMap new TreeMap(); private double totalWeight; public void addPrize(double weight, Prize prize) { totalWeight weight; weightMap.put(totalWeight, prize); } public Prize draw() { if (weightMap.isEmpty()) { return null; } double randomValue ThreadLocalRandom.current().nextDouble() * totalWeight; return weightMap.ceilingEntry(randomValue).getValue(); } }这里有个容易踩坑的细节用TreeMap.ceilingEntry(randomValue)而不是floorEntry。我举个反例你就明白了。假设三个奖品的权重区间分别是(0, 0.1)、(0.1, 0.3)、(0.3, 1.0)。如果用floorEntry(0.05)我们会拿到(0.3, 1.0)这个区间的奖品因为0.05的floor是它前面的所有小于等于0.05的键而第一个键0.1大于0.05TreeMap会返回null。所以必须用ceilingEntry向上取“第一个不小于randomValue的键”。2.3 二段式抽奖先判断是否中奖再决定中哪个运营活动中常见的做法是“整体中奖率5%”但奖池里细分多个奖项。这时的算法可以拆成两段public Prize draw() { double hitRate 0.05; // 整体中奖率 double random ThreadLocalRandom.current().nextDouble(); if (random hitRate) { return null; // 未中奖 } return weightedDrawFromAwardPool(); // 已确定中奖按各奖项权重二次抽取 }这个模式的好处是整体概率调节只需改动一个参数各奖项权重调整互不影响运营可以直接配置“今日大奖上浮权重”不会影响整体中奖率。2.4 特殊抽奖策略不放回抽取与抽完即止有些场景要求奖品抽完即止。例如活动结束时一共发放100份奖品每抽到一个就少一个不能超发。这种策略下奖项总数缩小剩余奖励数变得动态算法要在抽中后实时扣减库存并同步奖池状态。public Prize drawWithInventoryCheck() { ListPrize availablePrizes prizeRepository.findAvailable(); if (availablePrizes.isEmpty()) { return null; } Prize selected weightedDrawFrom(availablePrizes); boolean success inventoryService.decrement(selected.getId()); return success ? selected : drawWithInventoryCheck(); }这里有个decrement需要加锁或使用原子操作否则就会出现“多个线程同时抽中同一个奖品库存扣成负数”的问题后面章节我会专门展开。3. 从理论到落地抽奖功能的完整实现3.1 数据模型设计奖项与抽奖记录先把表结构理清楚。最少两张表award_config和draw_record。award_config字段建议CREATE TABLE award_config ( id bigint NOT NULL AUTO_INCREMENT, activity_id bigint NOT NULL COMMENT 活动ID, award_name varchar(64) NOT NULL COMMENT 奖品名称, award_type tinyint NOT NULL COMMENT 奖品类型 1-实物 2-优惠券 3-积分 4-谢谢参与, total_quantity int NOT NULL DEFAULT 0 COMMENT 总数量, remain_quantity int NOT NULL DEFAULT 0 COMMENT 剩余数量, weight int NOT NULL DEFAULT 0 COMMENT 权重, create_time datetime DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (id), KEY idx_activity_id (activity_id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT奖项配置表;draw_record字段建议CREATE TABLE draw_record ( id bigint NOT NULL AUTO_INCREMENT, user_id bigint NOT NULL COMMENT 用户ID, activity_id bigint NOT NULL, award_id bigint NOT NULL, award_name varchar(64) DEFAULT NULL, record_no varchar(32) NOT NULL COMMENT 抽奖记录编号用于幂等, draw_time datetime DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (id), UNIQUE KEY uk_record_no (record_no), KEY idx_user_activity (user_id, activity_id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT抽奖记录表;为什么要在draw_record里冗余award_name因为发奖时如果配置表被运营改坏了你仍然可以从历史记录知道当初用户中了什么。做对账和客诉处理时这个冗余非常值。3.2 完整的抽奖服务代码这里我写一个服务层示例涵盖权重配置校验、库存扣减、记录落库、异常回滚几个核心逻辑import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import java.util.List; import java.util.concurrent.ThreadLocalRandom; Service public class DrawService { private final AwardConfigMapper awardConfigMapper; private final DrawRecordMapper drawRecordMapper; public DrawService(AwardConfigMapper awardConfigMapper, DrawRecordMapper drawRecordMapper) { this.awardConfigMapper awardConfigMapper; this.drawRecordMapper drawRecordMapper; } public DrawResult draw(Long userId, Long activityId) { ListAwardConfig awards awardConfigMapper.selectByActivityId(activityId); AwardConfig selected doWeightedDraw(awards); if (selected null) { return DrawResult.of(null, 未中奖); } // 生成唯一记录编号用于幂等 String recordNo generateRecordNo(userId, activityId); DrawRecord record new DrawRecord(userId, activityId, selected.getId(), selected.getAwardName(), recordNo); try { awardConfigMapper.decreaseRemainQuantity(selected.getId()); drawRecordMapper.insert(record); } catch (Exception e) { // 如果库存扣减失败或记录插入失败视为抽奖失败 return DrawResult.of(null, 活动太火爆请稍后再试); } return DrawResult.of(selected, 中奖); } private AwardConfig doWeightedDraw(ListAwardConfig awards) { int totalWeight awards.stream() .filter(a - a.getRemainQuantity() 0) .mapToInt(AwardConfig::getWeight) .sum(); if (totalWeight 0) { return null; } int randomValue ThreadLocalRandom.current().nextInt(totalWeight); int cumulative 0; for (AwardConfig award : awards) { if (award.getRemainQuantity() 0) { continue; } cumulative award.getWeight(); if (randomValue cumulative) { return award; } } return null; } private String generateRecordNo(Long userId, Long activityId) { return activityId - userId - System.currentTimeMillis() - ThreadLocalRandom.current().nextInt(1000, 9999); } }仔细看这个实现我把“库存扣减”放在insert record之前并且decreaseRemainQuantity只做了UPDATE ... SET remain_quantity remain_quantity - 1 WHERE id ? AND remain_quantity 0这是后面不会超发的关键。update iddecreaseRemainQuantity UPDATE award_config SET remain_quantity remain_quantity - 1 WHERE id #{id} AND remain_quantity 0 /update注意这条SQL里没有把剩余数量查出来再减而是直接在数据库层面做了一个“条件更新”。如果影响行数为0说明当前库存已经被扣光了。3.3 为什么不用SELECT ... FOR UPDATE很多教程会教你用悲观锁先SELECT * FROM award_config WHERE id? FOR UPDATE锁住行再判断库存再更新。这个方案在抽奖这种高并发场景下存在明显问题FOR UPDATE会让同一奖品的所有抽奖请求串行排队吞吐量直接拉胯。Linux服务器上1秒可能过来几千个抽奖请求如果大家全都阻塞在一个行锁上后面请求全卡住接口超时率飙升。相比之下“条件更新UPDATE ... WHERE remain_quantity 0”是非阻塞的数据库行锁只锁在最终更新的那一瞬间并发能力好很多。当然条件更新也有一个前提你的逻辑必须在更新失败时主动放弃当前奖品重新走抽奖逻辑或直接返回“未中奖”不能让用户明明中了大奖却因为没抢到库存而尴尬。4. 并发控制与数据一致性抽奖最容易翻车的地方4.1 单机场景唯一约束实现幂等先不考虑分布式单机实例上最常见的重复问题是“用户狂点抽奖按钮”同一个请求被发送了多次。此时只要你在draw_record表上用record_no建了唯一索引重复插入就会直接报错你的业务层catch住异常后返回“请勿重复提交即可”。关键在于record_no要稳定。如果把System.currentTimeMillis()和ThreadLocalRandom混在一起生成用户两次请求生成的编号大概率不同唯一索引就防不住了。所以我推荐用activityId userId 固定业务标识这种更稳的组合或者前端在发起请求时传一个requestId后端用requestId做幂等键。4.2 分布式场景Redis分布式锁与Lua脚本单机靠数据库唯一索引就能防重但微服务多个实例同时跑抽奖请求被负载均衡打到不同机器时你还要考虑跨实例的锁竞争。这时候最普适的方案是Redis分布式锁。public DrawResult drawWithRedisLock(Long userId, Long activityId) { String lockKey draw:lock: activityId; String requestId userId - System.currentTimeMillis(); boolean locked redisTemplate.opsForValue() .setIfAbsent(lockKey, requestId, Duration.ofSeconds(3)); if (!locked) { return DrawResult.of(null, 抽奖队列繁忙请稍后再试); } try { // 原有抽奖逻辑 return doDraw(userId, activityId); } finally { // 只有持有当前锁的线程才能释放锁 if (requestId.equals(redisTemplate.opsForValue().get(lockKey))) { redisTemplate.delete(lockKey); } } }这段代码里有两个关键点setIfAbsent时加了Duration.ofSeconds(3)过期时间防止服务宕机导致锁永远不会释放。释放锁之前必须校验requestId这是防止“A线程超时释放锁B线程拿到锁A再删锁把B的锁误删”的经典事故。如果你对库存扣减的原子性有更高要求推荐把“判断剩余库存扣减库存”做成Redis Lua脚本整个流程原子执行。但注意用了Redis扣库存后后续需要定时同步回MySQL保证数据库最终一致否则一断电Redis缓存丢失库存数据就不准了。4.3 事务边界怎么划很多人写抽奖会把“校验用户资格、扣库存、写记录”全放在一个Transactional里认为所有操作要么全部成功要么全部回滚。但我要提醒你不要把网络调用或锁操作放在事务里。举例来说如果你的抽奖流程包含“发短信、通知消息队列、调外部发券接口”这些操作耗时不可控放在事务里会导致数据库连接长时间被持有连接池被耗尽系统雪崩。合理的边界是事务内只做数据库操作更新库存、插入抽奖记录。事务提交后再发异步通知短信、MQ消息如果通知失败通过定时补偿任务重试。5. 抽奖业务的进阶优化与面试加分项5.1 权重动态调整与人为干预真实的运营活动里奖品的中奖概率不是一成不变的。比如某个时段“锦鲤大奖”的人气特别高运营希望临时提高它的权重或者某个奖品库存快耗尽了希望降低它的概率。这个需求直接用后台修改weight字段即可算法不会受到影响。但要注意一种边界如果一个奖品的库存已经为0它仍然在奖池列表里权重仍然参与totalWeight计算导致用户抽中它后走“抱歉奖品没了”的流程体验极差。所以每次加载奖池时最好排掉remain_quantity 0的奖品或者直接用一个缓存把可用奖品加载进内存。5.2 平滑加权轮询与伪随机控制有些活动要求给人“中奖感”但又不能真的随机比如“每隔一段时间必定出一个大奖”。这时单纯靠随机数是不行的你需要引入平滑加权轮询调度算法类似Nginx负载均衡里的平滑策略。思路是维护每个奖项的当前权重每次抽奖时把当前权重加上配置权重选当前权重最大的那个奖项选完再减去总权重。这样在足够长的序列里每个奖项出现次数与配置权重完全成正比且分布均匀不会出现随机数带来的扎堆问题。public class SmoothWeightedDraw { private final MapPrize, Integer currentWeights new HashMap(); public Prize draw() { Prize selected null; int maxCurrent Integer.MIN_VALUE; int totalWeight 0; for (Prize prize : prizes) { totalWeight prize.getWeight(); int current currentWeights.getOrDefault(prize, 0) prize.getWeight(); currentWeights.put(prize, current); if (current maxCurrent) { maxCurrent current; selected prize; } } if (selected ! null) { currentWeights.put(selected, currentWeights.get(selected) - totalWeight); } return selected; } }这个实现是面试加分项但真实业务中要注意它和库存数量结合的问题——如果某个高权重奖品的库存已经耗尽需要提前过滤否则平滑算法会把流量导向一个已无货的奖品。5.3 抽奖记录的异步落库抽奖的接口QPS很高时每抽一次就同步插入一条draw_record数据库压力不小。可以采用异步批量插入把抽奖结果先放到内存队列或Redis List里后台任务定时批量落库。这种方案的问题是如果应用在数据落库前崩溃抽奖记录就丢了。所以一般只在记录与发奖联动不强、允许小概率丢记录时才用。大多数正规活动我建议同步落库用批量插入优化单次写入成本比如一个用户一天只能抽两次数据量根本不会大到需要异步这步。6. 常见问题与排查技巧实录6.1 抽奖概率看起来明显不对很多人在自测时发现“一等奖几乎抽不出来”觉得是算法问题。先别慌把样本量扩大——抽1万次再统计频率。如果概率偏差仍然很大大概率是权重treeMap边界问题回去检查ceilingEntry和floorEntry的使用。另一种低级错误是权重类型用了int小数权重全部被截断成0导致某些奖项永远不会被抽中。遇到这种情况权重字段改成decimal后再看结果。6.2 库存为负数这是最典型的超发问题根因99%是“先查库存再减库存”两步之间没有原子操作。比如if (award.getRemainQuantity() 0) { // 多个线程全走到这里全部都满足 award.setRemainQuantity(award.getRemainQuantity() - 1); // 入库 }解决办法就是前面讲的UPDATE award_config SET remain_quantity remain_quantity - 1 WHERE id ? AND remain_quantity 0判断影响行数来决定是否继续发奖。6.3 用户重复中奖需求没一致是主要矛盾。有的活动规则是“同一活动限领一次大奖”有的规则是“每天可以抽三次”。大部分开发只做了次数校验但忘了给记录表加唯一索引导致并发请求同时通过次数校验双双写入成功。我的经验是次数校验只能做业务拦截真正的防重必须靠唯一约束兜底两者都要有。6.4 测试环境的随机种子问题如果你在测试环境用固定seed进行自动化回归要特别小心ThreadLocalRandom不支持显式设置seed。建议在测试时注入一个java.util.Random实例生产环境用ThreadLocalRandom测试环境用固定seed这样自动化测试才能复现稳定结果。public class DrawService { private final Random random; public DrawService(Random random) { this.random random; } public DrawService() { this(ThreadLocalRandom.current()); } }6.5 发奖回调失败怎么补偿用户抽中实物奖品后如果发货系统异常导致发放失败用户是感知不到的只有对账时发现问题。所以我建议把抽奖记录设计成包含状态字段INIT、SUCCESS、FAILED。发奖回调失败时更新为FAILED后台有一个定时任务扫描失败的记录进行重试最多重试三次仍失败再走人工处理。7. 从面试角度聊聊抽奖题怎么答不翻车面试里只要问到Java抽奖或高并发加分点集中在三个方面随机算法的正确性、库存扣减的原子性、幂等与防重设计。很多人上来就讲Random、Math.random()这只能算及格线真正能拿高分的是把前面提到的ThreadLocalRandom和数据库条件更新、Redis分布式锁串起来讲让面试官觉得你不仅会写代码还思考过并发场景下的数据一致性问题。另外面试官很爱问“为什么不用synchronized锁抽奖方法”。你要答出来单机部署时synchronized确实能保证一个实例内抽奖方法串行执行但一旦部署两三个实例负载均衡一跳锁只锁住当前JVM其他JVM照样并发请求这就是synchronized在分布式场景下的局限。所以要么用数据库乐观锁/条件更新兜底要么用Redis分布式锁统一协调。如果再深入一点面试官问你“Redis锁如果过期但业务没执行完怎么办”你可以答用Redisson的看门狗机制自动续期或者用更切实际的方案——锁过期时间设置成业务最大耗时的2到3倍并在业务结束前手动释放锁。这两种答案都能证明你确实踩过坑、思考过细节。最后再分享一个自己的实操习惯写抽奖代码之前我会先花一个小时把“最坏情况数据量”估出来。活动预计100万人参加每人抽2次那就是200万次抽奖单机吞吐量大概多少数据库能不能扛住Redis缓存要不要介入。把小流量下的方案直接照搬到千万级活动翻车只是在时间上早晚的问题。宁可前期多想一步也不要等活动上线后半夜爬起来回滚赔礼道歉。抽奖这个功能说难不难说简单也不算简单它像一个很小的数据库一致性面试题缩影。把权重算法、条件更新、幂等唯一索引这三件事做扎实你的抽奖系统就已经跑赢大多数项目了。剩下的优化都是在“即时响应”和“最终一致”之间找平衡。
返回列表