PHP项目如何实现普通红包?

wen java案例 5

本文目录导读:

PHP项目如何实现普通红包?

  1. 核心算法:二倍均值法
  2. 数据库设计
  3. 核心业务逻辑(并发安全)
  4. 优化建议:使用Redis解决更高并发

在PHP项目中实现“普通红包”(即定额或随机金额的平均分配,常指微信那种每个红包金额随机但总和固定的玩法)通常涉及两个核心算法:金额分配并发安全(避免超发或漏发)。

下面为你提供一份完整的实现方案,包含核心算法、数据库设计和关键代码示例。

核心算法:二倍均值法

这是最流行的随机红包算法,它的核心思想是:*每次抢到的金额 = 随机区间[0.01 , 剩余金额 / 剩余数量 2]**

这个算法可以保证每次抢到的金额期望值相等,且不会出现最后金额过大的情况。

算法示例 (PHP):

<?php
/**
 * 生成红包金额数组 (普通红包/随机红包)
 * @param int $totalAmount 总金额 (单位:分,避免浮点数误差)
 * @param int $num 红包个数
 * @return array 返回每个红包的金额 (单位:分)
 * @throws Exception
 */
function generateRedPacket($totalAmount, $num) {
    if ($totalAmount <= 0 || $num <= 0) {
        throw new Exception("金额或数量必须大于0");
    }
    if ($totalAmount < $num) {
        throw new Exception("总金额不能小于人数(每人至少1分钱)");
    }
    $packets = [];
    $remainingAmount = $totalAmount;
    $remainingNum = $num;
    for ($i = 0; $i < $num; $i++) {
        if ($i == $num - 1) {
            // 最后一个人:拿走所有剩余
            $amount = $remainingAmount;
        } else {
            // 二倍均值算法
            $max = (int)($remainingAmount / $remainingNum * 2);
            // 保证至少有1分钱
            $amount = rand(1, $max);
        }
        $packets[] = $amount;
        $remainingAmount -= $amount;
        $remainingNum--;
    }
    return $packets;
}
// 示例:发100元(10000分)给10个人
$amountList = generateRedPacket(10000, 10);
print_r($amountList);
// 输出类似: [1234, 876, 1567, ...] 总和为10000
?>

重要说明:

  • 单位统一用 (例如100元=10000分),避免 float 运算的精度丢失。
  • rand(1, $max) 确保每人至少1分钱。

数据库设计

保证业务逻辑和资金安全,至少需要两张表:

红包总单表 (red_packet)

字段名 类型 说明
id int/bigint 主键,红包ID
total_amount int 总金额 (单位:分)
total_num int 总个数
remaining_amount int 剩余金额 (分)
remaining_num int 剩余个数
status tinyint 状态:0-未发完,1-已抢完
created_at datetime 创建时间

红包领取记录表 (red_packet_log)

字段名 类型 说明
id bigint 主键
red_packet_id int 红包ID
user_id int 用户ID
amount int 抢到的金额 (分)
created_at datetime 领取时间

核心业务逻辑(并发安全)

在高并发场景下(如秒杀、群红包),必须使用数据库行锁Redis分布式锁,下面以MySQL行锁为例:

发红包逻辑 (伪代码):

<?php
// 1. 用户发起发红包请求 (假设已校验余额)
$totalAmount = 10000; // 100元
$num = 10;
$packets = generateRedPacket($totalAmount, $num); // 预生成所有红包金额
// 2. 开启事务
$db->beginTransaction();
try {
    // 3. 扣用户余额 (可加锁)
    $db->exec("UPDATE user_balance SET balance = balance - $totalAmount WHERE user_id = ?");
    // 4. 插入红包总单
    $db->insert("INSERT INTO red_packet (total_amount, total_num, remaining_amount, remaining_num, status) VALUES (?,?,?,?,?)", [$totalAmount, $num, $totalAmount, $num, 0]);
    $redPacketId = $db->lastInsertId();
    // 5. 预插入所有红包记录 (状态为0:未领取)
    foreach ($packets as $amount) {
        $db->insert("INSERT INTO red_packet_log (red_packet_id, user_id, amount, status) VALUES (?, 0, ?, 0)", [$redPacketId, $amount]);
    }
    $db->commit();
    echo "红包已发出,ID: " . $redPacketId;
} catch (Exception $e) {
    $db->rollback();
    echo "发红包失败: " . $e->getMessage();
}
?>

抢红包逻辑 (高并发核心):

关键在于 原子性操作:先锁定,再更新,最后返回。

<?php
/**
 * 抢红包(保证并发安全)
 * @param int $redPacketId 红包ID
 * @param int $userId 用户ID
 * @return array [success, amount, msg]
 */
function grabRedPacket($redPacketId, $userId) {
    $db = new PDO(...);
    $db->beginTransaction();
    try {
        // 1. 检查用户是否已经抢过该红包 (可先查索引)
        $row = $db->query("SELECT id FROM red_packet_log WHERE red_packet_id = ? AND user_id = ? AND status = 1", [$redPacketId, $userId])->fetch();
        if ($row) {
            $db->rollback();
            return ['success' => false, 'msg' => '你已经抢过这个红包了'];
        }
        // 2. 核心:使用 FOR UPDATE 锁住红包总单行记录
        $packet = $db->query("SELECT id, remaining_num, remaining_amount, status FROM red_packet WHERE id = ? FOR UPDATE", [$redPacketId])->fetch();
        if (!$packet || $packet['status'] == 1) {
            $db->rollback();
            return ['success' => false, 'msg' => '红包已被抢完'];
        }
        // 3. 从预生成的记录中找到一条未被领取的记录 (可以用 limit 1 for update)
        $log = $db->query("SELECT id, amount FROM red_packet_log WHERE red_packet_id = ? AND status = 0 LIMIT 1 FOR UPDATE", [$redPacketId])->fetch();
        if (!$log) {
            // 理论上不会发生,但安全校验
            $db->rollback();
            return ['success' => false, 'msg' => '红包已被抢完'];
        }
        $amount = $log['amount'];
        // 4. 更新红包总单 (扣减剩余金额和剩余个数)
        $affected = $db->exec("UPDATE red_packet SET remaining_num = remaining_num - 1, remaining_amount = remaining_amount - ? WHERE id = ? AND remaining_num > 0", [$amount, $redPacketId]);
        if ($affected == 0) {
            $db->rollback();
            return ['success' => false, 'msg' => '红包已被抢完'];
        }
        // 5. 更新红包领取记录 (标记为用户领取)
        $db->exec("UPDATE red_packet_log SET user_id = ?, status = 1 WHERE id = ? AND status = 0", [$userId, $log['id']]);
        // 6. 如果剩余个数为0,更新状态
        if ($packet['remaining_num'] - 1 == 0) {
            $db->exec("UPDATE red_packet SET status = 1 WHERE id = ?", [$redPacketId]);
        }
        $db->commit();
        return ['success' => true, 'amount' => $amount, 'msg' => '恭喜,你抢到了' . ($amount/100) . '元'];
    } catch (Exception $e) {
        $db->rollback();
        // 记录日志
        return ['success' => false, 'msg' => '服务器繁忙,请稍后重试'];
    }
}
?>

优化建议:使用Redis解决更高并发

如果QPS非常高(例如春晚红包),MySQL的FOR UPDATE行锁会成为瓶颈,推荐采用 Redis + Lua 方案:

  1. Redis List:发红包时,将 generateRedPacket() 生成的每个金额(字符串) LPUSH 到一个以 red_packet_id 为key的List中。
  2. Lua脚本:使用 EVAL 执行原子操作:RPOP 取出一个金额 -> SADD 记录已抢用户 -> 返回金额,如果List为空或用户已存在,返回nil。
  • 算法:二倍均值法是首选,简单稳定。
  • 安全性:所有金额操作使用(int)而非小数(float)。
  • 高并发:首选MySQL行锁(SELECT...FOR UPDATE),极致性能要求用Redis+Lua。
  • 幂等性:抢红包接口必须保证用户只能抢一次(通过唯一索引或SADD检查)。

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