首页 > 其他分享 >高并发场景下的抢红包系统设计:实时拆分与预先生成方案的比较与优化

高并发场景下的抢红包系统设计:实时拆分与预先生成方案的比较与优化

时间:2024-11-01 13:47:08浏览次数:5  
标签:红包 缓存 Redis 抢红包 redis 并发 拆分 userId packetId

引言

在之前面试中经常会问到的一个经典场景问题是如何设计一个抢红包系统。我之前的项目场景中也会涉及到群红包的业务逻辑。今天我们来一起讨论下这个业务场景设计。这个问题不仅考察我们对高并发处理的理解,还涉及到数据库设计缓存优化分布式锁控制等技术细节。

在“抢红包”系统中,用户在短时间内向系统发起大量请求,同时争抢有限的红包资源。为了避免红包超发、用户重复领取等问题,系统需具备可靠的并发控制和数据一致性策略。此外,系统还要在保障稳定性的前提下优化用户体验。因此,设计一个高并发抢红包系统,需要从多个方面进行深入分析和精细设计。


架构面临的问题和挑战

针对高并发的“抢红包”场景,我们可以从以下几个关键问题和挑战入手分析:

1. 数据库高并发写入冲突
  • 问题描述:数十万甚至数百万用户同时请求同一个红包,数据库的写压力剧增。频繁的写入操作可能导致数据库死锁写入冲突,甚至系统崩溃
  • 挑战:如何分担数据库压力,减少频繁 I/O 操作对性能的影响,避免锁表和写冲突。
2. 缓存穿透与缓存击穿
  • 问题描述:高并发的场景下,热点红包数据会被大量访问,极易出现缓存穿透和缓存击穿问题。如果缓存数据失效或未命中,所有请求直接打到数据库,使数据库压力倍增。
  • 挑战:如何通过缓存机制将抢红包的热点数据存储在内存中,防止请求直接访问数据库,减少系统负载。
3. 红包分配时的数据一致性
  • 问题描述:在红包分配过程中,因高并发可能出现超发(发出的金额超过总金额)或重复领取(同一用户多次领取)等问题。
  • 挑战:如何使用分布式锁或原子操作保证红包剩余金额和数量的正确更新,并防止重复领取。
4. 请求高并发下的用户体验优化
  • 问题描述:在大流量请求时,服务器需要合理的限流机制,确保稳定服务。需要设计结果返回方式以避免用户等待时间过长。
  • 挑战:如何设计限流方案以保障服务器稳定,同时快速反馈领取结果,优化用户体验。

红包拆分选型

为了设计一个更灵活的抢红包系统,我们在设计抢红包逻辑之前 往往需要思考拆红包的逻辑的设计,用于控制红包金额的生成方式。这样可以满足不同业务需求,同时优化系统性能。在拆红包过程中,我们可以采用两种策略:

  1. 实时拆分:这种方式在用户抢红包时动态生成金额。每次用户请求时,系统根据红包剩余金额和数量,使用随机算法生成一个合适的金额,确保红包的剩余数量和总金额不会超出或不足。这种方式简单且灵活,但在高并发下可能对服务器的计算资源要求较高。
  2. 预先生成:这种方式适用于更高并发的场景。当用户在创建红包时输入总金额和数量,系统便会根据一定的随机算法,将总金额拆分为若干个随机金额,将这些金额存入队列中。抢红包时,用户仅需依次从队列中取出一个已生成的金额。这种方式可以减少抢红包时的计算资源消耗,提升系统性能,特别适合流量较大的场景。
结合队列的实现

预先生成策略可以通过消息队列来管理和调度:

  • 生成过程:红包创建时,将拆分好的随机金额逐一推入消息队列。
  • 抢红包时的取出操作:用户抢红包时,从队列中依次取出一个金额,并进行领取操作。
  • 保障顺序和完整性:通过队列确保金额的领取顺序和完整性,即每个金额只会被领取一次,避免超发或重复领取。
拆红包的伪代码示例

在预先生成策略下,红包创建和拆分金额的逻辑可如下实现:

/**
 * 拆红包逻辑 - 预生成金额拆分
 *
 * @param int $packetId 红包 ID
 * @param int $totalAmount 红包总金额
 * @param int $totalCount 红包总数量
 */
function generateAndEnqueueRedPacketAmounts($packetId, $totalAmount, $totalCount) {
    $redis = new Redis();
    $redis->connect('127.0.0.1', 6379);

    $queueKey = "packet_queue:{$packetId}";

    for ($i = 0; $i < $totalCount; $i++) {
        // 使用随机算法生成金额
        $grabAmount = generateRandomAmount($totalAmount, $totalCount - $i);
        
        // 将拆分好的金额依次存入队列
        $redis->rPush($queueKey, $grabAmount);

        // 更新剩余金额
        $totalAmount -= $grabAmount;
    }

    // 设置队列过期时间,与红包过期时间一致
    $redis->expire($queueKey, 3600);
}

/**
 * 抢红包 - 从预生成队列中取金额
 *
 * @param int $packetId 红包 ID
 * @return int|false 抢到的金额或 false 表示红包已被抢完
 */
function grabAmountFromQueue($packetId) {
    $redis = new Redis();
    $redis->connect('127.0.0.1', 6379);

    $queueKey = "packet_queue:{$packetId}";

    // 从队列左侧取出一个金额
    return $redis->lPop($queueKey);
}

这种设计结合了实时拆分预先生成的两种方式,根据业务需求灵活使用,既能满足高并发下的性能要求,又能提供较好的用户体验。


今天我们来讲解第一种方案,也就是实时拆分的方案设计,通过该方案保证在用户抢红包时动态生成领取金额。

系统架构设计

针对高并发的抢红包系统,为了应对数据库压力、缓存穿透、数据一致性和用户体验的多重挑战,我们设计了一个分层的系统架构:

架构总览

架构分为五个主要层次,依次为负载均衡层应用服务器层缓存层消息队列层数据库层。这些层次通过合理的分工和协作,确保系统在高并发和大流量下稳定运行。

1. 负载均衡层

负载均衡层位于架构的最前端,主要作用是将用户的请求均匀地分发到各个应用服务器节点,以避免单一服务器的超载。选择负载均衡技术(如 Nginx、HAProxy)并设置合适的均衡策略,可以根据流量情况灵活扩展服务器数量,进一步保障系统的高可用性。

2. 应用服务器层(API 层)

应用服务器是业务逻辑的核心所在,负责处理用户请求中的红包分配、领取等逻辑。为了保障高并发下的请求处理效率,这一层采用了分布式锁和缓存来控制并发,避免频繁的数据库访问。

  • 分布式锁:通过 Redis 分布式锁,确保同一时间仅允许一个用户领取红包,避免超发问题。
  • 缓存:将红包信息(如剩余金额、已领取记录)缓存到 Redis 中,快速读取,减少数据库的负载。
  • 限流控制:应用服务器中还可以配置限流策略,对单个用户的请求频率进行限制,防止恶意攻击和刷单。
3. 缓存层

缓存层的核心是 Redis,用于应对抢红包场景中的热点数据访问需求。缓存层既能避免高并发情况下的缓存穿透问题,又能有效降低数据库访问压力。

  • 数据缓存:将红包的主要数据(如金额、状态)存储在缓存中。
  • 热点数据保护:对热点红包(如大金额红包)数据进行缓存保护,防止在缓存失效时出现短时间内的大量数据库访问。
  • 缓存失效策略:设置合理的失效策略,保证缓存数据的实时性与一致性。

此外,Redis 支持 Lua 脚本,可以用来完成红包领取的多个操作(如减少金额、更新用户领取记录)的原子操作,避免在高并发场景下出现数据不一致的问题。

4. 消息队列层(MQ 层)

消息队列用于处理高并发场景下的请求削峰,在大量用户同时抢红包的场景中,消息队列可以起到缓冲的作用,避免请求直接打到数据库。

  • 请求入队:用户的抢红包请求被推入消息队列(如 RabbitMQ 或 Kafka),由后台消费者异步处理。
  • 异步消费:后台从消息队列中逐条读取请求,逐步完成领取操作,并将结果写入缓存和数据库。
  • 削峰填谷:通过异步处理减少瞬时数据库的写入压力,提高系统的吞吐量和响应效率。
5. 数据库层

数据库层负责持久化存储红包数据和领取记录。为了分散数据库负载,在数据库层采用了分库分表策略,将数据分散在多个数据库实例和表中。

  • 分库分表:通过 packet_iduser_id 进行分库分表,将红包表和领取记录表分别存储在不同的数据库或表中,避免单表数据过大带来的写入瓶颈。
  • 主从分离:使用数据库的主从架构,主库用于写操作,从库用于读取操作,进一步分担数据库压力。
  • 数据一致性:在高并发场景中,Redis 缓存可能与数据库的数据存在不一致的情况。设计定时任务将缓存数据同步回数据库,保证数据的一致性和持久性。
系统架构图
                      +------------------+
                      |     客户端       |
                      +------------------+
                               |
                               |
                       (负载均衡层)
                               |
                               v
                      +------------------+
                      |   应用服务器层    |
                      |      (API)       |
                      +------------------+
                               |
                  +------------+-------------+
                  |                          |
           (缓存层 - Redis)          (消息队列层 - MQ)
                  |                          |
                  v                          v
         +------------------+       +------------------+
         |   分布式缓存     |       |   消息队列(MQ)   |
         +------------------+       +------------------+
                  |                          |
                  |                          |
                  +------------+-------------+
                               |
                               v
                       (数据库集群层)
                               |
                     +------------------+
                     |    数据库集群    |
                     |    (分库分表)    |
                     +------------------+
架构图解读
  1. 客户端:用户通过移动设备或浏览器发起请求。
  2. 负载均衡层:将大量请求均匀分配到多个应用服务器实例中,避免单一服务器过载。
  3. 应用服务器层:API 服务负责红包分配、领取等核心业务逻辑。
  4. 缓存层(Redis) :分布式缓存快速读取红包信息,保证高并发下的数据快速访问,并使用分布式锁控制并发。
  5. 消息队列层(MQ) :消息队列缓冲高频请求,后台异步消费,以削峰填谷减少数据库压力。
  6. 数据库集群层:使用分库分表策略分散数据,保障数据持久化和一致性,最终写入领取记录和红包状态。

数据库设计

上面我们分析了抢红包系统的整体架构,接下来,我们开始具体分析并实现数据库的设计与分库分表策略。

在高并发、大流量的场景下,数据库的性能和可扩展性至关重要。对于抢红包系统而言,海量的领取请求集中访问数据库会带来巨大的写入压力,因此有必要评估单库的承载能力,并根据实际需求选择是否分库分表。

1. 单库的承载能力分析

在简单系统中,所有红包和领取记录都可以放入一个数据库中。但对于高并发的抢红包系统而言,单库的读写能力有限,难以支撑高频访问。单库会遇到以下瓶颈:

  • 写入冲突:并发写入红包领取记录,导致锁竞争和事务冲突。
  • 存储上限:数据量随着用户数增长迅速增大,单表的存储和查询性能降低。
  • 扩展性限制:单库难以横向扩展,数据库的瓶颈会影响系统整体性能。

因此,单库设计难以满足高并发需求,通常需要进行分库分表。

分库分表策略

分库分表主要分为水平拆分垂直拆分两种方式:

  1. 水平拆分:基于数据的某些字段值(如 user_idpacket_id),将数据均匀分配到多个库和表中。

    • 优点:适合大数据量的分布,可以有效分散单库单表的读写压力。
    • 缺点:数据分散后,跨库查询和聚合操作较为复杂。
  2. 垂直拆分:基于业务模块,将不同类型的数据拆分到不同的数据库中,比如将红包信息和领取记录分开存储。

    • 优点:数据逻辑上分离,便于管理和维护。
    • 缺点:跨库关联查询复杂,部分场景下可能导致冗余数据。
抢红包系统的分库分表选择

针对抢红包系统的特点和高并发需求,适合采用水平拆分,将数据按照 user_idpacket_id 进行均匀分布,具体分库分表策略如下:

  • 红包信息表(RedPacket) :红包数量相对较少,可以仅做水平分表。
  • 领取记录表(RedPacketGrabRecord) :数据量随用户增长而迅速积累,建议按 user_id 水平分库分表。
分片维度选择
  1. 红包信息表:可以基于 packet_id 取模分表,将数据均匀存储在多个表中。
  2. 领取记录表:基于 user_id 取模分库分表,适用于大数据量且按用户查询的场景。
分片算法
  1. 取模分片:对 user_idpacket_id 进行取模,分布到不同的库或表。例如 user_id % 10 可以分到 10 个库,每个库再取模分 10 张表。
  2. 一致性哈希:适合动态扩展的场景,允许后期添加数据库节点而尽可能减少数据迁移。
分库分表的插件选择

实现分库分表通常可以借助插件或中间件来进行路由和管理。常用的分库分表工具包括:

  • ShardingSphere:Java 开发的分布式数据库中间件,支持分库分表。
  • Mycat:开源的数据库中间件,支持水平拆分和垂直拆分。
  • Atlas:360 公司推出的数据库代理中间件。

在应用层,我们也可以手动编写分库分表逻辑,根据 packet_iduser_id 动态生成库名和表名。

数据表设计

抢红包系统的数据库设计包含以下两张表:

  1. 红包信息表(RedPacket) :存储红包的基础信息。

    CREATE TABLE `red_packet` (
        `packet_id` BIGINT PRIMARY KEY COMMENT '红包 ID',
        `total_amount` INT NOT NULL COMMENT '红包总金额,单位分',
        `remaining_amount` INT NOT NULL COMMENT '红包剩余金额',
        `total_count` INT NOT NULL COMMENT '红包总数量',
        `remaining_count` INT NOT NULL COMMENT '红包剩余数量',
        `status` TINYINT NOT NULL DEFAULT 0 COMMENT '红包状态:0-未领取完,1-已领取完,2-已过期',
        `create_time` TIMESTAMP DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
        `expire_time` TIMESTAMP COMMENT '过期时间'
    ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='红包信息表';
    
  2. 领取记录表(RedPacketGrabRecord) :记录每个用户的领取信息。

    CREATE TABLE `red_packet_grab_record` (
        `record_id` BIGINT AUTO_INCREMENT PRIMARY KEY COMMENT '记录 ID',
        `packet_id` BIGINT NOT NULL COMMENT '红包 ID',
        `user_id` BIGINT NOT NULL COMMENT '用户 ID',
        `amount` INT NOT NULL COMMENT '领取金额,单位分',
        `grab_time` TIMESTAMP DEFAULT CURRENT_TIMESTAMP COMMENT '领取时间',
        UNIQUE KEY `unique_user_packet` (`packet_id`, `user_id`) COMMENT '用户领取唯一索引'
    ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='红包领取记录表';
    

动态获取库名和表名的示例代码

下面是基于 user_idpacket_id 的动态库表生成代码示例。代码中采用了取模的方式,将用户数据分散到多个库和表中。

/**
 * 获取数据库和表名
 *
 * @param int $user_id 用户ID
 * @return array 返回数据库和表名
 */
public function getDatabaseAndTable($user_id): array
{
    $db_count = 10;  // 假设有10个库
    $table_count = 10;  // 每个库有10张表

    // 计算数据库索引
    $db_index = $user_id % $db_count;
    $db_name = 'red_packet_db_' . $db_index; // 数据库名称,区分不同数据库

    // 计算表索引
    $table_index = ($user_id + $db_index) % $table_count; // 使用 db_index 影响 table_index,避免数据分布不均
    $table_name = 'red_packet_grab_record_' . $table_index; // 表名称,区分不同表

    return ['db' => $db_name, 'table' => $table_name];
}

代码解读

  • $db_count$table_count 分别表示数据库和表的数量。
  • 通过 user_id % $db_count 计算出数据库索引,生成数据库名称。
  • 通过 ($user_id + $db_index) % $table_count 计算表索引。$db_index 的参与可以让数据库和表的分布更加均衡。
  • 返回的结果数组包含数据库名和表名,可用于后续的数据库操作。

Redis 缓存和分布式锁实现

为什么需要缓存?

在抢红包系统中,用户的请求会集中在短时间内高频访问某个红包数据,这种场景下,如果所有请求直接访问数据库,将会出现以下问题:

  1. 数据库压力过大:频繁的读写请求容易导致数据库过载,严重时可能导致数据库崩溃。
  2. 响应速度慢:用户在高并发时直接请求数据库,响应速度会因数据库瓶颈而降低,用户体验变差。
  3. 数据一致性:高并发时,数据库的更新操作容易发生冲突,导致数据不一致的问题。

因此,通过引入 Redis 作为缓存层,可以缓解数据库的压力,提高系统的响应速度,并辅助实现数据的分布式一致性控制。

使用缓存需要注意的问题

在抢红包系统中使用缓存时,需要解决以下关键问题:

  1. 热点数据问题:当某个红包被大量用户同时抢时,会形成热点数据。可以通过短期缓存保护,设置合适的缓存过期时间来缓解热点数据的压力。
  2. 缓存穿透:指的是缓存中没有数据,而用户频繁访问某个不存在的红包,导致请求不断打到数据库。可以通过布隆过滤器(Bloom Filter)或空结果缓存来减少缓存穿透。
  3. 数据一致性:Redis 缓存的数据和数据库中的数据可能存在不同步问题。例如,用户领取红包时 Redis 中的红包剩余数量减少,而数据库可能未及时更新。因此需要设计合理的数据回流机制,定期将 Redis 中的关键数据同步到数据库。
  4. 分布式锁:为避免多用户并发操作同一个红包导致超发或重复领取,使用 Redis 实现分布式锁,确保同一红包在同一时刻只允许一个用户领取。
  5. Lua 脚本原子性:在 Redis 中,Lua 脚本可保证多个操作的原子性。在领取红包时,使用 Lua 脚本将多个操作(如减少金额、更新领取记录)合并成一个事务,防止并发问题。

为了支持抢红包系统的高并发需求,缓存和分布式锁的使用尤为重要。以下是 Redis 缓存设计的详细说明。

1. Redis 缓存设计

缓存结构的设计可以有效减少数据库访问频率,提高系统响应速度。针对抢红包系统,我们设计了以下缓存方案:

红包缓存

存储红包的基本信息,如总金额、剩余金额、剩余数量等,避免频繁查询数据库。

  • 键名packet:{packet_id}
  • 字段total_amountremaining_amountremaining_countstatus
  • 作用:快速读取红包信息,降低数据库压力。

代码示例:

$redis = new Redis();
$redis->connect('127.0.0.1', 6379);

// 设置红包缓存
$packetId = 123;
$packetKey = "packet:{$packetId}";
$packetData = [
    'total_amount' => 10000,    // 红包总金额,单位分
    'remaining_amount' => 10000, // 剩余金额
    'remaining_count' => 100,   // 红包剩余数量
    'status' => 0               // 状态:0-进行中,1-已抢完
];
$redis->hmSet($packetKey, $packetData);

// 获取红包信息
$packetInfo = $redis->hGetAll($packetKey);
print_r($packetInfo);

// 设置过期时间(单位:秒),红包过期后自动删除
$redis->expire($packetKey, 3600);
领取记录缓存:

存储已领取红包的用户 ID,防止同一用户重复领取。

  • 键名packet:grabbed_users:{packet_id}
  • 字段user_id
  • 作用:通过记录领取状态避免重复领取,同时快速判断用户是否已领取。

代码示例:

// 设置领取记录缓存
$userSetKey = "packet:grabbed_users:{$packetId}";
$userId = 1001;

// 将用户 ID 添加到已领取用户集合中
$redis->sAdd($userSetKey, $userId);

// 检查用户是否已领取
if ($redis->sIsMember($userSetKey, $userId)) {
    echo "用户已领取该红包。\n";
} else {
    echo "用户尚未领取该红包。\n";
}

// 设置领取记录的过期时间,保持与红包一致
$redis->expire($userSetKey, 3600);
2. 高并发优化方案:Redis 分布式锁

在高并发场景中,为了避免多个用户同时领取同一个红包导致的冲突问题,使用 Redis 分布式锁来确保每个红包在同一时刻只会被一个用户领取。Redis 分布式锁的设计如下:

  • 加锁:在用户领取红包之前,对 packet_id 进行加锁,确保同一红包在同一时刻只被一个用户操作。

    • 键名packet_lock:{packet_id}
    • 字段:锁的持有者 user_id
    • 作用:确保每个红包在领取过程中只能被一个用户操作,避免并发冲突。
  • 检查锁:如果加锁失败(锁已被占用),则提示用户稍后重试,以防止并发冲突。

    • 作用:控制用户的抢红包操作频率,避免过多请求造成系统压力。
  • 解锁:领取完成后立即释放锁,确保其他用户可以继续领取红包。

    • 解锁方式:使用 Lua 脚本确保只有锁的持有者可以释放锁,保证锁的安全性。
    • 作用:释放锁后,其他用户可以继续领取,提升系统并发处理能力。

通过 Redis 分布式锁的加锁、检查、解锁三步操作,抢红包系统可以有效避免并发冲突问题,保障数据一致性和系统的高并发处理能力。

代码示例:

/**
 * 获取分布式锁
 *
 * @param Redis $redis Redis 实例
 * @param string $lockKey 锁的键
 * @param string $userId 请求锁的用户 ID
 * @return bool 成功获取锁返回 true,失败返回 false
 */
function acquireLock($redis, $lockKey, $userId) {
    $lockTimeout = 5; // 锁超时时间,单位秒
    // 'nx' 表示只有当键不存在时才设置,'ex' 设置键的过期时间
    return $redis->set($lockKey, $userId, ['nx', 'ex' => $lockTimeout]);
}

/**
 * 使用 Lua 脚本原子性释放锁
 *
 * @param Redis $redis Redis 实例
 * @param string $lockKey 锁的键
 * @param string $userId 请求锁的用户 ID
 * @return bool 是否成功释放锁
 */
function releaseLockWithLua($redis, $lockKey, $userId) {
    $luaScript = <<<LUA
    if redis.call("GET", KEYS[1]) == ARGV[1] then
        return redis.call("DEL", KEYS[1])
    else
        return 0
    end
    LUA;

    // 执行 Lua 脚本,确保只有锁持有者可以解锁
    $result = $redis->eval($luaScript, [$lockKey, $userId], 1);
    return $result === 1;
}

示例调用:

使用上述的 acquireLockreleaseLockWithLua 函数来获取和释放锁。

$redis = new Redis();
$redis->connect('127.0.0.1', 6379);

$packetId = 123;
$lockKey = "packet_lock:{$packetId}";
$userId = 1001;

if (acquireLock($redis, $lockKey, $userId)) {
    try {
        echo "成功获取锁,可以领取红包。\n";
        // 执行领取红包的逻辑
    } finally {
        // 领取红包操作完成,使用 Lua 脚本释放锁
        if (releaseLockWithLua($redis, $lockKey, $userId)) {
            echo "锁已释放。\n";
        } else {
            echo "锁释放失败,当前用户可能不是锁的持有者。\n";
        }
    }
} else {
    echo "无法获取锁,稍后重试。\n";
}

代码说明

  1. 获取锁:使用 acquireLock 设置带过期时间的锁。

  2. 释放锁:通过 releaseLockWithLua 函数调用 Lua 脚本检查锁的持有者,如果持有者是当前用户,则成功释放锁。

  3. Lua 脚本逻辑

    • GET 检查当前锁的持有者是否为 userId
    • 如果一致,则调用 DEL 释放锁;否则返回 0 表示无法释放锁。

这样保证了锁的释放是安全的,避免因误操作导致锁被其他用户意外释放。

3. 红包状态检查器

将红包状态检查封装成一个方法,返回状态码和对应的消息:

  • 状态码

    • 0:可以领取红包
    • 1:已领取
    • 2:已抢完
    • -1:已过期

代码实现:

/**
 * 检查红包状态
 *
 * @param Redis $redis Redis 实例
 * @param int $packetId 红包 ID
 * @param int $userId 用户 ID
 * @return array 红包状态数组,包括状态码和消息
 */
function checkPacketStatus($redis, $packetId, $userId) {
    $packetKey = "packet:{$packetId}";
    $userSetKey = "packet:grabbed_users:{$packetId}";

    if ($redis->hGet($packetKey, "remaining_count") <= 0) {
        return ['code' => 2, 'message' => '红包已抢完'];
    }

    if ($redis->hGet($packetKey, "status") == "1") {
        return ['code' => -1, 'message' => '红包已过期'];
    }

    if ($redis->sIsMember($userSetKey, $userId)) {
        return ['code' => 1, 'message' => '已领取过红包'];
    }

    return ['code' => 0, 'message' => '可以领取红包']; // 红包可以领取
}
4. 随机金额生成器

当红包状态为 '0' 时,调用 generateRandomAmount 方法生成随机金额,确保不会超出红包的剩余金额。

/**
 * 生成随机领取金额
 *
 * @param int $remainingAmount 剩余金额
 * @param int $remainingCount 剩余人数
 * @return int 随机领取金额
 */
function generateRandomAmount($remainingAmount, $remainingCount) {
    // 使用二倍均值法生成随机金额,确保不会超发
    $grabAmount = mt_rand(1, 2 * intval($remainingAmount / $remainingCount));
    return min($grabAmount, $remainingAmount);
}
5. Lua 脚本执行领取操作

Lua 脚本确保减少金额和数量的操作在 Redis 中是原子的,避免并发问题。

$luaScript = <<<LUA
local packetKey = KEYS[1]
local userSetKey = KEYS[2]
local userId = ARGV[1]
local grabAmount = tonumber(ARGV[2])

-- 更新红包剩余金额和数量
redis.call("HINCRBY", packetKey, "remaining_amount", -grabAmount)
redis.call("HINCRBY", packetKey, "remaining_count", -1)

-- 添加领取记录
redis.call("SADD", userSetKey, userId)
return grabAmount
LUA;
6. 限流策略:计数器实现限流

在高并发的抢红包系统中,限流策略可以有效防止恶意用户频繁请求,保护系统资源,提升服务稳定性。以下是几种常用的限流策略:

常用限流策略
  1. 漏桶算法 (Leaky Bucket)

    • 原理:将请求存入一个“桶”中,以固定速率流出,防止突发流量。超出桶容量的请求会被丢弃。
    • 适用场景:严格的请求速率控制,适用于系统资源有限且需保持平稳负载的场景。
    • 优点:保证了系统的处理速度平稳,避免瞬时流量高峰对系统造成冲击。
    • 缺点:无法很好地处理突发流量。
  2. 令牌桶算法 (Token Bucket)

    • 原理:系统按固定速率生成“令牌”,请求到达时消耗一个令牌。没有令牌时,超出流量会被阻塞或丢弃。
    • 适用场景:允许一定的突发请求并控制流量平滑,适用于用户体验要求高的场景。
    • 优点:支持一定的突发流量请求,同时保证流量的整体平稳。
    • 缺点:需要控制令牌的生成速率和上限,以避免瞬间大流量冲击。
  3. 滑动窗口限流

    • 原理:将时间划分为多个小窗口,统计每个窗口内的请求次数,防止单一时间段内的流量突增。
    • 适用场景:细粒度的流量控制,适合连续性请求量高的场景。
    • 优点:控制精细,适合流量波动频繁的情况。
    • 缺点:实现稍微复杂,且对系统性能有一定要求。
  4. 计数器限流

    • 原理:在固定时间窗口内记录请求次数,超出限制则阻止请求。
    • 适用场景:简单限流需求,适合单用户限频,防止短时间内频繁访问。
    • 优点:实现简单,效率高,适用于轻量限流场景。
    • 缺点:不适合特别复杂的限流需求,且对突发流量控制不佳。
基于计数器的限流方案

在抢红包系统中,我们采用相对简单的计数器限流方案,以便在短时间内对用户的请求频率进行限制。例如,设置每个用户每分钟只能请求一次抢红包接口,超出次数则阻止请求。这种方案能高效地保护系统资源,防止单一用户频繁访问造成系统压力。

计数器限流的实现

在 Redis 中使用计数器限流的方法简单高效。可以利用 Redis 的 INCR 命令对每个用户请求次数进行计数,并在规定时间内自动过期。以下是计数器限流的实现代码示例:

代码示例

/**
 * 检查用户的请求频率,限制在一定时间内的请求次数
 *
 * @param Redis $redis Redis 实例
 * @param int $userId 用户 ID
 * @param int $limit 每分钟最大请求次数
 * @return bool 是否允许继续请求
 */
function isRequestAllowed($redis, $userId, $limit = 1) {
    $key = "rate_limit:user:{$userId}";
    $currentCount = $redis->incr($key);

    if ($currentCount == 1) {
        // 第一次请求,设置过期时间为 60 秒
        $redis->expire($key, 60);
    }

    return $currentCount <= $limit;
}

// 示例调用
$redis = new Redis();
$redis->connect('127.0.0.1', 6379);

$userId = 1001;
if (isRequestAllowed($redis, $userId)) {
    echo "允许请求。\n";
    // 执行抢红包逻辑
} else {
    echo "请求过于频繁,请稍后再试。\n";
}

说明

  1. 定义用户的限流键:每个用户都有一个特定的限流键 (rate_limit:user:{userId}),用于标记该用户的请求次数。
  2. 计数器自增:通过 INCR 命令,每次请求计数加一,如果计数为1(即首次请求),则设置过期时间为60秒(或自定义的时间窗口)。
  3. 请求次数校验:如果当前计数小于或等于设定的上限,则允许请求;否则,返回限流提示信息。
  4. 自动过期重置:Redis 自动管理键的过期时间,一旦限流窗口结束,计数器重置,下一次请求重新开始计数。
计数器限流的优势
  • 实现简单:通过 Redis 的 INCREXPIRE 功能,可以轻松实现限流。
  • 效率高:无需复杂的算法,适用于高并发场景。
  • 适合抢红包限流:这种方式非常适合控制单用户的频繁抢红包行为,有效保护系统资源。
应用场景

计数器限流尤其适用于请求频率控制API 接口保护等简单的限流需求。在抢红包场景中,可以确保单个用户短时间内无法频繁发起请求,减轻服务器压力。

7. 热点数据保护:热点红包短期缓存

热点数据问题是指大额红包或抢手红包在短时间内被大量请求访问。可以通过设置短期缓存,保护这些热点数据,避免频繁请求数据库。

实现思路

  • 对红包设置较短的缓存过期时间(如几秒到几分钟)。
  • 在缓存即将过期时,提前更新缓存,使热点数据始终存在于缓存中。

代码示例

/**
 * 获取红包数据,优先从缓存中读取,若缓存接近过期则更新
 *
 * @param Redis $redis Redis 实例
 * @param int $packetId 红包 ID
 * @return array 红包数据
 */
function getPacketData($redis, $packetId) {
    $packetKey = "packet:{$packetId}";
    $packetData = $redis->hGetAll($packetKey);

    // 如果缓存即将过期,提前更新缓存
    if ($redis->ttl($packetKey) < 10) { // 小于 10 秒则刷新缓存
        // 这里假设从数据库中获取最新的红包数据并更新缓存
        $packetData = [
            'total_amount' => 10000,
            'remaining_amount' => 5000,
            'remaining_count' => 50,
            'status' => 0
        ];
        $redis->hmSet($packetKey, $packetData);
        $redis->expire($packetKey, 300); // 设置 5 分钟缓存
    }

    return $packetData;
}

// 示例调用
$redis = new Redis();
$redis->connect('127.0.0.1', 6379);

$packetId = 123;
$packetData = getPacketData($redis, $packetId);
print_r($packetData);

说明

  • 使用 TTL 检查缓存剩余时间,如果即将过期则重新缓存数据。
  • 这样可以有效保护热点数据,减少数据库访问。
8. 缓存穿透保护:布隆过滤器和空值缓存

缓存穿透是指用户请求不存在的数据,频繁访问时直接穿透到数据库。可以使用布隆过滤器空值缓存来缓解此问题。

布隆过滤器示例

布隆过滤器可以判断某个红包 ID 是否存在,避免无效请求直接查询数据库。

代码示例(假设使用 RedisBloom)

// 设置布隆过滤器,初始化红包存在状态
$packetId = 123;
$filterKey = "bloom:packet_exists";
$redis->bfAdd($filterKey, $packetId); // 将现有的红包 ID 加入过滤器

// 检查红包是否存在
function isPacketExists($redis, $filterKey, $packetId) {
    return $redis->bfExists($filterKey, $packetId);
}

// 示例调用
$redis = new Redis();
$redis->connect('127.0.0.1', 6379);

if (isPacketExists($redis, $filterKey, $packetId)) {
    echo "红包存在。\n";
    // 继续查询红包数据或进行抢红包操作
} else {
    echo "红包不存在,阻止查询。\n";
}
空值缓存示例

若某个红包 ID 不存在,将空值短期缓存到 Redis,避免重复查询数据库。

/**
 * 获取红包数据,若不存在则缓存空值防止穿透
 *
 * @param Redis $redis Redis 实例
 * @param int $packetId 红包 ID
 * @return array|null 红包数据或空
 */
function getPacketDataWithCache($redis, $packetId) {
    $packetKey = "packet:{$packetId}";
    $packetData = $redis->hGetAll($packetKey);

    if (!$packetData) {
        // 如果红包不存在,将空值缓存到 Redis
        $redis->set($packetKey, "null", ['ex' => 60]); // 缓存空值 60 秒
        return null;
    }

    return $packetData !== "null" ? $packetData : null;
}

// 示例调用
$packetData = getPacketDataWithCache($redis, $packetId);
if ($packetData) {
    echo "红包数据已找到。\n";
} else {
    echo "红包数据不存在,已缓存空值。\n";
}
抢红包完整伪代码示例
$redis = new Redis();
$redis->connect('127.0.0.1', 6379);

$packetId = 123;
$userId = 1001;
$lockKey = "packet_lock:{$packetId}";
$filterKey = "bloom:packet_exists";

// 1. 限流策略:限制每个用户每分钟只能请求一次
if (!isRequestAllowed($redis, $userId)) {
    echo json_encode(['code' => -2, 'message' => '请求过于频繁,请稍后再试', 'amount' => 0]);
    exit;
}

// 2. 缓存穿透保护:检查红包是否存在(使用布隆过滤器)
if (!isPacketExists($redis, $filterKey, $packetId)) {
    echo json_encode(['code' => -3, 'message' => '红包不存在', 'amount' => 0]);
    exit;
}

// 3. 热点数据保护:获取红包数据并更新缓存
$packetData = getPacketData($redis, $packetId);
if (!$packetData) {
    echo json_encode(['code' => -3, 'message' => '红包不存在', 'amount' => 0]);
    exit;
}

// 4. 获取分布式锁,确保领取操作的唯一性
if (acquireLock($redis, $lockKey, $userId)) {
    try {
        // 5. 红包状态检查
        $status = checkPacketStatus($redis, $packetId, $userId);
        if ($status['code'] !== 0) {
            echo json_encode(['code' => $status['code'], 'message' => $status['message'], 'amount' => 0]);
            exit;
        }

        // 6. 获取红包剩余金额和数量
        $packetKey = "packet:{$packetId}";
        $remainingAmount = $redis->hGet($packetKey, "remaining_amount");
        $remainingCount = $redis->hGet($packetKey, "remaining_count");

        // 7. 随机金额生成
        $grabAmount = generateRandomAmount($remainingAmount, $remainingCount);

        // 8. 使用 Lua 脚本原子性地减少红包剩余金额和数量
        $userSetKey = "packet:grabbed_users:{$packetId}";
        $luaScript = <<<LUA
        local packetKey = KEYS[1]
        local userSetKey = KEYS[2]
        local userId = ARGV[1]
        local grabAmount = tonumber(ARGV[2])

        redis.call("HINCRBY", packetKey, "remaining_amount", -grabAmount)
        redis.call("HINCRBY", packetKey, "remaining_count", -1)
        redis.call("SADD", userSetKey, userId)
        return grabAmount
        LUA;

        $result = $redis->eval($luaScript, [$packetKey, $userSetKey, $userId, $grabAmount], 2);

        echo json_encode(['code' => 0, 'message' => '领取成功', 'amount' => $result]);
    } finally {
        // 释放锁
        releaseLockWithLua($redis, $lockKey, $userId);
    }
} else {
    echo json_encode(['code' => -4, 'message' => '系统繁忙,请稍后再试', 'amount' => 0]);
}

辅助方法
1. 限流方法
function isRequestAllowed($redis, $userId, $limit = 1) {
    $key = "rate_limit:user:{$userId}";
    $currentCount = $redis->incr($key);

    if ($currentCount == 1) {
        $redis->expire($key, 60);
    }

    return $currentCount <= $limit;
}
2. 布隆过滤器方法
function isPacketExists($redis, $filterKey, $packetId) {
    return $redis->bfExists($filterKey, $packetId);
}
3. 热点数据保护方法
/**
 * 获取红包数据,优先从缓存中读取,若缓存接近过期则更新
 *
 * @param Redis $redis Redis 实例
 * @param int $packetId 红包 ID
 * @return array|null 红包数据
 */
function getPacketData($redis, $packetId) {
    $packetKey = "packet:{$packetId}";
    $packetData = $redis->hGetAll($packetKey);

    // 如果缓存即将过期,提前更新缓存
    if ($redis->ttl($packetKey) < 10) {
        // 从数据库中获取最新的红包数据并更新缓存 (假设数据在数据库中)
        $packetData = [
            'total_amount' => 10000,
            'remaining_amount' => 5000,
            'remaining_count' => 50,
            'status' => 0
        ];
        $redis->hmSet($packetKey, $packetData);
        $redis->expire($packetKey, 300);
    }

    return $packetData;
}
4. 分布式锁方法
function acquireLock($redis, $lockKey, $userId) {
    $lockTimeout = 5;
    return $redis->set($lockKey, $userId, ['nx', 'ex' => $lockTimeout]);
}

function releaseLockWithLua($redis, $lockKey, $userId) {
    $luaScript = <<<LUA
    if redis.call("GET", KEYS[1]) == ARGV[1] then
        return redis.call("DEL", KEYS[1])
    else
        return 0
    end
    LUA;

    $result = $redis->eval($luaScript, [$lockKey, $userId], 1);
    return $result === 1;
}
5. 红包状态检查器
function checkPacketStatus($redis, $packetId, $userId) {
    $packetKey = "packet:{$packetId}";
    $userSetKey = "packet:grabbed_users:{$packetId}";

    if ($redis->hGet($packetKey, "remaining_count") <= 0) {
        return ['code' => 2, 'message' => '红包已抢完'];
    }

    if ($redis->hGet($packetKey, "status") == "1") {
        return ['code' => -1, 'message' => '红包已过期'];
    }

    if ($redis->sIsMember($userSetKey, $userId)) {
        return ['code' => 1, 'message' => '已领取过红包'];
    }

    return ['code' => 0, 'message' => '可以领取红包'];
}
6. 随机金额生成器
function generateRandomAmount($remainingAmount, $remainingCount) {
    $grabAmount = mt_rand(1, 2 * intval($remainingAmount / $remainingCount));
    return min($grabAmount, $remainingAmount);
}

消息队列设计分析

消息队列的作用
  1. 流量削峰:在抢红包的高并发场景下,直接将用户请求发送至数据库会引起巨大的写入压力。使用消息队列缓冲请求,可以有效减轻数据库压力。
  2. 异步处理:消息队列允许后台异步消费领取请求,提升用户的响应速度,让用户能迅速获得请求已提交的反馈,而实际领取操作稍后完成。
  3. 削峰填谷:通过队列将请求分批处理,避免瞬时高流量造成的系统崩溃,从而提升系统的整体吞吐量和稳定性。
选型建议

可以选择适合分布式环境的消息队列服务:

  • Kafka:适合大规模数据处理和吞吐量较高的场景,持久化和分布式支持较强。
  • RabbitMQ:支持消息确认和路由配置,适合较为复杂的队列和可靠的消息投递需求。
流程设计
  1. 请求入队:用户的抢红包请求首先被推入消息队列(如 RabbitMQ 或 Kafka),带上必要的参数(userIdpacketId 等)。
  2. 异步消费:后台消费者从消息队列中逐条读取请求,处理领取逻辑,包括红包状态检查、分配金额等操作。
  3. 削峰填谷:异步消费将处理结果(如领取金额和状态)写入缓存和数据库,减少瞬时对数据库的写入压力,平滑处理高并发请求。
实现伪代码

下面的伪代码展示了如何将抢红包请求放入消息队列,并在后台异步消费处理。假设使用 RabbitMQ 实现。

流程方案
  1. 点击红包时即刻计算领取金额:用户点击红包时,立即进行领取金额计算和状态检查,并将结果返回给前端。
  2. 队列处理剩余逻辑:将红包的领取操作(如扣减金额、更新状态、数据库写入等)异步推入消息队列,让消费者异步完成这些操作。
  3. 确保数据一致性:使用 Redis 分布式锁和 Lua 脚本来保证更新操作的原子性,避免并发冲突。
实现代码
用户点击红包处理逻辑(同步扣减金额)

点击红包时,立即进行领取金额的计算,同步扣减红包金额,并将后续处理推入消息队列

use PhpAmqpLib\Connection\AMQPStreamConnection;
use PhpAmqpLib\Message\AMQPMessage;

/**
 * 用户点击红包后的处理逻辑(同步扣减金额)
 *
 * @param int $userId 用户 ID
 * @param int $packetId 红包 ID
 * @return array 领取结果
 */
function handleClickAndReturnAmount($userId, $packetId) {
    $redis = new Redis();
    $redis->connect('127.0.0.1', 6379);

    $filterKey = "bloom:packet_exists";

    // 1. 限流策略:限制每个用户每分钟只能请求一次
    if (!isRequestAllowed($redis, $userId)) {
        return ['code' => -2, 'message' => '请求过于频繁,请稍后再试', 'amount' => 0];
    }

    // 2. 缓存穿透保护:检查红包是否存在(使用布隆过滤器)
    if (!isPacketExists($redis, $filterKey, $packetId)) {
        return ['code' => -3, 'message' => '红包不存在', 'amount' => 0];
    }

    // 3. 热点数据保护:获取红包数据并更新缓存
    $packetData = getPacketData($redis, $packetId);
    if (!$packetData) {
        return ['code' => -3, 'message' => '红包不存在', 'amount' => 0];
    }

    // 4. 分布式锁,确保领取操作的唯一性
    $lockKey = "packet_lock:{$packetId}";
    if (!acquireLock($redis, $lockKey, $userId)) {
        return ['code' => -4, 'message' => '系统繁忙,请稍后再试', 'amount' => 0];
    }

    try {
        // 5. 红包状态检查
        $status = checkPacketStatus($redis, $packetId, $userId);
        if ($status['code'] !== 0) {
            return ['code' => $status['code'], 'message' => $status['message'], 'amount' => 0];
        }

        // 6. 获取红包剩余金额和数量
        $remainingAmount = $redis->hGet("packet:{$packetId}", "remaining_amount");
        $remainingCount = $redis->hGet("packet:{$packetId}", "remaining_count");

        // 7. 随机金额生成
        $grabAmount = generateRandomAmount($remainingAmount, $remainingCount);

        // 8. 使用 Lua 脚本原子性地减少红包剩余金额和数量
        $packetKey = "packet:{$packetId}";
        $userSetKey = "packet:grabbed_users:{$packetId}";
        $luaScript = <<<LUA
        local packetKey = KEYS[1]
        local userSetKey = KEYS[2]
        local userId = ARGV[1]
        local grabAmount = tonumber(ARGV[2])

        redis.call("HINCRBY", packetKey, "remaining_amount", -grabAmount)
        redis.call("HINCRBY", packetKey, "remaining_count", -1)
        redis.call("SADD", userSetKey, userId)
        return grabAmount
        LUA;

        // 执行 Lua 脚本,直接扣减金额和数量
        $result = $redis->eval($luaScript, [$packetKey, $userSetKey, $userId, $grabAmount], 2);

        if ($result <= 0) {
            return ['code' => 2, 'message' => '领取失败', 'amount' => 0];
        }

        // 9. 将异步处理任务推入消息队列(如写入日志、数据库更新等)
        enqueueAsyncTask($userId, $packetId, $grabAmount);

        // 返回领取金额给前端
        return ['code' => 0, 'message' => '领取成功', 'amount' => $result];
    } finally {
        releaseLockWithLua($redis, $lockKey, $userId);
    }
}

/**
 * 将异步处理任务推入消息队列
 *
 * @param int $userId 用户 ID
 * @param int $packetId 红包 ID
 * @param int $grabAmount 领取金额
 */
function enqueueAsyncTask($userId, $packetId, $grabAmount) {
    $connection = new AMQPStreamConnection('localhost', 5672, 'user', 'password');
    $channel = $connection->channel();

    // 声明队列
    $channel->queue_declare('grab_packet_async_queue', false, true, false, false);

    // 创建消息内容
    $data = json_encode(['userId' => $userId, 'packetId' => $packetId, 'amount' => $grabAmount]);
    $msg = new AMQPMessage($data, ['delivery_mode' => 2]); // 持久化消息

    // 推入队列
    $channel->basic_publish($msg, '', 'grab_packet_async_queue');
    $channel->close();
    $connection->close();
}

// 示例调用
$userId = 1001;
$packetId = 123;
$result = handleClickAndReturnAmount($userId, $packetId);
echo json_encode($result);
异步消费代码示例(包括重试机制和死信队列的实现)。
use PhpAmqpLib\Connection\AMQPStreamConnection;
use PhpAmqpLib\Message\AMQPMessage;

/**
 * 消费异步任务队列,将领取记录和用户余额同步到数据库
 */
function consumeAsyncTaskQueue() {
    $connection = new AMQPStreamConnection('localhost', 5672, 'user', 'password');
    $channel = $connection->channel();

    // 声明主队列和死信队列
    $channel->queue_declare('grab_packet_async_queue', false, true, false, false, false, [
        'x-dead-letter-exchange' => '',           // 指定死信队列的交换机,默认空
        'x-dead-letter-routing-key' => 'dlq'      // 死信队列的路由键
    ]);
    $channel->queue_declare('grab_packet_dlq', false, true, false, false);

    // 消费主队列消息
    $callback = function($msg) use ($channel) {
        $data = json_decode($msg->body, true);
        $userId = $data['userId'];
        $packetId = $data['packetId'];
        $grabAmount = $data['amount'];

        $retryLimit = 3;    // 最大重试次数
        $retryCount = $msg->has('application_headers') 
                      ? $msg->get('application_headers')->getNativeData()['x-retry-count'] ?? 0 
                      : 0;

        try {
            // 尝试执行数据库事务
            processGrabRequestToDatabase($userId, $packetId, $grabAmount);
            echo "异步处理完成:用户 $userId 领取红包 $packetId 的金额为 $grabAmount\n";
        } catch (Exception $e) {
            $retryCount++;
            if ($retryCount >= $retryLimit) {
                // 超过重试次数,将消息发送至死信队列
                echo "处理失败,已将消息发送至死信队列:用户 $userId 领取红包 $packetId\n";
                $channel->basic_publish(
                    new AMQPMessage($msg->body, ['delivery_mode' => 2]), 
                    '', 
                    'grab_packet_dlq'
                );
            } else {
                // 重试次数未超限,重新发布消息到主队列
                echo "重试 $retryCount 次:用户 $userId 领取红包 $packetId\n";
                $retryMsg = new AMQPMessage($msg->body, [
                    'delivery_mode' => 2,
                    'application_headers' => ['x-retry-count' => $retryCount]
                ]);
                $channel->basic_publish($retryMsg, '', 'grab_packet_async_queue');
            }
        }
    };

    // 监听主队列
    $channel->basic_consume('grab_packet_async_queue', '', false, true, false, false, $callback);

    // 等待消息
    while ($channel->is_consuming()) {
        $channel->wait();
    }

    $channel->close();
    $connection->close();
}

/**
 * 处理抢红包数据库更新操作
 *
 * @param int $userId 用户 ID
 * @param int $packetId 红包 ID
 * @param int $grabAmount 抢到的金额
 */
function processGrabRequestToDatabase($userId, $packetId, $grabAmount) {
    $db = new PDO("mysql:host=localhost;dbname=redpacket", "username", "password");

    $db->beginTransaction();
    try {
        // 插入领取记录到领取记录表
        $stmt = $db->prepare("INSERT INTO red_packet_grab_records (packet_id, user_id, amount, created_at) VALUES (?, ?, ?, NOW())");
        $stmt->execute([$packetId, $userId, $grabAmount]);

        // 更新红包表的剩余金额和数量
        $stmt = $db->prepare("UPDATE red_packets SET remaining_amount = remaining_amount - ?, remaining_count = remaining_count - 1 WHERE id = ?");
        $stmt->execute([$grabAmount, $packetId]);

        // 更新用户余额
        $stmt = $db->prepare("UPDATE users SET balance = balance + ? WHERE id = ?");
        $stmt->execute([$grabAmount, $userId]);

        // 检查剩余数量,若为0则更新红包状态为结束
        $stmt = $db->prepare("SELECT remaining_count FROM red_packets WHERE id = ?");
        $stmt->execute([$packetId]);
        $remainingCount = $stmt->fetchColumn();

        if ($remainingCount <= 0) {
            $stmt = $db->prepare("UPDATE red_packets SET status = 1 WHERE id = ?");
            $stmt->execute([$packetId]);
        }

        $db->commit();
    } catch (Exception $e) {
        $db->rollBack();
        throw $e;  // 抛出异常,触发重试机制
    }
}

// 启动消费
consumeAsyncTaskQueue();

代码说明

  1. 重试次数检查:通过消息头中的 x-retry-count 来记录当前消息的重试次数。每次消费失败时,增加 retryCount,若达到 retryLimit(最大重试次数),则将消息发送至死信队列。
  2. 死信队列:如果消息在重试次数内未成功处理,会被转移至 grab_packet_dlq 死信队列,以便后续人工检查和处理,防止消息永久滞留在主队列。
  3. 异常处理:数据库写入失败时,回滚事务并抛出异常,以触发重试机制。
  4. 消息持久化:使用消息的持久化属性(delivery_mode => 2),确保消息不会因 RabbitMQ 重启或异常关闭而丢失。

高并发情况下的优化

在高并发情况下,将限流判断获取金额减少金额等操作放在同步流程中确实需要特别设计以确保系统的高效和稳定性。主要问题包括:

  1. 锁等待:同步操作可能会导致锁等待,影响响应时间。
  2. Redis 的性能瓶颈:在极高并发下,Redis 的单线程特性可能会受到影响。

以下是一些优化策略,可以减少同步流程中的性能瓶颈并确保高并发下的系统稳定性:

1. 使用 Redis 的多级限流策略

可以通过多级限流来减少 Redis 的压力。例如,首先在 API 网关级别进行粗粒度的限流,之后在应用层的 Redis 中进一步限流。这种方式可以减少大量无效请求进入 Redis 系统。

实现示例

  • 一级限流:在 API 网关(如 Nginx 或 Kong)上设定每分钟请求次数的限制,限制某些 IP 的请求频率。
  • 二级限流:在 Redis 中为每个用户设置更细粒度的限流规则(例如一分钟只能请求一次),通过 Redis 计数器和过期时间实现。
2. 优化随机金额生成和扣减操作

使用 Lua 脚本将随机金额生成扣减操作打包成一个原子操作。这确保了 Redis 中金额的生成和扣减是原子的,不会因为并发而导致数据不一致。Lua 脚本在 Redis 中执行,具有高性能且不会产生额外的锁等待。

3. 限制分布式锁的持有时间

使用分布式锁时,确保锁的持有时间较短,同时为锁设置自动过期时间。在 Lua 脚本中控制整个抢红包的流程,可以避免 Redis 长时间占用锁。

4. 使用分片的 Redis 实例(水平扩展)

当单实例 Redis 在极高并发下无法满足需求时,可以使用 Redis 分片(Redis Cluster 或多个 Redis 实例)来水平扩展,将用户请求分散到多个 Redis 实例上,进一步提升并发性能。

具体实现方案

以下是改进后的伪代码示例,包括限流、多级缓存保护、随机金额生成和扣减的优化。

use PhpAmqpLib\Connection\AMQPStreamConnection;
use PhpAmqpLib\Message\AMQPMessage;

/**
 * 用户点击红包后的处理逻辑(同步扣减金额,改进后的流程)
 *
 * @param int $userId 用户 ID
 * @param int $packetId 红包 ID
 * @return array 领取结果
 */
function handleClickAndReturnAmount($userId, $packetId) {
    $redis = new Redis();
    $redis->connect('127.0.0.1', 6379);

    $filterKey = "bloom:packet_exists";

    // 1. 一级限流策略:在 API 网关已设定粗粒度限流
    // 2. 二级限流策略:Redis 中检查每个用户的请求频率
    if (!isRequestAllowed($redis, $userId)) {
        return ['code' => -2, 'message' => '请求过于频繁,请稍后再试', 'amount' => 0];
    }

    // 3. 缓存穿透保护:检查红包是否存在(使用布隆过滤器)
    if (!isPacketExists($redis, $filterKey, $packetId)) {
        return ['code' => -3, 'message' => '红包不存在', 'amount' => 0];
    }

    // 4. 获取分布式锁,确保领取操作的唯一性
    $lockKey = "packet_lock:{$packetId}";
    if (!acquireLock($redis, $lockKey, $userId)) {
        return ['code' => -4, 'message' => '系统繁忙,请稍后再试', 'amount' => 0];
    }

    try {
        // 5. 使用 Lua 脚本完成限流检查、金额生成和扣减的原子操作
        $packetKey = "packet:{$packetId}";
        $userSetKey = "packet:grabbed_users:{$packetId}";

        $luaScript = <<<LUA
        local packetKey = KEYS[1]
        local userSetKey = KEYS[2]
        local userId = ARGV[1]

        -- 检查用户是否已领取过
        if redis.call("SISMEMBER", userSetKey, userId) == 1 then
            return {err="已领取"}
        end

        -- 获取剩余金额和数量
        local remainingAmount = tonumber(redis.call("HGET", packetKey, "remaining_amount"))
        local remainingCount = tonumber(redis.call("HGET", packetKey, "remaining_count"))

        -- 检查是否有剩余红包
        if remainingCount <= 0 or remainingAmount <= 0 then
            return {err="红包已抢完"}
        end

        -- 生成随机金额
        local grabAmount = math.random(1, 2 * math.floor(remainingAmount / remainingCount))
        if remainingAmount < grabAmount then
            grabAmount = remainingAmount
        end

        -- 扣减金额和数量,记录领取状态
        redis.call("HINCRBY", packetKey, "remaining_amount", -grabAmount)
        redis.call("HINCRBY", packetKey, "remaining_count", -1)
        redis.call("SADD", userSetKey, userId)

        return grabAmount
        LUA;

        // 执行 Lua 脚本
        $result = $redis->eval($luaScript, [$packetKey, $userSetKey, $userId], 2);

        if (is_array($result) && isset($result['err'])) {
            return ['code' => 2, 'message' => $result['err'], 'amount' => 0];
        }

        // 6. 将异步处理任务推入消息队列(如写入日志、数据库更新等)
        enqueueAsyncTask($userId, $packetId, $result);

        // 返回领取金额给前端
        return ['code' => 0, 'message' => '领取成功', 'amount' => $result];
    } finally {
        releaseLockWithLua($redis, $lockKey, $userId);
    }
}

/**
 * 将异步处理任务推入消息队列
 *
 * @param int $userId 用户 ID
 * @param int $packetId 红包 ID
 * @param int $grabAmount 领取金额
 */
function enqueueAsyncTask($userId, $packetId, $grabAmount) {
    $connection = new AMQPStreamConnection('localhost', 5672, 'user', 'password');
    $channel = $connection->channel();

    // 声明队列
    $channel->queue_declare('grab_packet_async_queue', false, true, false, false);

    // 创建消息内容
    $data = json_encode(['userId' => $userId, 'packetId' => $packetId, 'amount' => $grabAmount]);
    $msg = new AMQPMessage($data, ['delivery_mode' => 2]); // 持久化消息

    // 推入队列
    $channel->basic_publish($msg, '', 'grab_packet_async_queue');
    $channel->close();
    $connection->close();
}

// 示例调用
$userId = 1001;
$packetId = 123;
$result = handleClickAndReturnAmount($userId, $packetId);
echo json_encode($result);

代码改进说明

  1. 限流策略:将限流分成多个层次来减少 Redis 的负载。
  2. Lua 脚本优化:使用 Lua 脚本完成领取金额生成和扣减的原子操作,确保在高并发下操作安全且不会超发。
  3. 分布式锁:为每个红包设置分布式锁,确保在并发环境下的领取流程唯一性。
  4. 多级缓存保护:结合布隆过滤器和热点数据保护,减少对 Redis 和数据库的无效请求。

这样通过多层限流、优化 Lua 脚本操作、锁控制和多级缓存保护,系统可以有效地在高并发环境下保持效率并减少超发风险。


第二种方案的技术实现(预先生成方案)

在预先生成方案中,我们在红包创建时即完成所有金额的拆分,使抢红包的请求无需进行实时的金额计算,仅需从队列中依次取出预生成的金额。这种设计相比于第一种方案具有以下好处:

  1. 性能更优:第一种方案在抢红包时进行实时金额拆分,而预先生成方案将金额的计算和拆分工作放在创建红包时完成,减少了抢红包请求的计算压力,优化了响应时间。
  2. 更适合高并发场景:在高并发情况下,抢红包操作仅需从队列中取出金额,加快处理速度,适合流量更大的场景,避免在用户抢红包时计算随机金额带来的性能瓶颈。
  3. 简化请求逻辑:预先生成方案简化了每次抢红包请求的逻辑,使红包操作集中在一个异步任务队列中处理,避免多次分布式锁的获取。
方案改进:全部异步处理

结合第一种方案的逻辑,我们可以将抢红包的流程优化为完全异步处理。在此方案下,红包金额在红包创建时即通过预先生成策略分配好,抢红包请求仅需将领取操作加入到消息队列中,由后台的异步消费者完成数据库更新和日志记录等操作。

这种方式可以显著提升系统的吞吐能力,将之前的同步操作移到 Redis 和消息队列组合中,实现更高效的请求处理。

优化流程示例
  1. 红包创建时生成金额:红包在创建时,系统根据红包的总金额和数量,将其拆分为随机金额,并存入 Redis 队列中。

  2. 用户抢红包请求处理

    • 用户点击抢红包时,系统将抢红包请求推送到消息队列中,等待异步消费。
    • Redis Lua 脚本负责完成领取金额的扣减和状态更新,确保操作的原子性和一致性。
  3. 异步处理抢红包操作:消息队列的消费者从队列中获取领取任务,更新数据库中红包和领取记录,将操作结果写入缓存并确保数据的最终一致性。

在第二种方案(预先生成方案)中,我们不再需要在抢红包时实时生成金额,而是在红包创建时生成所有金额并存入 Redis 列表。在用户点击抢红包时,只需从 Redis 队列中获取预先生成的金额,大幅减少同步计算和锁操作,提升系统的性能和响应效率。以下是改进后的伪代码示例:

use PhpAmqpLib\Connection\AMQPStreamConnection;
use PhpAmqpLib\Message\AMQPMessage;

/**
 * 用户点击红包后的处理逻辑(预先生成方案)
 *
 * @param int $userId 用户 ID
 * @param int $packetId 红包 ID
 * @return array 领取结果
 */
function handleClickAndReturnAmount($userId, $packetId) {
    $redis = new Redis();
    $redis->connect('127.0.0.1', 6379);

    $filterKey = "bloom:packet_exists";

    // 1. 一级限流策略:在 API 网关已设定粗粒度限流
    // 2. 二级限流策略:Redis 中检查每个用户的请求频率
    if (!isRequestAllowed($redis, $userId)) {
        return ['code' => -2, 'message' => '请求过于频繁,请稍后再试', 'amount' => 0];
    }

    // 3. 缓存穿透保护:检查红包是否存在(使用布隆过滤器)
    if (!isPacketExists($redis, $filterKey, $packetId)) {
        return ['code' => -3, 'message' => '红包不存在', 'amount' => 0];
    }

    // 4. 获取分布式锁,确保领取操作的唯一性
    $lockKey = "packet_lock:{$packetId}";
    if (!acquireLock($redis, $lockKey, $userId)) {
        return ['code' => -4, 'message' => '系统繁忙,请稍后再试', 'amount' => 0];
    }

    try {
        // 5. 使用 Lua 脚本完成检查和扣减的原子操作
        $packetKey = "packet:{$packetId}";
        $userSetKey = "packet:grabbed_users:{$packetId}";

        $luaScript = <<<LUA
        local packetKey = KEYS[1]
        local userSetKey = KEYS[2]
        local userId = ARGV[1]

        -- 检查用户是否已领取过
        if redis.call("SISMEMBER", userSetKey, userId) == 1 then
            return {err="已领取"}
        end

        -- 从预生成金额列表中弹出一个金额
        local grabAmount = redis.call("LPOP", packetKey)
        if not grabAmount then
            return {err="红包已抢完"}
        end

        -- 添加领取记录
        redis.call("SADD", userSetKey, userId)
        return grabAmount
        LUA;

        // 执行 Lua 脚本,直接获取预生成金额
        $result = $redis->eval($luaScript, [$packetKey, $userSetKey, $userId], 2);

        if (is_array($result) && isset($result['err'])) {
            return ['code' => 2, 'message' => $result['err'], 'amount' => 0];
        }

        // 6. 将异步处理任务推入消息队列(如写入日志、数据库更新等)
        enqueueAsyncTask($userId, $packetId, $result);

        // 返回领取金额给前端
        return ['code' => 0, 'message' => '领取成功', 'amount' => $result];
    } finally {
        releaseLockWithLua($redis, $lockKey, $userId);
    }
}

/**
 * 将异步处理任务推入消息队列
 *
 * @param int $userId 用户 ID
 * @param int $packetId 红包 ID
 * @param int $grabAmount 领取金额
 */
function enqueueAsyncTask($userId, $packetId, $grabAmount) {
    $connection = new AMQPStreamConnection('localhost', 5672, 'user', 'password');
    $channel = $connection->channel();

    // 声明队列
    $channel->queue_declare('grab_packet_async_queue', false, true, false, false);

    // 创建消息内容
    $data = json_encode(['userId' => $userId, 'packetId' => $packetId, 'amount' => $grabAmount]);
    $msg = new AMQPMessage($data, ['delivery_mode' => 2]); // 持久化消息

    // 推入队列
    $channel->basic_publish($msg, '', 'grab_packet_async_queue');
    $channel->close();
    $connection->close();
}

// 示例调用
$userId = 1001;
$packetId = 123;
$result = handleClickAndReturnAmount($userId, $packetId);
echo json_encode($result);

代码说明

  1. 用户点击红包后的处理逻辑

    • 请求限流、缓存穿透保护和分布式锁操作保持不变。
    • 使用 Lua 脚本实现原子操作,通过 LPOP 从 Redis 列表中获取预生成的金额。
  2. 异步任务处理

    • 将领取结果加入到异步队列 grab_packet_async_queue 中,由后台异步消费者负责后续的持久化处理。

这种设计下,通过将预生成的金额列表存入 Redis,抢红包时只需获取分布式锁,执行简化的 Lua 脚本获取金额,大大降低了处理复杂度,进一步提高了系统的响应速度和吞吐量。


总结

在高并发抢红包系统的设计中,我们分析了两种红包拆分策略:实时拆分预先生成,并分析了它们各自的优缺点以及在系统中的应用场景。同时,结合限流、缓存、分布式锁和消息队列的综合方案,可以有效地解决了系统在高并发场景下的性能和一致性问题。针对两种拆分方式及系统关键点的总结:

拆分策略对比
  1. 实时拆分

    • 实现方式:在用户抢红包时,系统根据剩余金额和剩余数量动态生成一个随机金额,确保每次领取的金额和剩余金额、数量同步更新。

    • 优点

      • 灵活性高:可以随时依据红包的剩余状态动态生成随机金额。
      • 适合中低并发:适合流量适中的场景,避免了预生成的大量存储占用。
    • 缺点

      • 计算压力:高并发情况下,频繁的随机金额计算和更新对系统性能要求较高。
      • 容易出现性能瓶颈:在大量请求时可能造成 Redis 或数据库的计算延迟,影响响应时间。
  2. 预先生成

    • 实现方式:在创建红包时,系统将红包总金额按照一定算法预先拆分成若干随机金额,存入 Redis 列表中。用户抢红包时仅需从列表中依次取出金额。

    • 优点

      • 高并发友好:用户请求时无需计算金额,直接取值,显著降低抢红包时的计算压力。
      • 更适合流量峰值:可将用户请求快速入队,异步处理提升系统吞吐能力。
    • 缺点

      • 较高存储需求:需要提前生成和存储每一个红包的拆分金额,导致 Redis 内存占用较大。
      • 缺乏灵活性:一旦金额生成,无法动态调整红包的分配策略。
系统设计中的关键优化点

在实现上述拆分策略时,还需考虑以下高并发下的性能优化:

  1. 布隆过滤器的同步管理:布隆过滤器有效阻止无效请求,但在高并发场景下需要定期同步布隆过滤器和数据库,防止因数据更新不及时导致的穿透问题。
  2. 热点数据多级缓存策略:对特别高频访问的热点红包数据,可采用多级缓存策略(如在 Nginx 层设置短时缓存或 Redis 分片缓存),在 Redis 负载过高时保护数据的可访问性并减少数据库压力。
  3. Redis Cluster 水平扩展:在高并发下,通过 Redis Cluster 的数据分片,系统可以将流量分散到多个 Redis 节点,进一步提高访问效率,避免单点瓶颈。
  4. 优化 Lua 脚本的原子操作:Lua 脚本的原子性非常重要,但过多操作会影响性能。可以将状态判断和数据更新拆分,减少脚本内部操作,优化 Redis 处理效率。
  5. 消息队列的积压管理:当队列出现大量堆积请求时,采用动态限流或降级策略,避免消息队列资源过载。可对超时消息设定优先处理或重试机制,确保用户在合理时间内得到响应。
  6. 全面的日志和监控:高并发情况下,系统需要全面的日志和异常管理机制,通过监控 Redis 命中率、数据库写入延迟、队列积压等关键性能指标,及时预警并调整策略,以保证系统的稳定性。

两种拆分方案各有优缺点,适用的业务场景不同。实时拆分更灵活,适合负载适中场景,而预先生成在流量较高的场景下提供了更好的并发性能。在实际应用中可以根据业务需求选择适合的拆分策略,并结合缓存、分布式锁和消息队列的优化手段,使系统能够在高并发环境下高效运行,并在各种流量情况下保持数据一致性和系统的稳定性。

标签:红包,缓存,Redis,抢红包,redis,并发,拆分,userId,packetId
From: https://blog.csdn.net/qq_38917476/article/details/143430085

相关文章

  • 高并发技术:表锁
    表锁概述定义:表锁是MySQL中的一种锁策略,介于全局锁和行锁之间,力度适中。类型:表锁分为表共享读锁(读锁)和表独占写锁(写锁)。读锁:允许其他事务读取表,但阻止其他事务写入表。写锁:阻止其他事务读取和写入表。2.表锁的引擎支持MyISAM引擎:读操作自动加读锁,写操作自动加......
  • 管家婆财贸ERP BB072.销售单草稿明细生成组装拆分单
    最低适用版本:财贸系列22.0插件简要功能说明:销售单草稿明细支持生成组装拆分单入库明细更多细节描述见下方详细文档插件操作视频:进销存类定制插件--销售单草稿明细生成组装拆分单插件详细功能文档:1.应用中心增加报表菜单【销售单草稿明细生成组装拆分单】a......
  • 高并发IPC通信实现:HarmonyOS中的异步调用与多线程处理
    本文旨在深入探讨华为鸿蒙HarmonyOSNext系统(截止目前API12)的技术细节,基于实际开发实践进行总结。主要作为技术分享与交流载体,难免错漏,欢迎各位同仁提出宝贵意见和问题,以便共同进步。本文为原创内容,任何形式的转载必须注明出处及原作者。在当今的移动应用开发领域,高并发通信场......
  • 鸿蒙高并发环境下的服务状态监控系统
    本文深入探讨HarmonyOSIPCKit的进程间通信及服务监控技术,演示如何构建一个高并发服务状态监控系统,实时管理多个服务进程的运行状态,确保进程在意外终止时触发恢复操作和资源回收机制。本文旨在帮助开发者掌握IPCKit的异步通信、消亡通知订阅等核心功能,为高并发监控系统的开......
  • 【GeoScene】五、影像TIF创建tpk切片缓存并发布
    阿巴阿巴.....,我这拖延症也是没谁了,还是来个总结吧,好歹让自己以后有迹可循;一定要看到最后再去尝试,一路的坑原本觉得这不简简单单嘛,还需要写?不对,这还需要查教程吗?然后劈里啪啦一通操作......什么鬼啊,这么简单的操作还失败???我的姿势不对?然后又尝试了一次......不出意外一样的问......
  • 【操作系统】2.并发控制
    并发控制(ConcurrencyControl)是指在多线程或多进程环境中,确保多个操作在共享资源上的访问不会发生冲突或产生不一致的情况。并发控制的核心目标是在允许并发操作的同时,保证系统的正确性、数据的一致性和完整性。在并发环境下,不同的线程或进程可能会同时访问共享资源(例如变量、文......
  • Oracle 第14章:并发控制
    在Oracle数据库中,并发控制是一个关键概念,因为它确保了多个用户或事务可以同时访问数据库而不干扰彼此的工作。并发问题主要出现在多用户环境中,当多个事务试图同时修改相同的数据时可能发生数据不一致的问题。并发问题及解决方案并发问题:脏读(DirtyReads):一个事务读取了另......
  • 16.1 并发编程基础——Java多线程
    16.1并发编程基础——Java多线程16.1.1 引言Java语言的一个重要特点是内在支持多线程的程序设计。多线程的程序设计具有广泛的应用。线程的概念来源于操作系统进程的概念。进程是一个程序关于某个数据集的一次运行。也就是说,进程是运行中的程序,是程序的一次运行活动。线......
  • InnoDB存储引擎、多版本并发控制(MVCC)简介、Redis简介
    (一)InnoDB存储引擎InnoDB是MySQL最常用的存储引擎之一,有支持事务处理、行级锁定和外键约束等高级功能而著称。1、InnoDB架构物理结构表空间:InnoDB的数据存储空间在表空间中,表空间可以分为系统表空间、文件表空间和通用表空间。系统表空间:默认存储在ibdata1文件中,包含系统......
  • 【C/C++】5.并发控制
    并发控制(ConcurrencyControl)是指在多线程或多进程环境中,确保多个操作在共享资源上的访问不会发生冲突或产生不一致的情况。并发控制的核心目标是在允许并发操作的同时,保证系统的正确性、数据的一致性和完整性。在并发环境下,不同的线程或进程可能会同时访问共享资源(例如变量、文......