尧图精选

PHP九宫格抽奖系统:权重概率控制与高并发库存扣减实战

🕒 发布时间:2026/10/2 18:22:35 📁 来源:尧图网络
简介PHP九宫格抽奖系统源码面向网站开发人员或运营人员用于快速搭建营销抽奖、粉丝互动等场景。系统由PHP后端与前端页面组成后台可自定义奖品名称、抽奖概率与库存数量数量为0的奖品不会出现在中奖结果并提供抽奖码验证机制用户须凭有效抽奖码才能参与抽奖码可随机生成或绑定指定奖品有效控制活动成本。资源共710个文件压缩包大小约17.66MB主要包含PHP业务逻辑、HTML/CSS/JS前端脚本、GIF/PNG图片素材、SQL数据库文件及少量音频文件结构清晰后台功能模块完整便于二次开发时定位与修改。后台还支持自定义抽奖背景、背景音乐和虚假购买人数增强活动氛围。目前已有73人学习/下载适合需要快速上线抽奖活动或希望二次开发完善批量抽奖码功能的开发者。1. 九宫格抽奖不是转盘PHP 实现前先想清楚这三件事市面上凡是带“PHP九宫格抽奖系统源码”关键词的交付物绝大多数是给你一套可以用的抽奖页面加后台管理核心就三块九宫格界面渲染、抽奖概率控制、中奖记录与库存扣减。很多人拿到源码第一件事是改奖品名和图片结果上线第二天就出问题——要么概率对不上要么同一个人刷爆奖品要么并发一高就超发。问题不在 PHP 本身而在抽奖系统的设计逻辑。做九宫格抽奖首先要区分“前端动画”和“后端抽奖”。九宫格的转动动画只是展示层真正决定用户中什么奖的是服务端生成的一个中奖结果。前端转了 3 圈停在某个格子那是根据后端返回的奖品 ID 反推出来的动画路径绝不是随机停在哪个格子就算哪个奖品。理解这一点后面所有代码才能写对。再就是概率模型。九宫格一般有 8 个奖品位加 1 个“谢谢参与”但概率不是平均分配的。有的奖品要控制中奖率在 5% 以内有的要 30%还要保证所有奖品的中奖率加起来不超过 100%。这需要一个可靠的权重算法而不是用array_rand或mt_rand(1, 8)直接完事。第三个要提前定的是库存维度每个奖品有独立库存抽完就没了。你需要在一次抽奖请求内完成“判断剩余次数 → 计算中奖结果 → 扣减库存 → 写入记录”这四步任何一步出问题都会造成超发或数据不一致。这套系统的目标用户很明确做营销活动的运营人员、接外包的 PHP 开发、以及想在自己业务里快速嵌入抽奖模块的产品团队。适合做的场景是微信内 H5 活动、电商节庆营销、会员积分消耗类玩法。不适合做的场景是现金红包、高价值实物抽奖且没有第三方公证或风控的情况——那对防刷和审计的要求远超一套普通源码的承载范围。下面按实际落地顺序逐步拆解。2. 核心抽奖逻辑与概率控制先用权重算法撑住中奖率2.1 九宫格抽奖的两种概率模型别混着用抽奖系统的概率模型有两大流派固定概率和剩余库存概率。PHP 九宫格抽奖源码里最常见的是固定概率也就是每个奖品在配置表里写死一个权重值比如 5、10、20表示相对中奖可能性。这个模型实现简单适合预算可控、奖品数量充足的场景。但实际做营销活动时固定概率会有个尴尬局面某个奖品库存只剩最后 5 份但权重还是 20于是这 5 份可能在前 1 小时内被抽完后 23 小时这个奖品的位置一直是空的但概率还占着。用户转到一个空奖品格体验很差。所以很多商业源码会在固定概率基础上叠加一个“剩余库存检查”一旦库存为 0就把该奖品从抽奖池里摘除把它的权重让渡给“谢谢参与”或其它奖品。我的建议是如果你是把这套源码用在真实活动上直接用“库存感知 动态权重”模型。它只比固定概率多十来行代码但能避免大量售后问题。下面给出的实现也是基于这个模型。2.2 PHP 实现动态权重抽奖代码与参数说明先定义一个标准的奖品数组结构包含奖品 ID、名称、权重、库存。这是一个最小可跑的配置$prizes [ [id 1, name iPhone 15, weight 5, stock 3], [id 2, name 蓝牙耳机, weight 10, stock 20], [id 3, name 优惠券 10元, weight 30, stock 500], [id 4, name 再来一次, weight 15, stock 100], [id 5, name 积分 50, weight 20, stock 1000], [id 6, name 帆布袋, weight 10, stock 50], [id 7, name 台历, weight 8, stock 80], [id 8, name 鼠标垫, weight 12, stock 120], [id 0, name 谢谢参与, weight 30, stock PHP_INT_MAX], ];这里注意 ID 为 0 的“谢谢参与”库存设为 PHP_INT_MAX表示它永远可抽。接下来写抽奖核心函数function drawPrize(array $prizes): array { // 第一步过滤已无库存的奖品并从抽奖池中移除 $pool array_filter($prizes, function ($item) { return $item[stock] 0; }); // 第二步计算总权重 $totalWeight 0; foreach ($pool as $item) { $totalWeight $item[weight]; } // 第三步生成随机数并分段判断 $rand mt_rand(1, $totalWeight); $cursor 0; foreach ($pool as $item) { $cursor $item[weight]; if ($rand $cursor) { return $item; } } // 理论上不会走到这里但为防御返回谢谢参与 return $prizes[0]; }这段代码分三步走。第一步用array_filter把无库存的奖品剔除这样权重就不会被死奖品占用。第二步累加所有剩余奖品权重得到总权重区间。第三步生成一个 1 到总权重之间的随机整数然后逐个累加权重区间判断落点。这个算法的核心是把每个奖品的权重映射到一段连续区间上随机数落在哪个区间就中哪个奖。关于随机数函数的选择老代码里常见rand()但 PHP 7 以后mt_rand()的随机性更好PHP 8 里rand()与mt_rand()已经是同一种实现了。如果你的 PHP 版本默认启用了random_int对安全要求高的场景建议用random_int(1, $totalWeight)替代mt_rand它基于 CSPRNG不可预测性更强也更能防止用户通过多次抽奖统计推算随机规律。2.3 概率配比经验让总概率不超 100% 的调参方法动态权重算法的特点是概率等于权重除以当前总权重。所以配置权重时你要先定好目标概率再反推权重值。比如想让 iPhone 中奖率接近 5%总权重设计在 200 左右那 iPhone 权重就设为 10其它所有奖品之和为 190。实际计算时还要注意库存越少的奖品在它耗尽之前被抽中的概率随时间推移反而变高——因为其它奖品可能先耗尽被移除而它自己还没被抽中。这是一个容易被忽视的特性。刚才说“活动预算可控”指的是你要在配置权重时预判最坏情况。举例总权重 200iPhone 占比 5%活动预计 1 万人次参与那么理论上会有约 500 次命中 iPhone 的概率事件。如果 iPhone 库存只有 3 台那么它很可能会在活动开始后不久就被抽完。一旦库存归零剩余 497 次“本该命中 iPhone”的随机机会会被其它奖品吸收表现为其它奖品的中奖率上升。实际操作中我一般会建一个权重配置表字段包括prize_id, weight, stock, daily_limit然后写一个drawPrize的单元测试脚本循环调用 10 万次统计每个奖品的实际中奖次数与理论概率的偏差。偏差在 1% 以内就可以接受。不要把权重调完直接上线这种玄学在抽奖系统上最容易翻车。3. 抽奖接口与九宫格动画联动从后端结果到前端转盘3.1 前端旋转结果必须由后端驱动否则用户能摸出规律很多初版九宫格源码的做法是前端先转转完再把最终格子 ID 发给后端记录。这个顺序完全错误。如果前端能决定停在哪一格懂技术的人可以直接改 JS 变量或拦截接口响应把结果改成任意奖品。正确顺序是用户点击抽奖按钮前端把用户标识 本次抽奖的令牌发给后端后端校验资格、执行抽奖、扣库存、写记录后端把中奖奖品 ID 返回给前端前端根据奖品 ID 计算目标格子的角度和圈数执行转盘动画这样无论用户怎么改前端代码最终结果都由后端兜底。哪怕他伪造接口返回数据库里的记录还是后端生成的那个结果不会产生真实奖品损失——除非他伪造的是“再调一次接口”。3.2 PHP 抽奖接口一次请求内完成资格校验、抽奖、扣库存、落库接口文件按常见 MVC 结构放在controller层实际逻辑写入服务类。这里给出一个精简可用的接口主体在一个事务里完成关键步骤public function lottery(Request $request) { $userId $request-post(user_id); $actId $request-post(act_id); // 1. 校验活动状态和用户剩余抽奖次数 $act ActModel::find($actId); if (!$act || $act-status ! 1) { return json([code 1, msg 活动未开启]); } $remainTimes $this-checkRemainTimes($userId, $actId); if ($remainTimes 0) { return json([code 2, msg 抽奖次数已用完]); } // 2. 加锁防止并发重复请求这里用 Redis 原子自增 $lockKey lottery:act:{$actId}:user:{$userId}; $lock Redis::set($lockKey, 1, EX, 10, NX); if (!$lock) { return json([code 3, msg 操作太频繁]); } try { // 3. 事务内抽奖读取库存、计算奖品、扣库存、写记录 $pdo-beginTransaction(); $prizes PrizeModel::where(act_id, $actId)-get()-toArray(); $winner drawPrize($prizes); if ($winner[id] ! 0) { $affected PrizeModel::where(id, $winner[id]) -where(stock, , 0) -decrement(stock); if (!$affected) { throw new Exception(库存扣减失败); } } $pdo-commit(); // 4. 写抽奖记录异步或同步均可 $this-writeLotteryRecord($userId, $actId, $winner[id]); // 5. 延迟释放锁防止请求结束后立即重放 Redis::del($lockKey); return json([code 0, data [ prize_id $winner[id], prize_name $winner[name] ]]); } catch (Exception $e) { $pdo-rollBack(); Redis::del($lockKey); return json([code 4, msg 系统繁忙]); } }这段代码里有两个关键点值得说明。第一是 Redis 锁只用来防同一用户短时间内的重复提交不是分布式锁的全量方案——真正的超卖防护靠的是decrement语句本身带where(stock, , 0)条件。这一步是原子操作即便并发请求同时进来数据库行锁也会保证只有库存大于 0 的那个请求能扣减成功。第二是先扣库存再写记录。库存是硬资源记录只是流水如果反过来写一旦扣库存失败就会出现一条记录对应不到库存消耗的脏数据。需要注意的细节是drawPrize在事务里读取的是当前库存状态但 PHP-FPM 模式下多个进程同时读同一个奖品数组各自执行完抽奖后统一到decrement这一步靠数据库兜底。这意味着可能出现 A 进程和 B 进程都算出中了同一个奖品但只有一个能成功扣库存。A 成功返回 iPhoneB 扣减失败抛出异常用户看到的是“系统繁忙”。从数据一致性角度这没毛病但用户体验差。优化办法是在读取库存前先用SELECT ... FOR UPDATE锁住奖品表的相关行保证一次只有一个请求能读到当前库存。小活动可以不锁但高并发场景建议加锁。3.3 九宫格动画角度计算把奖品 ID 映射到格子位置前端拿到prize_id后需要把它转换成第几个格子。九宫格布局顺序一般是从左上角开始顺时针编号共 8 个格子加中间 1 个按钮位。中间按钮不参与中奖所以奖品只能落在 8 个外围格子中的某一个。// 九宫格顺时针索引的格子角度映射 const gridAngles { 1: 0, // 上方正中 2: 45, // 右上角 3: 90, // 右侧正中 4: 135, // 右下角 5: 180, // 下方正中 6: 225, // 左下角 7: 270, // 左侧正中 8: 315 // 左上角 }; function rotatePointer(targetGridIndex, targetAngle) { const pointer document.getElementById(pointer); const currentRotation getCurrentRotation(pointer); // 转盘至少转 5 圈再到达目标角度 const fullSpins 360 * 5; const targetFinal fullSpins targetAngle; const nextRotation currentRotation 360 - (currentRotation % 360) targetFinal; pointer.style.transition transform 4s cubic-bezier(0.2, 0.8, 0.2, 1); pointer.style.transform rotate(${nextRotation}deg); }这段 JS 的逻辑核心是计算“当前旋转角度 至少 5 圈 目标角度”的最终值。getCurrentRotation从元素的transform矩阵里解析出当前角度避免下次抽奖时从零开始转造成指针倒退的违和感。cubic-bezier(0.2, 0.8, 0.2, 1)是减速曲线让转盘在最后 1 秒缓缓停在目标格子上观感更接近物理惯性。这里的格子索引约定要和后端奖品表里的grid_index字段保持一致否则会出现数据中 iPhone、动画指到优惠券的尴尬局面。常见做法是把奖品表和格子索引绑定后端返回prize_id时同时返回grid_index前端不再自己维护映射关系。这样后端调整格子顺序时前端不用改代码。别问我为什么强调这一点——很多外包项目就是前端把格子写死后端一改奖品顺序整个转盘指向全乱。4. 数据库设计与高并发扣减别让库存超发成为事故现场4.1 奖项配置表、抽奖记录表、用户次数表的结构怎么定一个能支撑真实活动的抽奖系统至少要三张表活动表、奖品表、抽奖记录表。抽奖次数可以放到用户表或单独的次数流水表。这是最常见的结构大部分 PHP 九宫格抽奖系统源码也沿用这个设计差别在于字段细节和索引。CREATE TABLE act_prize ( id int unsigned NOT NULL AUTO_INCREMENT, act_id int unsigned NOT NULL DEFAULT 0 COMMENT 活动ID, prize_name varchar(64) NOT NULL DEFAULT COMMENT 奖品名称, weight int unsigned NOT NULL DEFAULT 0 COMMENT 权重, stock int unsigned NOT NULL DEFAULT 0 COMMENT 总库存, remain_stock int unsigned NOT NULL DEFAULT 0 COMMENT 剩余库存, grid_index tinyint unsigned NOT NULL DEFAULT 0 COMMENT 九宫格位置 1-8, start_time datetime DEFAULT NULL COMMENT 可抽开始时间, end_time datetime DEFAULT NULL COMMENT 可抽结束时间, PRIMARY KEY (id), KEY idx_act_id (act_id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4; CREATE TABLE act_lottery_record ( id bigint unsigned NOT NULL AUTO_INCREMENT, act_id int unsigned NOT NULL DEFAULT 0, user_id int unsigned NOT NULL DEFAULT 0, prize_id int unsigned NOT NULL DEFAULT 0, create_time datetime NOT NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (id), KEY idx_user_act (user_id, act_id), KEY idx_act_time (act_id, create_time) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;注意奖品表里我设计了stock和remain_stock两个字段。stock是初始库存用于后台展示“总共几个”remain_stock是实际扣减字段。如果你只有一个字段后台想改初始库存或复盘时就没有基准数可对照。扣减时使用UPDATE act_prize SET remain_stock remain_stock - 1 WHERE id ? AND remain_stock 0这样可以在 SQL 层面杜绝超卖。抽奖记录表必须建立(user_id, act_id)联合索引这是查询用户剩余次数、判断是否已中奖的最常用路径。(act_id, create_time)索引用于后台按活动维度导出中奖名单。没有这两个索引数据量到十万级后查询会明显变慢——这在营销活动结束后导出报表时特别明显。4.2 并发扣库存的三种方案PHP 场景该怎么选方案一是数据库原子更新就是上面写的UPDATE ... WHERE remain_stock 0。这是最稳妥的兜底方案缺点是行锁在高并发下会串行化单表支撑每秒几百次扣减没问题几千次就开始吃力。方案二是 Redis 预扣库存所有扣减走 Redis 的DECR命令异步同步到数据库。这个方案吞吐量高但会出现 Redis 和数据库库存不一致的风险比如 Redis 扣了 10、数据库只扣了 5因为同步脚本挂了。方案三是 Redis 原子操作 数据库最终一致适合大型活动普通业务用不上。PHP 项目天然是请求结束后进程销毁的模型所以方案二是最容易翻车的。我见过一个项目用 Redis 扣库存结果活动结束后对账发现少发了几十件奖品——原因是某个用户抽中后主动取消订单或退款但 Redis 里的扣减没有回滚。所以小规模活动直接用数据库原子扣减最靠谱配合事务能保证绝不超发。如果确实并发很高可以再加一层 Redis 令牌桶限流把瞬时击穿的压力挡在业务层之外而不是用 Redis 直接替代数据库库存。// Redis 预扣库存 数据库最终扣减的折中方案 $redis Redis::connection(); $remain $redis-decr(act:stock:{$prizeId}); if ($remain 0) { $redis-incr(act:stock:{$prizeId}); // 回补 throw new Exception(该奖品已抽完); } // 异步写入数据库扣减记录或用消息队列处理 Queue::push(new DeductStockJob($prizeId));这个折中方案适合有一定开发能力的团队使用Redis 负责流量削峰异步任务负责最终扣减数据库库存。但注意如果异步任务失败且没有补偿机制库存还是会漂移。所以无论选哪种方案都必须有定时对账脚本定期把 Redis 扣减总数与数据库扣减总数做比对发现不一致就告警。4.3 抽奖次数的正确扣法先减次数还是先抽奖这是整个系统里最容易逻辑颠倒的地方。必须先扣次数再抽奖还是先抽奖再扣次数答案是在同一事务里先锁定用户次数记录再抽奖再一起提交。顺序上“先扣次数”更安全因为如果抽奖成功但次数扣减失败用户会无限抽反过来次数扣了但抽奖异常用户会投诉。$pdo-beginTransaction(); try { // 锁定用户次数记录 $userTimes UserTimesModel::where(user_id, $userId) -where(act_id, $actId) -lockForUpdate() -first(); if (!$userTimes || $userTimes-remain_times 0) { $pdo-rollBack(); return json([code 2, msg 抽奖次数已用完]); } // 扣减次数 $userTimes-remain_times - 1; $userTimes-save(); // 抽奖 $winner drawPrize($prizeList); // 扣库存、写记录... $pdo-commit(); } catch (Exception $e) { $pdo-rollBack(); }lockForUpdate()是这里的关键——它会对该用户的行加排他锁保证同一时刻只有一个请求能读到最新的剩余次数。不加锁的话两个并发请求同时读到剩余次数为 1都执行扣减结果用户实际抽了 2 次但数据库只扣了 1 次。这个问题在高并发下几乎必现而且很难从日志里排查因为单看每次请求都“正常”。用lockForUpdate后并发请求会排队执行性能损耗可接受但数据一致性有保障。5. 避坑与排查PHP 九宫格抽奖系统常见的 5 个翻车点5.1 中奖结果和格子对不上现象后台配置的奖品在九宫格的第 3 格但用户转到的位置是第 5 格中奖记录却记录为第 3 格的奖品。原因格子编号约定不统一。前端按从左到右从上到下编号后端按顺时针编号或者两者都用了顺时针但起始位置不同从左上角开始 vs 从正上方开始。解决把grid_index定义在奖品表里以后端数据为准前端动态读取这个字段计算角度。后端修改格子顺序时前端代码不用动。5.2 库存显示为 0 但还能抽中现象后台奖品库存显示剩余 0但用户仍然抽到了这个奖品。原因抽奖算法里的array_filter过滤了库存为 0 的奖品但库存字段读取的是缓存值不是数据库实时值。常见于用了 Redis 缓存奖品列表且没有在扣减后同步更新缓存。解决扣减库存后立即刷新缓存或直接不缓存库存字段——库存走数据库实时读取奖品基础配置走缓存。拆开缓存粒度这个坑自然消失。5.3 同一用户并发请求抽了多次现象用户快速连点抽奖按钮后端收到多个请求用户剩余次数被扣除多次。原因前端只做了按钮 disabled 防点没做后端幂等控制后端也缺少用户级别的锁。解决在接口层加用户 活动的 Redis 锁锁存在期间拒绝重复请求。锁的过期时间设为 10 秒正常情况下一次抽奖请求 500ms 内结束不会误伤正常用户。前端也要在收到响应后才解除按钮禁用不能等动画结束就解锁——动画 4 秒接口 0.5 秒用户可能在动画期间再次点击。5.4 超卖导致发不出奖品现象活动结束后统计中奖记录比实际库存多出若干条。原因抽奖算法先读库存再扣库存两个步骤之间存在时间窗口并发请求下多个进程同时读到库存为 1各自判断“可以中奖”然后都执行了扣减。解决把扣减条件带上remain_stock 0并且用UPDATE返回的受影响行数判断是否真正扣成功。受影响行数为 0 说明库存已经被其它请求扣没了此时重新抽奖或直接返回失败。注意商品表要用 InnoDB不能用 MyISAM后者不支持行锁。5.5 概率设置总和超过 100% 但系统不报错现象运营配置权重后感觉一等奖总是不出而“谢谢参与”占比过高。原因运营把“权重”理解成“百分比”100 分填了 30 又填 20最后各项总和超过 100 也没有校验。权重模型的正确理解是相对比例不是百分数。解决后台配置页加一个实时计算条——总权重是多少每个奖品当前折算概率是多少超过 100% 时自动按比例归一化。如果后台没有这个功能就自己在配置表上写个校验脚本所有奖品权重加起来除以总权重超过 100% 就报警。这是我个人在交付抽奖源码时必须补的一个功能能省掉一半以上的运营沟通成本。6. 验证抽奖系统可靠性的三个手段从概率自测到生产模拟抽奖系统上线前最重要的一步是验证两个指标概率是否符合预期、高并发下是否不超卖。第一个用批量模拟第二个用并发压测。先说概率验证。// 模拟 20 万次抽奖统计中奖分布 $prizes include prize_config.php; $stats []; for ($i 0; $i 200000; $i) { $result drawPrize($prizes); $stats[$result[name]] ($stats[$result[name]] ?? 0) 1; } foreach ($stats as $name $count) { printf(%s: %.4f%%\n, $name, $count / 200000 * 100); }这段脚本跑完后把每个奖品的实际概率和期望概率对比偏差超过 1% 就需要检查权重计算逻辑。注意一个细节模拟时要用与生产环境相同的 PHP 版本和随机数函数不同 PHP 版本的mt_rand实现细节有差异虽然在统计学上影响极小但既然做验证就做到位。并发压测用ab或wrk就行不必上复杂工具。压测目标是200 并发持续 30 秒总请求 6000 次活动配置 3 个奖品各有库存 100验证最终数据库剩余库存不为负数中奖记录条数与扣减总数一致。ab -n 6000 -c 200 -p post_data.txt -T application/x-www-form-urlencoded http://your-domain.com/api/lottery压测结束后执行这条对账 SQL检查是否有中奖记录数超过扣减数的异常SELECT p.id, p.remain_stock, COUNT(r.id) AS record_count, (p.stock - p.remain_stock) AS deducted_count FROM act_prize p LEFT JOIN act_lottery_record r ON r.prize_id p.id GROUP BY p.id HAVING deducted_count ! record_count;任何一行结果里deducted_count不等于record_count都说明扣减逻辑有问题绝对不能上线。常见的情况是deducted_count大于record_count——库存扣了但记录没写另一种是record_count大于deducted_count——记录写了但库存没扣。前者发生在事务提交前进程退出后者发生在写记录的代码放在事务之外。我的处理习惯是把库存扣减和记录写入放在同一个事务里事务结束后再返回结果这样就不会出现两侧不一致。最后一个技巧是给抽奖接口加一个测试模式开关。在配置表里加一个test_mode字段开启后 drawPrize 函数不读权重、直接按奖品 ID 顺序命中方便前端联调转盘动画。真正的活动上线时关闭这个开关。这个功能虽然简单但在多轮联调和测试中能节省大量时间比反复清数据库调权重高效得多。希望这些方法能帮你把九宫格抽奖做得少踩坑。本文还有配套的精品资源点击获取
上一篇/下一篇内容由系统自动关联 返回资讯列表 →