# 红包系统完整流程文档 ## 一、整体架构设计 ### 核心原则 - **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写入失败 → 记录日志,不影响主流程