前言

逢年过节,微信群里抢红包已经成了一种”传统活动”。看似简单的”点一下就能抢到钱”的背后,实际上涉及了并发控制、一致性、性能等多个技术问题。一个完整的抢红包流程,远没有看上去那么简单。

本文从一个最简单的红包模型开始,逐步深入,聊聊抢红包背后的技术方案演进。

红包的基本模型

发红包

发红包时,用户需要指定总金额和个数。系统需要将这 N 个红包的金额分配好,让每个抢到的人随机获得一定金额。

拆分的核心是二倍均值法

每次拆分的金额 = 随机区间 [0.01, 剩余金额 / 剩余个数 × 2]

举个例子,发 100 元,10 个人:

  • 第 1 个人抢:金额范围 [0.01, 100/10×2=20],平均 10 元
  • 第 2 个人抢:剩余 90 元左右,金额范围 [0.01, 90/9×2=20],平均 10 元
  • 最后一个人:拿剩下的全部

这种方式的优点是公平——每个人抢到的金额期望值相等,不会出现前面的人抢太多后面的人没得抢的情况。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
public static List<BigDecimal> splitRedPacket(BigDecimal total, int count) {
List<BigDecimal> list = new ArrayList<>(count);
BigDecimal remain = total;
for (int i = 0; i < count - 1; i++) {
// 最大金额 = 剩余金额 / 剩余个数 * 2
BigDecimal max = remain.divide(BigDecimal.valueOf(count - i), 2, RoundingMode.FLOOR)
.multiply(BigDecimal.valueOf(2));
// 随机 [0.01, max]
BigDecimal amount = BigDecimal.valueOf(Math.random())
.multiply(max.subtract(BigDecimal.valueOf(0.01)))
.add(BigDecimal.valueOf(0.01))
.setScale(2, RoundingMode.FLOOR);
list.add(amount);
remain = remain.subtract(amount);
}
list.add(remain); // 最后一个人拿剩余的全部
return list;
}

抢红包流程

抢红包的核心流程分为两步:

flowchart LR
    A[用户点击抢红包] --> B{红包是否存在
且未被抢完?} B -->|否| C[返回 已抢完] B -->|是| D[原子扣减红包余额] D --> E{扣减成功?} E -->|否| C E -->|是| F[分配金额给用户] F --> G[返回 抢到 x 元]

这里的核心约束是:同一个红包不能被抢超过它设定的次数,同一个用户不能重复抢同一个红包。这看起来简单,但在高并发场景下,要做到正确且高性能,并不容易。

Redis 方案

高并发场景下,直接操作数据库会成为瓶颈。Redis 凭借其单线程模型和丰富的数据结构,成为抢红包场景的首选方案。

数据结构设计

1
2
3
4
5
6
7
8
// 红包预拆分 - 发红包时一次性拆好
RPUSH red_packet:{packetId} 金额1 金额2 金额3 ...

// 已抢用户集合 - 防止重复抢
SADD red_packet:{packetId}:users userId

// 红包记录
HMSET red_packet:{packetId} total 100 count 10

Lua 脚本保证原子性

抢红包操作涉及多个步骤:判断是否已抢完 → 判断是否已抢过 → 弹出一个金额 → 记录用户。这些步骤需要用 Lua 脚本封装成原子操作:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
-- 参数: KEYS[1]=红包ID, KEYS[2]=用户ID
-- 返回值: -1=已抢完, -2=重复抢, >0=金额

-- 1. 检查红包是否还有余额
local remain = redis.call('LLEN', KEYS[1])
if remain == 0 then
return -1
end

-- 2. 检查用户是否已抢过
local isMember = redis.call('SISMEMBER', KEYS[1] .. ':users', KEYS[2])
if isMember == 1 then
return -2
end

-- 3. 抢红包:弹出一个金额
local amount = redis.call('LPOP', KEYS[1])
if not amount then
return -1
end

-- 4. 记录用户
redis.call('SADD', KEYS[1] .. ':users', KEYS[2])

-- 5. 异步记录明细(丢到消息队列)
-- redis.call('RPUSH', 'red_packet:detail', ...)

return tonumber(amount)
flowchart TD
    A[用户请求] --> B[Redis执行Lua脚本]
    B --> C{"LLEN 还有余额?"}
    C -->|否| D[返回 已抢完]
    C -->|是| E{"SISMEMBER
已抢过?"} E -->|是| F[返回 重复抢] E -->|否| G[LPOP 弹出一个金额] G --> H{SADD 记录用户} H --> I[返回金额] I --> J["异步落库
(消息队列)"]

Redis 方案将抢红包操作控制在了内存中,单个 Lua 脚本的原子性保证了数据一致,性能上单机可以达到每秒数万次的处理能力。

数据库方案

如果不依赖 Redis,纯数据库方案应该怎么做?

表结构

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
-- 红包主表
CREATE TABLE red_packet (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
total_amount DECIMAL(10,2) NOT NULL, -- 总金额
remain_amount DECIMAL(10,2) NOT NULL, -- 剩余金额
total_count INT NOT NULL, -- 总个数
remain_count INT NOT NULL, -- 剩余个数
version INT DEFAULT 0, -- 乐观锁版本号
status TINYINT DEFAULT 0 -- 0:可用 1:已抢完
);

-- 抢红包明细
CREATE TABLE red_packet_record (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
packet_id BIGINT NOT NULL,
user_id BIGINT NOT NULL,
amount DECIMAL(10,2) NOT NULL,
create_time DATETIME DEFAULT CURRENT_TIMESTAMP,
UNIQUE KEY uk_packet_user (packet_id, user_id)
);

乐观锁扣减

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
@Transactional
public boolean grabRedPacket(Long packetId, Long userId) {
// 1. 检查是否已抢过
Integer count = recordDao.countByPacketAndUser(packetId, userId);
if (count > 0) {
return false;
}

// 2. 乐观锁扣减余额和个数
int updated = redPacketDao.updateRemain(packetId);
if (updated == 0) {
return false; // 已抢完
}

// 3. 分配金额(二倍均值法)
BigDecimal amount = calculateAmount(packetId);
recordDao.insert(packetId, userId, amount);
return true;
}
1
2
3
4
5
6
7
8
-- 乐观锁更新的 SQL
UPDATE red_packet
SET remain_count = remain_count - 1,
remain_amount = remain_amount - #{amount},
version = version + 1
WHERE id = #{packetId}
AND remain_count > 0
AND version = #{version}

数据库方案的瓶颈

数据库方案在高并发下有几个明显的问题:

  1. 行锁竞争:所有用户抢同一个红包,UPDATE 会锁定该行,其他请求排队等待
  2. 连接池压力:等待锁的请求会占用数据库连接,可能导致连接池耗尽
  3. 事务开销:每抢一次红包都需要开启和提交事务

压测数据下,数据库单机能支撑的抢红包 QPS 大概在几百到一千左右,远低于 Redis 方案。

异步架构

对于类似春晚红包这种千万级并发的场景,仅靠 Redis 也不够,需要引入异步架构来削峰。

flowchart LR
    subgraph 接入层[接入层]
        Nginx["Nginx/LVS
负载均衡"] Waf["WAF
防刷"] end subgraph 缓存层[缓存层] Redis["Redis Cluster
预拆分余额
Lua原子操作"] end subgraph 消息队列[消息队列] MQ["Kafka/RocketMQ
削峰填谷"] end subgraph 持久层[持久层] DB["MySQL
最终落库"] end Nginx --> Waf Waf --> Redis Redis --> MQ MQ --> DB

这种架构的特点是:

  • 接入层:通过负载均衡分流,配合 WAF 过滤机器刷单
  • 缓存层:Redis 做核心的抢红包操作,Lua 脚本保证原子性
  • 消息队列:抢成功后将明细写入 MQ,异步落库,不阻塞主流程
  • 持久层:消费 MQ 完成最终的数据库持久化

扩展问题

红包过期退回

超时未抢完的红包,需要将剩余金额退回给发红包的人。可以用定时任务扫描”已过期但未退回”的红包,或者用 Redis 的 TTL + 过期回调来自动触发。

用户限流

防刷是红包系统的必修课。常见的策略有:

  • 每个用户对单个红包限抢一次(SISMEMBER 判断)
  • 同一用户抢红包频率控制(滑动窗口限流)
  • IP 级别限流(Nginx 级别)

最终一致性

异步架构下,可能会出现用户抢成功了但落库失败的情况。这时候需要补偿机制——记录流水日志,通过定时对账发现不一致的数据,进行补偿处理。

总结

抢红包看似简单,实际上从发红包时的金额拆分到抢红包时的并发控制,每个环节都有不同的技术方案可以选择:

方案 优点 缺点 适用场景
纯数据库 实现简单,数据强一致 并发低,行锁瓶颈 小范围活动
Redis + Lua 并发高,原子性有保证 增加运维复杂度 中等规模抢红包
异步架构 可支撑千万级并发 架构复杂,需要补偿机制 大型活动如春晚

做技术选型时,不要为了炫技而引入复杂的架构。如果你的业务场景只是几百人抢红包,数据库乐观锁就足够了。架构是服务于业务的,不是反向的。