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


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

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


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

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

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

    核心矛盾 本质问题 设计目标
    高并发库存安全 峰值流量下奖品绝对不能超卖、不少发 Redis + Lua 原子扣减,异步削峰落库
    全链路防作弊 脚本、羊毛党、设备农场批量刷奖 端-网-云三层纵深防御,假中奖兜底
    多层级风控 规则过严误杀用户,过松造成资损 实时规则引擎 + 离线大数据,高价值奖品二次复核

    把这三个矛盾想清楚,架构自然就出来了。


    二、整体架构:流量过滤 → 风险决策 → 原子扣库存 → 异步持久化

    用户端
      │
      ▼
    ┌──────────────────────────────┐
    │  接入层:网关 / Nginx          │  ← 限流、鉴权、请求签名、验证码
    └──────────────┬───────────────┘
                   ▼
    ┌──────────────────────────────┐
    │  前置校验层                    │  ← 幂等校验、参与次数限制、设备指纹
    └──────────────┬───────────────┘
                   ▼
    ┌──────────────────────────────┐
    │  实时风控引擎                  │  ← 规则引擎打分,高风险直接"假中奖"
    └──────────────┬───────────────┘
                   ▼
    ┌──────────────────────────────┐
    │  抽奖核心服务                  │  ← 概率匹配 + Redis Lua 原子扣库存
    └──────────────┬───────────────┘
                   ▼
    ┌──────────────────────────────┐
    │  消息队列(RocketMQ / Kafka)  │  ← 削峰、异步落库、触发发奖
    └──────────────┬───────────────┘
                   ▼
    ┌──────────────────────────────┐
    │  数据库 / 离线风控平台          │  ← 中奖记录持久化、T+1 聚类分析
    └──────────────────────────────┘
    

    核心思路一句话:每一层都有防御,层层过滤后,真正到达核心扣库存逻辑的请求已经非常干净。


    关于这个问题的底层原理和更多实战细节,我整理了一份《大厂面试手册》,包含大厂高频面试题、源码解析和性能调优案例。
    关注公众号【Rain的Java大神之路】,回复“Java”即可免费领取,持续更新中。


    三、硬核部分:高并发库存原子扣减

    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 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 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 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的Java大神之路】。
    专注 Java 面试、源码、高并发实战,回复“Java”领取《大厂面试手册》,持续更新。

  • 相关阅读:
    【C语言学习笔记---字符串函数】
    精益项目管理的流程
    onnx模型转换opset版本和固定动态输入尺寸
    Seata 源码篇之AT模式启动流程 - 下 - 04
    Python 实现秒表功能(比较好玩的题目)
    软件测试之路已不再是坦途
    无线产品欧盟CE认证RED指令测试要求解读
    Web服务器项目实战(一)
    基于数据增强与集成学习的小样本识别技术
    网络面试-ox09 http是如何维持用户的状态?
  • 原文地址:https://www.cnblogs.com/zrui-xyu/p/22960699