抽奖系统后端设计与高并发防超发实战:权重抽取、库存扣减与幂等

抽奖系统后端设计与高并发防超发实战:权重抽取、库存扣减与幂等 朋友圈里有人晒秋天的第一杯奶茶游戏社区里有人晒出秋天的第一个堡堡还配文“欧皇之堡秋天会一直欧气满满”。作为一个后端开发我第一反应不是羡慕而是想如果这个“堡堡”背后是抽奖、盲盒、掉落活动那系统到底是怎么判定用户抽中的最容 易翻车的地方又在哪里很多非技术读者会把抽奖成功归因于运气把抽奖失败归因于“黑幕”做过活动系统的工程师则知道抽奖系统真正难的从来不是“让某个用户中奖”而是成千上万人同时点击抽奖按钮时奖品不会多发、概率不会失真、重复请求不会刷穿、中奖记录能对得上账。这篇文章就把抽奖系统、盲盒系统、活动掉落这类场景的工程实现拆开讲一遍从权重抽取、库存扣减、幂等防重到并发压测、风控审计和异常对账。读完你会明白一个看起来只靠运气的功能上线前到底要经过哪些设计。1. 这篇文章真正要解决的问题抽奖系统给人第一印象是“写个 random 就行”但真正上线后问题通常出现在四个方向一是超发。奖品库存只有 100 份结果同时有 300 个人抽中。这是典型的并发扣减问题。二是重复领取。用户快速点击两次或者网络重试导致同一次抽奖发了两个奖品。这是幂等问题。三是概率漂移。配置的某个奖品中奖率是 10%发奖统计下来却是 15% 或者 8%从系统日志里查不到原因。这是权重计算和随机数使用不规范导致的。四是被羊毛党刷穿。活动刚上线真实用户还没抽到结果一批异常账号先把高价值奖品扫空。所以这里的核心判断是抽奖系统不等于随机函数。随机函数只负责“选出哪一个奖品”而前面这些稳定性和资金安全相关的问题决定了一个活动系统能不能上线。这篇文章适合三类读者第一次做活动系统的后端开发想知道完整的工程链路负责游戏掉落、积分盲盒、电商优惠券抽奖的同学想排查线上问题以及准备服务端架构面试的人想理解“高并发下的状态一致性”是怎么落到具体业务里的。2. 概率抽奖的核心概念与适用场景在动手写代码前有几个概念必须先理清楚。2.1 概率与权重每种奖品有一个权重权重不一定是最终概率最终概率是“该奖品权重 / 所有奖品权重之和”。假设有三个奖品奖品权重理论概率谢谢参与7070%优惠券2020%欧皇之堡1010%总权重是 100抽中“欧皇之堡”的概率就是 10%。如果奖池总权重不是 100也没关系只要按比例计算即可。很多配置系统喜欢把权重直接写成百分数但更好的做法是存独立权重方便后续调整。2.2 随机数的生成位置随机数必须由服务端生成绝不能由客户端传入。例如把用户 ID 当成种子甚至把“用户点击次数”也传进来这会让抽奖结果可以被预估和伪造。在高敏感场景建议使用SecureRandom或SplittableRandom这类质量更高的随机源避免使用Math.random()。Math.random()在并发和安全性上都不算最优选择。2.3 库存扣减的原子性“剩余库存”是一个共享可变状态。并发下必须保证扣减是一个原子操作常见手段有三种数据库原子更新UPDATE ... SET remain remain - 1 WHERE id ? AND remain 0Redis Lua 脚本在 Redis 内完成判断和扣减分布式锁实现简单但吞吐量不如前两者如果扣减操作不是原子的两个请求同时读到库存为 1就会都扣减成功最终库存变成 -1这就是超发。2.4 幂等用户点击一次抽奖前端因为网络原因重发了两次或者后端超时后调用方重试同一个抽奖请求就可能被执行多次。解决方法是引入幂等键例如requestId或userId activityId 业务流水号并在数据库层面加唯一索引兜底。2.5 适用场景对比场景核心关注点常见风险游戏抽卡 / 掉落权重配置、随机公平性、抽卡记录审计概率配置错误、抽卡记录丢失电商优惠券抽奖并发扣减、防止超发、用户权益发放超卖、重复领取积分盲盒活动库存控制、多档奖品组合、支付接口库存负数、对账不一致内部年会抽奖人员白名单、单次抽取、防重复重复中奖、名单泄露无论哪一种场景底层都离不开“随机选择 库存扣减 记录流水”这三个基本动作。3. 环境准备与前置条件本文示例以 Java Spring Boot Redis MySQL 为主版本建议以你当前项目的实际版本为准下面只列出相对通用的环境。JDK 8 或 JDK 17根据 Spring Boot 版本决定Maven 3.6MySQL 8.xRedis 6.xIDE 或命令行工具先准备三张表奖品表、用户中奖记录表、活动信息表。-- 文件路径src/main/resources/db/schema.sql CREATE TABLE activity ( id BIGINT NOT NULL AUTO_INCREMENT, activity_name VARCHAR(64) NOT NULL, start_time DATETIME NOT NULL, end_time DATETIME NOT NULL, status TINYINT NOT NULL DEFAULT 0 COMMENT 0-未开始 1-进行中 2-已结束, PRIMARY KEY (id) ) ENGINE InnoDB DEFAULT CHARSET utf8mb4; CREATE TABLE prize ( id BIGINT NOT NULL AUTO_INCREMENT, activity_id BIGINT NOT NULL, prize_name VARCHAR(64) NOT NULL, weight INT NOT NULL DEFAULT 0 COMMENT 权重不直接等于概率, total_inventory INT NOT NULL DEFAULT 0 COMMENT 总库存, remain_inventory INT NOT NULL DEFAULT 0 COMMENT 剩余库存, create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (id), KEY idx_activity_id (activity_id) ) ENGINE InnoDB DEFAULT CHARSET utf8mb4; CREATE TABLE user_prize_record ( id BIGINT NOT NULL AUTO_INCREMENT, request_id VARCHAR(64) NOT NULL COMMENT 幂等键, user_id BIGINT NOT NULL, activity_id BIGINT NOT NULL, prize_id BIGINT NOT NULL, prize_name VARCHAR(64) NOT NULL, status VARCHAR(16) NOT NULL DEFAULT INIT COMMENT INIT-初始化 GRANTED-已发放, create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (id), UNIQUE KEY uk_request_id (request_id), KEY idx_user_activity (user_id, activity_id) ) ENGINE InnoDB DEFAULT CHARSET utf8mb4;这里最关键的是user_prize_record表的request_id唯一索引。既然幂等键已经唯一即使应用层逻辑有漏洞数据库也能兜住重复插入。4. 核心流程拆解一次抽奖请求的完整流程可以拆成下面几个步骤。4.1 请求接入前端调用后端接口时必须携带userId、activityId和requestId。requestId在前端生成后端不信任但可以用来做幂等判断。4.2 活动校验校验活动是否存在、是否在白名单内、活动是否在有效时间范围内。活动时间校验建议使用服务端时间不要依赖客户端传入的时间。4.3 权重抽取从数据库中读取该活动下的所有奖品按权重随机选中一个。抽取必须由服务端完成奖品列表和权重不能暴露给前端。4.4 库存扣减这一步是防超发的关键。最简单的正确做法是数据库原子更新UPDATE prize SET remain_inventory remain_inventory - 1 WHERE id #{prizeId} AND remain_inventory 0;如果执行后影响行数为 1说明扣减成功如果影响行数为 0说明该奖品已经没货。这里不需要先查询再更新因为“先查询再更新”在并发下会读到过期数据。4.5 记录中奖流水同一个事务里把中奖记录插入user_prize_record状态先置为INIT或直接置为GRANTED取决于发放是同步还是异步。如果是同步发放一般直接置为GRANTED如果是异步发放则需要引入消息表和定时任务。4.6 发放奖品优惠券、积分这类权益通常调用下游发放接口。发放失败时不能直接把用户请求返回失败而要把记录状态改成FAILED通过补偿任务重试。4.7 对账与审计活动结束后至少要核对三组数字中奖记录数、实际发放数、库存减少数。如果这三个数字不相等系统一定有 bug。5. 完整示例代码实现下面用一个最小可运行示例串起整个流程。5.1 权重抽取算法// 文件路径src/main/java/com/example/lucky/core/WeightedRandomPicker.java public class WeightedRandomPicker { public static Prize pick(ListPrize prizes) { if (prizes null || prizes.isEmpty()) { throw new IllegalArgumentException(prize list must not be empty); } int totalWeight prizes.stream() .mapToInt(Prize::getWeight) .sum(); if (totalWeight 0) { throw new IllegalStateException(total weight must be greater than 0); } SplittableRandom random new SplittableRandom(); int target random.nextInt(totalWeight); int accumulated 0; for (Prize prize : prizes) { accumulated prize.getWeight(); if (target accumulated) { return prize; } } // 理论不会走到这里兜底返回最后一个奖品 return prizes.get(prizes.size() - 1); } }这段代码的核心思想是把总权重看成一条线段每次随机落在线段上的一个点落在哪个奖品区间就选中哪个奖品。这样不需要把每个奖品都生成独立编号性能稳定。5.2 Spring Boot 抽奖 Service先定义一个数据库 Mapper 方法// 文件路径src/main/java/com/example/lucky/mapper/PrizeMapper.java public interface PrizeMapper { ListPrize selectByActivityId(Long activityId); int decreaseInventory(Param(prizeId) Long prizeId, Param(count) int count); }对应 XML 或注解 SQL!-- 文件路径src/main/resources/mapper/PrizeMapper.xml -- update iddecreaseInventory UPDATE prize SET remain_inventory remain_inventory - #{count} WHERE id #{prizeId} AND remain_inventory #{count} /update然后写抽奖 Service// 文件路径src/main/java/com/example/lucky/service/DrawService.java Service public class DrawService { Resource private PrizeMapper prizeMapper; Resource private UserPrizeRecordMapper userPrizeRecordMapper; Transactional(rollbackFor Exception.class) public DrawResult draw(Long userId, Long activityId, String requestId) { // 1. 幂等校验数据库唯一索引兜底 int exists userPrizeRecordMapper.countByRequestId(requestId); if (exists 0) { return DrawResult.repeat(请勿重复提交); } // 2. 查询奖品列表 ListPrize prizes prizeMapper.selectByActivityId(activityId); if (CollectionUtils.isEmpty(prizes)) { throw new BusinessException(活动未配置奖品); } // 3. 权重抽取 Prize prize WeightedRandomPicker.pick(prizes); // 4. 原子扣减库存 int updated prizeMapper.decreaseInventory(prize.getId(), 1); if (updated 0) { // 这里要重新选择一个奖品而不是直接失败 // 简单演示时也可以直接返回失败生产环境建议循环选择 throw new BusinessException(手慢了该奖品已被抢完); } // 5. 写入中奖记录 UserPrizeRecord record new UserPrizeRecord(); record.setRequestId(requestId); record.setUserId(userId); record.setActivityId(activityId); record.setPrizeId(prize.getId()); record.setPrizeName(prize.getPrizeName()); record.setStatus(GRANTED); userPrizeRecordMapper.insert(record); return DrawResult.success(prize, record.getId()); } }这个版本以正确性优先。用户抽中一个奖品如果该奖品库存刚好为 0示例直接返回失败。生产环境更合理的做法是“重新从剩余奖品中抽取一次”但这个逻辑会让示例变复杂所以先用这种直观方式演示核心链路。5.3 高并发优化Redis Lua 预扣上面的方案在低并发下完全没问题但遇到大促所有请求都打数据库库存行锁会成为瓶颈。更常见的优化是 Redis 预扣。先准备 Lua 脚本-- 文件路径src/main/resources/lua/draw_inventory.lua -- KEYS[1]: 奖品库存 key, 例如 inv:prize:100 -- KEYS[2]: 用户抽奖次数 key, 例如 draw:limit:10001:200 -- ARGV[1]: userId -- ARGV[2]: 每人活动期间限抽次数 -- 返回值1-成功 0-库存不足 -1-次数超限 local limit tonumber(ARGV[2]) local current tonumber(redis.call(get, KEYS[2]) or 0) if limit 0 and current limit then return -1 end local remain tonumber(redis.call(get, KEYS[1]) or -1) if remain 1 then return 0 end redis.call(decrby, KEYS[1], 1) redis.call(incr, KEYS[2]) redis.call(expire, KEYS[2], 86400) return 1在代码中调用// 文件路径src/main/java/com/example/lucky/service/RedisDrawService.java private static final DefaultRedisScriptLong DRAW_SCRIPT new DefaultRedisScript(); static { DRAW_SCRIPT.setLocation(new ClassPathResource(lua/draw_inventory.lua)); DRAW_SCRIPT.setResultType(Long.class); } public DrawResult drawWithRedis(Long userId, Long activityId, Long prizeId, String requestId) { ListString keys Arrays.asList( inv:prize: prizeId, draw:limit: userId : activityId ); Long code redisTemplate.execute(DRAW_SCRIPT, keys, userId.toString(), 3); if (code null) { throw new BusinessException(抽奖服务异常); } if (code -1) { return DrawResult.error(今日抽奖次数已用完); } if (code 0) { return DrawResult.error(该奖品已被抢完); } // 预扣成功后异步落库 发放 drawRecordService.createRecordAsync(userId, activityId, prizeId, requestId); return DrawResult.success(抽奖成功奖品发放中); }这个方案把库存热点从数据库转移到了 Redis。但必须注意Redis 预扣成功后如果异步落库失败Redis 中的库存已经被扣掉数据库库存却没有减少两者就不一致了。因此生产环境需要引入本地消息表、定时对账、补偿任务以 Redis 预扣为“前置判断”以数据库扣减为“最终依据”。这些机制属于“最终一致”的工程范畴不能在示例代码里简化为一句“同步调用”就完事。5.4 Controller 接口// 文件路径src/main/java/com/example/lucky/controller/DrawController.java RestController RequestMapping(/draw) public class DrawController { Resource private DrawService drawService; PostMapping(/do) public ResultDrawResult draw(RequestBody DrawRequest request) { // 生产环境 userId 从登录态获取不能完全信任前端传入 DrawResult result drawService.draw( request.getUserId(), request.getActivityId(), request.getRequestId() ); return Result.success(result); } }6. 运行结果与效果验证本地启动项目后可以用 curl 模拟一次抽奖请求。curl -X POST http://localhost:8080/draw/do \ -H Content-Type: application/json \ -d { userId: 10001, activityId: 200, requestId: req-001 }如果系统正常预期返回类似{ code: 0, message: success, data: { prizeId: 3, prizeName: 欧皇之堡, recordId: 10086 } }然后去数据库验证SELECT * FROM user_prize_record WHERE request_id req-001; SELECT * FROM prize WHERE activity_id 200;确认中奖记录只有一条且对应奖品remain_inventory比抽奖前减少 1。如果同一个requestId请求两次第二次应该返回“请勿重复提交”。并发验证建议用 JMeter 或ab命令下面是一个简单的ab示例ab -n 1000 -c 50 -p /tmp/draw_body.json -T application/json \ http://localhost:8080/draw/do其中/tmp/draw_body.json是请求体文件。压测后要看两个数据接口成功率不是 100% 可以接受但库存扣减总数必须等于成功扣减次数。数据库中不会出现负库存。如果压测过程中出现库存负数、中奖记录重复优先检查UPDATE ... WHERE remain count是否生效以及request_id唯一索引是否建立。7. 常见问题与排查思路问题现象可能原因排查方式解决方案并发下库存变负数扣减 SQL 缺少remain_inventory 0条件或先查询再更新查看慢 SQL复现并发场景改为数据库原子更新并加条件同一个请求出现两条中奖记录缺少幂等键或幂等只做了应用层判断查询user_prize_record中相同request_id的记录增加唯一索引应用层查询数据库兜底中奖概率和配置不一致权重表计算错误或随机数范围越界打印总权重、目标值、每个奖品区间边界单元测试覆盖权重累计区间Redis 预扣成功但数据库库存没变化Redis 与数据库不是同一事务异步落库失败查看异步任务日志比对 Redis key 和数据库库存引入本地消息表和定时对账任务活动奖品被“秒空”缺少风控羊毛党批量请求查看中奖用户 IP/设备指纹分布增加频控、账号风险评分、黑名单活动期间配置修改导致无货管理端直接改权重/库存没有版本管理查看配置变更日志活动上线后锁定奖品配置变更走审批每一条都要落到“能查、能修”不要只停留在理论上。8. 最佳实践与工程建议抽奖系统上线前建议把下面这些工程细节逐项过一遍。幂等设计要双层兜底。第一层用 RedisSETNX做快速判断第二层用数据库唯一索引做最终兜底。只依赖一层迟早会在某个瞬间出现重复请求穿透。库存扣减要选择合适粒度。小型活动直接用数据库原子更新简单可靠大促场景用 Redis Lua 预扣加数据库最终扣减再配合对账任务。不要一上来就上分布式锁锁会让活动接口吞吐量急剧下降。概率配置要可解释、可验证。建议把奖品权重放到配置中心通过配置版本号管理。每次调整都要记录操作人和变更时间。上线前用本地测试脚本跑 10 万次随机抽取核对实际频率和配置概率是否一致。安全边界要提前划好。后端接口不要相信前端传入的userId应该从登录态中解析。管理端接口要做权限校验敏感操作加审计日志。抽奖结果和下发的奖品不能暴露内部编码。对“盲盒类”玩法更要谨慎活动规则要公开透明奖品概率要如实公示避免触碰合规红线。日志和监控要留足关键信息。一条抽奖链路至少要有请求 ID、用户 ID、活动 ID、奖品 ID、库存扣减前剩余、扣减后剩余、耗时、错误码。线上排查问题时没有这些日志基本等于盲猜。灰度放量比全量上线安全得多。新抽奖系统上线时先让 10% 流量进来观察中奖记录、库存一致性、接口耗时和错误率。确认没问题后再逐步放量。活动系统一旦出现超发修复成本往往远高于普通 bug。9. 总结与后续学习方向别人晒秋天的“欧皇之堡”背后真正让人安心的不是运气而是一套能对抗高并发和资金风险的系统设计。这篇文章讲清楚了抽奖系统的四个主链路权重抽取、库存扣减、幂等防重、对账恢复也给出了基于 Spring Boot MySQL Redis 的最小实现。下一步你可以继续研究三个方向一是分布式事务和最终一致把 Redis 预扣、数据库扣减、异步补偿串成完整方案二是可验证随机数在公平性要求极高的场景中证明结果不可被篡改三是风控体系把设备指纹、频控、账号风险评分接入抽奖链路。建议收藏这篇文章等真的接到活动系统需求时再对照着设计一遍。做一个让用户“欧气满满”而又不超发、不重复、不翻车的系统比抽中一次欧皇之堡更有成就感。