10 KiB
10 KiB
红包系统完整流程文档
一、整体架构设计
核心原则
- 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)
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)
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记录
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脚本逻辑
-- 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. 立即返回结果
resp.RedPacketID = req.RedPacketID
resp.Amount = amount
resp.IsLucky = false
apiresp.GinSuccess(c, resp) // 立即返回,不等待MongoDB写入
5. 【异步写入】后台goroutine处理
5.1 写入补偿Stream
streamKey := "rp:" + redPacketID + ":stream"
XAdd(streamKey, {
"userID": userID,
"amount": amount,
"time": timestamp
})
作用:用于失败补偿,确保数据最终一致性
5.2 写入MongoDB领取记录
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 更新钱包余额
// 查询或创建钱包
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(待实现)
// 后台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()- 异步写入MongoDBinternal/api/redpacket.go:updateWalletBalanceAsync()- 异步更新钱包余额internal/api/redpacket.go:writeToCompensationStream()- 写入补偿Stream
Redis Key生成
internal/api/redpacket.go:getRedPacketQueueKey()- 队列Keyinternal/api/redpacket.go:getRedPacketUsersKey()- 用户Set Keyinternal/api/redpacket.go:getRedPacketStreamKey()- Stream Key
十、注意事项
- Redis必须可用:发送和领取红包都依赖Redis
- MongoDB异步写入:可能短暂延迟,但不影响用户体验
- 补偿机制:需要实现后台worker消费Stream
- 唯一索引:MongoDB领取记录的唯一索引是幂等性的关键
- 钱包余额:异步更新,可能有短暂延迟
十一、流程图
发送红包流程图
验证参数
↓
验证群信息
↓
生成红包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写入失败 → 记录日志,不影响主流程