抢红包系统设计

hykeda23小时前杂项12

实现类似微信红包的抢红包功能,支持拼手气红包、普通红包、高并发秒杀。


系统功能

核心功能:
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,2NOT NULL COMMENT '总金额',
    total_count INT NOT NULL COMMENT '红包个数',
    remain_amount DECIMAL(10,2NOT 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,2NOT 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,2DEFAULT 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,2NOT NULL,
    balance DECIMAL(10,2NOT 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);
        }
    }
}

新手易错点:

  1. ❌ 忘记使用事务,导致数据不一致
  2. ❌ 不使用乐观锁,并发扣款会出问题
  3. ❌ 金额计算精度问题(使用 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

方案对比:

方案
优点
缺点
适用场景
MySQL 悲观锁
实现简单,数据一致性强
性能差,不适合高并发
小规模(<100人)
Redis + Lua
性能高,适合高并发
实现复杂,需要数据同步
大规模(>1000人)


第三步:异步写入数据库

<?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%

抢红包是高并发场景的经典案例!

转:码上微光

标签: 抢红包

相关文章

批量修改文本文件的编码格式

批量修改文本文件的编码格式

1.使用EditPlus打开要修改编码格式的文件所属目录 2.按shift,选中左下角列表中需要转换编码格式的文本,右击选择“打开”,打开的效果如下: 3.依次选择菜单栏中的“文档”-&g...

超有用地址链接

正则匹配...

动态排名数据可视化

动态排名数据可视化

最近在抖音中很流行的一种柱状图动态排名的短视频,再配上背景音乐,这个是有一个小插件实现的,然后配上录屏+背景。 插件介绍: 效果图: 此图为动态...

discuz 插件调用钩子未执行问题

discuz 插件调用一些钩子没有被执行到,有可能是dz模板进行了缓存处理,比如统一的头部上的钩子,那么就必须要在后台把dz的缓存清空一下,再去执行就可以执行到了。...

win10配置java环境(JDK)

下载JDK下载地址:http://www.oracle.com/technetwork/java/javase/downloads/index.html选择需要下载的版本:然后进行安装,该部分只要按照...

直播推流、播放使用方法及注意点

直播推流、播放使用方法及注意点

1、阿里云播放器需要引入文件(css和js):<link rel="stylesheet" href="https://g.alicdn.com/de/prismp...

发表评论    

◎欢迎参与讨论,请在这里发表您的看法、交流您的观点。