抢红包系统设计
实现类似微信红包的抢红包功能,支持拼手气红包、普通红包、高并发秒杀。
系统功能
核心功能:
1. 发红包(拼手气/普通)
2. 抢红包
3. 红包详情
4. 红包记录
技术难点:
- 高并发(1万人同时抢)
- 数据一致性(不能超发)
- 金额随机算法
- 性能优化
业务规则
红包类型
1. 拼手气红包
- 总金额固定(如100元)
- 个数固定(如10个)
- 每个金额随机
- 最小0.01元
2. 普通红包
- 总金额固定(如100元)
- 个数固定(如10个)
- 每个金额相等(10元)
业务约束
1. 每人只能抢一次
2. 红包24小时过期
3. 过期金额退回发送者
4. 最少2个红包
5. 单个红包最大200元数据库设计
-- 红包表
CREATE TABLE red_packets (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
user_id BIGINT NOT NULL COMMENT '发送者',
type TINYINT NOT NULL COMMENT '1拼手气 2普通',
total_amount DECIMAL(10,2) NOT NULL COMMENT '总金额',
total_count INT NOT NULL COMMENT '红包个数',
remain_amount DECIMAL(10,2) NOT NULL COMMENT '剩余金额',
remain_count INT NOT NULL COMMENT '剩余个数',
status TINYINT DEFAULT 1 COMMENT '1进行中 2已抢完 3已过期',
expired_at TIMESTAMP NOT NULL COMMENT '过期时间',
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
INDEX idx_user (user_id),
INDEX idx_status (status),
INDEX idx_expired (expired_at)
);
-- 红包明细表
CREATE TABLE red_packet_details (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
packet_id BIGINT NOT NULL COMMENT '红包ID',
user_id BIGINT NULL COMMENT '领取者(NULL表示未领取)',
amount DECIMAL(10,2) NOT NULL COMMENT '金额',
grabbed_at TIMESTAMP NULL COMMENT '领取时间',
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
INDEX idx_packet (packet_id),
INDEX idx_user (user_id)
);
-- 用户账户表
CREATE TABLE user_accounts (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
user_id BIGINT UNIQUE NOT NULL,
balance DECIMAL(10,2) DEFAULT 0 COMMENT '余额',
version INT DEFAULT 0 COMMENT '乐观锁版本号',
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
);
-- 账户流水表
CREATE TABLE account_logs (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
user_id BIGINT NOT NULL,
type TINYINT NOT NULL COMMENT '1发红包 2抢红包 3退款',
amount DECIMAL(10,2) NOT NULL,
balance DECIMAL(10,2) NOT NULL COMMENT '操作后余额',
packet_id BIGINT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
INDEX idx_user (user_id)
);
表设计要点:
red_packets:红包主表,记录总金额和剩余金额red_packet_details:红包明细,预先生成所有红包user_accounts.version:乐观锁,防止并发扣款问题
第一步:发红包
1.1 红包金额算法
<?php
namespace App\Services;
class RedPacketService
{
/**
* 拼手气红包算法(二倍均值法)
*
* 原理:
* 每次抢到的金额 = 随机(0.01, 剩余平均值 * 2)
*
* 例如:100元10个红包
* 第1个:随机(0.01, 20),假设抢到15元
* 第2个:随机(0.01, (100-15)/9*2 = 18.89)
* ...
* 最后1个:剩余所有金额
*/
public function generateLuckyAmounts(float $totalAmount, int $count): array
{
$amounts = [];
$remainAmount = $totalAmount;
$remainCount = $count;
for ($i = 0; $i < $count - 1; $i++) {
// 计算当前可抢的最大金额(剩余平均值的2倍)
$avg = $remainAmount / $remainCount;
$max = $avg * 2;
// 随机金额(保留2位小数)
$amount = mt_rand(1, $max * 100) / 100;
$amount = max(0.01, $amount); // 最少0.01元
$amounts[] = $amount;
$remainAmount -= $amount;
$remainCount--;
}
// 最后一个红包是剩余所有金额
$amounts[] = round($remainAmount, 2);
return $amounts;
}
/**
* 普通红包算法
*/
public function generateNormalAmounts(float $totalAmount, int $count): array
{
$amount = round($totalAmount / $count, 2);
// 处理除不尽的情况
$amounts = array_fill(0, $count, $amount);
$diff = $totalAmount - ($amount * $count);
if ($diff > 0) {
$amounts[0] += $diff; // 第一个红包补上差额
}
return $amounts;
}
}
算法说明:
二倍均值法保证了金额的随机性和公平性 最后一个红包拿剩余所有金额,避免精度问题 普通红包平均分配,第一个补差额
1.2 发红包接口
<?php
namespace App\Http\Controllers;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
class RedPacketController extends Controller
{
private RedPacketService $service;
public function __construct(RedPacketService $service)
{
$this->service = $service;
}
/**
* 发红包
*/
public function send(Request $request)
{
// 1. 验证参数
$request->validate([
'type' => 'required|in:1,2', // 1拼手气 2普通
'total_amount' => 'required|numeric|min:0.02|max:200',
'total_count' => 'required|integer|min:2|max:100',
]);
$userId = auth()->id();
$type = $request->input('type');
$totalAmount = $request->input('total_amount');
$totalCount = $request->input('total_count');
// 2. 检查余额
$account = DB::table('user_accounts')->where('user_id', $userId)->first();
if ($account->balance < $totalAmount) {
return response()->json(['error' => '余额不足'], 400);
}
DB::beginTransaction();
try {
// 3. 扣除余额(乐观锁)
$affected = DB::table('user_accounts')
->where('user_id', $userId)
->where('version', $account->version)
->where('balance', '>=', $totalAmount)
->update([
'balance' => DB::raw("balance - {$totalAmount}"),
'version' => DB::raw('version + 1'),
]);
if ($affected === 0) {
throw new \Exception('余额不足或并发冲突');
}
// 4. 创建红包
$packetId = DB::table('red_packets')->insertGetId([
'user_id' => $userId,
'type' => $type,
'total_amount' => $totalAmount,
'total_count' => $totalCount,
'remain_amount' => $totalAmount,
'remain_count' => $totalCount,
'status' => 1,
'expired_at' => now()->addHours(24),
'created_at' => now(),
]);
// 5. 生成红包明细
$amounts = $type == 1
? $this->service->generateLuckyAmounts($totalAmount, $totalCount)
: $this->service->generateNormalAmounts($totalAmount, $totalCount);
$details = [];
foreach ($amounts as $amount) {
$details[] = [
'packet_id' => $packetId,
'amount' => $amount,
'created_at' => now(),
];
}
DB::table('red_packet_details')->insert($details);
// 6. 记录流水
DB::table('account_logs')->insert([
'user_id' => $userId,
'type' => 1, // 发红包
'amount' => -$totalAmount,
'balance' => $account->balance - $totalAmount,
'packet_id' => $packetId,
'created_at' => now(),
]);
DB::commit();
return response()->json([
'packet_id' => $packetId,
'message' => '发送成功',
]);
} catch (\Exception $e) {
DB::rollBack();
return response()->json(['error' => $e->getMessage()], 500);
}
}
}
新手易错点:
❌ 忘记使用事务,导致数据不一致 ❌ 不使用乐观锁,并发扣款会出问题 ❌ 金额计算精度问题(使用 DECIMAL 而不是 FLOAT)
第二步:抢红包(方案对比)
2.1 方案一:MySQL 悲观锁(不推荐)
<?php
/**
* 使用 FOR UPDATE 锁定红包
*
* 缺点:
* - 性能差(串行执行)
* - 锁等待时间长
* - 不适合高并发
*/
public function grabV1(int $packetId, int $userId)
{
DB::beginTransaction();
try {
// 1. 锁定红包(悲观锁)
$packet = DB::table('red_packets')
->where('id', $packetId)
->where('status', 1)
->lockForUpdate() // 加锁
->first();
if (!$packet || $packet->remain_count <= 0) {
throw new \Exception('红包已抢完');
}
// 2. 检查是否已抢过
$exists = DB::table('red_packet_details')
->where('packet_id', $packetId)
->where('user_id', $userId)
->exists();
if ($exists) {
throw new \Exception('已经抢过了');
}
// 3. 随机获取一个未领取的红包
$detail = DB::table('red_packet_details')
->where('packet_id', $packetId)
->whereNull('user_id')
->lockForUpdate()
->first();
// 4. 领取红包
DB::table('red_packet_details')
->where('id', $detail->id)
->update([
'user_id' => $userId,
'grabbed_at' => now(),
]);
// 5. 更新红包剩余
DB::table('red_packets')
->where('id', $packetId)
->update([
'remain_amount' => DB::raw("remain_amount - {$detail->amount}"),
'remain_count' => DB::raw('remain_count - 1'),
'status' => DB::raw('IF(remain_count - 1 = 0, 2, status)'),
]);
// 6. 增加用户余额
DB::table('user_accounts')
->where('user_id', $userId)
->update([
'balance' => DB::raw("balance + {$detail->amount}"),
]);
DB::commit();
return $detail->amount;
} catch (\Exception $e) {
DB::rollBack();
throw $e;
}
}
性能测试:
1000人抢10个红包 耗时:约 5-10 秒 TPS:约 100-200
2.2 方案二:Redis + Lua(推荐)
<?php
/**
* 使用 Redis 原子操作
*
* 优点:
* - 性能高(内存操作)
* - 原子性(Lua 脚本)
* - 适合高并发
*/
public function grabV2(int $packetId, int $userId)
{
// 1. 检查是否已抢过(Redis Set)
$key = "red_packet:grabbed:{$packetId}";
if (Redis::sismember($key, $userId)) {
throw new \Exception('已经抢过了');
}
// 2. 使用 Lua 脚本原子操作
$lua = <<<LUA
-- 检查红包是否存在
local packet_key = KEYS[1]
local detail_key = KEYS[2]
local grabbed_key = KEYS[3]
local user_id = ARGV[1]
-- 检查是否已抢过
if redis.call('SISMEMBER', grabbed_key, user_id) == 1 then
return {err = 'already_grabbed'}
end
-- 弹出一个红包
local detail = redis.call('LPOP', detail_key)
if not detail then
return {err = 'empty'}
end
-- 标记已抢
redis.call('SADD', grabbed_key, user_id)
-- 减少剩余数量
redis.call('HINCRBY', packet_key, 'remain_count', -1)
return detail
LUA;
$packetKey = "red_packet:{$packetId}";
$detailKey = "red_packet:details:{$packetId}";
$grabbedKey = "red_packet:grabbed:{$packetId}";
$result = Redis::eval($lua, 3, $packetKey, $detailKey, $grabbedKey, $userId);
if (isset($result['err'])) {
throw new \Exception($result['err'] == 'empty' ? '红包已抢完' : '已经抢过了');
}
$detail = json_decode($result, true);
// 3. 异步写入数据库
dispatch(new UpdateRedPacketJob($packetId, $userId, $detail['id'], $detail['amount']));
return $detail['amount'];
}
/**
* 初始化 Redis 数据
*/
public function initRedis(int $packetId)
{
$packet = DB::table('red_packets')->find($packetId);
$details = DB::table('red_packet_details')
->where('packet_id', $packetId)
->get();
// 红包信息
Redis::hmset("red_packet:{$packetId}", [
'total_amount' => $packet->total_amount,
'remain_count' => $packet->total_count,
]);
// 红包明细(List)
foreach ($details as $detail) {
Redis::rpush("red_packet:details:{$packetId}", json_encode([
'id' => $detail->id,
'amount' => $detail->amount,
]));
}
// 设置过期时间(24小时)
Redis::expire("red_packet:{$packetId}", 86400);
Redis::expire("red_packet:details:{$packetId}", 86400);
}
性能测试:
1000人抢10个红包 耗时:约 0.1-0.5 秒 TPS:约 2000-10000
方案对比:
第三步:异步写入数据库
<?php
namespace App\Jobs;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Support\Facades\DB;
class UpdateRedPacketJob implements ShouldQueue
{
use Queueable;
private int $packetId;
private int $userId;
private int $detailId;
private float $amount;
public function __construct(int $packetId, int $userId, int $detailId, float $amount)
{
$this->packetId = $packetId;
$this->userId = $userId;
$this->detailId = $detailId;
$this->amount = $amount;
}
public function handle()
{
DB::beginTransaction();
try {
// 1. 更新红包明细
DB::table('red_packet_details')
->where('id', $this->detailId)
->update([
'user_id' => $this->userId,
'grabbed_at' => now(),
]);
// 2. 更新红包剩余
DB::table('red_packets')
->where('id', $this->packetId)
->update([
'remain_amount' => DB::raw("remain_amount - {$this->amount}"),
'remain_count' => DB::raw('remain_count - 1'),
]);
// 3. 增加用户余额
DB::table('user_accounts')
->where('user_id', $this->userId)
->update([
'balance' => DB::raw("balance + {$this->amount}"),
'version' => DB::raw('version + 1'),
]);
// 4. 记录流水
DB::table('account_logs')->insert([
'user_id' => $this->userId,
'type' => 2, // 抢红包
'amount' => $this->amount,
'balance' => DB::table('user_accounts')->where('user_id', $this->userId)->value('balance'),
'packet_id' => $this->packetId,
'created_at' => now(),
]);
DB::commit();
} catch (\Exception $e) {
DB::rollBack();
\Log::error('UpdateRedPacketJob failed', [
'packet_id' => $this->packetId,
'user_id' => $this->userId,
'error' => $e->getMessage(),
]);
}
}
}
第四步:红包详情
<?php
/**
* 红包详情
*/
public function detail(int $packetId)
{
// 1. 红包信息
$packet = DB::table('red_packets')->find($packetId);
// 2. 领取记录
$records = DB::table('red_packet_details as d')
->leftJoin('users as u', 'd.user_id', '=', 'u.id')
->where('d.packet_id', $packetId)
->whereNotNull('d.user_id')
->select('u.nickname', 'd.amount', 'd.grabbed_at')
->orderBy('d.grabbed_at')
->get();
// 3. 标记最佳手气
if ($records->isNotEmpty()) {
$maxAmount = $records->max('amount');
foreach ($records as $record) {
$record->is_best = ($record->amount == $maxAmount);
}
}
return response()->json([
'packet' => $packet,
'records' => $records,
]);
}
第五步:定时任务(退款)
<?php
namespace App\Console\Commands;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\DB;
class RefundExpiredRedPackets extends Command
{
protected $signature = 'redpacket:refund';
protected $description = '退款过期红包';
public function handle()
{
// 查找过期且未抢完的红包
$packets = DB::table('red_packets')
->where('status', 1)
->where('expired_at', '<', now())
->where('remain_count', '>', 0)
->get();
foreach ($packets as $packet) {
DB::beginTransaction();
try {
// 1. 更新红包状态
DB::table('red_packets')
->where('id', $packet->id)
->update(['status' => 3]); // 已过期
// 2. 退款给发送者
if ($packet->remain_amount > 0) {
DB::table('user_accounts')
->where('user_id', $packet->user_id)
->update([
'balance' => DB::raw("balance + {$packet->remain_amount}"),
]);
// 3. 记录流水
DB::table('account_logs')->insert([
'user_id' => $packet->user_id,
'type' => 3, // 退款
'amount' => $packet->remain_amount,
'balance' => DB::table('user_accounts')->where('user_id', $packet->user_id)->value('balance'),
'packet_id' => $packet->id,
'created_at' => now(),
]);
}
DB::commit();
$this->info("红包 {$packet->id} 退款成功");
} catch (\Exception $e) {
DB::rollBack();
$this->error("红包 {$packet->id} 退款失败: " . $e->getMessage());
}
}
}
}
定时任务配置:
// app/Console/Kernel.php
protected function schedule(Schedule $schedule)
{
// 每小时执行一次
$schedule->command('redpacket:refund')->hourly();
}
压力测试
使用 Apache Bench
# 1000个并发请求
ab -n 1000 -c 100 -p grab.json -T application/json \
http://localhost/api/red-packet/grab
# grab.json 内容
{
"packet_id": 123,
"user_id": 456
}
使用 JMeter
1. 创建线程组
- 线程数:1000
- Ramp-Up:10秒
2. 添加 HTTP 请求
- 方法:POST
- 路径:/api/red-packet/grab
- Body:{"packet_id": 123}
3. 添加监听器
- 聚合报告
- 查看结果树
性能优化
1. Redis 预加载
- 发红包时立即写入 Redis
- 避免首次抢红包时加载
2. 限流
- 单用户每秒最多抢3次
- 防止恶意刷接口
3. 降级
- Redis 故障时降级到 MySQL
- 保证可用性
4. 监控
- 抢红包成功率
- 接口响应时间
- Redis 命中率总结
抢红包核心:
1. 金额算法:二倍均值法
2. 并发控制:Redis + Lua
3. 数据一致性:异步写入 + 补偿
4. 性能优化:内存操作 + 队列
技术栈:
- Redis(高并发)
- Lua(原子操作)
- MySQL(持久化)
- Queue(异步处理)
性能指标:
- TPS:10000+
- 响应时间:< 100ms
- 成功率:99.9%
抢红包是高并发场景的经典案例!
转:码上微光


