☰
抽奖系统设计:概率权重、库存扣减与高并发防超发实战
2026/10/16 10:17:12 网站建设 项目流程

简介:Smart-Lottery 抽奖系统是一套面向C端营销活动的Java后端项目,基于Dubbo与SpringBoot构建,聚焦抽奖场景中的服务拆分、RPC远程调用、并发控制与奖品发放,适合中高级Java开发者及微服务架构学习者深入研读。压缩包共163个文件,其中140个Java源码文件覆盖活动、参与、抽奖、奖品、用户等完整业务模块;另有16个XML配置文件、YAML配置及少量图片与文档,可对照理解Spring Bean装配、Dubbo服务暴露与消费、数据持久化映射等细节,整体仅264KB,轻量便于检索。源码经过严格测试,可结合测试代码与工程配置学习JUnit测试、环境切换、并发幂等、事务处理及缓存优化等工程化实践;同时可从ActivityPartake、ActivityProcess等核心类入手,梳理状态流转、分布式调用链与异常处理逻辑。目前已有191人学习,对想快速掌握营销类微服务项目结构、抽奖业务建模与高并发实现的开发者具有较高参考价值。

1. smart-lottery 抽奖系统:面向C端的营销活动,核心在概率与成本平衡

smart-lottery 抽奖系统,面向 C 端人群的互联网营销活动系统,表面看是“随机抽奖”,本质是在随机表象下精准控制成本、刺激用户参与、达成转化指标。我最早参与这类系统是某电商平台的大促活动,流量峰值是日常的 20 倍,抽奖接口 QPS 冲到 8000,第一次上线就因为库存扣减和幂等问题出现奖品超发,被运营追着改了三天,那段经历告诉我抽奖系统真正的难点不在随机算法。后来重构时把概率、库存、预算、异步发奖全部拆开治理,系统才稳住。这篇笔记把整套方案完整拆解:从概率模型讲起,到数据库设计、并发防护、踩坑记录,最后落到 AB 测试和监控验证。适合正在做营销活动系统的后端工程师,以及准备把抽奖模块从单体应用里独立出来的架构师。

2. 抽奖系统核心模型:概率、库存、预算三方博弈的取舍

2.1 概率分配策略:从均匀随机到权重可配

先明确一个容易被忽略的事实:抽奖系统的“随机”从来不是数学意义上的均匀随机,而是运营视角的“可控随机”。如果直接调用rand.Intn(totalCount)做均匀抽取,在奖品成本差异大的活动里一定会翻车。比如一等奖是一部手机,二等奖是一张五元券,均匀概率下手机可能几分钟就被抽光,预算直接失控,后续活动周期内所有用户都只能抽到低价值奖品,体验迅速恶化。

所以电商和营销类抽奖普遍采用权重方案。每个奖品配一个 weight 字段,系统按权重比例生成随机区间,落在哪个区间就中哪个奖。核心实现很短,但边界条件要写完整:

// 奖品权重配置 type PrizeWeight struct { PrizeID int64 Weight int // 权重值越大,中奖概率越高 } // DrawByWeight 按权重抽取一个奖品ID // prizes 不能为空,totalWeight 必须大于 0 func DrawByWeight(prizes []PrizeWeight) (int64, error) { totalWeight := 0 for _, p := range prizes { totalWeight += p.Weight } if totalWeight <= 0 { return 0, errors.New("权重配置异常:总权重必须大于0") } roll := rand.Intn(totalWeight) // 生成 [0, totalWeight) 区间的随机数 for _, p := range prizes { if roll < p.Weight { return p.PrizeID, nil } roll -= p.Weight // 减去当前权值,继续向下匹配 } return 0, errors.New("抽奖算法异常:未命中任何奖品") }

这段逻辑的核心是rand.Intn(totalWeight)生成一个整数,然后循环里用roll依次减去每个奖品的 weight,第一次出现roll < weight时返回对应奖品。理解了这段代码,也就理解了为什么“奖品顺序”会影响概率偏差——如果权重表按奖品价值从高到低排列,并且roll的落点刚好越过前几个高权重奖品,用户的体验就是“总是差一点中大奖”,这种心理暗示在很多活动中是刻意设计的结果,但在技术层面要注意,运营调整权重后,绝对中奖率会发生变化,需要后台同步展示归一化概率。

动态调权重是这类系统的常规操作。比如一等奖库存还剩 3 台时,运营希望降低它的中奖概率,直接把一等奖的 weight 从 5 改成 1,下一次抽奖立即生效,不用发版、不用重启。另一种常见策略是阶梯概率:用户参与第 1 次中奖率给 5%,参与第 5 次给 20%,这在中奖率和用户活跃度之间找一个平衡区间。实现阶梯时,我在权重计算前根据该用户当天的参与次数重新生成一份权重表,底层抽取逻辑完全复用,改动很小。

提示:weight 是相对比例,不是绝对概率。奖品 A 权重 1、奖品 B 权重 9,B 的中奖率是 90%,但一旦新增一个权重为 10 的奖品 C,B 的绝对概率就掉到 9/20 = 45%。运营后台必须展示归一化后的绝对概率,否则改配置的人会一直骂你。

2.2 库存设计:实时扣减与原子操作怎么选

库存是抽奖系统最硬的约束。权重算出来中了奖,但库存没了,这个矛盾必须在结果返回之前解决,不能让用户看到“恭喜中奖”之后再告诉他“奖品没了”。库存控制有两种主流思路:预扣和实时扣。

预扣是用户点击抽奖后先锁定一份库存,给他一个“抽奖中”的中间状态,等结果计算完成再确认。这种设计用户体验好,但引入了中间状态,超时未确认会占用大量库存,还需要额外的定时任务做超时释放,复杂度明显上升。实时扣是抽奖瞬间直接执行扣减,扣成功才有资格参与,扣失败直接提示“奖品已抽完”。我经历过的项目里,最终都改成了实时扣,原因很简单:链路短,没有中间状态,不容易产生脏数据。

实时扣的核心是原子操作。数据库层面最稳的写法是条件更新:

-- 扣减库存,只有剩余库存大于 0 时才允许扣减 UPDATE prize SET remaining_count = remaining_count - 1 WHERE id = #{prizeId} AND remaining_count > 0;

然后在业务代码里检查这条 SQL 影响的行数:影响 1 行表示扣减成功,0 行说明库存已经不足。这个方案不需要额外加分布式锁,MySQL 的行锁已经帮我们做了串行化;代价是每个参与用户都会触发一次行锁竞争,单库单表情况下能扛到每秒几千的写入,再高就要把扣减动作搬到 Redis。

Redis 扣减的典型做法是把库存预热到 Redis,用 DECR 命令原子自减:

DECR draw:stock:{activityId}:{prizeId}

DECR 返回的值小于等于 0 时,说明库存被扣光,需要执行一次 INCR 做回拨。注意 DECR 返回 0 的那一次其实已经扣超了,必须补回 1,否则库存会变成 -1。Redis 单实例 DECR 的 QPS 能到十万级别,远高于数据库方案,适合万级并发的大促场景。我在项目里给库存加了一层冷热切换:活动开始前把全部库存预热进 Redis,活动结束后把 Redis 剩余库存回写数据库,保证两边一致。回写这一步至关重要,很多系统漏了它,活动一复盘就会看到数据库剩余库存和 Redis 剩余库存对不上。

注意:使用 Redis 做库存扣减时,数据库仍然是对账基准。Redis 只是“高速状态层”,真正的库存流水要落库。否则活动结束后只能靠人肉对账,那种感觉就像是把一个埋了雷的系统交接给下一个人。

2.3 预算控制:金额成本不是库存,是熔断线

库存约束的是“有几个奖品”,预算约束的是“值多少钱”。对于积分、优惠券这类奖品,库存和成本并不是绑定关系——一张 100 元券和一张 5 元券都是“一张”,但消耗的预算差了 20 倍。所以预算必须独立于库存进行控制。

日常运营中最常见的是每日预算控制,比如活动配置daily_budget = 10000元,每当用户中出优惠券,就从“今日已发放金额”累加对应面值。当日已发金额达到预算的 90%,触发告警;超过 100%,触发熔断,优惠券类奖品不再参与抽奖,只保留低成本积分奖品兜底。熔断不是为了砍用户体验,而是为了不让活动成本失控。

预算的实时扣减适合放在 Redis 做累加计数,用 INCRBY 原子地增加券面值:

INCRBY draw:daily_cost:{activityId}:{yyyyMMdd} 100

但这里有一个并发窗口问题:如果先 GET 再 INCRBY,两个请求可能同时读到旧值,分别判断没有超限后再累加,最终把预算用超。更严谨的做法是用 Lua 脚本,把“检查当前值 + 累加”合到一步:

-- 检查并累加当日预算 -- KEYS[1] = 当日累计成本 key -- ARGV[1] = 预算上限 -- ARGV[2] = 本次增加金额 local current = tonumber(redis.call('GET', KEYS[1]) or '0') if current + tonumber(ARGV[2]) > tonumber(ARGV[1]) then return 0 -- 预算超限,拒绝发放 end redis.call('INCRBY', KEYS[1], ARGV[2]) return 1 -- 预算扣减成功

预算判断的时机放在中奖结果算出之后、发奖消息发送之前。如果预算超限,这一抽不发高成本奖品,降级为“谢谢参与”。注意这里不能直接让用户重抽——营销活动通常限制每人每天只能抽一次,所以降级也消耗参与机会,文案上要明确说明。高频活动的预算粒度还要细化到小时,防止某个渠道的流量在半小时内打穿一天预算,后面的用户全部扑空。

预算和库存的联动关系,我用一句话总结:概率管“谁中”,库存管“能不能中”,预算管“中了之后发不发得起”。一个抽奖模块的稳定性,九成取决于这三个参数设计是否联动合理。

3. 从零搭 smart-lottery:四张表设计与抽奖主流程代码

3.1 四张核心表:活动、奖品、记录、用户参与

落地到数据库,一个完整的抽奖系统最少需要四张表。下面的设计是经过几个项目迭代后沉淀下来的精简版本,字段不多,但足以支撑中等规模的活动运营。

第一张是活动表 activity,保存活动的基础信息和预算约束:

CREATE TABLE `activity` ( `id` bigint(20) unsigned NOT NULL AUTO_INCREMENT, `name` varchar(100) NOT NULL DEFAULT '' COMMENT '活动名称', `status` tinyint(4) NOT NULL DEFAULT '0' COMMENT '状态:0未开始 1进行中 2已结束', `start_time` datetime NOT NULL COMMENT '活动开始时间', `end_time` datetime NOT NULL COMMENT '活动结束时间', `daily_budget` decimal(12,2) NOT NULL DEFAULT '0.00' COMMENT '每日预算金额', `total_budget` decimal(12,2) NOT NULL DEFAULT '0.00' COMMENT '活动总预算金额', `create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (`id`), KEY `idx_status_time` (`status`, `start_time`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='抽奖活动配置表';

第二张是奖品表 prize,记录每个奖品的库存、权重和面值:

CREATE TABLE `prize` ( `id` bigint(20) unsigned NOT NULL AUTO_INCREMENT, `activity_id` bigint(20) unsigned NOT NULL COMMENT '所属活动ID', `name` varchar(100) NOT NULL DEFAULT '' COMMENT '奖品名称', `type` tinyint(4) NOT NULL DEFAULT '1' COMMENT '奖品类型:1实物 2优惠券 3积分', `total_count` int(11) NOT NULL DEFAULT '0' COMMENT '总库存', `remaining_count` int(11) NOT NULL DEFAULT '0' COMMENT '剩余库存', `weight` int(11) NOT NULL DEFAULT '1' COMMENT '中奖权重', `face_value` decimal(10,2) NOT NULL DEFAULT '0.00' COMMENT '奖品面值,用于预算计算', `daily_limit` int(11) NOT NULL DEFAULT '0' COMMENT '每日发放上限,0表示不限', `create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (`id`), KEY `idx_activity` (`activity_id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='奖品配置表';

第三张是中奖记录表 lottery_record,第四张是用户参与表 user_participation:

CREATE TABLE `lottery_record` ( `id` bigint(20) unsigned NOT NULL AUTO_INCREMENT, `activity_id` bigint(20) unsigned NOT NULL COMMENT '活动ID', `user_id` bigint(20) unsigned NOT NULL COMMENT '用户ID', `prize_id` bigint(20) unsigned NOT NULL DEFAULT '0' COMMENT '奖品ID,0表示未中奖', `prize_name` varchar(100) NOT NULL DEFAULT '' COMMENT '奖品名称快照', `cost` decimal(10,2) NOT NULL DEFAULT '0.00' COMMENT '本次消耗预算金额', `status` tinyint(4) NOT NULL DEFAULT '0' COMMENT '发奖状态:0待发放 1已发放 2发放失败', `create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (`id`), KEY `idx_user_activity` (`user_id`, `activity_id`), KEY `idx_activity_time` (`activity_id`, `create_time`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='抽奖中奖记录表'; CREATE TABLE `user_participation` ( `id` bigint(20) unsigned NOT NULL AUTO_INCREMENT, `activity_id` bigint(20) unsigned NOT NULL COMMENT '活动ID', `user_id` bigint(20) unsigned NOT NULL COMMENT '用户ID', `draw_count` int(11) NOT NULL DEFAULT '0' COMMENT '参与次数', `last_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '最近参与时间', PRIMARY KEY (`id`), UNIQUE KEY `uk_activity_user` (`activity_id`, `user_id`), KEY `idx_last_time` (`last_time`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='用户参与记录表';

lottery_record 表里特意冗余了 prize_name 字段做一个名称快照。原因是奖品名称运营随时会改,如果只存 prize_id,活动复盘时查出的是当下的奖品名,而不是用户中奖那一刻的名称,容易引发用户的质疑和客服工单。这个冗余是刻意为之,不是设计疏漏。user_participation 表的唯一索引uk_activity_user用来从数据库层面保证一个用户在一个活动里只能有一条参与记录,draw_count 字段支撑阶梯概率判断,last_time 字段辅助每日次数重置。

3.2 抽奖主流程:状态校验、库存扣减、结果计算

主流程我一般按五个步骤来写:校验活动状态、校验参与资格、扣减总库存、计算中奖结果、异步发奖。代码整体放在一个服务方法里,各部分通过接口依赖注入,方便单元测试和链路追踪。

// LotteryService 抽奖服务 type LotteryService struct { redis *redis.Client prizeRepo PrizeRepository recordRepo LotteryRecordRepository } // Draw 执行抽奖 // activityId: 活动ID // userId: 用户ID // 返回 DrawResult,IsWin=false 表示未中奖 func (s *LotteryService) Draw(ctx context.Context, activityId, userId int64) (*DrawResult, error) { // 第一步:读取活动配置,校验活动状态 act := s.getActivity(ctx, activityId) if act == nil || act.Status != ActivityRunning { return nil, errors.New("活动未开始或已结束") } // 第二步:校验当日参与资格,一个用户每天只能抽一次 if s.alreadyParticipated(ctx, activityId, userId) { return nil, errors.New("今日已参与过抽奖") } // 第三步:扣减总库存,保证奖池整体还有余量 ok, err := s.deductTotalStock(ctx, activityId) if err != nil || !ok { return nil, errors.New("奖池已空,请明天再来") } // 第四步:按权重计算中奖结果 prize := s.drawByWeight(ctx, activityId) if prize == nil { // 未中奖也要记录参与次数,防止用户反复刷 s.markParticipated(ctx, activityId, userId) return &DrawResult{IsWin: false}, nil } // 第五步:扣减该奖品库存,扣失败说明被其他用户抢光了 if !s.deductPrizeStock(ctx, prize.ID) { s.markParticipated(ctx, activityId, userId) return &DrawResult{IsWin: false}, nil } // 写入中奖记录后异步发奖 recordId := s.saveWinRecord(ctx, activityId, userId, prize) s.dispatchPrize(ctx, recordId, userId, prize) return &DrawResult{IsWin: true, Prize: prize}, nil }

第一步和第二步是硬校验,任意一个不通过就直接返回错误,不消耗库存也不计参与次数。第三步扣总库存,防止整个奖池已经没有奖品时,用户还在反复点击消耗接口资源。第四步算中奖结果,这里藏着一个容易忽略的设计:权重列表中要包含“谢谢参与”这个伪奖品,因为运营需要控制整体中奖率,比如活动要求整体中奖率 30%,那么“谢谢参与”的权重就占 70%。

第五步扣具体奖品库存,这里有一个业务选择:扣失败时不能回滚总库存,因为用户已经消耗了一次参与机会,只是因为该奖品刚好被其他并发用户抽走了,返回未中奖即可。如果回滚总库存,用户会反复点击直到抽中,反而放大了并发压力。我在代码里把“是否计入参与次数”放在每个分支上单独处理,就是为了保证活动结束后参与表、记录表、库存流水三张表的数量能对齐。

3.3 发奖异步化:为什么同步发券不可行

新人在设计抽奖接口时,常常把发奖逻辑(发优惠券、加积分、生成物流单)直接写在抽奖方法里。小流量时问题不大,但发奖涉及外部系统调用,响应时间可能从 5ms 涨到 500ms,抽奖接口被拖慢,用户等待中反复点击,又引入新的幂等问题。我在项目里把发奖拆成了异步任务,主流程只负责计算中奖结果、写记录、投递发奖消息。

发奖消息通过消息队列传递,生产者侧的关键是投递的消息必须带上完整的中奖记录 ID,消费者靠它做幂等:

// dispatchPrize 将发奖任务投递到消息队列 func (s *LotteryService) dispatchPrize(ctx context.Context, recordId, userId, prizeID int64) { msg := &DispatchMessage{ RecordId: recordId, UserId: userId, PrizeID: prizeID, Timestamp: time.Now().Unix(), } body, err := json.Marshal(msg) if err != nil { log.WithError(err).Error("序列化发奖消息失败") return } s.mq.Produce(ctx, "topic_lottery_prize", body) }

消费者实现“至少一次”语义,所以必须对 recordId 做幂等,同一个 recordId 只能发一次奖。具体做法是消费端先查 lottery_record 的 status,如果已经是“已发放”,直接返回成功,不再重复发奖。

异步化之后,线上还需要一个兜底补偿任务,定期扫描中奖记录表里 status 为“待发放”的记录,重新投递。补偿任务的间隔我一般设 5 分钟,活动结束后的对账脚本也基于这张表执行,保证每一笔中奖最终都有结果。

4. 高并发防线:缓存、限流与幂等设计

4.1 缓存策略:把活动配置和库存热数据搬进 Redis

抽奖活动的典型特征是“配置低频读、接口高频写”。活动配置和奖品列表一天改不了几次,但抽奖接口每秒要看几千次。把配置直接放在数据库里,MySQL 的连接池和 InnoDB 的 Buffer Pool 很快就会被读请求打满。我的做法是把活动配置整体序列化后放进 Redis,key 设计为draw:activity:{activityId},value 是 JSON 字符串。

// getActivity 带缓存的读取活动配置,TTL 设为 30 秒 func (s *LotteryService) getActivity(ctx context.Context, activityId int64) *Activity { key := fmt.Sprintf("draw:activity:%d", activityId) val, err := s.redis.Get(ctx, key).Result() if err == nil { act := &Activity{} json.Unmarshal([]byte(val), act) return act } // 缓存未命中,查数据库并回填 act := s.activityRepo.FindByID(ctx, activityId) if act != nil { data, _ := json.Marshal(act) s.redis.Set(ctx, key, data, 30*time.Second) } return act }

TTL 定 30 秒是一个经验值。太短会导致缓存穿透频繁落地到数据库,太长会导致运营改了奖品配置后前端迟迟看不到生效。对实时性要求更高的场景,应该放到运营后台逻辑里:修改配置时主动 DELETE 对应 key,而不是等待过期。两个机制可以并行用,缓存更新延迟理论上可以降到秒级。

库存的缓存要更保守。数据库是强一致来源,Redis 只负责扛高并发读和原子扣减。每次扣减都产生一条库存变更消息,异步写入库存流水表,方便后续对账。这个设计的核心原则是:数据库可以被 Redis 挡在流量后面,但绝对不能和 Redis 失去同步,否则活动结束后对账会让你通宵。

4.2 双维限流:接口总QPS限流与用户维度防刷

抽奖场景里有两类流量需要分开限制。第一类是瞬时集中流量,比如整点开奖瞬间所有用户一起点,需要对接口做全局限流,防止服务被打垮。第二类是单用户高频刷接口,用户用脚本一秒点十几次,只影响单个资源消耗,但也可能把接口带宽和下游存储打满。

全局限流可以直接用单机限流器,在 Go 里标准库的 x/time/rate 包开箱即用:

// 全局限流:每秒最多放行 5000 个抽奖请求 var globalLimiter = rate.NewLimiter(rate.Limit(5000), 1000) // beforeDraw 处理前调用,返回 false 表示流量已超限 func beforeDraw() bool { return globalLimiter.Allow() }

rate.NewLimiter 的第一个参数是每秒令牌生成速率,第二个参数是桶容量。5000/1000 表示允许瞬时突发 1000 个请求,但长期平均不超过每秒 5000 个。这个数值必须来自压测数据,不能拍脑袋,通常按单机内存占用和数据库连接池上限倒推。

用户维度限流用 Redis 计数器实现,比较简单:

// checkUserLimit 用户维度限流,每分钟最多 3 次 func checkUserLimit(ctx context.Context, userId int64) bool { key := fmt.Sprintf("draw:user:%d:%d", userId, time.Now().Minute()) cnt, _ := s.redis.Incr(ctx, key).Result() if cnt == 1 { s.redis.Expire(ctx, key, 65*time.Second) } return cnt <= 3 }

分钟窗口有一个边界问题:第 59 秒和第 1 秒属于两个连续窗口,用户可以在两分钟内刷 6 次。如果业务对限流有严格要求,可以考虑滑动窗口,但 Redis 操作复杂度和成本都会上升。营销活动对单用户限流不必做到极致严格,重点还是每日只能参与一次这个业务规则,交给幂等逻辑去保证即可。

4.3 幂等设计:重复点击怎么保证只中一次奖

抽奖接口最大的坑是前端按钮防抖失效或网络超时后的自动重试,同一个请求到达后端多次。如果每次都走完整抽奖流程,一次点击可能中出多份奖品。标准解决方案是给一次抽奖请求生成一个全局唯一的 requestId,后端基于它做幂等。

// DrawWithIdempotency 带幂等的抽奖 // requestId 由前端生成,或者由后端在预抽接口中生成 func (s *LotteryService) DrawWithIdempotency(ctx context.Context, requestId string, activityId, userId int64) (*DrawResult, error) { lockKey := fmt.Sprintf("draw:lock:%s", requestId) // SET NX EX 保证同一个 requestId 只有一个线程进入抽奖逻辑 ok, err := s.redis.SetNX(ctx, lockKey, "1", 30*time.Second).Result() if err != nil { return nil, err } if !ok { // 已经处理过的请求,直接查缓存返回上一次结果 return s.getDrawResult(ctx, requestId) } defer s.redis.Del(ctx, lockKey) // 执行真正的抽奖流程 result, err := s.Draw(ctx, activityId, userId) if err != nil { return nil, err } // 保存抽奖结果,重复请求时能查到相同数据 s.cacheDrawResult(ctx, requestId, result) return result, nil }

SetNX 是关键:同一个 requestId 只有一个请求能拿到锁,其他并发请求走 getDrawResult 读取已保存的结果。TTL 设 30 秒是合理的,如果抽奖逻辑异常导致锁没释放,30 秒后自动过期,用户重新发起抽奖可以继续执行。锁的 TTL 不建议设太长,否则异常期间用户会一直拿不到结果。

幂等和数据一致性要一起考。抽奖写库顺序是:先写参与记录,再扣库存,再写中奖记录。中间任何一步失败,都要有补偿逻辑。扣库存失败时回滚参与记录;写中奖记录失败时回滚库存扣减。实际上即使有补偿,也一定存在极小概率的两边不一致,所以定时对账任务必须存在,核对参与表、中奖记录表、库存流水表三者的数量关系。

5. 抽奖系统避坑指南:5 个高频故障与排查路径

5.1 中奖概率和配置不一致

现象:运营在后台配置一等奖概率 5%,上线后实际中奖率统计出来只有 2%,活动做了一半才发现,运营火急火燎找到后端。

原因:权重是相对比例,不是绝对概率。权重表里通常包含“谢谢参与”这个伪奖品。运营只改了真实奖品的权重,没有同步调整总权重,导致绝对概率静默变化。比如一等奖权重从 5 调到 6,但“谢谢参与”权重还是 100,一等奖实际概率只是从 4.7% 涨到 5.6%,远没到运营以为的 6%。

解决:运营后台必须实时展示每个奖品的绝对中奖率,权重表任何变更都自动重新计算并展示。后端提供的接口里,直接返回归一化后的概率给前端展示,不让运营自己换算。

5.2 奖品超发:并发扣减做对了才有底气

现象:活动上线第一天,一等奖库存 10 台,活动结束统计中出 15 台,超发 5 台。用户投诉、客服工单、事故报告一起压过来。

原因:库存扣减用了“先 SELECT 查 remaining_count,再 UPDATE 减一”,两个操作之间存在并发窗口。请求 A 和请求 B 同时读到 remaining_count=1,都执行了 UPDATE,最终库存变成 -1,但两个奖都发出去。这类问题在低并发压测中很难稳定复现,一上大促就露馅。

解决:库存扣减必须原子化。数据库方案用条件更新UPDATE ... WHERE remaining_count > 0并检查影响行数;Redis 方案用 Lua 脚本把检查和扣减合并。接受一条经验:永远不要相信“先查再写”的两步操作,除非你加了分布式锁,但锁又会引发性能问题,能用条件更新解决的就用条件更新。

5.3 同一用户重复中奖

现象:活动规则是“每人每天抽一次”,后端日志里同一个用户在一分钟内中出 3 次二等奖。

原因:前端请求被重试,或者用户直接拿接口工具循环调用,后端没有幂等拦截。另一种更隐蔽的情况是判断用户当日参与次数的 SQL 存在漏洞——先插入参与记录再统计数量,并发下两个请求都统计到 0,于是都通过了资格校验。

解决:幂等锁放在抽奖流程最前面,SetNX 对 requestId 去重。参与次数判断加两条防线:user_participation 表的唯一索引确保一个活动一个用户只有一条记录,业务代码在写参与记录前再做一次缓存计数。即使两个请求穿透了缓存,唯一索引也会把第二条插入直接打回去。

5.4 缓存和数据库库存不一致

现象:活动还剩 100 份库存,但 Redis 已经扣到 0,用户全部抽不到奖;或者反过来,Redis 显示还有 50 份,数据库实际已经没了,用户显示中奖,发奖时就没货可发。

原因:库存预热到 Redis 后,扣减流水没有同步回数据库,或者同步脚本出现故障未及时发现,两边状态漂移。这个问题比超发更隐蔽,因为线上接口还在正常返回,只是异常率在悄悄上升。

解决:每次 Redis 扣减都写一条库存变更流水,消费者异步把流水同步到数据库,同步过程要幂等,避免流水重复消费导致数据库多扣。活动结束后跑“库存收敛”任务,把 Redis 剩余值和数据库剩余值做全量校准,以数据库为准。Redis 只做高速状态层,数据库永远是对账基准。

5.5 发奖消息丢失

现象:用户看到“恭喜中奖”页面,但优惠券迟迟没到账,客诉进来才排查出消息队列已经丢了一批消息。

原因:发奖消息在投递到队列前就抛了异常,代码里 catch 住后只写日志没有重试;或者消费者处理消息时进程重启,消息消费超时被队列自动丢弃。

解决:建立兜底补偿机制。中奖记录表 status 从“待发放”变为“已发放”依赖消费端回写。补偿任务每 5 分钟扫描一次 status=待发放 的记录,重新投递发奖请求。补偿任务直接调发奖服务接口,不要重新往队列投递,避免队列本身故障时整个链路瘫痪。核心逻辑就十二个字:记录先行、消息异步、补偿兜底。

6. 上线后的进阶玩法:AB测试与效果追踪

6.1 抽奖策略的AB测试分组

抽奖系统上线不意味着结束,价值要体现在数据上。做 AB 测试时,我的做法是按 user_id 哈希分桶,比如user_id % 100取模,1~50 为 A 组,51~100 为 B 组。A 组用常规概率,B 组用高概率低面值策略,跑两天对比核心指标。

关注的指标不能只有中奖率,应该是一个复合集:参与率、分享率、次日留存、人均成本。中奖率高但留存没提升,说明奖品对目标用户吸引力不够;中奖率低但分享率高,说明“抽奖”本身作为娱乐机制已经起了作用。AB 测试结果要按用户来源渠道做交叉分析,否则流量结构差异会污染结论。

6.2 监控指标与告警基线

监控除了 QPS、响应时间、错误率这些常规项,抽奖系统必须要盯几个业务指标。我列一个常用的告警基线表:

指标告警阈值说明
实时中奖率低于 3% 或高于 20%权重配置被改坏或预算超支的前兆
发奖成功率低于 99%发奖链路不稳定,消费端处理异常
预算消耗速率超过预估 20%可能存在刷单用户批量中奖
库存水位低于总库存 10%提醒运营决定是否补货或调低权重

监控分两层搭。第一层资源监控,涵盖 CPU、内存、连接池、Redis 命中率;第二层业务监控,中奖率、参与人数、发奖成功率,基于日志埋点采集。我自己的一个习惯是每次活动结束后做一次全链路数据回放,把当天的抽奖请求按 requestId 重放,核对每一步扣减、结果和发放状态。抽奖系统虽然不是支付系统,但每一份奖品都有真实成本,本质上是一种成本流出,值得用同样严谨的态度对待。希望这些踩坑经验和实现思路对你有帮助,做抽奖系统的路上,愿你不用像我一样翻过车才想到加补偿任务。

本文还有配套的精品资源,点击获取

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

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

立即咨询