PHP项目怎么实现红包雨?完整技术方案与实战指南
📖 目录导读
红包雨业务逻辑与核心机制
红包雨是典型的“限时抢红包”玩法,用户在规定时间内点击屏幕上落下的红包图标,即可随机获得金额。核心业务流程如下:

- 活动创建:后台设定总金额、红包个数、每人上限、活动时长。
- 预分配红包:将总金额拆分为多个随机红包(二倍均值法),存入Redis队列。
- 用户参与:前端倒计时开始,红包图标随机下落,用户点击触发抢红包请求。
- 扣减与返回:后端从队列中弹出红包,记录用户领取记录,返回金额。
- 兜底策略:红包抢完或活动结束,前端停止动画。
关键难点:高并发下保证“不超发、不漏发”,且金额分配均匀。
技术选型:为何选择PHP + Redis?
很多开发者会问:PHP单线程模型能扛住红包雨的高并发吗?答案是 “能,但必须配合Redis”。
- PHP(Laravel/ThinkPHP):负责业务逻辑、模板渲染、API路由,使用
Swoole或Workerman可提升PHP并发能力。 - Redis:核心神器,利用其
LPUSH/RPOP实现红包队列,原子操作DECR保证扣减不超。 - MySQL:持久化用户领取记录,用于对账和防重复。
架构图简介:
Nginx负载均衡 → PHP-FPM + Swoole → Redis(红包池 + 计数器) → MySQL(异步落库)
数据库与缓存设计
1 MySQL表结构
-- 红包活动表 CREATE TABLE `red_packet_activity` ( `id` int(11) NOT NULL AUTO_INCREMENT, `total_amount` decimal(10,2) DEFAULT '0.00' COMMENT '总金额', `total_num` int(11) DEFAULT '0' COMMENT '总个数', `per_limit` int(11) DEFAULT '1' COMMENT '每人上限', `start_time` datetime DEFAULT NULL, `end_time` datetime DEFAULT NULL, `status` tinyint(1) DEFAULT '0' COMMENT '0未开始 1进行中 2已结束', PRIMARY KEY (`id`) ) ENGINE=InnoDB;
-- 领取记录表 CREATE TABLE `red_packet_log` ( `id` int(11) NOT NULL AUTO_INCREMENT, `act_id` int(11) DEFAULT NULL, `user_id` int(11) DEFAULT NULL, `amount` decimal(10,2) DEFAULT '0.00', `create_time` datetime DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (`id`), UNIQUE KEY `uk_user_act` (`act_id`,`user_id`) -- 防止重复领取 ) ENGINE=InnoDB;
2 Redis数据结构
| 数据结构 | 用途 | key示例 |
|---|---|---|
| List | 红包队列(预拆分好的金额) | red:packet:list:活动ID |
| String | 总剩余个数(原子DECR) | red:packet:count:活动ID |
| String | 用户领取次数(原子INCR) | red:packet:user:活动ID:用户ID |
| Set | 已领取用户白名单(防重复) | red:packet:users:活动ID |
核心代码实现:队列、并发与随机算法
1 红包拆分算法(二倍均值法)
/**
* 拆分红包:每个红包金额在 [0.01, 剩余均值*2] 之间
*/
function splitRedPacket($totalAmount, $num, $min = 0.01) {
$amounts = [];
$remaining = $totalAmount;
for ($i = 0; $i < $num - 1; $i++) {
$avg = $remaining / ($num - $i);
$max = $avg * 2 - $min; // 保证最后不小于min
$amount = round(mt_rand($min * 100, $max * 100) / 100, 2);
$amounts[] = $amount;
$remaining -= $amount;
}
$amounts[] = round($remaining, 2);
shuffle($amounts); // 打乱顺序
return $amounts;
}
2 将红包存入Redis队列(活动开启前)
// 假设已查询活动信息:$actId, $totalNum, $totalAmount
$amounts = splitRedPacket($totalAmount, $totalNum);
$redis = Redis::connection();
$listKey = "red:packet:list:{$actId}";
foreach ($amounts as $amount) {
$redis->lpush($listKey, $amount);
}
$redis->set("red:packet:count:{$actId}", $totalNum);
3 用户抢红包接口(核心加锁逻辑)
public function grabRedPacket($actId, $userId) {
$redis = Redis::connection();
$listKey = "red:packet:list:{$actId}";
$countKey = "red:packet:count:{$actId}";
$userKey = "red:packet:user:{$actId}:{$userId}"; // 每人次数
$userSet = "red:packet:users:{$actId}";
// 1. 检查是否已领完 (原子操作)
if ($redis->get($countKey) <= 0) {
return ['code' => -1, 'msg' => '红包已被抢完'];
}
// 2. 检查用户上限(每人最多N次)
$userCount = $redis->incr($userKey);
if ($userCount > 活动配置的每人上限) {
$redis->decr($userKey); // 回滚
return ['code' => -2, 'msg' => '已达到领取上限'];
}
// 3. 从队列右侧弹出红包 (RPOP)
$amount = $redis->rpop($listKey);
if ($amount === false) {
$redis->decr($userKey); // 回滚
return ['code' => -3, 'msg' => '红包已空'];
}
// 4. 减少总数并记录用户 (原子操作)
$redis->decr($countKey);
$redis->sadd($userSet, $userId);
// 5. 异步写入MySQL(用消息队列如RabbitMQ,避免阻塞)
dispatch(new SaveRedPacketLogJob($actId, $userId, $amount));
return ['code' => 1, 'amount' => $amount];
}
注意:使用 rpop + decr 并非完全原子,但在千万级并发下,实际项目中可配合 Redis Lua脚本 实现原子性,如下所示。
4 Lua脚本保证原子操作
-- grab.lua
local listKey = KEYS[1]
local countKey = KEYS[2]
local userKey = KEYS[3]
local userSet = KEYS[4]
local userId = ARGV[1]
local perLimit = tonumber(ARGV[2])
-- 检查剩余个数
local count = redis.call('GET', countKey)
if not count or tonumber(count) <= 0 then
return -1
end
-- 检查用户领取次数
local userCount = redis.call('INCR', userKey)
if userCount > perLimit then
redis.call('DECR', userKey)
return -2
end
-- 弹出红包
local amount = redis.call('RPOP', listKey)
if not amount then
redis.call('DECR', userKey)
return -3
end
-- 更新统计
redis.call('DECR', countKey)
redis.call('SADD', userSet, userId)
return amount
PHP端调用:
$script = file_get_contents('grab.lua');
$sha = $redis->script('load', $script);
$result = $redis->evalSha($sha, [
$listKey, $countKey, $userKey, $userSet,
$userId, $perLimit
], 4); // 4个key
前端H5红包雨交互方案
- 核心库:使用Canvas或CSS3动画生成随机下落的红包图标。
- 点击检测:监听
click或touchstart事件,判断点击坐标是否在红包元素内。 - 请求节流:每点击一个红包发起一次API请求,为防止重复点击,可设置
isClicking锁。 - 反馈动画:抢中后显示“+X.XX元”,未中显示“手慢了”,并播放碎片动画。
// 简化版点击处理
let isClicking = false;
function onRedClick(redEl) {
if (isClicking) return;
isClicking = true;
redEl.style.opacity = 0; // 红包消失
fetch('/api/grab', {
method: 'POST',
body: JSON.stringify({ actId, userId })
}).then(res => res.json()).then(data => {
if (data.code === 1) {
showAmount(data.amount);
} else {
showMsg(data.msg);
}
isClicking = false;
}).catch(() => { isClicking = false; });
}
高并发场景下的防刷与限流策略
-
用户级别限流
使用Redis滑动窗口限制用户每秒请求数(如最多5次/秒):
INCR red:limit:user:用户ID:秒级时间戳,超过阈值返回“操作频繁”。 -
IP级别限流
同上,key为red:limit:ip:用户IP:秒,防止脚本刷单。 -
接口令牌桶
利用Redis的INCRBY模拟令牌桶,每100ms补一个令牌,请求消耗令牌。 -
前端验证码
短时间内点击过快触发图形验证码或滑块验证。 -
结果异步落库
抢中红包立刻返回金额,但数据库写入通过消息队列(RabbitMQ/Redis Stream)异步进行,避免MySQL成为瓶颈。
常见问题与问答环节
Q1:红包队列中的金额会不会被重复取出?
A:不会,使用 RPOP 是Redis的原子弹出操作,一次只能被一个进程取走,配合Lua脚本,整个取包、扣减、校验过程一步完成,彻底杜绝重复。
Q2:如果Redis挂掉了怎么办?
A:红包雨核心依赖Redis,建议部署Redis集群(主从+哨兵)保证高可用,可在活动开始前将红包数据持久化到MySQL,Redis宕机后从MySQL恢复队列,但实际生产中,短时红包雨更看重性能,一般接受短暂不可用。
Q3:如何保证金额分配是随机的,但又是固定的总金额?
A:使用二倍均值法(见4.1节),每次从剩余金额中随机抽取,最后一人拿到剩余全部,这种方法保证了每次不均匀但总和等于初始总金额,并且加上了 shuffle() 打乱顺序,避免“越早抢越大”或“越晚抢越大”。
Q4:如果用户抢了红包但还没入数据库,回滚怎么办?
A:我们采用“先返回后异步落库”的策略,如果后续落库失败,可通过定时任务或日志补偿,但实际发生概率极低,加上MySQL唯一索引 uk_user_act 防止重复入账,即使补偿也很安全。
Q5:PHP能否支撑10万QPS的红包雨?
A:PHP-FPM单机可达几千QPS,通过Nginx + 多台PHP服务器水平扩展可实现10万QPS,但瓶颈通常在Redis,建议Redis集群分片存储,如果预算有限,可使用 Swoole 常驻内存,将业务代码升级为协程模式,单机可达上万QPS。
红包雨的核心在于 预分配 + 原子弹出 + 异步落库,利用Redis的高性能队列和原子命令,配合PHP的业务编排,完全可以构建一个高并发、高可靠的抢红包系统,实际商业项目中还需加入风控、降级、监控等模块,但本文提供的方案已覆盖了核心链路。