十年匠心定制 · 商业建站与技术教学双线并行 咨询热线:400-886-1026 service@lmnt.cn
ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

从零构建生产级抽奖系统:高并发、防超发与风控实战

从零构建生产级抽奖系统:高并发、防超发与风控实战 简介smart-lottery 抽奖系统是一套面向互联网C端人群营销活动场景的Java源码项目基于Dubbo与SpringBoot构建适合后端开发者学习分布式服务拆分、RPC调用及高并发抽奖业务落地。资源共163个文件主体为140个Java类文件覆盖活动配置、抽奖执行、奖品管理、用户参与等核心模块16个XML文件多用于服务配置与Mapper映射另含yml配置、Markdown说明及图片整包仅264KB结构精简便于快速定位。目前已有190人学习下载。通过研读源码可掌握Dubbo服务治理与SpringBoot自动装配在真实项目中的组合方式理解抽奖业务从活动创建、参与、执行到中奖结果的完整链路并学习到单元测试、异常处理、安全防护、缓存与并发控制等工程实践适合希望从单体应用走向微服务、或正在设计营销活动类系统的开发者参考。1. 项目整体设计与核心思路1.1 为什么抽奖系统需要单独立项这两年做C端营销活动抽奖几乎成了标配玩法。从电商大促到App拉新从公众号涨粉到线下门店引流运营同学张口就是“搞个H5抽奖呗”。但真上手做了才知道一个能扛住线上流量、能灵活配置活动、能保证奖品不超发、还能防羊毛党薅穿的抽奖系统远不是后端写个random()就能交差的。smart-lottery这个项目本质上就是给业务方提供一个可配置、可扩展、可观测的抽奖中台。它不是针对某一次活动定制的“一次性代码”而是把抽奖场景里最通用的能力抽出来——活动管理、奖品池配置、概率控制、库存扣减、发奖流程、风控拦截——做成一套可以复用的系统。这样运营每次搞活动只需要在后台配置活动规则和奖品前端接好接口就能快速上线不用每次都从零开发一遍。从定位上看这个项目是典型的互联网面向C端人群营销活动类系统。这意味着它和内部用的抽奖工具比如年会抽奖、团建抽奖有本质区别C端系统需要面对海量并发请求需要考虑用户体验不能转半天圈需要防范恶意刷取普通用户抽一次羊毛党能抽一万次还需要能追溯每一笔发奖记录。这些要求叠加起来就决定了系统的技术选型和架构设计必须一开始就往“生产可用”的方向走。提示如果你只是写个demo或者内部小工具完全不用这么复杂。但要做成面向C端营销场景的抽奖系统并发、风控、库存一致性这三件事一个都躲不掉。1.2 整体架构与核心模块划分smart-lottery的系统架构按职责可以拆成四个核心模块活动与规则模块负责管理抽奖活动的生命周期创建、上线、下线、活动时间范围、每人可抽次数、抽奖消耗的积分或次数。这部分是业务入口运营直接操作所以要做成可视化管理后台。奖品与库存模块管理奖品信息、奖品级别一等奖、二等奖、谢谢参与等、库存数量。这里最核心的是“库存扣减”的原子性——高并发下不能出现奖品发超了、中奖了但库存没了这类问题。抽奖引擎模块这是整个系统的心脏。它根据配置好的概率规则、奖品库存、当前参与人数等条件计算本次请求的中奖结果。抽奖引擎的输入是“活动ID用户标识”输出是“是否中奖中奖的奖品ID”。发奖与对账模块用户中奖后系统需要记录中奖记录、发送发奖通知比如优惠券编码、实物奖品寄送信息收集链接同时通过消息队列做异步处理避免发奖逻辑阻塞主链路。从部署视角看系统还需要支撑层Redis缓存活动配置、扣减库存、MySQL持久化获奖记录和活动配置、MQ消息队列做异步发奖。这套架构不是什么新鲜玩意但贵在“够用且稳”——每个组件都是抽奖场景里绕不开的标配。2. 抽奖算法与概率控制的核心实现2.1 概率模型固定概率还是总量控制抽奖系统里最常见的坑就是把“概率”理解得太简单。运营说“一等奖中奖率1%”很多新手就直接random()小于0.01就中一等奖。这个逻辑在奖品无限多、并发量很低的场景下勉强能用但在真实营销场景中一定会出事——因为用户的抽奖行为不是均匀分布的大概率集中在活动开始的前几分钟或者某个固定时段算上并发因素中奖数量会在短时间内剧烈波动可能奖品池一分钟就被抽光了。smart-lottery里有两种概率控制方式需要仔细理解固定概率每个奖品单独配置中奖概率比如一等奖0.01%、二等奖0.1%、三等奖1%、谢谢参与98.89%每次抽奖时按概率独立判定。这种方式实现简单但存在一个固有缺陷——奖品总量不可控。如果奖品池里一等奖只有10个而实际参与抽奖的人数有10万固定1%概率就意味着理论上会有1000人中奖但库存只有10个系统就面临“要么超发、要么发不出”的两难。总量控制奖品总数固定系统根据“当前剩余库存/预估剩余参与人数”动态调整中奖率保证活动结束时奖品刚好发完或者尽量控制在一个合理区间内。这种方式对用户来说感知更自然不会出现大奖一开始就被抽完后面全是谢谢参与但实现复杂一些需要做概率的动态计算。smart-lottery的实际做法是把两者结合活动配置时同时设置“奖品总数”和“单次抽奖基准概率”系统在运行时通过Redis记录实时库存每次抽奖前先检查库存库存不足的奖品直接从候选池中移除然后再按剩余奖品的配置权重大概率抽取。这种“权重库存过滤”的方案既保证了奖品不会超发又能让概率大致符合运营配置的期望。注意配置概率时所有奖品的概率之和必须是100%如果算上“谢谢参与”这个虚拟奖品。否则可能出现概率真空用户抽奖时什么结果都没有接口报错体验极差。2.2 抽奖引擎的具体实现方案抽奖引擎的核心数据结构是奖池。奖池里放着所有可抽的奖品每个奖品有唯一ID和对应的权重。用户发起抽奖时引擎按以下流程执行根据活动ID从Redis缓存中加载奖池配置如果没有缓存则从MySQL加载并写入缓存。过滤掉库存为0的奖品剩余奖品组成“可抽奖池”。计算可抽奖池的权重总和生成一个[0, 总权重)区间的随机数。遍历可抽奖池按权重累加当累加值大于随机数时当前奖品即中奖结果。如果可抽奖池为空所有奖品库存都发完了则直接返回“未中奖”。这里的随机算法我推荐使用“加权随机”而不是Math.random()直接比大小——加权随机天然支持权重调整比如你想让某个奖品的中奖概率提高只需要把它的权重值从10改成20不需要改代码逻辑。在代码实现上Redis缓存奖池配置可以显著降低数据库压力。但要注意缓存的更新时机运营在后台修改奖品配置后必须立刻删除对应活动ID的缓存否则用户会一直在用旧配置抽奖。实际开发中我用的是一个简单的方案——运营后台每次保存配置时主动删除该活动ID的缓存Key下次请求自动回源MySQL加载最新数据。// 加权随机抽奖核心逻辑伪代码 public Prize drawLottery(Activity activity) { ListPrize prizePool getAvailablePrizes(activity.getActivityId()); if (prizePool.isEmpty()) { return null; // 无奖品可抽返回未中奖 } int totalWeight prizePool.stream() .mapToInt(Prize::getWeight) .sum(); int randomNum ThreadLocalRandom.current().nextInt(totalWeight); int currentWeight 0; for (Prize prize : prizePool) { currentWeight prize.getWeight(); if (randomNum currentWeight) { return prize; } } return null; }3. 核心链路与关键场景实操3.1 从“用户点击抽奖”到“奖品到账”的完整链路面向C端的抽奖系统完整链路不能只停留在“算出中奖结果”这一步。一个用户在前端点击抽奖按钮后后端要经历的事情远比想象中多。我把smart-lottery的主链路按顺序拆出来每一步都有它存在的理由第一步前置风控校验。在进入抽奖逻辑之前先要判断这个用户“有没有资格抽”。比如活动是否在有效时间内、用户当天抽奖次数是否用尽、用户是否在风控黑名单里、请求IP的频次是否异常。这些校验放在最前面可以拦截掉大量无效请求节省后端的计算资源。第二步锁定用户抽奖资格。校验通过后需要对用户本次抽奖资格做“预占”。这里用Redis的INCR或者Lua脚本比较稳妥保证同一时刻一个用户只能有一个抽奖请求在处理中避免用户疯狂点击、后端被同一请求打穿。这个步骤本质上是做并发控制。第三步执行抽奖引擎。用刚才说的加权随机算法算出中奖结果。这时要注意只是算出了“中奖”还没有实际扣库存。第四步扣减库存。这是全链路里最关键的步骤采用Redis原子操作扣减扣减成功才继续失败则返回“手慢了奖品被抢光了”之类的提示文案。第五步写中奖记录与异步发奖。中奖记录写入MySQL这里要保证主流程只写一条关键记录然后发送MQ消息由发奖消费者去处理具体的发奖动作——发优惠券、生成兑换码、发送实物寄送通知等。把发奖动作异步化后主链路延迟不会增加用户体验更平滑。第六步返回结果给前端。前端拿到中奖结果后展示给用户整个链路完成。3.2 库存扣减的原子性与超发问题库存扣减是抽奖系统最容易翻车的地方。我曾经见过一个上线不到半小时奖品就超发了几千份的活动原因就是用了select后再update这种检查再修改的方式。在高并发下两个请求同时读到库存还剩1个都判断可以扣减结果两个人都中奖了库存变成负数依然继续发。规范的做法是使用Redis的Lua脚本实现原子性扣减。示例逻辑如下-- 扣减库存的Lua脚本 -- KEYS[1]: 活动库存Key -- ARGV[1]: 需要扣减的数量通常为1 -- 返回值: 1扣减成功, 0库存不足 local stock redis.call(GET, KEYS[1]); if not stock then return 0; end if tonumber(stock) tonumber(ARGV[1]) then redis.call(DECRBY, KEYS[1], ARGV[1]); return 1; end return 0;用Lua脚本的好处是整个“查询-比较-扣减”过程在Redis单线程模型下是原子性的不会被其他命令插队。但要注意Redis库存和MySQL中的库存记录要保持一致。Redis作为缓存层可能因为宕机、Key过期等原因丢失数据所以库存的最终账本还是要落在MySQL上。我采用的方式是Redis负责实时的库存扣减抗住高并发MQ消费者异步地把中奖记录写入MySQL定时任务比对Redis剩余库存和MySQL已发奖品数量如有差异则告警处理。注意Redis库存Key务必设置一个合理的过期时间且不能依赖Redis的持久化来保证库存准确。宁可做定期对账也不要把所有鸡蛋放在Redis一个篮子里。3.3 幂等设计重复点击与重复发奖的解决用户抽奖时最容易出现的问题是“手抖”——前端按钮还没反应过来用户已经连点了三下。如果后端不做幂等处理这三次请求会全部进入抽奖流程用户可能中了三个奖如果库存充足但实际上运营只想让用户抽一次。解决办法是在用户点击抽奖时生成一个请求唯一标识requestId后端用Redis的SETNX命令以requestId为Key做幂等校验。如果Key已存在说明该请求已处理过直接返回上一次的结果不再重复执行抽奖逻辑。// 幂等校验示例 boolean firstRequest redisTemplate.opsForValue() .setIfAbsent(lottery:request: requestId, 1, 10, TimeUnit.MINUTES); if (!firstRequest) { return previousResult; // 直接返回已处理的结果 }这个设计的巧妙之处在于它同时解决了“重复点击”和“接口重试”两个问题。对于发奖流程幂等也非常关键——异步消费者处理MQ消息时需要保证同一条消息不会因为消费者重启或网络超时而被处理两次。做法是在发奖记录表里为“用户ID活动ID奖品ID”建唯一索引插入时如果冲突则说明已发过奖直接跳过。4. 高并发场景下的性能优化与保障4.1 缓存策略哪些数据该缓存哪些不该缓存抽奖系统天然是高并发读、高频写的场景。每个用户抽奖时都要读取活动配置和奖池信息如果全部走MySQL数据库很容易被打爆。我的经验是“动静分离”——不变或者少变的配置数据放缓存变化频繁的数据也要放缓存但要做好过期策略。活动配置活动时间、奖品列表、权重配置属于“静态数据”活动一旦配置好很少变动可以缓存在Redis中缓存时间设置成活动结束或配置变更后主动失效。奖品库存属于“动态数据”必须缓存但更新的频率极高要注意用原子操作而不是先读后写。除了Redis缓存还有一些性能优化手段值得尝试**本地缓存Caffeine/Guava Cache**存储活动配置减少对Redis的依赖进一步降低IO开销。每个抽奖服务节点启动时从数据库加载活动配置到本地运营修改配置后通过Redis发布订阅Pub/Sub通知各节点刷新本地缓存。这套方案实测下来核心链路完全不依赖MySQL查询抗压能力能翻好几倍。4.2 限流与降级防止活动被流量打垮C端营销活动的流量曲线往往是“尖峰模式”——活动预告说晚上8点开始到了7点59分流量就开始聚集8点整瞬间涌入大量并发请求。如果事先不做限流系统可能在几秒内被打到宕机。smart-lottery里我用了两层防护接入层限流Nginx或网关层配好每台服务器的QPS上限超过阈值的请求直接返回“系统繁忙请稍后重试”。这层限流的作用是保护后端服务不被没有业务意义的流量淹没属于最基础的保命手段。应用层限流针对单个用户做频次控制。比如同一个用户ID或设备ID每秒钟最多允许一次抽奖请求超出的直接拦截。这里可以用Redis的INCR配合EXPIRE实现一个简单的滑动窗口计数器// 简单限流实现 String key lottery:rate: userId; Long count redisTemplate.opsForValue().increment(key); if (count 1) { redisTemplate.expire(key, 1, TimeUnit.SECONDS); } if (count 1) { throw new RateLimitException(操作太频繁请稍后再试); }降级方面如果抽奖引擎依赖的Redis出现故障系统要能自动切换到降级模式——比如直接关闭抽奖入口返回“活动暂不可用”而不是让用户在页面上无限转圈。降级逻辑要提前写好别等事故发生了再临时改代码。4.3 数据库分表与读写分离抽奖记录表是中奖数据量增长最快的地方。一次大型活动下来可能产生几十万甚至上百万条抽奖记录。如果所有记录都写在单表里后续查询和统计都会变得很慢。我的做法是按活动ID分表lottery_record_${activityId % 10}每张表只存对应活动的数据。查询时根据活动ID找到对应的表写入时也是同样的路由逻辑。如果活动数量特别多还可以再按时间维度做二级拆分比如按月分表。另外读写分离也值得考虑。用户查询自己的中奖记录属于读操作管理员后台做活动数据统计也属于读操作这些都可以走只读从库把主库的负载省下来应对写入。实操心得分表字段的选择很关键。我用的是“活动ID取模”因为运营查数据通常是按活动维度查的这样路由效率最高。如果用用户ID分表反而会让“按活动查全量数据”变成噩梦。5. 常见问题与排查技巧实录5.1 “明明配置了中奖率但用户死活中不了奖”这是运营最常反馈的问题。排查思路一般是先确认该奖品的库存是否已经为0。很多时候是库存发完了系统按预期把奖品从奖池里过滤掉了但运营看的还是活动初期的配置。确认奖池权重总和是否正确。如果配置时不小心把“谢谢参与”的权重也设置成了0而所有实物奖品的库存又刚好为0就会出现“无奖可抽”的情况用户看到的永远是未中奖。查看Redis缓存中的奖池配置是否被刷新。如果缓存未失效系统用的可能是修改前的配置。我的建议是在管理后台加一个“模拟抽奖”功能运营可以输入一个测试用户ID做100次模拟抽奖实时统计每个奖品的中奖次数分布。这比让开发去翻日志高效多了。5.2 “奖品库存显示还有但用户说抽不到”这种情况十有八九是“Redis库存和实际发奖数量不一致”。常见原因有Redis库存Key被过期删除了。缓存过期后重新初始化加载的是MySQL里的初始库存导致Redis显示库存充足但实际MySQL里记录的中奖数量已经到了上限。MQ消息重复消费发奖记录写入MySQL时被唯一索引拦截了但Redis库存已经扣减了。定时对账任务没跑Redis库存和MySQL记录之间的差异没有被及时发现。解决方法是对账任务必须每天都跑。将MySQL中的发奖记录按活动ID聚合统计实际发奖数量再和活动的初始总库存减去Redis当前库存做比对。如果两边不一致以MySQL记录为准回写修正Redis库存。这类问题最容易发生在活动高峰期过后Redis缓存过期或者数据被清理而MySQL数据是完整的。这也是我一直强调“MySQL才是最终账簿”的原因。5.3 “用户抽中了奖但发奖消息一直没到账”这条链路涉及MQ。排查思路是先确认中奖记录是否已经写入MySQL。如果写了说明抽奖引擎和库存扣减没问题卡在发奖环节。查看MQ消费者日志确认是否消费了消息消费过程中有没有抛出异常。如果消费者报错看是不是发奖接口比如对接优惠券系统依赖的外部系统超时或报错。如果消息被消费了但发奖没成功检查是否有手动重试机制或者死信队列里有没有堆积的消息。我实际踩过的一个坑是MQ消费者没有做重试退避外部发奖接口网络抖动了一下消息消费失败后被立即重试还是失败再重试最终消息被丢弃了。后来我改成消费失败后延迟重试比如10秒后、1分钟后、10分钟后各重试一次还失败就进死信队列人工处理。实操中另一个很重要的点发奖结果要支持人工补偿。管理后台应该提供一个“补发奖品”的操作入口运营发现用户中奖但未到账时可以手动触发补发流程。很多时候快速的人工介入比反复排查代码更实际。6. 个人实操经验与项目复盘抽奖系统这个项目做完以后我有一个很直观的感受营销类系统拼的不是算法多炫酷而是稳定性、一致性和可观测性。用户不会关心你的抽奖引擎用了什么随机算法他们只关心“我抽的时候系统卡不卡”“我中了奖能不能拿到”。而运营只关心“活动配置能不能灵活改”“奖品会不会超发”“数据报表准不准”。技术上的每一个决策最终都要落到这两类人的体感上。如果让我给准备做类似系统的朋友几个建议我会说第一从第一天就把可观测性做进去。抽奖主链路的每一步耗时、Redis命中和扣减的成功率、MQ消息积压量这些都要有指标监控。线上活动出问题时这些数据能帮你快速定位到底是风控拦截太多、库存扣减失败还是发奖消费者卡住了。第二给运营留够灵活配置的空间。权重配置、奖品上下架、活动白名单、每人限抽次数这些都应该做成后台可视化配置。运营能够自己调整的活动就不会半夜打电话找你对数据改代码。第三不要过度设计。这个项目最开始我考虑过引入分布式事务框架后来发现抽奖场景根本用不上。库存一致性的关键点在Redis扣减发奖一致性的关键点在MQ消费的幂等把这两个点做好系统的稳定性就足够了。复杂的分布式事务只会增加实现成本和排查难度。最后分享一个小技巧抽奖活动的概率配置建议上线前先在测试环境用脚本跑十万次模拟抽奖统计每个奖品的实际中奖分布和预期配置做比对。这个验证步骤可以帮你提前发现权重配错、库存不足之类的低级问题而不是等用户上线后去投诉。本文还有配套的精品资源点击获取
返回列表