本文目录导读:

- 目录导读
- 红包系统的核心需求与业务逻辑
- 数据结构设计:如何用MySQL存储红包与领取记录
- 红包分配算法:二倍均值法与随机算法的对比
- 防止超发与重复领取的锁机制
- 高并发下的优化:Redis队列与事务
- 常见问题问答(FAQ)
- 从理论到线上部署的注意事项
PHP项目实现红包系统的完整指南:从算法到高并发实战
目录导读
- 红包系统的核心需求与业务逻辑
- 数据结构设计:如何用MySQL存储红包与领取记录
- 红包分配算法:二倍均值法与随机算法的对比
- 防止超发与重复领取的锁机制
- 高并发下的优化:Redis队列与事务
- 常见问题问答(FAQ)
- 从理论到线上部署的注意事项
红包系统的核心需求与业务逻辑
在PHP项目中实现红包系统,首先要厘清用户场景:发红包(拆分为多个子红包)和抢红包(随机分配金额),一个成熟的红包系统需要满足:
- 固定金额+随机拆分:例如发100元,分10个红包,每个红包金额随机,但总额固定。
- 防止超发:不能使总领取金额超过总金额。
- 防止重复领取:同一用户只能领一次。
- 高并发支持:抢红包瞬间可能有数千人同时请求。
业务数据流通常为:
- 用户A发起红包 → 系统生成红包记录(总金额、个数、剩余金额、过期时间)。
- 用户B抢红包 → 系统先判断剩余个数>0且未领过,再随机分配金额,更新剩余记录。
- 若红包未领完且超时,可设置退回原账户。
数据结构设计:如何用MySQL存储红包与领取记录
红包主表(red_packet)
CREATE TABLE `red_packet` ( `id` int(11) NOT NULL AUTO_INCREMENT, `user_id` int(11) NOT NULL COMMENT '发红包用户ID', `total_amount` decimal(10,2) NOT NULL COMMENT '总金额(单位:元)', `total_num` int(11) NOT NULL COMMENT '红包总数', `remain_amount` decimal(10,2) NOT NULL DEFAULT '0.00', `remain_num` int(11) NOT NULL DEFAULT '0', `expire_time` datetime DEFAULT NULL, `status` tinyint(4) DEFAULT '0' COMMENT '0未生效 1可抢 2已抢完 3已过期', `created_at` timestamp NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (`id`), KEY `idx_status` (`status`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
红包领取记录表(red_packet_log)
CREATE TABLE `red_packet_log` ( `id` int(11) NOT NULL AUTO_INCREMENT, `packet_id` int(11) NOT NULL, `user_id` int(11) NOT NULL, `amount` decimal(10,2) NOT NULL COMMENT '领取金额', `created_at` timestamp NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (`id`), UNIQUE KEY `uk_packet_user` (`packet_id`,`user_id`), KEY `idx_user_id` (`user_id`) ) ENGINE=InnoDB;
- 关键点:
remain_amount和remain_num用于快速判断是否可抢;uk_packet_user唯一索引防止重复领取。
红包分配算法:二倍均值法与随机算法的对比
经典算法:二倍均值法(推荐用于普通红包)
每次抢红包时,随机范围是 [1, 剩余金额/剩余个数 * 2](单位:分),示例:
- 总金额100元(10000分),10个红包,第一次随机范围:
[1, 10000/10*2=2000],假设随机到800分(8元),剩余9200分。 - 第二次随机范围:
[1, 9200/9*2≈2044],以此类推。
PHP实现代码:
function getRedPacketAmount($remainAmount, $remainNum) {
if ($remainNum <= 1) return $remainAmount; // 最后一个红包直接返回
$max = ceil($remainAmount / $remainNum * 2) - 1; // 最大可抢金额(单位:分)
$amount = rand(1, $max);
// 防止最后一个红包金额为0
return min($amount, $remainAmount - ($remainNum - 1));
}
说明:此算法能保证每次随机金额不超过平均值的两倍,且最终总和等于总金额。
对比:纯随机分割
先随机生成N-1个分割点,再排序后计算差值,优点是概率均匀,但实现略复杂,且可能产生极小金额(如0.01元)更多,二倍均值法更常用。
防止超发与重复领取的锁机制
在高并发场景下,直接更新数据库可能存在“超发”(例如剩余个数为1时,两个请求同时判断都认为可抢),解决方案:
方案1:数据库悲观锁(行级锁)
// 开启事务
$pdo->beginTransaction();
$packet = $pdo->query("SELECT * FROM red_packet WHERE id = $packetId FOR UPDATE")->fetch();
if ($packet['remain_num'] <= 0 || $packet['status'] != 1) {
$pdo->rollback();
return '红包已抢完';
}
// 分配算法获取金额
$amount = getRedPacketAmount($packet['remain_amount'], $packet['remain_num']);
// 更新剩余
$pdo->exec("UPDATE red_packet SET remain_num = remain_num - 1, remain_amount = remain_amount - $amount WHERE id = $packetId");
// 插入领取记录
$pdo->exec("INSERT INTO red_packet_log (packet_id, user_id, amount) VALUES ($packetId, $userId, $amount)");
$pdo->commit();
弊端:FOR UPDATE会阻塞其他请求,并发能力下降,适合红包数量较小时(如每日限量的优惠券红包)。
方案2:Redis+Lua原子操作(推荐)
使用Redis的原子特性:将红包剩余信息和用户领取状态放在Redis中,用Lua脚本保证原子性。
-- Lua脚本
local packetKey = KEYS[1] -- 红包剩余信息:hash {remain_num, remain_amount}
local userKey = KEYS[2] -- 用户已领集合:set
local userId = ARGV[1]
local minAmount = 1 -- 最小单位分
-- 检查是否已领
if redis.call('sismember', userKey, userId) == 1 then
return 0 -- 已领取
end
-- 检查剩余数量
local remainNum = redis.call('hget', packetKey, 'remain_num')
if remainNum <= 0 then
return -1 -- 已抢完
end
-- 分配金额(这里用Lua实现二倍均值法)
local remainAmount = redis.call('hget', packetKey, 'remain_amount')
local amount = 0
if remainNum == 1 then
amount = remainAmount
else
local max = math.ceil(remainAmount / remainNum * 2) - 1
amount = math.random(1, max)
end
-- 更新剩余
redis.call('hset', packetKey, 'remain_num', remainNum - 1)
redis.call('hset', packetKey, 'remain_amount', remainAmount - amount)
redis.call('sadd', userKey, userId)
return amount -- 返回金额(单位:分)
PHP调用:
$script = "(上述Lua脚本)";
$amount = $redis->eval($script, ["packet:$packetId", "user:$packetId"], 2);
if ($amount <= 0) {
echo $amount === 0 ? '已领取' : '已抢完';
} else {
// 异步将领取结果写入MySQL(需最终一致)
}
高并发下的优化:Redis队列与事务
- 预分片金额:在发红包时,用算法提前算出所有金额并存入Redis List,抢红包时直接从List右侧pop一个金额,优点是无需计算,速度快;缺点是金额序列固定,失去每次随机感。
- 库存扣减:使用Redis DECR命令扣减剩余个数,避免全部依赖Lua。
- 异步落库:抢红包成功的信息先写入Redis(或MQ),再由定时任务批量更新MySQL,避免数据库成为瓶颈。
常见问题问答(FAQ)
Q1:二倍均值法是否会导致金额分布不均匀? A:该算法能防止金额出现极端值(如一个小红包金额特别大),但总体趋于平均,若需要更均匀的分布,可考虑调整随机范围。
Q2:发红包后用户始终未领,如何自动退回?
A:可设置定时任务(如cron),每分钟扫描expire_time小于当前时间且status=1的红包,将剩余金额退款原用户,注意需在事务中修改订单状态。
Q3:用户抢到红包但写入MySQL失败怎么办? A:采用“最终一致性”策略:先在Redis记录成功,再异步写入MySQL,若写入失败,可在MySQL中设置唯一索引+重试机制,或通过补偿任务修复。
Q4:如何测试红包系统的高并发? A:使用JMeter或Locust模拟并发请求,关注“总金额是否等于各红包之和”、“是否有重复领取”等校验点,建议先压测Redis方案,再对比数据库方案性能。
从理论到线上部署的注意事项
红包系统的核心是原子性与一致性,推荐技术栈:
- Redis:作为高速缓存与原子操作介质,存储剩余金额、用户已领状态。
- MySQL:持久化红包主记录与领取记录,提供最终一致性保障。
- 队列(RabbitMQ/Redis stream):承接收发异步任务,削峰填谷。
线上部署需警惕:
- 将金额换算为分为单位,避免浮点运算误差。
- 数据库字段使用
decimal(10,0)存储分,而非直接存元。 - 添加监控:如每分钟检查红包数量与总额是否匹配,报警异常。
通过合理的数据结构、原子算法和并发策略,PHP完全可以实现稳定、高效的抢红包功能,实际项目中,建议优先采用Redis+Lua+异步落库的架构,在保证数据准确的前提下支撑数千QPS。