Files
open-im-server-deploy/docs/redpacket-flow.md
kim.dev.6789 e50142a3b9 复制项目
2026-01-14 22:16:44 +08:00

391 lines
10 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 红包系统完整流程文档
## 一、整体架构设计
### 核心原则
- **Redis 负责并发控制与计算**:使用 Lua 脚本实现原子操作
- **MongoDB 负责最终记录与查询**:异步写入,不参与并发竞争
- **立即返回 + 异步写入**:提升响应速度,降低延迟
- **失败补偿机制**:使用 Redis Stream 确保数据最终一致性
### 数据流向
```
客户端请求
Go API无状态
Redis Lua脚本原子抢红包← 并发控制核心
立即返回结果 ✅
异步写入 MongoDBgoroutine
├─ 写入领取记录
├─ 更新钱包余额
└─ 写入补偿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写入失败 → 记录日志,不影响主流程