1. 原生PHP实战抢红包的庖丁解牛
抢红包作为移动互联网时代最具社交属性的功能之一,背后隐藏着高并发、数据一致性等核心技术挑战。去年双十一期间,我们团队用原生PHP重构的抢红包系统成功扛住了每秒3.2万次的请求峰值。今天我就从技术选型到异常处理,完整拆解这个看似简单实则暗藏玄机的功能实现。
2. 核心架构设计
2.1 并发控制方案对比
在初期技术选型时,我们对比了三种主流方案:
| 方案类型 | 实现方式 | 吞吐量 | 数据一致性 | 适用场景 |
|---|---|---|---|---|
| 文件锁 | flock()函数 | 800QPS | 可靠 | 小型活动 |
| MySQL乐观锁 | version字段+CAS | 3500QPS | 可靠 | 中型并发 |
| Redis+Lua | 原子化脚本 | 30000QPS | 可靠 | 大型秒杀场景 |
最终选择Redis+Lua方案,主要基于两点考量:
- 红包金额计算需要保证原子性,避免超发
- 读多写少的特性适合内存数据库
2.2 数据模型设计
红包系统的核心数据表结构如下:
sql复制CREATE TABLE `red_packet` (
`id` bigint(20) UNSIGNED NOT NULL COMMENT '红包ID',
`user_id` int(11) NOT NULL COMMENT '发起人ID',
`amount` decimal(10,2) NOT NULL COMMENT '总金额',
`remain_amount` decimal(10,2) NOT NULL COMMENT '剩余金额',
`total` int(11) NOT NULL COMMENT '总个数',
`remain_total` int(11) NOT NULL COMMENT '剩余个数',
`version` int(11) NOT NULL DEFAULT '0' COMMENT '版本号',
`created_at` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
关键设计要点:使用DECIMAL(10,2)存储金额避免浮点精度问题,version字段用于乐观锁控制
3. 核心算法实现
3.1 红包分配算法
我们测试了三种分配策略:
php复制// 1. 完全随机算法(会产生极端不公平情况)
function randomAllocate($amount, $num) {
$result = [];
for($i=1; $i<$num; $i++){
$safe = ($amount - ($num - $i) * 0.01) / ($num - $i);
$money = mt_rand(1, $safe*100) / 100;
$amount -= $money;
$result[] = $money;
}
$result[] = $amount;
return $result;
}
// 2. 二倍均值法(推荐方案)
function doubleAverage($amount, $num) {
$result = [];
$remainingAmount = $amount;
for($i=1; $i<$num; $i++){
$avg = $remainingAmount * 2 / ($num - $i + 1);
$money = mt_rand(1, $avg*100) / 100;
$remainingAmount -= $money;
$result[] = $money;
}
$result[] = $remainingAmount;
return $result;
}
// 3. 线段切割法(适合超大金额场景)
function lineSegment($amount, $num) {
$points = [];
for($i=0; $i<$num-1; $i++){
$points[] = mt_rand(1, $amount*100);
}
sort($points);
$result = [];
$prev = 0;
foreach($points as $point){
$result[] = ($point - $prev) / 100;
$prev = $point;
}
$result[] = ($amount*100 - $prev) / 100;
return $result;
}
实测数据显示,二倍均值法在公平性和性能上达到最佳平衡,最终采用该方案。
3.2 Redis原子化操作
使用Lua脚本保证操作的原子性:
lua复制-- KEYS[1]: 红包key
-- ARGV[1]: 用户ID
local packet = redis.call('HMGET', KEYS[1], 'total', 'remain_total', 'amount', 'remain_amount')
if tonumber(packet[2]) <= 0 then
return 0
end
local remain_total = tonumber(packet[2]) - 1
local remain_amount = tonumber(packet[4])
-- 最后一个红包直接返回剩余金额
if remain_total == 0 then
redis.call('HMSET', KEYS[1], 'remain_total', 0, 'remain_amount', 0)
return math.floor(remain_amount * 100)
end
-- 二倍均值算法
local max = remain_amount * 2 / remain_total
local money = math.random(1, math.floor(max * 100))
money = money / 100
redis.call('HMSET', KEYS[1], 'remain_total', remain_total, 'remain_amount', remain_amount - money)
return math.floor(money * 100)
4. 高并发优化实践
4.1 缓存预热策略
在红包创建时预先生成所有分配记录:
php复制public function create($userId, $amount, $total) {
// 生成分配记录
$details = $this->doubleAverage($amount, $total);
// 使用管道批量写入Redis
$redis = Redis::pipeline();
foreach($details as $money){
$redis->lPush('red_packet:'.$this->id.':queue', $money);
}
$redis->exec();
// 写入数据库
DB::table('red_packet')->insert([
'id' => $this->id,
'user_id' => $userId,
'amount' => $amount,
'remain_amount' => $amount,
'total' => $total,
'remain_total' => $total
]);
}
4.2 异步日志处理
使用Swoole的协程通道实现日志异步落盘:
php复制$chan = new \Swoole\Coroutine\Channel(1000);
go(function() use ($chan){
$pdo = new PDO($dsn, $user, $pass);
while(true){
$data = $chan->pop();
$stmt = $pdo->prepare("INSERT INTO red_packet_log (...) VALUES (...)");
$stmt->execute($data);
}
});
// 在抢红包逻辑中
$chan->push([
'packet_id' => $packetId,
'user_id' => $userId,
'amount' => $money,
'created_at' => date('Y-m-d H:i:s')
]);
5. 异常处理与监控
5.1 防重入机制
采用Redis SETNX实现分布式锁:
php复制$lockKey = 'packet:'.$packetId.':user:'.$userId;
if(!Redis::setnx($lockKey, 1)){
throw new Exception('请勿重复抢红包');
}
Redis::expire($lockKey, 60);
5.2 熔断降级策略
当系统负载超过阈值时自动降级:
php复制$load = sys_getloadavg()[0];
if($load > 10){
// 返回排队提示
return [
'code' => 503,
'msg' => '当前参与人数过多,请稍后再试'
];
}
6. 性能压测数据
使用JMeter进行基准测试:
| 并发用户数 | 平均响应时间 | 错误率 | 吞吐量 |
|---|---|---|---|
| 1000 | 23ms | 0% | 4200QPS |
| 5000 | 67ms | 0.2% | 18500QPS |
| 10000 | 142ms | 1.5% | 26300QPS |
关键优化点:
- 将红包分配计算从MySQL转移到Redis
- 使用连接池减少数据库连接开销
- 日志异步写入提升主流程性能
7. 安全防护措施
7.1 防刷策略
php复制// 基于用户IP的限流
$key = 'packet:limit:'.md5($packetId.$_SERVER['REMOTE_ADDR']);
if(Redis::incr($key) > 3){
throw new Exception('操作过于频繁');
}
Redis::expire($key, 5);
// 设备指纹验证
$fingerprint = $_SERVER['HTTP_USER_AGENT'].$_SERVER['HTTP_ACCEPT_LANGUAGE'];
if(Redis::sIsMember('packet:blacklist', md5($fingerprint))){
throw new Exception('非法请求');
}
7.2 数据加密
红包金额传输使用RSA加密:
php复制$publicKey = openssl_pkey_get_public(file_get_contents('public.pem'));
openssl_public_encrypt($money, $encrypted, $publicKey);
$money = base64_encode($encrypted);
8. 实际踩坑记录
- 金额精度问题:初期使用浮点数导致金额合计出现0.01分误差,改为以分为单位整数计算
- 库存超卖:未使用原子操作导致红包多发,引入Lua脚本解决
- 热点Key问题:单个红包Key访问过热,通过分片存储缓解
- 事务失效:MySQL事务中混用Redis操作导致不一致,改为最终一致性
关键教训:所有资金相关操作必须进行对账核查,我们建立了每小时跑一次的校验任务,比对Redis与MySQL数据一致性
这套系统经过三年春节红包活动的考验,最关键的体会是:高并发系统不能只考虑正常流程,异常情况的处理往往消耗80%的开发时间。建议在方案设计阶段就建立完整的监控和降级预案,我们现在的系统包含17个不同级别的自动降级策略,这是保证稳定性的关键所在。
