高并发抽奖系统全链路设计:从防超卖到反作弊
每逢大促、年会、营销活动,"抽奖"几乎是标配。但真正能把抽奖系统做稳的人,远比想象中少。本文从一个真实的高并发抽奖系统出发,从架构分层、库存原子扣减、全链路防作弊、多层风控,到兜底容灾,带你完整拆解一个生产级抽奖系统的设计思路与核心代码。全文干货,建议收藏。
一、先想清楚:抽奖系统到底在解什么题?
很多人第一反应是"抽奖不就是扣库存吗?"——这恰恰是最危险的误区。
一个生产级抽奖系统,本质要同时解决三个核心矛盾:
| 核心矛盾 | 本质问题 | 设计目标 |
|---|---|---|
| 高并发库存安全 | 峰值流量下奖品绝对不能超卖、不少发 | Redis + Lua 原子扣减,异步削峰落库 |
| 全链路防作弊 | 脚本、羊毛党、设备农场批量刷奖 | 端-网-云三层纵深防御,假中奖兜底 |
| 多层级风控 | 规则过严误杀用户,过松造成资损 | 实时规则引擎 + 离线大数据,高价值奖品二次复核 |
把这三个矛盾想清楚,架构自然就出来了。
整理了面试真题、每日技术知识点、系统学习路线,都汇总在个人网站 [www.javadashen.com] 有需要的同学自取。
公众号「Rain的Java大神之路」每天拆一个知识点,陪你悄悄变强,有空来坐坐。
二、整体架构:流量过滤 → 风险决策 → 原子扣库存 → 异步持久化
用户端
│
▼
┌──────────────────────────────┐
│ 接入层:网关 / Nginx │ ← 限流、鉴权、请求签名、验证码
└──────────────┬───────────────┘
▼
┌──────────────────────────────┐
│ 前置校验层 │ ← 幂等校验、参与次数限制、设备指纹
└──────────────┬───────────────┘
▼
┌──────────────────────────────┐
│ 实时风控引擎 │ ← 规则引擎打分,高风险直接"假中奖"
└──────────────┬───────────────┘
▼
┌──────────────────────────────┐
│ 抽奖核心服务 │ ← 概率匹配 + Redis Lua 原子扣库存
└──────────────┬───────────────┘
▼
┌──────────────────────────────┐
│ 消息队列(RocketMQ / Kafka) │ ← 削峰、异步落库、触发发奖
└──────────────┬───────────────┘
▼
┌──────────────────────────────┐
│ 数据库 / 离线风控平台 │ ← 中奖记录持久化、T+1 聚类分析
└──────────────────────────────┘
核心思路一句话:每一层都有防御,层层过滤后,真正到达核心扣库存逻辑的请求已经非常干净。
三、硬核部分:高并发库存原子扣减
3.1 库存预热:活动全程不碰数据库
活动开始前,将所有奖品库存、中奖概率配置全量预热到 Redis:
lottery:stock:{activityId}:{prizeId}— 奖品剩余库存prize:daily:{prizeId}:{date}— 奖品日发放量user:daily:win:{uid}:{date}— 用户每日中奖次数lottery:idempotent:{requestId}— 幂等标记
整个抽奖核心流程只操作 Redis,不查数据库,这是扛住万级 QPS 的基础。
3.2 Lua 脚本:一次 IO 完成全部校验与扣减
这是整个系统最核心的技术点。抽奖概率匹配后,用一段 Lua 脚本在 Redis 中原子执行「幂等校验 + 库存扣减 + 不足回补」:
-- lottery_deduct_stock.lua
-- KEYS[1] 幂等校验 key: lottery:idempotent:{requestId}
-- KEYS[2] 目标奖品库存 key: lottery:stock:{activityId}:{prizeId}
-- KEYS[3] 用户当日中奖次数 key: user:daily:win:{uid}:{date}
-- KEYS[4] 奖品当日限量 key: prize:daily:{prizeId}:{date}
-- ARGV[1] 幂等过期时间(秒,与活动时长对齐)
-- ARGV[2] 单次扣减数(固定为 1)
-- ARGV[3] 奖品当日上限
-- ARGV[4] 用户当日上限
-- ARGV[5] 奖品 ID(用于返回)
-- ① 幂等校验:重复请求直接拦截
if redis.call('EXISTS', KEYS[1]) == 1 then
return {0, 'duplicate_request'}
end
-- ② 奖品总库存校验
local stock = redis.call('GET', KEYS[2])
if not stock or tonumber(stock) <= 0 then
return {-1, 'stock_empty'}
end
-- ③ 奖品日限校验
local daily_prize = redis.call('GET', KEYS[4])
if daily_prize and tonumber(daily_prize) >= tonumber(ARGV[3]) then
return {-2, 'prize_daily_limit'}
end
-- ④ 用户日限校验
local user_daily = redis.call('GET', KEYS[3])
if user_daily and tonumber(user_daily) >= tonumber(ARGV[4]) then
return {-3, 'user_daily_limit'}
end
-- ⑤ 全部校验通过,原子扣减
redis.call('DECRBY', KEYS[2], ARGV[2])
redis.call('INCR', KEYS[3])
redis.call('INCR', KEYS[4])
-- ⑥ 写入幂等标记
redis.call('SETEX', KEYS[1], ARGV[1], 'WIN')
return {1, ARGV[5]}
为什么这段脚本是灵魂?
- 单次网络 IO:所有校验 + 扣减在一次 Redis 调用中完成,没有多次 RTT 的开销
- 原子性保证:Redis 单线程执行 Lua 脚本,天然无锁,杜绝超卖
- 幂等防护:同一 requestId 重复提交直接返回,不会重复中奖
- 库存回补:扣减后库存不足时自动 INCR 回补,避免负数异常
- 性能极高:单机可达 10W+ QPS
3.3 Java 调用层:生产级落地
@Service
public class LotteryService {
@Resource
private StringRedisTemplate stringRedisTemplate;
// 预加载 Lua 脚本,启动时一次性解析,避免运行时重复解析开销
private final DefaultRedisScript<List> deductStockScript;
public LotteryService() {
deductStockScript = new DefaultRedisScript<>();
deductStockScript.setScriptSource(
new ClassPathResource("lua/lottery_deduct_stock.lua"));
deductStockScript.setResultType(List.class);
}
public LotteryResultDTO doLottery(String requestId, Long activityId,
Long userId, Long prizeId) {
// 1. 前置校验:活动有效期、用户参与次数(省略)
// 2. 概率匹配:基于本地缓存的概率配置,计算命中奖品
if (prizeId == null) {
return LotteryResultDTO.fail("未中奖");
}
// 3. 组装 Redis Key
String today = LocalDate.now().format(DateTimeFormatter.BASIC_ISO_DATE);
List<String> keys = Arrays.asList(
"lottery:idempotent:" + requestId, // 幂等 key
"lottery:stock:" + activityId + ":" + prizeId, // 奖品库存
"user:daily:win:" + userId + ":" + today, // 用户日限
"prize:daily:" + prizeId + ":" + today // 奖品日限
);
// 4. 执行 Lua 原子扣减
List result;
try {
result = stringRedisTemplate.execute(
deductStockScript, keys,
String.valueOf(24 * 3600), // 幂等过期 24h
"1", // 扣减数
"100", // 奖品日上限
"3", // 用户日上限
String.valueOf(prizeId)
);
} catch (Exception e) {
// Redis 异常降级:切换数据库乐观锁扣库存
log.error("Redis 扣库存异常,降级到数据库", e);
return deductStockByDbWithOptimisticLock(activityId, prizeId, userId);
}
// 5. 结果分支处理
int code = Integer.parseInt(result.get(0).toString());
if (code == 0) {
return LotteryResultDTO.fail("重复请求");
} else if (code < 0) {
return LotteryResultDTO.fail("未中奖");
}
// 6. 中奖成功:异步 MQ 落库 + 触发发奖
rocketMQTemplate.asyncSend("lottery_win_topic",
buildWinMessage(requestId, activityId, userId, prizeId),
new SendCallback() { /* 失败重试 + 对账兜底 */ });
return LotteryResultDTO.win(prizeId);
}
}
代码亮点:
- Lua 脚本预加载,启动时解析一次,运行时零额外开销
- Redis 异常时自动降级到数据库乐观锁(version 字段),保证极端场景不超卖
- 中奖后异步 MQ 削峰,消费端做幂等校验,支撑万级 QPS
四、纵深防御:全链路防作弊体系
不能只做"扣库存",要先回答**"这个请求有没有资格扣"**。
4.1 三层防御架构
| 防护层级 | 核心手段 | 解决的问题 |
|---|---|---|
| 端侧(第一道) | 设备指纹 + JS 混淆 + 动态令牌 | 识别模拟器、设备农场,防接口抓包重放 |
| 接入层(第二道) | 令牌桶限流 + 滑块验证 + 请求签名 | 单用户 1次/秒,IP 10次/秒,防暴力刷奖 |
| 业务层(第三道) | 设备指纹 + 实名校验 + 行为异常检测 | 拦截一机多号、虚拟号、脚本化操作 |
4.2 风控引擎:实时决策,假中奖兜底
风控引擎接收用户行为特征,实时算分:
- 设备维度:是否 root/越狱、设备指纹是否命中黑库
- 账号维度:注册时长、好友数、实名等级、历史行为
- 网络维度:IP 归属地变动频率、同 IP 多账号检测
- 行为维度:请求频率、时间间隔规律性、操作轨迹
关键规则示例:
// 责任链模式:可插拔的规则引擎
public abstract class RiskRule {
public abstract RiskResult evaluate(UserContext ctx);
}
// 规则1:新注册(<24h)用户中大奖 → 高风险
public class NewUserBigPrizeRule extends RiskRule {
public RiskResult evaluate(UserContext ctx) {
if (ctx.getRegisterHours() < 24 && ctx.getPrizeLevel() > 5) {
return RiskResult.reject("新用户中大奖风险");
}
return RiskResult.pass();
}
}
// 规则2:同 IP 下多账号 → 羊毛党特征
public class IPBatchRule extends RiskRule {
public RiskResult evaluate(UserContext ctx) {
long count = redisTemplate.opsForSet()
.size("ip:users:" + ctx.getIp());
if (count > 5) {
return RiskResult.reject("IP 下账号异常多");
}
return RiskResult.pass();
}
}
// 风控引擎:遍历规则链,任一拦截即拒绝
public class RiskEngine {
private List<RiskRule> rules = Arrays.asList(
new NewUserBigPrizeRule(), new IPBatchRule());
public boolean pass(UserContext ctx) {
for (RiskRule rule : rules) {
if (rule.evaluate(ctx).isReject()) return false;
}
return true;
}
}
最狠的一招:风控返回"高风险"时,服务端直接返回"谢谢参与"或默认奖品,不扣真实大奖库存。表面上看用户"没中奖",实际上他的请求根本没进入真正的抽奖池。黑产完全无感知。
4.3 后置离线风控:持续进化
T+1 用 Spark 跑全量用户行为数据,通过聚类算法识别羊毛党集群、批量注册账号,更新风险标签同步到 Redis,供实时风控使用。防护能力随时间持续迭代。
高价值奖品(手机、现金等)中奖后,还会触发深度风控 + 人工复核,确认非作弊账号再发奖,兜住大额资损。
五、兜底与容灾:生产环境不能没有的安全网
5.1 库存回补机制
中奖后发送延迟消息,15 分钟未领奖/未填写收货地址的订单,自动触发库存回补。回补操作同样用 Lua 原子执行,并增加幂等标记避免重复回补导致库存虚高。
5.2 降级兜底策略
| 异常场景 | 降级方案 |
|---|---|
| Redis 宕机 | 自动切换数据库乐观锁(version 字段)扣库存 + 全局限流 |
| 风控系统故障 | 降级为仅校验黑名单 + 基础限流,保证核心流程可用 |
| 数据库压力过高 | 关闭中奖记录查询等非核心接口 |
| 新活动上线 | 灰度放量,异常自动熔断降级为"谢谢参与" |
5.3 数据一致性保障
- 事务消息(RocketMQ)或本地消息表 + 定时补偿:Redis 扣减成功但 MQ 发送失败时,确保不丢奖品
- 每日定时对账:凌晨跑批,核对 Redis 库存、数据库库存、中奖记录三者一致性,修复异常数据
- 幂等性:每个抽奖请求携带唯一 requestId,Redis 缓存结果,重复提交直接返回
5.4 热门奖品热点 Key 打散
头部大奖的库存 Key 可能成为超级热点,单 Redis 分片 CPU 被打满。解决方案:库存分片——将单个奖品库存拆成 N 个子 Key(如 stock:0 ~ stock:9),扣减时随机选择子 Key,将热点流量分散到多个 Redis 分片。
六、前端协同:别忽视体验层的优化
- 抽奖按钮点击后立即置灰,防止重复提交
- 结果 loading 动画,降低用户焦虑和重复点击
- 活动倒计时 + 库存余量展示,营造紧迫感
- 前端 JS 混淆 + 动态令牌,抽奖按钮点击后才生成一次性 token,防抓接口重放
七、核心技术难点速查表
| 难点 | 问题本质 | 落地方案 |
|---|---|---|
| 奖品超卖 | 并发下多请求同时扣库存 | Redis + Lua 原子脚本,单次 IO 完成校验+扣减 |
| 热点 Key | 热门奖品 Redis key 访问集中 | 库存分片打散 + 本地预扣 + 定时回补 |
| 黑产识别 | 脚本、模拟器、设备农场难区分 | 端-网-云三层纵深 + 假中奖兜底 |
| 数据一致性 | Redis 扣成功但消息丢失 | 事务消息 / 本地消息表 + 定时对账 |
| 风控延迟 | 复杂规则计算拖慢接口 | 轻量规则前置网关,核心规则内存并行计算,复杂规则异步后置 |
| 概率与库存矛盾 | 抽中奖品但库存已空 | Lua 扣减失败自动降级到其他奖品或"未中奖",前端无感知 |
| 峰值雪崩 | 同步写库扛不住 | Redis 托管核心读写 + MQ 削峰异步落库 + 降级兜底 |
八、总结
一个高并发抽奖系统的本质是:
在巨大流量下,用最少的热点资源(Redis + Lua)原子化地完成"资格校验 + 库存扣减",用异步解耦处理耗时的发货与记录,并叠加多维度纵深风控,把作弊损失降到最低。
记住三个关键词:原子扣减、纵深防御、异步兜底。把这三件事做扎实,抽奖系统就稳了。
如果这篇文章对你有帮助,欢迎点赞、收藏、转发,你的支持是我持续输出的最大动力。有任何问题欢迎评论区交流,我们下期见。
【别走,交个朋友】
我是 Rain ,一个喜欢把复杂技术讲透、让代码落地的实践者。
如果你厌倦了四处收集碎片化的八股文,想看看一线项目里真实的架构决策和踩坑记录,欢迎来我的公众号「Rain 的 Java 大神之路」坐坐。 在这里,我会把每一次技术复盘、每一个项目的设计源码,毫无保留地分享给你。
🔥 技术这条路很酷,我们结伴同行。
🚀Keep Coding, Keep Loving —— 与所有 Java 同路人共勉。