PHP项目怎么实现红包雨?

wen java案例 5

PHP项目怎么实现红包雨?完整技术方案与实战指南

📖 目录导读

  1. 红包雨业务逻辑与核心机制
  2. 技术选型:为何选择PHP + Redis?
  3. 数据库与缓存设计
  4. 核心代码实现:队列、并发与随机算法
  5. 前端H5红包雨交互方案
  6. 高并发场景下的防刷与限流策略
  7. 常见问题与问答环节

红包雨业务逻辑与核心机制

红包雨是典型的“限时抢红包”玩法,用户在规定时间内点击屏幕上落下的红包图标,即可随机获得金额。核心业务流程如下:

PHP项目怎么实现红包雨?

  • 活动创建:后台设定总金额、红包个数、每人上限、活动时长。
  • 预分配红包:将总金额拆分为多个随机红包(二倍均值法),存入Redis队列。
  • 用户参与:前端倒计时开始,红包图标随机下落,用户点击触发抢红包请求。
  • 扣减与返回:后端从队列中弹出红包,记录用户领取记录,返回金额。
  • 兜底策略:红包抢完或活动结束,前端停止动画。

关键难点:高并发下保证“不超发、不漏发”,且金额分配均匀。


技术选型:为何选择PHP + Redis?

很多开发者会问:PHP单线程模型能扛住红包雨的高并发吗?答案是 “能,但必须配合Redis”

  • PHP(Laravel/ThinkPHP):负责业务逻辑、模板渲染、API路由,使用 SwooleWorkerman 可提升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动画生成随机下落的红包图标。
  • 点击检测:监听 clicktouchstart 事件,判断点击坐标是否在红包元素内。
  • 请求节流:每点击一个红包发起一次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; });
}

高并发场景下的防刷与限流策略

  1. 用户级别限流
    使用Redis滑动窗口限制用户每秒请求数(如最多5次/秒):
    INCR red:limit:user:用户ID:秒级时间戳,超过阈值返回“操作频繁”。

  2. IP级别限流
    同上,key为 red:limit:ip:用户IP:秒,防止脚本刷单。

  3. 接口令牌桶
    利用Redis的 INCRBY 模拟令牌桶,每100ms补一个令牌,请求消耗令牌。

  4. 前端验证码
    短时间内点击过快触发图形验证码或滑块验证。

  5. 结果异步落库
    抢中红包立刻返回金额,但数据库写入通过消息队列(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的业务编排,完全可以构建一个高并发、高可靠的抢红包系统,实际商业项目中还需加入风控、降级、监控等模块,但本文提供的方案已覆盖了核心链路。

抱歉,评论功能暂时关闭!