复制项目
This commit is contained in:
390
docs/redpacket-flow.md
Normal file
390
docs/redpacket-flow.md
Normal file
@@ -0,0 +1,390 @@
|
||||
# 红包系统完整流程文档
|
||||
|
||||
## 一、整体架构设计
|
||||
|
||||
### 核心原则
|
||||
- **Redis 负责并发控制与计算**:使用 Lua 脚本实现原子操作
|
||||
- **MongoDB 负责最终记录与查询**:异步写入,不参与并发竞争
|
||||
- **立即返回 + 异步写入**:提升响应速度,降低延迟
|
||||
- **失败补偿机制**:使用 Redis Stream 确保数据最终一致性
|
||||
|
||||
### 数据流向
|
||||
```
|
||||
客户端请求
|
||||
↓
|
||||
Go API(无状态)
|
||||
↓
|
||||
Redis Lua脚本(原子抢红包)← 并发控制核心
|
||||
↓
|
||||
立即返回结果 ✅
|
||||
↓
|
||||
异步写入 MongoDB(goroutine)
|
||||
├─ 写入领取记录
|
||||
├─ 更新钱包余额
|
||||
└─ 写入补偿Stream
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 二、发送红包流程(SendRedPacket)
|
||||
|
||||
### 1. 参数验证
|
||||
- 验证 `totalAmount > 0`
|
||||
- 验证 `totalCount > 0`
|
||||
- 验证 `redPacketType` 为 1(普通)或 2(拼手气)
|
||||
|
||||
### 2. 群信息验证
|
||||
- 获取群信息,验证群是否存在
|
||||
- 获取群主ID(发送用户默认为群主)
|
||||
- 获取群主用户信息(昵称、头像)
|
||||
|
||||
### 3. 生成红包ID和基本信息
|
||||
- 生成红包ID:`MD5(sendUserID + groupID + timestamp)`
|
||||
- 计算会话ID
|
||||
- 设置过期时间:24小时
|
||||
|
||||
### 4. 初始化Redis数据结构(关键步骤)
|
||||
|
||||
#### Redis Key 设计
|
||||
- `rp:{packetId}:list` - 红包金额队列(List)
|
||||
- `rp:{packetId}:users` - 已领取用户集合(Set)
|
||||
- 过期时间:24小时
|
||||
|
||||
#### 普通红包(type=1)
|
||||
```go
|
||||
avgAmount := totalAmount / totalCount
|
||||
for i := 0; i < totalCount; i++ {
|
||||
RPush("rp:{packetId}:list", avgAmount) // 每个元素都是平均金额
|
||||
}
|
||||
Expire("rp:{packetId}:list", 24h)
|
||||
Expire("rp:{packetId}:users", 24h)
|
||||
```
|
||||
|
||||
#### 拼手气红包(type=2)
|
||||
```go
|
||||
amounts := allocateRandomAmounts(totalAmount, totalCount) // 预先分配随机金额
|
||||
for _, amount := range amounts {
|
||||
RPush("rp:{packetId}:list", amount) // 每个元素是不同的随机金额
|
||||
}
|
||||
Expire("rp:{packetId}:list", 24h)
|
||||
Expire("rp:{packetId}:users", 24h)
|
||||
```
|
||||
|
||||
**拼手气红包分配算法**:
|
||||
- 确保每个红包至少 1 分
|
||||
- 前 n-1 个红包:在最小金额和最大金额之间随机分配
|
||||
- 最后一个红包:直接分配剩余金额
|
||||
- 打乱顺序增加随机性
|
||||
|
||||
### 5. 创建MongoDB记录
|
||||
```go
|
||||
redPacketRecord := &model.RedPacket{
|
||||
RedPacketID: redPacketID,
|
||||
SendUserID: sendUserID,
|
||||
GroupID: groupID,
|
||||
RedPacketType: req.RedPacketType,
|
||||
TotalAmount: req.TotalAmount,
|
||||
TotalCount: req.TotalCount,
|
||||
RemainAmount: req.TotalAmount, // 初始等于总金额
|
||||
RemainCount: req.TotalCount, // 初始等于总个数
|
||||
Status: Active,
|
||||
ExpireTime: expireTime,
|
||||
CreateTime: time.Now(),
|
||||
}
|
||||
```
|
||||
|
||||
### 6. 发送消息
|
||||
- 构建红包消息内容(自定义消息格式)
|
||||
- 通过 RPC 发送消息到群聊
|
||||
|
||||
### 7. 返回响应
|
||||
- 返回 `redPacketID`、`serverMsgID`、`clientMsgID`、`sendTime`
|
||||
|
||||
---
|
||||
|
||||
## 三、领取红包流程(ReceiveRedPacket)
|
||||
|
||||
### 1. 参数验证
|
||||
- 获取用户ID(从token中)
|
||||
- 验证请求参数
|
||||
|
||||
### 2. 红包状态检查
|
||||
- 查询MongoDB获取红包基本信息
|
||||
- 检查红包是否过期(`Status == Expired`)
|
||||
- **注意**:不检查是否已领完,因为Redis会处理
|
||||
|
||||
### 3. 【核心】Redis Lua脚本抢红包
|
||||
|
||||
#### Lua脚本逻辑
|
||||
```lua
|
||||
-- KEYS[1] = rp:{packetId}:list (红包金额队列)
|
||||
-- KEYS[2] = rp:{packetId}:users (已领取用户集合)
|
||||
-- ARGV[1] = userId
|
||||
|
||||
-- 步骤1:检查是否已领取
|
||||
if redis.call('SISMEMBER', KEYS[2], ARGV[1]) == 1 then
|
||||
return -1 -- 用户已领取
|
||||
end
|
||||
|
||||
-- 步骤2:从队列中抢红包(原子操作)
|
||||
local money = redis.call('LPOP', KEYS[1])
|
||||
if not money then
|
||||
return -2 -- 红包已领完
|
||||
end
|
||||
|
||||
-- 步骤3:记录用户已领取(原子操作)
|
||||
redis.call('SADD', KEYS[2], ARGV[1])
|
||||
|
||||
-- 步骤4:返回金额
|
||||
return money
|
||||
```
|
||||
|
||||
#### 返回值处理
|
||||
- `-1` → 用户已领取 → 返回 `ErrRedPacketAlreadyReceived`
|
||||
- `-2` → 红包已领完 → 返回 `ErrRedPacketFinished`
|
||||
- `>0` → 领取成功 → 返回金额(分)
|
||||
|
||||
### 4. 立即返回结果
|
||||
```go
|
||||
resp.RedPacketID = req.RedPacketID
|
||||
resp.Amount = amount
|
||||
resp.IsLucky = false
|
||||
apiresp.GinSuccess(c, resp) // 立即返回,不等待MongoDB写入
|
||||
```
|
||||
|
||||
### 5. 【异步写入】后台goroutine处理
|
||||
|
||||
#### 5.1 写入补偿Stream
|
||||
```go
|
||||
streamKey := "rp:" + redPacketID + ":stream"
|
||||
XAdd(streamKey, {
|
||||
"userID": userID,
|
||||
"amount": amount,
|
||||
"time": timestamp
|
||||
})
|
||||
```
|
||||
**作用**:用于失败补偿,确保数据最终一致性
|
||||
|
||||
#### 5.2 写入MongoDB领取记录
|
||||
```go
|
||||
receiveRecord := &model.RedPacketReceive{
|
||||
ReceiveID: receiveID,
|
||||
RedPacketID: redPacketID,
|
||||
ReceiveUserID: userID,
|
||||
Amount: amount,
|
||||
ReceiveTime: time.Now(),
|
||||
IsLucky: false,
|
||||
}
|
||||
Create(receiveRecord)
|
||||
```
|
||||
**幂等性保护**:
|
||||
- 唯一索引:`(receive_user_id, red_packet_id)`
|
||||
- 如果唯一索引冲突(E11000错误),说明已写入,忽略错误
|
||||
|
||||
#### 5.3 更新钱包余额
|
||||
```go
|
||||
// 查询或创建钱包
|
||||
wallet := Take(userID)
|
||||
if not exists {
|
||||
Create(wallet) // 初始余额为0
|
||||
}
|
||||
|
||||
// 使用版本号乐观锁更新余额
|
||||
UpdateBalanceWithVersion(userID, "add", amount, oldVersion)
|
||||
// 如果版本号冲突,自动重试(最多3次)
|
||||
|
||||
// 创建余额记录
|
||||
CreateBalanceRecord({
|
||||
Operation: "add",
|
||||
Amount: amount,
|
||||
Remark: "领取红包: " + redPacketID
|
||||
})
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 四、并发控制机制
|
||||
|
||||
### 1. Redis层面(核心)
|
||||
- **Lua脚本原子性**:整个抢红包过程是原子操作
|
||||
- **LPOP原子性**:从队列中取出金额是原子操作
|
||||
- **SADD原子性**:记录用户是原子操作
|
||||
- **SISMEMBER检查**:防止重复领取
|
||||
|
||||
### 2. MongoDB层面(兜底)
|
||||
- **唯一索引**:`(receive_user_id, red_packet_id)` 唯一索引
|
||||
- **幂等写入**:如果已存在,忽略重复写入错误
|
||||
|
||||
### 3. 钱包余额更新
|
||||
- **版本号乐观锁**:使用 `version` 字段防止并发覆盖
|
||||
- **自动重试**:并发冲突时自动重试(最多3次)
|
||||
|
||||
---
|
||||
|
||||
## 五、失败补偿机制
|
||||
|
||||
### 1. Redis Stream记录
|
||||
- 每次领取成功后,写入Redis Stream
|
||||
- Stream包含:`userID`、`amount`、`time`
|
||||
|
||||
### 2. 补偿Worker(待实现)
|
||||
```go
|
||||
// 后台worker消费Stream
|
||||
for {
|
||||
messages := XRead("rp:{packetId}:stream", lastID)
|
||||
for _, msg := range messages {
|
||||
// 检查MongoDB是否已写入
|
||||
if !ExistsInMongoDB(msg.userID, msg.redPacketID) {
|
||||
// 重新写入MongoDB
|
||||
WriteToMongoDB(msg)
|
||||
}
|
||||
// ACK消息
|
||||
XAck(streamKey, msg.ID)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 3. 补偿场景
|
||||
- Redis成功,但MongoDB写入失败
|
||||
- 服务宕机导致异步写入中断
|
||||
- 网络闪断导致写入失败
|
||||
|
||||
---
|
||||
|
||||
## 六、Redis Key设计总结
|
||||
|
||||
| Key | 类型 | 说明 | 过期时间 |
|
||||
|-----|------|------|----------|
|
||||
| `rp:{packetId}:list` | List | 红包金额队列 | 24小时 |
|
||||
| `rp:{packetId}:users` | Set | 已领取用户集合 | 24小时 |
|
||||
| `rp:{packetId}:stream` | Stream | 领取日志流(补偿用) | 24小时 |
|
||||
|
||||
---
|
||||
|
||||
## 七、数据一致性保证
|
||||
|
||||
### 1. 强一致性(Redis)
|
||||
- Redis Lua脚本保证抢红包的原子性
|
||||
- 确保不会超发、不会重复领取
|
||||
|
||||
### 2. 最终一致性(MongoDB)
|
||||
- 异步写入MongoDB,可能短暂延迟
|
||||
- 唯一索引确保幂等性
|
||||
- 补偿机制确保最终一致
|
||||
|
||||
### 3. 钱包余额
|
||||
- 使用版本号乐观锁
|
||||
- 自动重试机制
|
||||
- 确保余额更新成功
|
||||
|
||||
---
|
||||
|
||||
## 八、性能特点
|
||||
|
||||
### 1. 高并发支持
|
||||
- Redis Lua脚本原子操作,天然支持高并发
|
||||
- 支持万人同时抢红包
|
||||
|
||||
### 2. 低延迟
|
||||
- 立即返回结果,不等待MongoDB写入
|
||||
- 响应时间主要取决于Redis性能
|
||||
|
||||
### 3. 高可用
|
||||
- MongoDB不参与并发竞争,避免被打爆
|
||||
- 异步写入不影响主流程
|
||||
- 补偿机制确保数据不丢失
|
||||
|
||||
---
|
||||
|
||||
## 九、关键代码位置
|
||||
|
||||
### 发送红包
|
||||
- `internal/api/redpacket.go:SendRedPacket()` - 主流程
|
||||
- `internal/api/redpacket.go:allocateRandomAmounts()` - 拼手气红包分配算法
|
||||
|
||||
### 领取红包
|
||||
- `internal/api/redpacket.go:ReceiveRedPacket()` - 主流程
|
||||
- `internal/api/redpacket.go:grabRedPacketFromRedis()` - Redis Lua脚本执行
|
||||
- `internal/api/redpacket.go:grabRedPacketLuaScript` - Lua脚本定义
|
||||
- `internal/api/redpacket.go:writeRedPacketRecordToMongoDB()` - 异步写入MongoDB
|
||||
- `internal/api/redpacket.go:updateWalletBalanceAsync()` - 异步更新钱包余额
|
||||
- `internal/api/redpacket.go:writeToCompensationStream()` - 写入补偿Stream
|
||||
|
||||
### Redis Key生成
|
||||
- `internal/api/redpacket.go:getRedPacketQueueKey()` - 队列Key
|
||||
- `internal/api/redpacket.go:getRedPacketUsersKey()` - 用户Set Key
|
||||
- `internal/api/redpacket.go:getRedPacketStreamKey()` - Stream Key
|
||||
|
||||
---
|
||||
|
||||
## 十、注意事项
|
||||
|
||||
1. **Redis必须可用**:发送和领取红包都依赖Redis
|
||||
2. **MongoDB异步写入**:可能短暂延迟,但不影响用户体验
|
||||
3. **补偿机制**:需要实现后台worker消费Stream
|
||||
4. **唯一索引**:MongoDB领取记录的唯一索引是幂等性的关键
|
||||
5. **钱包余额**:异步更新,可能有短暂延迟
|
||||
|
||||
---
|
||||
|
||||
## 十一、流程图
|
||||
|
||||
### 发送红包流程图
|
||||
```
|
||||
验证参数
|
||||
↓
|
||||
验证群信息
|
||||
↓
|
||||
生成红包ID
|
||||
↓
|
||||
初始化Redis(关键)
|
||||
├─ 普通红包:推送N个平均金额
|
||||
└─ 拼手气红包:推送N个随机金额
|
||||
↓
|
||||
创建MongoDB记录
|
||||
↓
|
||||
发送消息
|
||||
↓
|
||||
返回结果
|
||||
```
|
||||
|
||||
### 领取红包流程图
|
||||
```
|
||||
验证参数
|
||||
↓
|
||||
查询红包基本信息
|
||||
↓
|
||||
检查是否过期
|
||||
↓
|
||||
【核心】Redis Lua脚本抢红包
|
||||
├─ 检查是否已领取(SISMEMBER)
|
||||
├─ 抢红包(LPOP)
|
||||
└─ 记录用户(SADD)
|
||||
↓
|
||||
立即返回结果 ✅
|
||||
↓
|
||||
异步写入(goroutine)
|
||||
├─ 写入补偿Stream
|
||||
├─ 写入MongoDB领取记录
|
||||
└─ 更新钱包余额
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 十二、错误处理
|
||||
|
||||
### 发送红包错误
|
||||
- Redis不可用 → 返回错误,不允许发送
|
||||
- MongoDB写入失败 → 返回错误,不允许发送
|
||||
|
||||
### 领取红包错误
|
||||
- 红包不存在 → 返回 `red packet not found`
|
||||
- 红包已过期 → 返回 `red packet expired`
|
||||
- 用户已领取 → 返回 `red packet already received`(Redis检查)
|
||||
- 红包已领完 → 返回 `red packet finished`(Redis检查)
|
||||
- Redis不可用 → 返回 `redis client is not available`
|
||||
|
||||
### 异步写入错误
|
||||
- MongoDB写入失败 → 记录日志,补偿机制处理
|
||||
- 钱包余额更新失败 → 记录日志,可手动补偿
|
||||
- Stream写入失败 → 记录日志,不影响主流程
|
||||
Reference in New Issue
Block a user