运营同学又来找我做活动页了,这次的需求是“幸运大转盘抽奖”——用户在 H5 页面上转一次转盘,指针停在哪个奖品就发哪个。这种玩法在电商、教育、餐饮行业都特别常见,低门槛、高参与感,做一次活动能拉不少用户互动量。但真把需求拆开看,它并不只是画个转盘、转一下指针这么简单:概率怎么控、库存怎么扣、并发怎么防、前端动画和后端结果怎么保持一致,这些都是藏在“转盘”背后的核心问题。
这篇文章我就围绕一套可以直接拿去用的幸运大转盘抽奖系统源码来拆解,涵盖整体设计、技术选型、概率算法、防刷方案、前后端实现以及我实际踩过的坑。适合刚接触活动系统的开发者、想自建抽奖功能的站长,以及要跟开发沟通需求的运营同学,读完你就能知道一个转盘系统是怎么从零跑起来的,也能直接复现到自己的项目里。
标注一下这篇博文的落地技术栈:后端用 PHP,前端用原生 JavaScript + Canvas 绘制转盘,数据库用 MySQL。这个组合在轻量级活动页里非常常见,服务器要求低、部署简单、改起来也快。如果你更熟悉 Java 或者 Node.js,核心思路完全一样,照着迁移就行。
1. 整体设计与技术选型思路
1.1 核心需求拆解
一个完整的“幸运大转盘活动抽奖系统”,表面上是用户点一下按钮、转盘转起来、弹窗告诉用户中了什么。但背后需要拆分出这几块:
- 转盘展示:奖品名称、奖品图标、指针、按钮状态。这是用户唯一能看到的部分,决定了活动的“质感”。
- 抽奖逻辑:用户点击后,系统判断“这次是否中奖、中了哪个档位”,这是服务端需要保证的核心,不能被前端篡改。
- 概率控制:每个奖品的命中概率不是均等的,一般会设计成“感谢参与”“积分”“实物奖品”不同权重,而且可以配置。
- 库存管理:实物奖品有数量限制,发完之后再抽到就需要走“无奖”或“自动替换为下一个奖品”的逻辑。
- 防刷限制:用户是否登录、每人每天抽几次、是否限IP、是否需要消耗积分或抽奖次数。
- 活动配置后台:运营需要能配置奖池、奖品、概率、活动时间,不能每次改奖品都找开发。
把这六个需求理清楚之后,你会发现这基本就是一个小型的活动引擎,而不是一个单纯的“转盘动画”。而这正是很多初学项目最容易漏掉的部分——把动画做得很好,但后端结果是一句固定的“谢谢参与”,这种系统上线后运营会非常痛苦。
1.2 技术选型为什么这样定
先说几个被验证过很多次的成熟方案:
前端绘制转盘和指针,主流有三种做法:
- Canvas 绘制的旋转大转盘:用
canvas画出扇形、文字、图片,每次抽奖时通过requestAnimationFrame控制圆心角旋转。优点:高清、灵活、动态数据注入简单;缺点:需要自己处理绘制细节,不同屏幕适配要测。 - CSS3 transform + pre-made 图片:设计出一张转盘底图(360°圆形带奖品分区),然后用
transform: rotate(deg)旋转。优点:实现最快;缺点:中奖位置和旋转角度的计算要精确,如果分区不是整数很容易歪。 - SVG 扇形 + 动画库:用 SVG 路径绘制扇区,配合 GSAP 做动画。体验好,但代码量也上来了。
我最终选了 Canvas 实现,核心原因是奖品数量和每个扇区角度可以由后端返回的数据动态生成,运营后台改了奖池,前端的转盘分区就跟着变,不需要设计师重新出图。这在长期运营的场景下能省掉大量重复沟通。
后端选择 PHP 而不是 Node.js 或 Java,是考虑到很多中小站点、公众号营销平台和活动页部署在虚拟主机或轻量服务器上,PHP 的部署成本最低。而且这类抽奖接口本身不重,PHP-FPM 配合 Redis 做限流和库存扣减完全够用。
数据存储用 MySQL + Redis 组合:MySQL 存放奖品配置、中奖纪录、用户参与记录,持久化且方便运营后台统计;Redis 用于库存扣减、用户每日抽奖次数计数、互斥锁等高频操作。后面讲到并发防刷时你会看到 Redis 的价值。
这样组合下来,整个系统的优点是:
- 部署轻,任意一台跑得动 PHP 的服务器就行,不需要容器化,不需要复杂的中间件。
- 前后端职责清晰,前端只管转盘动画和接口调用,后端管概率、库存、风控。
- 数据结构清晰,后续如果要把“抽奖次数”换成“消耗积分”“消耗金币”,只需要在抽奖接口里加一个校验,不会影响其他模块。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 核心功能设计与细节解析
2.1 奖池配置与数据库设计
先设计数据库表,这是整个系统稳定性的基础。我把所有配置都拆到表里,避免硬编码。
奖品表 lp_prize 的结构大概是这样:
sql复制CREATE TABLE `lp_prize` (
`id` int(11) unsigned NOT NULL AUTO_INCREMENT,
`prize_name` varchar(255) NOT NULL COMMENT '奖品名称',
`prize_type` tinyint(4) NOT NULL DEFAULT '1' COMMENT '奖品类型:1积分,2实物,3优惠券,4无奖',
`prize_img` varchar(500) DEFAULT NULL COMMENT '奖品图片路径',
`total_num` int(11) NOT NULL DEFAULT '0' COMMENT '库存总量',
`remain_num` int(11) NOT NULL DEFAULT '0' COMMENT '剩余库存',
`probability` decimal(10,4) NOT NULL DEFAULT '0.0000' COMMENT '概率权重,万分位',
`sort_order` int(11) NOT NULL DEFAULT '0' COMMENT '排序,角度越小越靠前',
`status` tinyint(4) NOT NULL DEFAULT '1' COMMENT '0下架 1上架',
PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='奖品表';
概率字段 probability 用万分之几来存,也就是说如果一个奖品的抽中概率是 15%,那存 1500。用整型/万分数可以避免浮点数比较带来的精度问题,后面做概率抽取时也能直接用整数运算。
还需要一张用户参与记录表 lp_lottery_record,记录每次抽奖的时间、奖品ID、结果状态,这张表主要给运营看数据、排查问题用。索引要建 (user_id, created_at),因为最常见的查询就是“某个用户在活动期间抽了几次”“某个人中了什么”。
库存字段为什么单独放 remain_num?因为如果每次抽中实物商品都去查 total_num 再更新,逻辑上很绕。独立维护 remain_num,配合 Redis 做原子扣减,这是控制超高并发下“不超卖”的关键。
2.2 概率控制算法——两种写法都要会
概率控制是抽奖系统最核心的逻辑。我见过很多项目用随机数范围硬编码,比如“生成 1~100,小于 50 就是一等奖”,这样写有俩问题:第一,改概率要改代码;第二,随机区间稍微设计错,多个奖品概率总和可能超过 100%,结果就是某些奖品永远抽不到。
正确做法是把概率交给数据。我常用的有两套算法:
算法一:概率权重池法(适合奖品总数少、频率低)
把每个奖品按权重放进一个池子,权重是万分比,然后按总权重生成一个随机数 random_weight,遍历奖品,累加权重,直到累加值大于等于随机数就选中那个奖品。
php复制function lotteryDraw(array $prizes) {
$totalWeight = array_sum(array_column($prizes, 'probability'));
$randWeight = mt_rand(1, $totalWeight);
$cursor = 0;
foreach ($prizes as $prize) {
$cursor += $prize['probability'];
if ($randWeight <= $cursor) {
return $prize;
}
}
return $prizes[count($prizes) - 1] ?? null;
}
这段代码看着简单,但它是所有概率抽奖问题的基础。关键是 mt_rand 的范围是 1~totalWeight,不是 0~totalWeight,避免随机数为 0 时第一个奖品被无条件命中的问题。我在早期版本里就踩过这个坑,概率为 0 的下架奖品反而被高频抽中。
算法二:区间二分查找法(适合奖品数量多、性能敏感)
当奖池有几十上百万条数据、每次抽都需要高吞吐时,可以先把权重聚合成前缀和数组,再用二分查找到目标索引。PHP 中可以用数组配合手写二分,Java/Go 可以直接用标准库。普通活动系统其实用算法一就够了,不必过度设计。
关键点:无奖项怎么处理?
几乎所有的运营场景都不会让用户“必中奖”,而是有一个“谢谢参与”或者“再接再厉”的档位,这个档位在数据库里就是 prize_type = 4,prize_name = '谢谢参与',它也有概率权重,比如 40%。这样做的好处是,转盘上所有扇形区域在视觉上都是有奖的,用户不会觉得“这块区域标了个假的”,实际上中奖概率控制就靠这个无奖项的概率大小来调节。
2.3 中奖状态与库存扣减设计
如果用户抽中实物奖品,但是库存只剩 0 件了,怎么办?我见过直接返回“已售罄”的,也见过抽到了却无法兑换导致客诉的。这里必须有一个二次校验库存的过程。
在抽奖接口里,先做前置库存判断:
- 从 Redis 读取该奖品剩余库存
remain_num(key 如lottery:stock:prize_1)。 - 小于等于 0 时,不直接返回失败,而是走“降级奖品”逻辑——把中奖结果替换成无奖项,并重新执行一次不带该奖品池的抽奖。
- 大于 0 时,使用 Redis 的
DECR命令原子扣减库存,扣减成功才确认中奖。
php复制$stockKey = 'lottery:stock:prize_' . $prizeId;
$remain = $redis->decr($stockKey);
if ($remain < 0) {
// 库存被扣超了,回滚并走降级
$redis->incr($stockKey);
// 从可用奖品列表中移除该奖品后重新抽取
$prize = lotteryDraw($availablePrizesWithoutStockout);
}
这里切忌用“先查再改”的方式(GET 后 SET),因为并发情况下两个请求同时查到库存为 1,然后都执行扣减,就会出现卖超。DECR 是原子的,一次请求成功返回 0,另一个请求就可能返回 -1,此时就知道已经超卖,需要立即回滚。这条逻辑我实际压测过,1000 并发同时抽一个只有 10 件库存的奖品,最终库存只会从 10 扣到 0,一条都不会超卖。
2.4 防刷与用户参与限制
每人每天抽奖次数。
以登录用户 ID 为维度,在 Redis 里维护一个计数 key,例如 lottery:times:user_10001:20250110,每次抽奖前 INCR,如果大于上限就拒绝请求。这个 key 要设置 EXPIRE,过期时间设为第二天凌晨。
php复制$key = 'lottery:times:user_' . $userId . ':' . date('Ymd');
$times = $redis->incr($key);
if ($times == 1) {
$redis->expire($key, 86400);
}
if ($times > $dailyLimit) {
// 返回“今日抽奖次数已用尽”
}
你可能注意到 if ($times == 1) 才设置过期时间。这是为了避免每次请求都调 EXPIRE 增加一次额外网络开销,也防止 EXPIRE 命令失败后 key 永远不会过期。
人机校验。
如果活动有价值较高的奖品(比如手机、大额优惠券),建议在抽奖前接入现有的人机验证能力(滑块、点选等)。原因很直接:纯脚本刷抽奖接口的成本极低,一旦被刷,奖品秒没,运营数据也会变成垃圾数据。我见过太多活动因为漏了这一步,导致前 10 分钟被脚本抽走全部大奖,后面真实用户进来全是“谢谢参与”,活动口碑直接崩了。
IP 限制和账号唯一性校验根据业务需要自行决定,但有一条原则:抽奖次数限制一定要落在服务端,不能只在前端隐藏按钮,脚本可以直接绕过前端发 HTTP 请求。
3. 完整实操过程与核心实现
3.1 环境准备与项目结构
先搭一个干净的 PHP 项目结构,我习惯用原生 PHP + PDO 做数据库访问,不引入重量级框架——因为抽奖接口就三五个,没必要为了一两个接口拉一个 ThinkPHP 或 Laravel 全家桶。不过如果你项目本身已经用了框架,那直接在框架里加控制器更省事。
目录结构:
text复制lottery/
├── api/
│ ├── lottery.php # 抽奖接口
│ ├── config.php # 配置文件:数据库、Redis
│ └── init.php # 公共加载文件
├── admin/
│ └── index.php # 极简奖品管理(增删改查)
├── public/
│ ├── index.html # 转盘页面
│ ├── js/
│ │ ├── lottery.js # 转盘绘制 + 抽奖交互
│ │ └── request.js # 封装请求
│ └── css/
│ └── lottery.css
├── sql/
│ └── lottery.sql # 初始化表
└── lib/
├── Db.php # PDO 单例
├── Redis.php # Redis 连接单例
└── Lottery.php # 抽奖核心类
数据库连接用 PDO 的预处理参数绑定,防止 SQL 注入;Redis 连接用 phpredis 扩展。如果你的环境没有装 phpredis,也可以直接用文件锁方案做并发控制(后面的常见问题会讲)。
3.2 数据库初始化脚本
把上文的 lp_prize 和 lp_lottery_record 两表敲进 sql/lottery.sql,再插入几条示例数据:
sql复制INSERT INTO `lp_prize`
(`id`, `prize_name`, `prize_type`, `prize_img`, `total_num`, `remain_num`, `probability`, `sort_order`, `status`) VALUES
(1, '特等奖:iPhone 16', 2, '/img/p1.png', 1, 1, 10, 1, 1),
(2, '一等奖:蓝牙耳机', 2, '/img/p2.png', 10, 10, 50, 2, 1),
(3, '二等奖:20元优惠券', 3, '/img/p3.png', 200, 200, 500, 3, 1),
(4, '三等奖:100积分', 1, '/img/p4.png', 1000, 1000, 3000, 4, 1),
(5, '谢谢参与', 4, '/img/p5.png', 0, 0, 6440, 5, 1);
注意五档概率加起来刚好 10000(万分之 100%)。这是必须保证的约束条件,可以在后台保存时做个校验。
3.3 抽奖核心类实现
在 lib/Lottery.php 中封装抽奖逻辑,核心方法是 draw($userId),它做的事情按顺序来:
- 从 Redis 校验用户抽奖次数是否超限。
- 从数据库查出所有
status=1的奖品,组装可用奖池。 - 如果有奖品的实时库存为 0,把它从奖池临时剔除。
- 调用概率抽取算法拿到“理论中奖奖品”。
- 如果中奖的是实物且有库存,用 Redis 原子扣减库存。
- 写入中奖记录(先写记录,再执行库存扣减,通过事务保证一致性)。
- 返回前端需要的数据结构:
prize_id、prize_name、prize_type、angle(前端转盘停止角度)。
关于角度,有一个所有前端开发都会困惑的点:转盘旋转的角度由谁来决定?
我推荐的做法是后端决定停止角度,前端仅做动画展示。后端在返回结果时,根据中奖奖品所在的扇区索引和每块扇区的角度计算出一个目标角度,前端拿到这个角度后转过去就行。这样前后端的结果是强一致的,不会出现“转盘停在一等奖,弹窗却提示谢谢参与”的尴尬。
后端计算目标角度的逻辑:
php复制public function calcAngle($turns, $prizeIndex, $sectorAngle) {
// $turns 为转盘旋转圈数,增加视觉效果,一般是 5~10 圈
// 让指针停留在第 $prizeIndex 个扇区的中心位置
$baseAngle = 360 * $turns;
// 转盘按顺时针绘制,第0个扇区起始角度为0
$targetAngle = $prizeIndex * $sectorAngle + $sectorAngle / 2;
$totalAngle = $baseAngle + (360 - ($targetAngle % 360)) % 360;
return $totalAngle;
}
这里用 (360 - ($targetAngle % 360)) % 360 是为了把角度归一到 $0\sim360$,再让最终角度的余数刚好落在目标位置,确保指针(固定在顶部 12 点钟方向)停到指定奖品扇区。
3.4 前端转盘绘制与抽奖动画
前端我选了 Canvas 绘制,核心代码是绘制一个 8 等份扇区的转盘,并对每块做不同填充色和文字标签。
javascript复制function drawWheel(canvasId, prizes) {
const canvas = document.getElementById(canvasId);
const ctx = canvas.getContext('2d');
const len = prizes.length;
const anglePerSec = (Math.PI * 2) / len;
const radius = canvas.width / 2;
// 绘制每个扇形
for (let i = 0; i < len; i++) {
const startAngle = i * anglePerSec;
const endAngle = startAngle + anglePerSec;
ctx.beginPath();
ctx.moveTo(radius, radius);
ctx.arc(radius, radius, radius - 20, startAngle, endAngle);
ctx.closePath();
ctx.fillStyle = i % 2 === 0 ? '#ff6b6b' : '#ffa94d';
ctx.fill();
ctx.strokeStyle = '#fff';
ctx.lineWidth = 2;
ctx.stroke();
// 写奖品名称(居中在扇形中线)
ctx.save();
ctx.translate(radius, radius);
ctx.rotate(startAngle + anglePerSec / 2);
ctx.textAlign = 'right';
ctx.fillStyle = '#fff';
ctx.font = 'bold 14px sans-serif';
ctx.fillText(prizes[i].prize_name, radius - 40, 6);
ctx.restore();
}
// 绘制中心圆和指针装饰
ctx.beginPath();
ctx.arc(radius, radius, 30, 0, Math.PI * 2);
ctx.fillStyle = '#fff';
ctx.fill();
}
绘制时要注意文字方向:把所有文字旋转到扇形中线的角度,从右向左写,文字底部对齐半径中线,这样文字是沿着半径方向朝外的,视觉上比较舒服。如果你想让文字始终水平,那需要额外计算坐标变换,会更复杂,一般不建议。
抽奖时的动画我直接用 CSS3 的 transform: rotate() 配合 transition,这样最简单。在拿到后端返回的 angle 后,更新转盘容器的 rotate 值:
javascript复制function rotateWheel(angle) {
const el = document.getElementById('wheel');
el.style.transition = 'transform 4s cubic-bezier(0.17, 0.67, 0.12, 0.99)';
el.style.transform = `rotate(${angle}deg)`;
}
这里的 cubic-bezier(0.17, 0.67, 0.12, 0.99) 是模拟转盘减速的效果:启动快、结束慢,感觉像是真实转盘在惯性滑动后停下。如果你直接写 linear 或 ease-out,在 4 秒的动画结束时会有明显的突兀感。
3.5 后端抽奖接口代码
api/lottery.php 中接收用户请求,要做几件事:判断用户是否登录、校验请求频率、调用抽奖核心类、返回 JSON。
php复制require_once __DIR__ . '/../lib/init.php';
require_once __DIR__ . '/../lib/Lottery.php';
$userId = intval($_POST['user_id'] ?? 0);
if ($userId <= 0) {
jsonResponse(400, '未登录或登录已过期');
}
$lottery = new Lottery();
try {
$result = $lottery->draw($userId);
jsonResponse(0, 'ok', $result);
} catch (Exception $e) {
jsonResponse(500, $e->getMessage());
}
因为这块是给前端 H5 的接口,跨域问题要考虑。如果页面和 API 不在同一个域名下,需要在 PHP 里设置跨域响应头:
php复制header('Access-Control-Allow-Origin: *');
header('Access-Control-Allow-Headers: Content-Type');
header('Access-Control-Allow-Methods: POST, OPTIONS');
不过生产环境不建议 *,最好指定具体域名,否则容易被第三方站点直接调用你的接口刷奖。
3.6 管理后台配置奖品
运营要自己配置奖池,如果每次都走 SQL 修改,那就别想着“自动运营”了。我这里提供了一个极简的后台 admin/index.php,就是一张 HTML 表格 + 几个表单输入框,支持新增奖品、修改概率、上下架、查库存。如果你只想验证系统,这个后台已经够用,更美观的后台可以自己再接一个 Vue 或 React 项目。
后台有几个字段需要重点做校验:
- 概率总和不能超过 10000,否则抽奖池计算会失真。
- 实物奖品必须填
total_num,并自动同步remain_num。 - 无奖项的
prize_type必须是 4,库存为 0。
4. 常见问题与排查技巧实录
4.1 转盘动画的结束角度和中奖结果不一致
这是出现频率最高的问题,现象是前端转盘停的位置看起来明显不是弹窗提示的那个奖品。
排查思路如下:
- 确认后端返回的
angle是否基于和前端一致的扇区排列顺序。例如前端绘制时奖品是从第 0 个开始顺时针排列,后端计算角度时也按同样的顺序计算prizeIndex,但你在数据库查询时如果加了ORDER BY sort_order ASC和ORDER BY id ASC不一致,顺序就会错位。 - 确认扇区角度是否为
360 / 奖品数。假设 6 个奖品,每块 60 度,后端返回的角度模 360 后,应该落在目标奖品覆盖的区间[index*60, (index+1)*60),如果差了几度,很可能是计算目标角度时用了扇区中心,而前端停下来的位置判断逻辑用了扇区边界。 - 前端最好在动画结束后再弹窗,不要调完接口立刻弹窗。因为转盘动画需要 4 秒,如果用户看到弹窗后转盘还在转,就会觉得转盘停在了奇怪的位置上。正确的交互应该是:接口返回后先把
angle存下来,等transitionend事件触发后再弹窗。
下面这段是修正后的前端逻辑:
javascript复制fetch('/api/lottery.php', { method: 'POST', body: formData })
.then(res => res.json())
.then(data => {
if (data.code === 0) {
rotateWheel(data.data.angle);
document.getElementById('wheel').addEventListener('transitionend', function() {
showResult(data.data);
}, { once: true });
}
});
用 { once: true } 保证事件只触发一次,不然转动几次就会连续弹多个弹窗。
4.2 高并发下库存超卖
上一节已经说过用 DECR 原子扣减库存可以解决,但这里再讲一个实际会遇到的问题:当 Redis 不可用(比如连接超时)时,接口直接返回 500,用户会看到“网络异常”。假如你的活动对实时库存一致性要求没那么高,也可以退化为数据库的乐观锁或悲观锁方案。
数据库方案代码如下:
php复制// 先开启事务
// 对未出售的奖品行加锁
$sql = "SELECT id, remain_num FROM lp_prize WHERE id = ? FOR UPDATE";
// 然后执行更新
$sql = "UPDATE lp_prize SET remain_num = remain_num - 1 WHERE id = ? AND remain_num > 0";
$affected = $pdo->exec($sql);
这里 WHERE remain_num > 0 是乐观锁的关键,即使两个事务同时读到 remain_num=1,也只有第一个事务的 UPDATE 能成功。缺点是要靠数据库行锁,对数据库压力大,并发极高时不推荐。我个人的思路是:优先用 Redis,缓存降级时用数据库,绝不裸用一个没有原子操作的方案。
4.3 Redis 连接异常导致整个服务不可用
有次活动大半夜收到告警,排查后发现是频繁创建 Redis 连接导致连接数被占满,后续所有需要 Redis 的操作全部卡住。后来我把 Redis 连接改成单例长期复用,并给操作加了超时降级策略:
php复制try {
$redis = Redis::getInstance();
$redis->ping(); // 快速探测
} catch (Exception $e) {
// 降级:直接查数据库库存
}
这样即使 Redis 挂了,也只是少了一个限流的保护层,库存还能靠数据库的 UPDATE ... WHERE remain_num > 0 兜底,不会导致整个抽奖系统崩溃。
4.4 前端 Canvas 绘制文字被截断
奖品名称比较长时(例如“珍藏版限量手办盲盒”),Canvas 的 fillText 不会自动换行,结果就是文字超出扇形边界,难看得要命。
解决办法有两种:一是运营后台限制奖品名称最多 6 个字;二是前端绘制时截断字符,超过一定长度换成省略号:
javascript复制function ellipsisText(text, maxLen = 6) {
if (text.length <= maxLen) return text;
return text.slice(0, maxLen - 1) + '…';
}
如果确实需要完整显示,也可以根据扇区角度和半径计算一行能放多少个字,但通常活动文案都会控制短,所以截断是最高效的方案。
4.5 用户重复点击按钮
即使后端有每人每天次数限制,前端按钮的防重复点击也要做:一旦点击抽奖,在请求返回前把按钮置灰,防止用户连续点十几次把一天的次数瞬间用光。这个很简单,但确实容易遗漏。
javascript复制btn.disabled = true;
btn.style.pointerEvents = 'none';
// 动画结束或接口返回后恢复
4.6 概率精度问题导致实际抽奖结果偏离配置
如果你用的是浮点概率(比如 0.1500),在比较和累加时有可能出现精度丢失。这就是我强烈建议把概率设计成万分位整数的原因。PHP 中 0.1 + 0.2 != 0.3 的坑应该都听过,但很少有人会因此在抽奖系统里栽跟头。
我有个朋友的项目就是直接把概率存为小数,后来发现某个奖品中奖率只有配置的一半,排查半天是浮点累加误差导致随机区间计算偏离。所以记住一句话:概率相关的数值,全部用整数算,最后输出给前端再格式化。
5. 运营层面的实用建议
写代码是一部分,活动真正上线后,运营视角的坑同样多。这里单独分享几个对活动效果影响很大的点。
5.1 奖品概率与奖品价值的匹配关系
如果你把“无奖”概率设得太高(超过 60%),用户体验会非常差,因为用户抽了几次全是“谢谢参与”,很容易流失。比较好的做法是设置一种“参与即得”的普惠奖项,比如“2 积分”“5 金币”,让无奖和普惠奖概率合起来控制在 50% 左右。
实物奖品里,大额奖(iPhone 这类)概率控制在 0.1%~0.5% 就很高了。你需要把奖品分成三档:稀有奖、普通奖、普惠奖,每档设置不同概率区间。这个逻辑可以和运营对齐后直接落地。
5.2 活动数据埋点
建议在抽奖接口中增加埋点参数记录:用户来源渠道、页面参数、设备类型等。一张记录表只有奖品记录还不够,活动结束后运营要分析“哪个渠道来的用户抽奖率最高”“大奖是被哪些用户抽走的”,没有埋点就两眼一抹黑。
最简方案是在 lp_lottery_record 表中加一列 channel,前端请求的时候把渠道参数传过来,后端存储即可。
5.3 Redis 内存估算
如果活动规模大,每天有十几万用户参与,Redis 里 lottery:times:user_* 这种 key 会非常多,注意给 Redis 设置内存淘汰策略(例如 allkeys-lru),并且要评估好活动期间的 Redis 容量。经验值:一个 key 带过期时间和计数器,大概占用 100 字节左右,1000 万用户一天的数据大约是 1GB 内存,需要提前规划。
6. 这套系统后续还能怎么扩展
抽奖系统的核心逻辑是通用的,不只是能跑“幸运大转盘”。换一个前端皮肤,后端几乎不用动就能改成“翻牌抽奖”“摇一摇抽奖”“刮刮卡”。
我在实际项目中做过类似的事:同一套抽奖核心类,活动 A 用转盘,活动 B 用九宫格,活动 C 用刮刮卡,后端全部复用 draw() 方法,只是前端把交互和结果展示换掉了。这得益于后端只返回“中奖结果 + 角度”,不关心前端用什么样的视觉形式展示。
如果你希望让这个系统更完善,还可以补上几块:用户中奖记录查询页、奖品发放流水与物流单号录入、活动数据报表(参与人数、抽奖次数、中奖分布)、对接企业微信/钉钉的中奖通知。这些模块都是围绕同一个 lp_prize 和 lp_lottery_record 表做数据操作,扩张成本很低。
从我个人的经验来看,做这种活动系统,最容易翻车的地方永远是后端逻辑和服务端防御,而不是转盘画得有多好看。画转盘半天就能搞定,真正的稳字,都体现在库存扣减是否原子、概率计算是否准确、防刷校验是否到位、异常降级是否优雅这几件事上。把这几件事挨个抠扎实了,剩下的就是把奖品配好,让用户开心地转起来。
