ARTICLE DETAIL

资讯详情

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

PHP幸运大转盘抽奖源码:从权重算法到并发控制

PHP幸运大转盘抽奖源码:从权重算法到并发控制 简介基于 PHP 与 MySQL 打造的幸运大转盘抽奖系统源码主要面向需要快速上线互动抽奖活动的网站开发者、站长或运营人员帮助解决活动页面开发成本高、奖品发放流程繁琐以及抽奖凭证难以管控等实际问题。系统采用红色主题与金色装饰转盘动画流畅并配有彩带庆祝效果前台必须输入有效卡密才能参与抽奖后台管理员可对每个奖品独立设置中奖概率维护独立库存抽完即止同时支持虚拟卡密与实物奖品两种模式实物可填写收货地址并标记发货状态还支持生成卡密时绑定指定奖品保证必中并可批量生成与 CSV 导出管理便捷。资源包共包含 15 个文件以 13 个 PHP 文件为主体划分出安装向导、后台管理、登录鉴权、抽奖逻辑、奖品管理、卡密记录、系统设置等功能模块另有 1 个 Markdown 说明文档和 1 张背景图片整体压缩包大小约 20.52MB。已有 38 人学习下载适合直接部署于 PHP 7.4 与 MySQL 5.6 环境安装时访问 /install/ 填写数据库信息即可也可根据需要二次开发将抽奖能力快速集成到现有业务中。1. 幸运大转盘抽奖一套 PHP 源码要回答的三个问题凌晨一点运营在群里发来一句下周要上线一个转盘抽奖礼品预算已经锁死后端两天内能不能给这时候你需要的不是从零想方案而是一套能被直接改造的 PHP 幸运大转盘活动抽奖系统源码。所谓转盘抽奖拆到底就是三件事奖项怎么配、概率怎么算、库存怎么扣。前端那只转盘只是壳真正决定用户能不能中奖、中到什么奖的逻辑全在 PHP 服务端。这套源码适合两类人一类是接外包或做活动的 PHP 工程师需要一份能快速二次开发的底子另一类是运营出身、想弄懂抽奖系统内部逻辑的产品同学。下面按我自己的落地习惯从概率引擎到并发控制、再到前端联动和排查把一条能上线的路径讲清楚。2. 奖项配置与概率引擎为什么权重算法比随机数靠谱2.1 转盘抽奖为什么要权重随机数只解决一半问题直接写mt_rand(1, 100)然后小于等于 30 就算中奖这算是最原始的抽奖实现。问题是活动奖品通常不是一个档位而是「一等奖 iPhone、二等奖耳机、三等奖优惠券、未中奖」同时存在每个档位还得能控制中奖率。如果用连环 if 判断改一次概率就要改代码运营根本没法自己调。所以正规的转盘实现都会引入权重weight概念每个奖项配置一个整数权重抽奖时把所有权重加起来作为总区间再生成一个随机数落在哪个区间就命中哪个奖项。权重越大命中概率越高概率可以直接用weight / 总权重算出来给运营看。这样做的好处是改概率只改数据库不用发版坏处是容易写出浮点数误差后面避坑章节细说。权重还有个隐藏价值它可以表达「相对关系」而不是「绝对概率」。比如一等奖权重 1、二等奖权重 5、未中奖权重 94运营想调高中奖率只需要把未中奖权重改成 80其他档位不用动总概率自动重算。这种设计在活动期间特别实用因为运营永远在改口径。2.2 奖品表怎么设计把库存、权重和状态一次放进去我一般会先建一张奖品配置表字段不要贪多但要保证抽奖主流程一次能取到所有需要的信息CREATE TABLE activity_prize ( id INT UNSIGNED NOT NULL AUTO_INCREMENT, activity_id INT UNSIGNED NOT NULL COMMENT 关联的活动场次, name VARCHAR(50) NOT NULL COMMENT 奖品名称, level TINYINT NOT NULL DEFAULT 0 COMMENT 奖项级别0代表未中奖, weight INT NOT NULL DEFAULT 0 COMMENT 抽奖权重越大越容易中, total INT NOT NULL DEFAULT 0 COMMENT 奖品总数, remain INT NOT NULL DEFAULT 0 COMMENT 剩余库存, status TINYINT NOT NULL DEFAULT 1 COMMENT 1启用 0停用, created_at DATETIME NOT NULL, PRIMARY KEY (id), KEY idx_activity (activity_id, status) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT转盘奖品配置;这里有几个参数值得单独说明。weight必须用 INT不要用 FLOAT 或 DECIMAL后面随机数区间要拿它做整数加法用浮点累加很容易出现 0.9999 对不上 1 的情况。remain是实时库存每次抽中都要原子扣减它和total分开存方便运营看「已发多少、剩多少」。status是活动中途停用某个奖品的开关停用后这个奖项要能从抽奖池里被过滤掉但历史记录不能丢。实践中活动可能有多个场次所以留了activity_id外键。如果只是单活动去掉这一列也可以。另外建议加一个sort_order排序字段前端转盘的格子顺序通常要跟奖项顺序一致否则会出现「转盘上看到一等奖在 12 点方向后端 index 算出来却是 3 点方向」这种对不上的问题。2.3 权重抽奖函数一个 PHP 函数跑通核心逻辑有了表接着写抽奖的核心函数。这个函数只做一件事从可用的奖品池里按权重随机返回一个结果。它不关心用户是谁、不关心扣库存职责单一方便单测。?php /** * 从奖品池中按权重抽一个结果 * param array $pool 每项至少包含 prize_id / name / weight / remain / status * return array 命中的奖品项未中奖返回 prize_id 为 0 的记录 */ function drawPrize(array $pool): array { // 先把已售罄或停用的项剔除掉避免命中之后才发现没库存 $available array_filter($pool, function ($item) { return $item[remain] 0 $item[status] 1; }); // 权重直接求和全部用整数运算 $totalWeight 0; foreach ($available as $item) { $totalWeight $item[weight]; } if ($totalWeight 0) { return [prize_id 0, name 未中奖, weight 0]; } // random_int 比 mt_rand 更适合抽奖分布更均匀且不可被预测 $rand random_int(1, $totalWeight); $cursor 0; foreach ($available as $item) { $cursor $item[weight]; if ($rand $cursor) { return $item; } } // 理论上走不到这里但保留兜底 return [prize_id 0, name 未中奖, weight 0]; }逻辑说明先array_filter剔除没有库存的奖品再把剩余权重相加得到$totalWeight用random_int(1, $totalWeight)生成落在整个权重区间内的随机数。随后遍历奖品把权重逐段累加进$cursor随机数落在哪一段就返回哪个奖品。这个算法的时间复杂度是 O(n)奖品通常不超过 10 个性能完全够。参数说明权重区间是[1, $totalWeight]注意不是从 0 开始避免边界争议$rand $cursor用的是小于等于保证最后一个奖品也能被命中。这里有个容易被忽略的细节如果一等奖库存变成 0它被过滤后总权重变小二等奖和未中奖的概率会按新权重重新分配而不是把一等奖的概率“空转”掉。如果不想要这种概率跳变可以把一等奖的权重临时转移给未中奖这就是活动运营常说的「保底」。3. 并发控制与防超卖让抽奖接口扛住活动前五分钟3.1 原子扣减用一条 UPDATE 防超卖抽奖接口最容易出事的地方不是概率而是库存。活动一上线前五分钟往往有大量请求同时涌入。如果代码写成「先 SELECT 查剩余库存判断大于 0再 UPDATE 扣减」那恭喜你超卖现场已经预定两个请求同时读到剩余 1同时判断能扣最后都扣成 -1。正确的做法是把「检查库存 扣减」合并成一条原子 UPDATE?php // $prizeId 是上面 drawPrize 返回的奖品主键 $sql UPDATE activity_prize SET remain remain - 1 WHERE id ? AND remain 0 AND status 1; $stmt $pdo-prepare($sql); $stmt-execute([$prizeId]); if ($stmt-rowCount() 1) { // 扣减成功才允许把奖品派发给用户 // 这里继续插入抽奖流水、返回前端结果 $pdo-commit(); } else { // 没扣到说明已售罄或停用回到未中奖分支 $pdo-rollBack(); }逻辑说明remain 0是这道 SQL 的关键锁条件。MySQL 在 InnoDB 引擎下执行这条更新时会锁住对应行并发请求同时到达时只有一个能真正把remain从 1 改成 0另一个因为remain 0条件不满足而影响 0 行。这样就从数据库层面堵死了超卖比在 PHP 代码里加锁更可靠。参数说明这个方案要求奖品表用 InnoDBMyISAM 不支持行锁并发下衰减会很难看。另外扣减和插入流水必须在同一个事务里否则会出现「库存扣了但中奖记录没写」的账实不符。事务隔离级别默认的 REPEATABLE READ 在这里够用不需要调。3.2 用户维度频控Redis 计数拦住重复点击库存防住了还有一个更隐蔽的风险同一个用户连点十次一次抽多个奖品。抽奖活动的规则通常是「每日 N 次」这个限制必须在服务端做不能只靠前端禁用按钮。最常见的方案是用 Redis 计数器按用户 ID 加日期做 key?php $key lottery:user: . $userId . : . date(Ymd); $count $redis-incr($key); // 第一次 incr 时设置过期时间避免 key 永远留在 Redis 里 if ($count 1) { $redis-expire($key, 86400); } if ($count $dailyLimit) { // 今日次数已用完直接拒绝 exit(json_encode([code 1, msg 今日抽奖次数已用完])); }逻辑说明incr是原子操作即使同一用户在同一毫秒发来多个请求计数也不会错乱。第一次请求时设置 86400 秒过期保证第二天自动清零不用写定时任务。参数说明$dailyLimit建议做成活动配置项而不是写死在代码里运营可能随时调整。如果项目里没接 Redis可以退回到数据库表加唯一索引user_id 抽奖日期做约束但并发量大时会成为瓶颈我经历过一次活动把频率判断放在 MySQL结果数据库连接数被打满后来才换的 Redis。对中小活动来说Redis 是性价比最高的选择。3.3 回落到未中奖与 PHP 队列落库抽奖结果返回和奖品发放这两个动作在架构上应该拆开。用户点击转盘接口同步返回「中了没、中什么」这是体验底线而发奖、发通知、写统计报表这些操作可以异步做避免接口被拖慢。一个简单实用的 PHP 队列做法是抽奖接口把中奖记录推到 Redis list后台常驻一个 PHP 脚本消费。用LPUSH推入、BRPOP阻塞弹出天然支持多个消费者并发处理?php // 抽奖接口里命中奖品后推入队列 $redis-lpush(lottery:reward_queue, json_encode([ user_id $userId, prize_id $prizeId, lottery_no $lotteryNo, created_at date(Y-m-d H:i:s) ]));?php // 消费脚本常驻运行BRPOP 阻塞等待任务 while ($task $redis-brpop(lottery:reward_queue, 5)) { $data json_decode($task[1], true); // 执行发奖更新用户奖品表、发短信或站内信 awardPrizeToUser($data); }逻辑说明BRPOP的第二个参数是超时秒数这里设 5 秒意思是阻塞最多等 5 秒没有任务就返回一次空结果循环继续。这样可以防止 PHP 脚本彻底挂死也能照顾到 Redis 连接空闲断开的问题。参数说明队列消费脚本需要用nohup或 Supervisor 托管不能用 PHP 内置的php -q裸跑否则进程一断中奖记录就积压在队列里没人管。发奖逻辑本身要保证幂等同一个lottery_no重复消费不能发两次奖最简单的方法是在用户奖品表给lottery_no加唯一索引插入失败就说明已经发过。4. 从 PHP 接口到转盘动画JSONP 跨域与角度计算4.1 从 PHP 返回 JSON 到指针落点角度计算前端转盘本质上是一个 CSS 或 Canvas 动画指针最后停在哪由旋转角度决定。这个角度不能让前端自己随机生成正确做法是后端在抽奖接口里直接返回「命中奖项的下标 index」前端只负责把指针转到位。常见的角度计算逻辑是这样的// 奖品列表顺序必须和后端 index 对齐 const prizeList [一等奖, 二等奖, 三等奖, 未中奖]; const unitAngle 360 / prizeList.length; // 每个格子占 90 度 // 后端返回 index 2表示命中三等奖 // 转 5 圈后停在目标格子中心加上 unitAngle/2 让指针对准格子中线 const targetAngle 360 * 5 unitAngle * index unitAngle / 2;逻辑说明360 * 5是让转盘先空转 5 圈视觉上更有抽奖感unitAngle * index定位到目标格子unitAngle / 2是让指针落在格子正中间而不是边界上。如果不用这个偏移指针容易刚好停在两个格子的交界线上用户会截图说自己中的是隔壁那格引发小额客诉。参数说明这里的index是 PHP 接口返回的数组下标所以后端在返回时最好直接给index而不是给奖品名称让前端去查表。如果转盘的起始角度不是 0 度比如第一个格子从 15 度开始画前端还要加一个起始偏移这个偏移必须跟页面实际渲染保持一致。怀疑转盘停错格子时先检查这个起始角八成是它的问题。4.2 JSONP 跨域与接口权限前端页面怎么安全地拿到结果转盘页面经常部署在独立的前端域名下PHP 接口在另一个域名这就绕不开跨域。老项目里用得最多的方案是 JSONPPHP 侧处理也很简单?php // 抽奖接口入口判断 JSONP 回调 if (isset($_GET[callback])) { // 生产环境一定要做白名单校验防 XSS $callback preg_replace(/[^0-9a-zA-Z_\$]/, , $_GET[callback]); header(Content-Type: application/javascript); echo $callback . ( . json_encode($response) . ); exit; } // 常规 JSON 响应 header(Content-Type: application/json); echo json_encode($response);逻辑说明JSONP 的原理是把响应包装成一段 JavaScript 函数调用浏览器通过script标签加载这段内容执行回调函数。这里用正则把回调名限定为字母、数字、下划线和美元符号避免恶意构造callbackscriptalert(1)/script之类的输入。如果接口只给自家前端用更推荐直接上 CORS返回Access-Control-Allow-Origin头比 JSONP 干净。参数说明$_GET[callback]为空时直接走普通 JSON 分支这样同一个接口既能被非跨域页面调用也能被跨域页面用 JSONP 调用。需要注意 JSONP 只支持 GET 请求抽奖接口如果要做签名校验签名参数也得走 URL所以敏感操作建议直接升级 HTTPS 加 CORS别在 JSONP 上堆业务。4.3 让前端只能「演」不能「算」服务端下发角度和凭证很多转盘项目犯过一个认知错误以为抽奖逻辑写在 JavaScript 里就行。实际上前端代码对用户完全透明懂一点浏览器的用户改一下控制台变量一等奖随便中。所以服务端必须做到前端只负责表演动画结果完全由 PHP 下发。我的做法是抽奖接口直接返回三个字段index中奖项下标、angle目标角度、lottery_no本次抽奖唯一编号。前端拿到angle后直接做旋转动画不需要自己算 targetAngle彻底消除「前端自己算导致落点跟后端不一致」的空间。lottery_no用来做后续的发奖凭证和对账依据。?php $response [ code 0, data [ index $prize[index], // 中奖项下标 angle $targetAngle, // 后端直接算好的落点角度 lottery_no $lotteryNo, // 服务端生成的唯一抽奖编号 ], ];逻辑说明$lotteryNo建议生成规则是日期 随机串 用户 ID 后四位保证唯一且便于人肉查问题。把angle的计算放在 PHP 侧还有一个额外好处测试环境可以直接指定角度复现某个表盘的落点问题不用在浏览器里反复转圈。参数说明angle直接用整数角度不要用弧度前端transform: rotate()只认 deg如果想做更平滑的减速效果前端可以把这个目标角度拆成多段动画但最终值必须等于后端下发的angle否则用户看到指针回落又是另一套结果客诉又来了。5. 抽奖系统常见问题与排查五次活动攒下的血泪经验5.1 权重正确但中奖率明显低随机数边界与浮点误差现象配置表里一等奖权重 1、未中奖权重 99日志里跑了 10000 次一等奖命中次数只有 30 多次明显低于理论值 1%。原因两个坑叠加。第一权重用了 FLOAT99.1 1.2这种浮点运算会产生尾数误差导致区间对不上第二遍历判断用的是$rand $cursor而不是$rand $cursor假设随机数永远取不到边界某些边缘权重会被系统性跳过。解决权重全部改成整数随机数生成改random_int(1, $totalWeight)比较操作统一用。改完之后用下面的校验脚本跑一万次统计各奖项命中次数命中率应该和weight / totalWeight落在同一个数量级偏差超过 0.5% 就继续查代码。5.2 转盘停在没中的格子上角度与索引对不上现象后端返回中奖 index 是三等奖前端转盘指针停下来却指着一等奖。用户截图投诉运营查流水发现中奖记录确实是三等奖两边都「没错」其实问题出在前端。原因前端奖品列表数组和后端数据库查询顺序不一致。比如数据库按id升序查出[一等奖, 二等奖, 三等奖, 未中奖]前端写死在 JS 里的是[未中奖, 三等奖, 二等奖, 一等奖]那后端 index2 传给前端落到的却是二等奖。解决后端查询时明确加上ORDER BY sort_order ASC并且把这个排序后的奖品名称数组作为接口字段一次性返回给前端让前端直接用接口的数组渲染转盘格子。也就是说前端不要自己维护奖品顺序以接口为准。上线前用「指定 index 压测」检查每一格对应关系。5.3 并发一高库存变负数读改写不是原子操作现象秒杀时段过后打开数据库一看某奖品remain变成 -3中奖记录却只有 1 条。原因抽奖接口先SELECT remain判断大于 0再UPDATE remain remain - 1中间没有任何锁。两个请求同时读到remain 1同时通过判断又同时执行 UPDATE库存被扣成负数。MySQL 默认的读操作不加锁这种「检查后修改」的竞态在并发下几乎必现。解决把扣减语句改成UPDATE activity_prize SET remain remain - 1 WHERE id ? AND remain 0靠rowCount()是否为 1 判断是否真正扣减成功。这是数据库层面的原子操作不需要在 PHP 里加锁性能和正确性都兼顾。5.4 同一用户抽中两次频控只做了前端现象一个用户一天内抽中了 8 次远超每日 3 次的规则限制奖品被薅走一大半。原因前端按钮在用户点击后置灰限住了普通用户但拦不住懂技术的人。对方直接抓包重放抽奖接口绕过前端按钮每次请求都通过。解决用户维度频控必须放服务端。最少要做的两层第一Redis 按user_id 日期计数超限直接拒绝第二数据库抽奖流水表加唯一索引(user_id, date, lottery_no)即使接口被重放也插不进第二条相同记录。前者是拦截后者是兜底缺一个都可能在活动时翻车。5.5 中奖记录只有缓存没有库查账无从下手现象活动结束后运营要发奖名单后端查数据库中奖流水表空的但用户在页面上明明看到自己中了奖投诉电话被打爆。原因开发为了降低数据库压力先把中奖结果写在 Redis打算「稍后落库」但异步任务崩了Redis 里的数据被过期策略清掉等于什么都没留下。解决抽奖结果落库不能依赖缓存。设计上把「中奖流水表插入」和「抽奖接口返回」放在同一个事务里事务提交成功才允许向前端返回中奖内容Redis 只用来做计数和队列不作为中奖结果的持久化存储。另外给流水表加一个create_time索引方便每天对账时按时间段拉数。6. 用日志和压测验证概率不中奖率不对劲时这么查6.1 用压测脚本验证命中率是否收敛概率代码写完了能不能上线不能靠感觉。我习惯在测试环境先做一个最小压测准备 5000 个测试用户 ID并发跑抽奖接口然后统计各奖项命中次数。# 用 xargs 并发跑 5000 次抽奖请求20 并发 seq 1 5000 | xargs -P 20 -I {} \ curl -s http://127.0.0.1/api/lottery/draw?user_idtest_{} \ /tmp/lottery_result.jsonl # 统计一等奖命中次数 grep -o prize_id:1 /tmp/lottery_result.jsonl | wc -l逻辑说明-P 20控制并发数-I {}把序列号替换进 URL 充当不同用户 ID。跑完后把统计出来的实际命中次数和weight / 总权重 * 5000做对比偏差在合理统计范围内才说明概率引擎和库存扣减符合预期。这一步同时也是对前面并发控制的实战检验如果库存变成负数压测日志里就会暴露出来。6.2 抽奖流水对账每天看这三个数活动上线后我每天会固定看三个数字中奖流水总条数、各奖品剩余库存变化量、以及 Redis 计数器的用户抽奖次数。中奖流水条数应该等于「奖品 total 减去 remain 的和」如果对不上优先查有没有异步消费脚本挂掉、有没有事务提交但接口超时重试的脏数据。这套验证习惯救过我一次。有一次运营把一等奖权重从 1 调成 10页面展示却还是原来的「中奖率 0.1%」用户大量投诉。后来一查是接口层加了配置缓存权重改了缓存没失效。从此我养成了一个动作运营后台改抽奖配置后必须到 Redis 里删一次对应 key再抽一发测试奖确认生效。希望帮到你。本文还有配套的精品资源点击获取
返回列表