高并发下的抽奖系统:不只是扣库存,还要防作弊与风控

0 阅读10分钟

高并发抽奖系统全链路设计:从防超卖到反作弊

每逢大促、年会、营销活动,"抽奖"几乎是标配。但真正能把抽奖系统做稳的人,远比想象中少。本文从一个真实的高并发抽奖系统出发,从架构分层、库存原子扣减、全链路防作弊、多层风控,到兜底容灾,带你完整拆解一个生产级抽奖系统的设计思路与核心代码。全文干货,建议收藏。


一、先想清楚:抽奖系统到底在解什么题?

很多人第一反应是"抽奖不就是扣库存吗?"——这恰恰是最危险的误区。

一个生产级抽奖系统,本质要同时解决三个核心矛盾

核心矛盾本质问题设计目标
高并发库存安全峰值流量下奖品绝对不能超卖、不少发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 同路人共勉。

image.png