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

10 KiB
Raw Blame History

红包系统完整流程文档

一、整体架构设计

核心原则

  • Redis 负责并发控制与计算:使用 Lua 脚本实现原子操作
  • MongoDB 负责最终记录与查询:异步写入,不参与并发竞争
  • 立即返回 + 异步写入:提升响应速度,降低延迟
  • 失败补偿机制:使用 Redis Stream 确保数据最终一致性

数据流向

客户端请求
  ↓
Go API无状态
  ↓
Redis Lua脚本原子抢红包← 并发控制核心
  ↓
立即返回结果 ✅
  ↓
异步写入 MongoDBgoroutine
  ├─ 写入领取记录
  ├─ 更新钱包余额
  └─ 写入补偿Stream

二、发送红包流程SendRedPacket

1. 参数验证

  • 验证 totalAmount > 0
  • 验证 totalCount > 0
  • 验证 redPacketType 为 1普通或 2拼手气

2. 群信息验证

  • 获取群信息,验证群是否存在
  • 获取群主ID发送用户默认为群主
  • 获取群主用户信息(昵称、头像)

3. 生成红包ID和基本信息

  • 生成红包IDMD5(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. 返回响应

  • 返回 redPacketIDserverMsgIDclientMsgIDsendTime

三、领取红包流程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包含userIDamounttime

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() - 异步写入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 receivedRedis检查
  • 红包已领完 → 返回 red packet finishedRedis检查
  • Redis不可用 → 返回 redis client is not available

异步写入错误

  • MongoDB写入失败 → 记录日志,补偿机制处理
  • 钱包余额更新失败 → 记录日志,可手动补偿
  • Stream写入失败 → 记录日志,不影响主流程