抽奖系统后端设计与高并发防超发实战:权重抽取、库存扣减与幂等
2026/9/1 2:46:44 网站建设 项目流程

朋友圈里有人晒秋天的第一杯奶茶,游戏社区里有人晒出秋天的第一个堡堡,还配文“欧皇之堡,秋天会一直欧气满满”。作为一个后端开发,我第一反应不是羡慕,而是想:如果这个“堡堡”背后是抽奖、盲盒、掉落活动,那系统到底是怎么判定用户抽中的?最容 易翻车的地方又在哪里?

很多非技术读者会把抽奖成功归因于运气,把抽奖失败归因于“黑幕”;做过活动系统的工程师则知道,抽奖系统真正难的从来不是“让某个用户中奖”,而是:成千上万人同时点击抽奖按钮时,奖品不会多发、概率不会失真、重复请求不会刷穿、中奖记录能对得上账。

这篇文章就把抽奖系统、盲盒系统、活动掉落这类场景的工程实现拆开讲一遍,从权重抽取、库存扣减、幂等防重,到并发压测、风控审计和异常对账。读完你会明白:一个看起来只靠运气的功能,上线前到底要经过哪些设计。

1. 这篇文章真正要解决的问题

抽奖系统给人第一印象是“写个 random 就行”,但真正上线后,问题通常出现在四个方向:

一是超发。奖品库存只有 100 份,结果同时有 300 个人抽中。这是典型的并发扣减问题。二是重复领取。用户快速点击两次,或者网络重试,导致同一次抽奖发了两个奖品。这是幂等问题。三是概率漂移。配置的某个奖品中奖率是 10%,发奖统计下来却是 15% 或者 8%,从系统日志里查不到原因。这是权重计算和随机数使用不规范导致的。四是被羊毛党刷穿。活动刚上线,真实用户还没抽到,结果一批异常账号先把高价值奖品扫空。

所以这里的核心判断是:抽奖系统不等于随机函数。随机函数只负责“选出哪一个奖品”,而前面这些稳定性和资金安全相关的问题,决定了一个活动系统能不能上线。

这篇文章适合三类读者:第一次做活动系统的后端开发,想知道完整的工程链路;负责游戏掉落、积分盲盒、电商优惠券抽奖的同学,想排查线上问题;以及准备服务端架构面试的人,想理解“高并发下的状态一致性”是怎么落到具体业务里的。

2. 概率抽奖的核心概念与适用场景

在动手写代码前,有几个概念必须先理清楚。

2.1 概率与权重

每种奖品有一个权重,权重不一定是最终概率,最终概率是“该奖品权重 / 所有奖品权重之和”。

假设有三个奖品:

奖品权重理论概率
谢谢参与7070%
优惠券2020%
欧皇之堡1010%

总权重是 100,抽中“欧皇之堡”的概率就是 10%。如果奖池总权重不是 100,也没关系,只要按比例计算即可。很多配置系统喜欢把权重直接写成百分数,但更好的做法是存独立权重,方便后续调整。

2.2 随机数的生成位置

随机数必须由服务端生成,绝不能由客户端传入。例如把用户 ID 当成种子,甚至把“用户点击次数”也传进来,这会让抽奖结果可以被预估和伪造。

在高敏感场景,建议使用SecureRandomSplittableRandom这类质量更高的随机源,避免使用Math.random()Math.random()在并发和安全性上都不算最优选择。

2.3 库存扣减的原子性

“剩余库存”是一个共享可变状态。并发下必须保证扣减是一个原子操作,常见手段有三种:

  • 数据库原子更新:UPDATE ... SET remain = remain - 1 WHERE id = ? AND remain > 0
  • Redis Lua 脚本:在 Redis 内完成判断和扣减
  • 分布式锁:实现简单,但吞吐量不如前两者

如果扣减操作不是原子的,两个请求同时读到库存为 1,就会都扣减成功,最终库存变成 -1,这就是超发。

2.4 幂等

用户点击一次抽奖,前端因为网络原因重发了两次;或者后端超时后调用方重试,同一个抽奖请求就可能被执行多次。解决方法是引入幂等键,例如requestIduserId + activityId + 业务流水号,并在数据库层面加唯一索引兜底。

2.5 适用场景对比

场景核心关注点常见风险
游戏抽卡 / 掉落权重配置、随机公平性、抽卡记录审计概率配置错误、抽卡记录丢失
电商优惠券抽奖并发扣减、防止超发、用户权益发放超卖、重复领取
积分盲盒活动库存控制、多档奖品组合、支付接口库存负数、对账不一致
内部年会抽奖人员白名单、单次抽取、防重复重复中奖、名单泄露

无论哪一种场景,底层都离不开“随机选择 + 库存扣减 + 记录流水”这三个基本动作。

3. 环境准备与前置条件

本文示例以 Java Spring Boot + Redis + MySQL 为主,版本建议以你当前项目的实际版本为准,下面只列出相对通用的环境。

  • JDK 8 或 JDK 17,根据 Spring Boot 版本决定
  • Maven 3.6+
  • MySQL 8.x
  • Redis 6.x+
  • IDE 或命令行工具

先准备三张表:奖品表、用户中奖记录表、活动信息表。

-- 文件路径: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 请求接入

前端调用后端接口时,必须携带userIdactivityIdrequestIdrequestId在前端生成,后端不信任,但可以用来做幂等判断。

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(List<Prize> 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 { List<Prize> selectByActivityId(Long activityId); int decreaseInventory(@Param("prizeId") Long prizeId, @Param("count") int count); }

对应 XML 或注解 SQL:

<!-- 文件路径:src/main/resources/mapper/PrizeMapper.xml --> <update id="decreaseInventory"> 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. 查询奖品列表 List<Prize> 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 DefaultRedisScript<Long> 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) { List<String> 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 Result<DrawResult> 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 预扣、数据库扣减、异步补偿串成完整方案;二是可验证随机数,在公平性要求极高的场景中证明结果不可被篡改;三是风控体系,把设备指纹、频控、账号风险评分接入抽奖链路。

建议收藏这篇文章,等真的接到活动系统需求时,再对照着设计一遍。做一个让用户“欧气满满”而又不超发、不重复、不翻车的系统,比抽中一次欧皇之堡更有成就感。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询