校园抽奖系统开发实战:并发安全、防刷设计与库存扣减全解析

发布时间:2026/9/10 20:15:11
校园抽奖系统开发实战:并发安全、防刷设计与库存扣减全解析 前阵子帮一所高校的信息中心搭了一套校园活动抽奖小程序需求扔过来的时候看着特别简单用户进入小程序、点抽奖、中奖显示恭喜、没中显示谢谢参与。等真正做起来才发现所有的坑都藏在“简单”这两个字后面——并发抽超、一人多号刷奖、兑奖核销对不上、活动结束后被质疑内定。这篇就把我从需求梳理到上线维护的完整思路写出来重点不是贴一堆代码而是讲清楚“为什么这么做”。不管你是拿这个题目做毕业设计还是接了个外包单子或者纯粹想给社团做个活动工具都应该能从中少走几步弯路。1. 把需求问清楚之前别急着写代码校园抽奖系统的本质是什么1.1 一次校园活动抽奖的典型流程拆解校园活动抽奖迎新晚会、社团嘉年华、运动会积分赛、读书节打卡和电商抽奖有个很大的区别它是一个有明确时间边界、有线下物理场景、还必须有公信力的短期活动。典型的流程是这样主办方创建活动配置抽奖规则、奖品和库存学生用微信扫码进入小程序完成微信授权登录绑定学号和姓名这里后面会细说为什么必须做活动开始后在抽奖页面点击抽奖中奖后生成兑奖码现场领奖或者指定地点领取后台核销奖品、导出中奖名单活动结束后公示参与人数看起来不算夸张一个300人的活动算是常态1000人以上的大型晚会也不少见。但有一个规律必须提前知道并发洪峰几乎都集中在活动开始后的前五分钟。大家的好奇心是高度同步的主持人话筒一响所有人都同时按下按钮。你的系统如果只按“300人在线”去估压力测试多半会翻车因为瞬间落到底层抽奖接口的QPS会远高于平均值。1.2 “为什么大多数抽奖系统会把奖品抽超”的根源我刚入行时也犯过这个毛病前端点击按钮 → 后端生成随机数 → 随机数落在哪个区间就返回哪个奖品 → 把库存减一。这套逻辑在单用户测试时跑得行云流水并发一上来就废了原因是经典的check-then-act 竞态问题。什么是 check-then-act就是“先检查再操作”这两个步骤之间存在一个时间窗口。两个抽奖请求同时到达同时读到库存是1同时判断“还有库存可以中奖”然后各自执行库存减一一等奖就这么发出去了两份。在电商场景这叫超卖在校园抽奖场景就是事故。晚会现场大屏幕上滚出两个一等奖主办方脸上挂不住你要半夜改数据这我经历过。所以要根治这个问题不能靠“运气好没撞上”而是要把“检查库存”和“扣减库存”合并成一个原子操作让数据库来保证并发安全。这个技术方案我放到第2章详细说。1.3 系统边界你需要哪些模块和页面很多拿到题目的人第一反应是“我要写一个超级完整的小程序”然后开始规划积分商城、分享得次数、签到加成。我劝你先打住校园抽奖的绝大多数需求根本不需要这些。先把核心链路跑通再谈扩展。按我的经验这套系统应该拆成三个端学生端小程序活动首页、登录/学号绑定页、抽奖页、中奖记录/兑奖码页、公示页管理端Web后台活动配置、奖品管理、中奖记录、兑奖核销、数据统计、管理员日志服务端身份认证、抽奖核心服务、数据存储、定时任务活动自动开奖/结束未领奖回收这个边界划分很重要。管理端尤其不能省因为校园活动抽奖不是“用户抽完就结束了”后面还有兑奖、核销、复盘、公示一整套流程。如果你只做小程序端做出来只能算个Demo没法真正落地给学生会用。2. 中奖算法和库存扣减公平与不超发才是核心2.1 随机数的本质与 Math.random() 是否够用先聊一个很多新手忽略的问题随机数从哪来小程序前端写Math.random()服务端也写Math.random()看起来都能出随机结果但两者有着本质区别。前端产生的随机数完全暴露在用户手里抓包的人可以翻来覆去分析你的随机规律。即便你用的是后端随机数如果随机数生成器本身可预测比如旧版一些语言里以时间为种子、弱随机算法在懂行的人眼里就是可破解的。我建议的做法随机数一律在服务端生成。Java 用SecureRandomNode.js 用crypto.randomInt成本不高而且对外也好解释——“我们的抽奖结果由服务端安全随机算法产生”。同时要把每个用户每次抽奖的关键参数用户标识、活动ID、时间戳、随机数落日志。这不只是随机数质量问题更是审计需求。一旦有人质疑抽奖内定你能拿出每一笔抽奖的产生记录来回应。2.2 三种抽奖算法选型对比“抽奖算法”听起来很高端其实核心就两件事怎么从奖池中选出奖品怎么保证奖池不被抽超。我把常见的几种方案整理成一张表方便直接对照选型。方案原理优点缺点适用场景直接随机映射区间所有奖品按概率划分0-100区间随机数落在哪个区间就中哪个实现最简单奖品库存为0时区间要动态排除逻辑会越写越绕奖品少、逻辑简单的活动权重随机库存过滤每次抽奖前过滤掉库存0的奖品剩余奖品按权重随机灵活、好维护需要一个可靠的随机数来源大多数活动首选Fisher-Yates洗牌把奖品看成一副牌预先打乱用户按顺序取牌总库存消耗精准活动进行中调整奖品很麻烦奖品总量固定、保证要全部发完的活动保底中奖抽满N次必中一次参与感强概率组合计算复杂需要提升参与度和粘性的活动我的默认选择是“权重随机库存过滤”。因为它能把“未中奖”也建模成一个普通选项中奖概率用权重表达运营配置起来很直观。伪代码大概是这个样子public static Prize randomByWeight(ListPrize prizeList) { int totalWeight prizeList.stream().mapToInt(Prize::getWeight).sum(); int randomValue ThreadLocalRandom.current().nextInt(totalWeight); int cursor 0; for (Prize prize : prizeList) { cursor prize.getWeight(); if (randomValue cursor) { return prize; } } return prizeList.get(prizeList.size() - 1); }这里有个容易踩的细节权重总和不是100没关系因为它被当成“总区间长度”来用了。但运营配置时如果非要写成百分比记得做一次归一化或者直接提示“所有奖品权重之和建议等于100”。2.3 库存扣减必须做到“抽了就是锁了”事务与原子更新抽奖的核心链路我的标准做法是五个步骤串在一个事务里Transactional(rollbackFor Exception.class) public DrawResult draw(String userId, String activityId) { // 1. 校验用户资格抽奖次数、活动时间、是否绑定学号 // 2. 加载当前可抽奖奖品列表过滤掉库存为0的 // 3. 按权重随机选中一个奖品可能为“未中奖” // 4. 原子扣减库存影响行数为0则重新抽或返回未中奖 // 5. 写入抽奖流水和中奖流水返回结果 }关键的第四步用一条SQL把“检查并扣减”合并成原子操作UPDATE prize SET stock stock - 1 WHERE id #{prizeId} AND stock 0这条SQL的执行原理是数据库在 UPDATE 时会对命中的行加行锁stock 0是更新条件的一部分。两个并发请求同时执行时只有第一个请求能扣成功并返回影响行数1第二个请求也会等到锁释放后再执行但此时stock 0已经不成立影响行数就是0。程序里判断rows 0就说明这个奖品已经被抢完了。这里提醒一句写事务方法时不要自己在方法内部把异常 catch 掉然后什么都不抛。事务回滚依赖异常传播你吞掉异常事务就以为“一切正常”然后提交了我当时代码评审看到不少这种写法测试环境还能跑线上就等着数据错乱。2.4 并发压力评估校园活动到底需要多强的并发我不建议一上来就上 Redis 分布式锁、MQ 削峰、Flink 实时计算那一套。先算笔账300人的活动理想情况下所有人都同时点抽奖落到后端真实接口的并发可能也就是几十到一两百 QPS。MySQL 单机完全可以扛住这个量级前提是表建好索引、事务别拖太长、每次请求控制在几十毫秒内。那什么情况才需要升级方案目标用户全校两万人同时参与单场活动百万级抽奖请求这时才需要考虑 Redis 预扣库存。设计思路是活动开始前把奖品库存预热到 Redis每次抽奖先DECR扣减成功后再异步写 MySQL 流水。但这会引入一致性问题万一 Redis 宕机、库存数据丢了你要额外写对账程序去修复。校园活动用不着拿稳定性去换这个吞吐量。3. 用户身份与防刷设计小程序端永远不能作为可信端3.1 openid、unionid、手机号、学号校园场景下谁是唯一身份做小程序登录绕不开这几个标识的区分标识获取方式特点使用建议openid微信登录 code2Session 换取在单个小程序内唯一换一个公众号/小程序就不同服务端标识“这个微信用户”的主键unionid微信开放平台绑定后获取同一开发者账号下的多个应用唯一只有需要打通公众号、App用户体系时才用手机号button open-type 弹窗授权敏感信息个人主体小程序拿不到非必要不收集学号/工号用户手动填写后台审核校园场景真正的身份依据现场兑奖核验凭证很多校园抽奖系统最大的问题是只做了 openid 登录没有做学号绑定。如果某位同学把自己中奖页面的截图发给舍友舍友拿着截图就能去领奖因为系统里根本没有“你是谁”的信息。虽然现场工作人员可以核对校园卡但电子兑奖码没和身份绑定终究是个大漏洞。3.2 用户可以伪造请求吗抓包、反编译视角必须把话说透小程序安装包就在用户手机里它本质上是可以被解包分析的。网上有很多现成工具可以把小程序完整解包出来看源码你的接口地址、参数名、加密逻辑在懂行的人眼里几乎是透明的。更直接的是用抓包工具在 PC 端代理小程序的流量用户点一次抽奖请求长什么样、参数怎么传、响应怎么解析全都看得清清楚楚。攻击者完全可以绕过小程序界面写一个脚本直接循环调用你的抽奖接口。所以这条铁律必须刻在脑子里前端传过来的任何参数都不可信所有资格校验必须在服务端完成。不能在接口里传“user_id”让前端自己填不能用前端传来的参数决定“这个人能不能抽”更不能把抽奖结果判断的逻辑暴露在前端代码里。安全设计上把所有涉及资格、库存、结果的判断全部收敛到服务端事务里。3.3 防刷拦截的层次与设计一套稳妥的防刷体系从低到高分五个层次微信登录凭证每次请求携带服务端签发的 token解析出真实用户身份而不是信任前端传的 userId业务资格校验是否在活动时间内、是否已绑定学号、当前用户今日/本次活动剩余抽奖次数是否为0频率限制同一个用户对抽奖接口的最短调用间隔比如3秒一次超出直接拒绝接口限流对抽奖接口做全局 QPS 限制避免被脚本打爆数据库异常行为审计同一IP大量请求、同一设备大量openid、凌晨高频率抽奖等后台记录并告警3.4 个人主体 vs 组织主体这个坑要先避开很多校园开发者自己用个人身份注册小程序就开工了结果做到一半发现功能受限。个人主体的小程序无法开通微信支付、无法获取用户手机号、部分类目也审核不过。校园抽奖系统通常不需要微信支付但如果你想做“积分兑换抽奖次数”这类有资金闭环的功能就必须用学校或公司的组织主体注册。我建议项目启动前先确认这个小程序准备挂在谁的资质下学校的组织主体虽然申请流程麻烦但后面功能限制少得多。4. 小程序端那些“看着不复杂、做起来很要命”的细节4.1 顶部导航栏与安全区适配很多人一看到“页面要适配不同手机”就头大其实搞懂原理就不难。如果你用了自定义导航栏navigationStyle: custom那导航栏高度绝对不能写死成一个颜色块不同机型的状态栏高度、胶囊按钮位置都不一样。最好的做法是直接读取微信提供的胶囊按钮位置来计算导航栏高度const systemInfo wx.getSystemInfoSync(); const menuButton wx.getMenuButtonBoundingClientRect(); const statusBarHeight systemInfo.statusBarHeight || 44; const navBarHeight (menuButton.top - statusBarHeight) * 2 menuButton.height;这段代码的意思是胶囊按钮的顶部减去状态栏高度乘以2再加上胶囊本身的高度就能算出导航栏自定义标题应该占的高度。这是目前最通用的适配方案单独写死44px或64px在刘海屏、折叠屏上都会出问题。页面底部用env(safe-area-inset-bottom)处理安全区避免抽奖按钮被手势条遮挡。4.2 抽奖按钮的防连点与请求竞态用户连点抽奖按钮是个必踩的坑。前端至少做两层防护按钮在请求期间置灰loading 状态在drawing标记为 true 时再次点击直接 return。Page({ data: { drawing: false }, async onDrawTap() { if (this.data.drawing) return; this.setData({ drawing: true }); try { const res await request(/api/draw, { requestId: this.generateRequestId() }); handleResult(res); } catch (e) { showErrorToast(网络异常请重试); } finally { this.setData({ drawing: false }); } } });但只做前端防护不够。绕过前端连点用脚本循环发请求的人根本不受按钮状态限制。所以后端也要做幂等前端每个抽奖请求生成一个requestId只要这个 ID 在 Redis 或数据库里存在直接返回上一次的处理结果不重复扣减抽奖次数。前端防误触后端防伪造两层一起才完整。还有页面生命周期的竞态用户点了抽奖请求还没返回就退出了页面这时候结果到底算不算我的处理方式是只要后端收到了请求并成功消费就算一次抽奖。页面销毁时如果发现还有未返回的抽奖请求可以提示一句“抽奖结果已生成请到中奖记录中查看”避免用户白白损失次数产生投诉。4.3 加载页、异常态和结果展示的体验设计抽奖结果展示要分三种状态设计中奖展示奖品名称、奖品图片、兑奖码按钮引导到“我的奖品”页未中奖展示“谢谢参与”可以加一句“分享活动可再获机会”如果需求里有分享逻辑异常网络请求失败或服务端错误必须展示重试按钮不能停在转圈动画里这里有个体验关键中奖后不要再让用户填一堆领奖信息。应该在学号绑定阶段就把姓名、学院、联系方式收全中奖后直接展示兑奖码就行。活动当天现场人多嘈杂让用户在网速波动下填表单非常容易填到一半放弃。4.4 如果项目用 uniapp还有几个额外注意点如果你打算用 uniapp 跨端开发热度很高但坑也不少。wx.getMenuButtonBoundingClientRect在 uniapp 里可以通过uni.getMenuButtonBoundingClientRect调用原理一样。另外 HBuilderX 运行到微信开发者工具时经常遇到“不是开发者”的报错这是因为没有在开发者工具的安全设置里打开服务端口或者是项目没有正确关联 AppID。这个问题百度一搜一大把提前知道能省半小时。5. 管理端活动、奖品、库存、公示缺一不可5.1 活动状态机与可配置项管理端第一个要设计清楚的是活动状态。我建议至少保留五种未开始、进行中、暂停、已结束、已归档。为什么必须有“暂停”因为校园活动是线下办的现场可能出各种意外——奖品发错了、主持人说“先停一下”、领导要加环节。如果后台没有一键暂停功能你就要临时连数据库改字段或者让所有学生干等着这在活动现场就是灾难。可配置项建议预留这些字段活动名称、活动时间范围、每人抽奖次数可拆分为总次数/每日次数、每个奖品的中奖概率或权重、奖品库存、是否开启兑奖码、是否展示中奖动画、活动后是否自动回收未领奖品。这些字段在后台表单里都做成可编辑前端页面尽量用配置驱动避免活动换一批奖品就要发一次版。对了如果用的是 Java Spring Boot 这类后端接口设计成 RESTful管理端和小程序端共用一套服务端只是权限角色不同。管理员走 Web 登录账号密码学生端走微信登录两套认证体系互不干扰。5.2 现场兑奖和过期未领的库存处理中奖后不能自动发奖必须有一个线下核销环节。我的做法是中奖时生成一个6位兑奖码现场工作人员在后台输入兑奖码或扫码核销。这个兑奖码有几个设计要求要短6位数字足够方便人工输入要带校验位比如前5位是随机码最后1位由前5位通过固定算法算出防止输错一位就冒领别人的奖核销后立即标记状态为“已兑奖”再次核销直接拒绝未领奖品的处理也是必须提前想好的。活动结束后如果有些中奖学生因为提前离场或者没看手机而没来领奖后台要能一键把“已中奖但未核销”的奖品重新回收进奖池或者按规则顺延给候补名单。这一步一定要和主办方确认不要自己擅自决定——有的主办方希望奖品作废收回有的希望留给下一场活动不一而足。5.3 公示页面与中奖记录导出校园活动对“公开公平公正”的要求非常高哪怕系统算法没问题一旦有人说“是不是内定”你没有公开数据就很难自证。所以最好专门做一个公示页活动结束后脱敏展示中奖记录姓名只保留姓、学号只保留后四位、显示中奖时间、奖品、中奖编号。主办方把这个页面截图发到公众号或者学生会群里就能挡住大部分质疑。数据导出也别忽略。后台用 CSV 导出就够了不要为了 Excel 引一个超重的前端库。后端生成 CSV 文件返回文件链接前端用window.location.href触发下载即可。导出的字段至少要包括抽奖时间、学号、姓名、奖品名称、兑奖码、核销状态、核销时间、核销人。5.4 管理员操作日志防内鬼也是公信力的一部分最后一个管理端模块是我在几次“活动后被质疑数据造假”之后才补上的管理员操作日志。谁能创建活动、谁改了奖品库存、谁手动补录了中奖记录、谁把某个奖品从暂停改成启用全部记录在案。哪怕学校内部的人想“走后门”他也会掂量一下事后审计时这些日志带来的麻烦。公信力这种事技术手段只能做到“尽量透明”但该做的防线一步都不能少。6. 实测踩坑复盘一次活动刚开始前50个人抽走了80%的奖品6.1 现象描述有一场活动运营反馈活动开始不到10分钟一等奖和二等奖就全部被抽完了。前50个用户抽走了80%的高价值奖品。用户当然没意见但主办方炸了——他们预期高价值奖品应该分散在整个活动的进程中这样才能持续吸引人到场参与。现在开场就发完了后半场活动的参与热情直接垮掉。第一反应是系统被刷了。我们立刻拉出抽奖流水看分布发现中奖用户的 openid 都是正常微信用户学号各不相同也没有同一个 IP 高频请求的迹象。看起来不像恶意刷奖。6.2 排查链路从日志到配置再到算法排查的第一步是看抽奖日志。我们记录了每次抽奖的随机数、时间戳、奖品ID随机数分布看起来是均匀的没有明显的规律。第二步看数据库事务有没有异常。查看库存扣减记录每个奖品的扣减时间和次数都没毛病没有出现超卖或者超发。第三步回到配置把活动的奖品配置表导出来。这里发现了问题——一等奖的权重被配成了和普通奖品一样的权重比如一等奖权重50、二等奖权重30、三等奖权重20而按主办方的意图一等奖的“中奖概率”应该远低于其他奖品。运营把“权重”理解成了“奖品数量”觉得一等奖50份、二等奖30份、三等奖20份再一填活动刚开始一等奖就被抽光了。6.3 根因与修复方案根因很清楚系统把“权重”这个技术概念直接暴露给业务人员配置了。技术人员眼里权重是概率区间长度运营人员眼里权重就是“数量多少”。同一个词两种理解线上直接出事故。修复方案我列了四条每一条都是这次事故换来的教训配置字段从weight改成更直观的probability中奖概率百分比并校验所有奖品的概率之和不得超过100后台增加模拟抽奖功能输入模拟次数比如10000次系统自动估算每个奖品的大致消耗时间和概率分布活动上线前发给主办方确认增加库存消耗预警一等奖库存剩余不足20%时管理端弹窗提醒增加“预演模式”用测试账号和模拟数据完整跑一遍抽奖、兑奖、核销流程确认异常情况不会被带进正式活动这个坑背后是一个很普适的道理不要直接把专业术语扔给业务人员。同样的教训在活动配置、奖品导入、兑奖流程里都会反复出现。后来我们每次上线新活动都走一遍“预演模式”让主办方亲手点几次抽奖、核销几次兑奖确认“节奏”符合预期再放量。这个习惯帮我挡掉了很多次现场事故。顺带说一句类似这种“看似数据异常、先怀疑有人刷接口、最后发现是配置错误”的排查链路在整个项目生命周期里会遇到不止一次。排查时最忌讳一上来就改代码先看日志、再看流水、最后查配置一步步缩小范围定位根因才快。现在这套排查顺序已经变成我们团队的固定套路了。