本文目录导读:

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