多人连麦场景下的群送礼物系统设计与实现
本文记录了一个多人连麦场景下群送礼物系统从0到1的完整技术演进过程涵盖架构设计、原子化事务、前端动画优化、高并发性能优化以及生产环境问题排查。一、引子一个看似简单的需求在一次产品迭代中产品经理拿着某社交平台的录屏找到我这个多人语音房用户点一次就能给所有麦上的人送礼礼物还会发散飞出去。我们也要做。当时我以为这不就是循环发送礼物顶多加个动画效果。结果三个月后这个功能经历了两次重构踩了不少坑最终礼物流水实现了显著增长。这篇文章会详细讲述我们如何从0到1搭建群送礼物系统包括那些生产环境中才会遇到的真实问题。二、业务场景分析2.1 什么是群送礼物传统单人送礼用户点击礼物送给一个主播播放一次动画。群送礼物用户点击一次同时送给麦上所有主播礼物从发送者头像发散飞向所有接收者每个人都播放独立的礼物动效。2.2 核心场景场景1多人语音连麦房6人/9人麦位┌─────────────────────────────┐ │ 派对房 - 6人麦位布局 │ ├─────────────────────────────┤ │ [主持人] [嘉宾1] [嘉宾2] │ │ [用户A] [用户B] [用户C] │ │ │ │ [观众席用户X] │ │ └─ 点击群送玫瑰 │ └─────────────────────────────┘ 用户X点击一次 → 6朵玫瑰同时飞向6个麦位 消费单价 × 6人业务逻辑只送给麦上用户on_mic状态观众席不计算空麦位不计费有人才扣费支持连击连续点10次 60朵玫瑰发散场景2PK连麦双方各3-4人┌──────────┬──────────┐ │ 红方 │ 蓝方 │ ├──────────┼──────────┤ │ [主播A] │ [主播B] │ │ [助攻1] │ [助攻1] │ │ [助攻2] │ [助攻2] │ └──────────┴──────────┘ 用户给红方群送 → 只给红方3人发散 用户给蓝方群送 → 只给蓝方3人发散特殊规则PK期间群送只能选择一方阵营礼物价值计入对应阵营的PK分数红方观众不能给蓝方群送业务规则场景3全局广播礼物用户A在房间001送出超级火箭高价值礼物 ↓ 全平台在线用户看到顶部飘屏 用户A 在 派对房001 送出了 超级火箭 x 6人技术挑战大量在线用户同时收到推送消息体积要小1KB3秒内送达率 95%2.3 核心技术指标上线3个月后系统技术指标礼物发送成功率99.7%动画渲染帧率55-60 FPS群送接口P99延迟180ms全局广播到达率96.3%业务侧在群送功能上线后多人语音房的用户互动与礼物流水均实现了明显提升。本文聚焦技术实现业务数据不做展开。三、架构设计3.1 V1版本循环发送第一周就翻车最初的想法很简单前端点击群送按钮后端循环给每个麦上用户发送单人礼物。// V1前端代码错误示范 async function sendGroupGift(giftId, micUsers) { for (let user of micUsers) { await sendSingleGift(giftId, user.userId); } showSuccessToast(群送成功); }// V1后端代码错误示范 func SendGroupGift(userID int64, giftID int64, roomID int64) error { micUsers, _ : GetMicUsers(roomID) for _, receiver : range micUsers { // 循环发送单人礼物 err : SendSingleGift(userID, receiver.UserID, giftID) if err ! nil { return err // 一个失败全部回滚 } } return nil }上线第一天就出现了问题问题1用户被重复扣款用户A点击群送6人因为网络抖动点了2次后端收到2次请求发送了12份礼物用户投诉被多扣了费用问题2部分成功导致分账错乱群送6人第3个人发送失败余额不足前2个人已经收到礼物并分成用户要求退款但钱已经分给主播了问题3动画不同步6个礼物是逐个发送的动画也逐个播放看起来像卡顿连续发送不是同时发散视觉效果与预期相差较大3.2 V2版本原子化事务 批量推送吸取教训后我们重新设计了架构。核心思路群送作为独立业务不是多次单人送礼而是一次群送事务原子化扣费要么全部成功要么全部失败批量推送后端一次性推送所有礼物消息前端协调动画收到消息后同时播放所有发散动画系统架构图┌──────────┐ 1.发起群送 ┌──────────────┐ │ 客户端 │ ───────────────── │ API Gateway │ └──────────┘ └──────────────┘ │ ↓ 2.幂等性检查扣款 ┌──────────────┐ │ Gift Service │ └──────────────┘ │ ┌──────────────────┼──────────────────┐ ↓ ↓ ↓ 3.写礼物记录 4.更新主播收入 5.发消息 ┌───────────────┐ ┌───────────────┐ ┌────────────┐ │ gift_records │ │ user_balance │ │ Kafka │ │ (MySQL) │ │ (MySQL) │ │ (消息队列) │ └───────────────┘ └───────────────┘ └────────────┘ │ ┌────────────────────────────────┼────────────────┐ ↓ ↓ ↓ 6.房间内推送 7.全局广播 8.数据分析 ┌─────────────────┐ ┌────────────────┐ ┌──────────┐ │ WebSocket Push │ │ Global Notify │ │ Analytics│ │ (给房间内用户) │ │ (给全平台用户) │ │ Service │ └─────────────────┘ └────────────────┘ └──────────┘数据库设计-- 群送礼物记录表 CREATE TABLE group_gift_records ( id BIGINT UNSIGNED PRIMARY KEY AUTO_INCREMENT, request_id VARCHAR(64) NOT NULL COMMENT 请求ID幂等性, sender_id BIGINT NOT NULL COMMENT 发送者ID, room_id BIGINT NOT NULL COMMENT 房间ID, gift_id INT NOT NULL COMMENT 礼物ID, gift_price INT NOT NULL COMMENT 单价钻石, receiver_count TINYINT NOT NULL COMMENT 接收人数, total_cost INT NOT NULL COMMENT 总消费钻石, receiver_ids JSON NOT NULL COMMENT 接收者ID列表 [123,456,789], status TINYINT NOT NULL DEFAULT 0 COMMENT 0:处理中 1:成功 2:失败, combo_count INT NOT NULL DEFAULT 1 COMMENT 连击次数, pk_side TINYINT COMMENT PK阵营1红方 2蓝方, is_broadcast TINYINT NOT NULL DEFAULT 0 COMMENT 是否全局广播, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, UNIQUE KEY uniq_request_id (request_id), KEY idx_sender_created (sender_id, created_at), KEY idx_room_created (room_id, created_at) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT群送礼物记录; -- 礼物接收明细表分表group_gift_details_0 ~ group_gift_details_9 CREATE TABLE group_gift_details ( id BIGINT UNSIGNED PRIMARY KEY AUTO_INCREMENT, group_gift_id BIGINT NOT NULL COMMENT 群送记录ID, receiver_id BIGINT NOT NULL COMMENT 接收者ID, gift_id INT NOT NULL COMMENT 礼物ID, gift_count INT NOT NULL COMMENT 礼物数量含连击, income_diamonds INT NOT NULL COMMENT 收入钻石数, income_cash DECIMAL(10,2) NOT NULL COMMENT 收入现金元, split_ratio DECIMAL(5,2) NOT NULL COMMENT 分成比例%, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, KEY idx_group_gift_id (group_gift_id), KEY idx_receiver_created (receiver_id, created_at) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT群送礼物明细;核心代码实现// 群送礼物服务 type GroupGiftService struct { db *gorm.DB redis *redis.Client kafkaClient *kafka.Producer } // 发送群送礼物 func (s *GroupGiftService) SendGroupGift(ctx context.Context, req *GroupGiftRequest) (*GroupGiftResponse, error) { // 1. 幂等性检查 requestID : req.RequestID if requestID { return nil, errors.New(request_id不能为空) } // 检查是否已处理 lockKey : fmt.Sprintf(group_gift_lock:%s, requestID) locked, err : s.redis.SetNX(ctx, lockKey, 1, 30*time.Second).Result() if err ! nil || !locked { return nil, errors.New(请勿重复提交) } defer s.redis.Del(ctx, lockKey) // 2. 查询房间麦位信息 micUsers, err : s.GetMicUsers(ctx, req.RoomID, req.PkSide) if err ! nil { return nil, err } if len(micUsers) 0 { return nil, errors.New(房间内无人连麦) } // 3. 计算费用 giftPrice : s.GetGiftPrice(req.GiftID) totalCost : giftPrice * len(micUsers) * req.ComboCount // 4. 开启数据库事务 tx : s.db.Begin() defer func() { if r : recover(); r ! nil { tx.Rollback() } }() // 5. 扣除发送者余额 err s.DeductBalance(tx, req.SenderID, totalCost) if err ! nil { tx.Rollback() return nil, errors.New(余额不足) } // 6. 创建群送记录 receiverIDs : make([]int64, len(micUsers)) for i, u : range micUsers { receiverIDs[i] u.UserID } groupGift : GroupGiftRecord{ RequestID: requestID, SenderID: req.SenderID, RoomID: req.RoomID, GiftID: req.GiftID, GiftPrice: giftPrice, ReceiverCount: len(micUsers), TotalCost: totalCost, ReceiverIDs: receiverIDs, ComboCount: req.ComboCount, PkSide: req.PkSide, IsBroadcast: giftPrice 9999, // 昂贵礼物触发全局广播 Status: StatusProcessing, } if err : tx.Create(groupGift).Error; err ! nil { tx.Rollback() return nil, err } // 7. 增加接收者收入批量插入明细 details : make([]*GroupGiftDetail, 0, len(micUsers)) for _, receiver : range micUsers { // 计算分成每个主播可能有不同的分成比例 splitRatio : s.GetUserSplitRatio(receiver.UserID) incomeDiamonds : int(float64(giftPrice*req.ComboCount) * splitRatio / 100) incomeCash : float64(incomeDiamonds) * 0.1 // 1钻石0.1元 // 更新主播余额 err s.AddBalance(tx, receiver.UserID, incomeDiamonds) if err ! nil { tx.Rollback() return nil, err } // 创建明细记录 details append(details, GroupGiftDetail{ GroupGiftID: groupGift.ID, ReceiverID: receiver.UserID, GiftID: req.GiftID, GiftCount: req.ComboCount, IncomeDiamonds: incomeDiamonds, IncomeCash: incomeCash, SplitRatio: splitRatio, }) } // 批量插入明细 if err : tx.CreateInBatches(details, 100).Error; err ! nil { tx.Rollback() return nil, err } // 8. 更新群送记录状态 groupGift.Status StatusSuccess if err : tx.Save(groupGift).Error; err ! nil { tx.Rollback() return nil, err } // 9. 提交事务 if err : tx.Commit().Error; err ! nil { return nil, err } // 10. 发送Kafka消息异步推送 s.sendGiftNotification(groupGift, micUsers) return GroupGiftResponse{ GroupGiftID: groupGift.ID, ReceiverCount: len(micUsers), TotalCost: totalCost, }, nil }3.3 前端动画实现贝塞尔曲线飞行群送礼物最核心的视觉效果是发散飞行动画礼物从发送者头像飞向所有接收者头像。动画设计思路发送者位置(x0, y0) 接收者位置[(x1,y1), (x2,y2), ..., (xn,yn)] 每条飞行路径 1. 起点发送者头像中心 2. 控制点路径中间偏移的点制造弧线效果 3. 终点接收者头像中心 4. 飞行时长800ms 5. 到达后播放SVGA礼物特效贝塞尔曲线实现// 群送礼物动画管理器 class GroupGiftAnimator { private canvas: HTMLCanvasElement; private ctx: CanvasRenderingContext2D; private animations: GiftAnimation[] []; // 发起群送动画 startGroupGift(sender: User, receivers: User[], giftId: number, combo: number) { const senderPos this.getUserPosition(sender.seatIndex); receivers.forEach((receiver, index) { const receiverPos this.getUserPosition(receiver.seatIndex); // 为每个接收者创建一个飞行动画 const animation new GiftAnimation({ giftId: giftId, startPos: senderPos, endPos: receiverPos, controlOffset: this.calculateControlOffset(index, receivers.length), duration: 800, // 800ms飞行时长 comboCount: combo, onComplete: () { // 到达后播放SVGA特效 this.playSVGAEffect(receiver.userId, giftId, combo); } }); this.animations.push(animation); }); // 启动动画循环 this.startAnimationLoop(); } // 计算控制点偏移制造发散效果 calculateControlOffset(index: number, total: number): Point { // 根据接收者索引计算不同的弧线方向 const angle (index / total) * Math.PI * 2; // 360度均匀分布 const radius 100; // 控制点偏移距离 return { x: Math.cos(angle) * radius, y: Math.sin(angle) * radius }; } // 二次贝塞尔曲线公式 private quadraticBezier(p0: Point, p1: Point, p2: Point, t: number): Point { const x (1-t)*(1-t)*p0.x 2*(1-t)*t*p1.x t*t*p2.x; const y (1-t)*(1-t)*p0.y 2*(1-t)*t*p1.y t*t*p2.y; return { x, y }; } // 播放SVGA礼物特效 private playSVGAEffect(userId: number, giftId: number, combo: number) { const userEl document.querySelector([data-user-id${userId}]); const svgaPlayer new SVGAPlayer(userEl); svgaPlayer.load(/assets/gifts/${giftId}.svga, () { svgaPlayer.loops combo; // 连击次数 svgaPlayer.play(); }); } }移动端优化Canvas性能问题上线后发现Android中低端机型在6人群送时会掉帧30 FPS以下。原因分析Canvas每帧都要clearRect 重绘所有动画SVGA特效同时播放6个占用大量GPU低端机型硬件加速不足优化方案// 优化1使用CSS Transform代替Canvas绘制 class OptimizedGroupGiftAnimator { startGroupGift(sender: User, receivers: User[], giftId: number, combo: number) { receivers.forEach((receiver, index) { // 创建DOM元素而不是Canvas绘制 const giftEl this.createGiftElement(giftId, combo); const startPos this.getUserPosition(sender.seatIndex); const endPos this.getUserPosition(receiver.seatIndex); // 使用CSS Transform动画GPU加速 giftEl.style.transform translate(${startPos.x}px, ${startPos.y}px); document.body.appendChild(giftEl); // 延迟不同时间启动制造发散效果 setTimeout(() { giftEl.style.transition transform 800ms cubic-bezier(0.25, 0.46, 0.45, 0.94); giftEl.style.transform translate(${endPos.x}px, ${endPos.y}px); // 动画结束后播放SVGA setTimeout(() { giftEl.remove(); this.playSVGAEffect(receiver.userId, giftId, combo); }, 800); }, index * 50); // 每个礼物延迟50ms制造波浪效果 }); } } // 优化2SVGA特效分批播放 playSVGAEffect(userId: number, giftId: number, combo: number) { // 限制同时播放的SVGA数量 if (this.activeSVGACount 3) { // 队列等待 this.svgaQueue.push({ userId, giftId, combo }); return; } this.activeSVGACount; const player new SVGAPlayer(/*...*/); player.onFinished () { this.activeSVGACount--; // 播放队列中的下一个 if (this.svgaQueue.length 0) { const next this.svgaQueue.shift(); this.playSVGAEffect(next.userId, next.giftId, next.combo); } }; player.play(); }效果优化后帧率稳定在55-60 FPS低端机型Android 6.0 2GB内存也能流畅运行四、生产环境踩坑实录4.1 问题1并发送礼导致超发现象用户A余额100钻石群送6人需要60钻石用户A快速点击2次成功发送了2次应该只能发1次最终用户A余额变成 -20 钻石超支了原因// 问题代码 func (s *GroupGiftService) SendGroupGift(req *GroupGiftRequest) error { // 检查余额 balance : s.GetUserBalance(req.SenderID) if balance req.TotalCost { return errors.New(余额不足) } // ⚠️ 这里有并发问题 // 2个请求同时通过了余额检查 // 扣款 s.DeductBalance(req.SenderID, req.TotalCost) // ... }解决方案使用Redis分布式锁 数据库悲观锁func (s *GroupGiftService) SendGroupGift(req *GroupGiftRequest) error { // 1. Redis分布式锁防止重复提交 lockKey : fmt.Sprintf(send_gift_lock:%d:%s, req.SenderID, req.RequestID) lock : s.redlock.Obtain(lockKey, 5*time.Second) if lock nil { return errors.New(请勿重复提交) } defer lock.Release() // 2. 数据库事务 悲观锁 tx : s.db.Begin() // SELECT ... FOR UPDATE 锁定用户余额 var user User err : tx.Raw(SELECT * FROM users WHERE id ? FOR UPDATE, req.SenderID).Scan(user).Error if err ! nil { tx.Rollback() return err } // 再次检查余额 if user.Balance req.TotalCost { tx.Rollback() return errors.New(余额不足) } // 扣款 err tx.Exec(UPDATE users SET balance balance - ? WHERE id ?, req.TotalCost, req.SenderID).Error if err ! nil { tx.Rollback() return err } // 创建礼物记录... tx.Commit() return nil }4.2 问题2PK结束后群送分账错乱现象用户在PK房间给红方群送3人PK在礼物飞行途中结束800ms飞行时间到达时麦位已经变了礼物发给了错误的人原因前端发起请求时麦位是 [A, B, C]后端处理时麦位还是 [A, B, C]但消息推送到前端时1秒后麦位变成了 [D, E, F]前端直接用当前麦位播放动画导致礼物飞向了错误的人错误代码// 前端收到消息 onGroupGiftMessage(msg: GiftMessage) { // ⚠️ 错误用当前房间麦位 const currentMicUsers this.roomStore.getMicUsers(); this.animator.startGroupGift(msg.senderId, currentMicUsers, msg.giftId, msg.combo); }解决方案在消息中携带接收者快照// 后端消息结构 type GiftMessage struct { Type string json:type GroupGiftID int64 json:group_gift_id SenderID int64 json:sender_id RoomID int64 json:room_id GiftID int json:gift_id ComboCount int json:combo_count // ✅ 新增接收者快照包含座位信息 Receivers []ReceiverSnapshot json:receivers } type ReceiverSnapshot struct { UserID int64 json:user_id Nickname string json:nickname Avatar string json:avatar SeatIndex int json:seat_index // 发送时的座位位置 }// 前端修复 onGroupGiftMessage(msg: GiftMessage) { // ✅ 使用消息中的接收者快照 this.animator.startGroupGift( msg.senderId, msg.receivers, // 使用快照不用当前麦位 msg.giftId, msg.combo ); }4.3 问题3全局广播消息风暴现象大主播在线同时有大量用户观看用户连续群送10次高价值礼物城堡触发10次全局广播推送给全平台在线用户WebSocket服务器CPU飙升到95%大量连接断开原因每次广播都推送给所有在线用户10次广播 数百万次推送没有消息合并和限流机制解决方案1消息合并// 广播消息去重合并 type GlobalBroadcastAggregator struct { mu sync.Mutex pending map[string]*AggregatedMessage // key: roomID_senderID_giftID flushTicker *time.Ticker } func (a *GlobalBroadcastAggregator) Add(msg *GiftMessage) { a.mu.Lock() defer a.mu.Unlock() key : fmt.Sprintf(%d_%d_%d, msg.RoomID, msg.SenderID, msg.GiftID) if existing, ok : a.pending[key]; ok { // 同一个人同一个房间同一种礼物累加数量 existing.TotalCount msg.ComboCount * msg.ReceiverCount existing.TotalValue msg.TotalCost } else { a.pending[key] AggregatedMessage{ RoomID: msg.RoomID, SenderID: msg.SenderID, GiftID: msg.GiftID, TotalCount: msg.ComboCount * msg.ReceiverCount, TotalValue: msg.TotalCost, StartTime: time.Now(), } } } func (a *GlobalBroadcastAggregator) flush() { a.mu.Lock() messages : a.pending a.pending make(map[string]*AggregatedMessage) a.mu.Unlock() // 批量推送 for _, msg : range messages { a.broadcast(msg) } }解决方案2用户分组推送// 将在线用户分成多组每组5000人 // 每组延迟50ms推送避免瞬时峰值 func (s *BroadcastService) SendGlobalNotification(msg *GiftMessage) { allUsers : s.GetOnlineUsers() groupSize : 5000 for i : 0; i len(allUsers); i groupSize { end : i groupSize if end len(allUsers) { end len(allUsers) } group : allUsers[i:end] // 延迟推送 go func(users []int64, delay time.Duration) { time.Sleep(delay) s.pushToUsers(users, msg) }(group, time.Duration(i/groupSize*50)*time.Millisecond) } }效果消息合并率72%10次请求合并成3次推送推送时长从瞬时峰值 → 分散到5秒内WebSocket服务器CPU95% →45%4.4 问题4MySQL主从延迟导致收入不一致现象主播A收到群送礼物查看收入是100钻石刷新页面后变成80钻石再刷新又变成100钻石原因写操作在主库读操作在从库主从延迟200-500ms用户刚收到礼物立即刷新页面从库数据还没同步解决方案使用Redis缓存兜底func (s *GroupGiftService) SendGroupGift(req *GroupGiftRequest) error { // 1. 写入MySQL主库 tx : s.masterDB.Begin() // ... 创建礼物记录、更新余额 tx.Commit() // 2. 同步写入Redis保证实时性 for _, receiver : range receivers { incomeKey : fmt.Sprintf(user_income:%d, receiver.UserID) s.redis.IncrBy(ctx, incomeKey, income) s.redis.Expire(ctx, incomeKey, 10*time.Minute) // 10分钟过期 } return nil } // 查询收入时优先读Redis func (s *UserService) GetUserIncome(userID int64) (int, error) { incomeKey : fmt.Sprintf(user_income:%d, userID) // 1. 先读Redis val, err : s.redis.Get(ctx, incomeKey).Int() if err nil { return val, nil // Redis命中直接返回 } // 2. Redis未命中读MySQL var user User s.slaveDB.First(user, userID) // 3. 回写Redis s.redis.Set(ctx, incomeKey, user.Balance, 10*time.Minute) return user.Balance, nil }效果收入查询一致性99.9%相关用户投诉显著下降五、性能优化5.1 数据库优化问题群送明细表数据量暴增上线一段时间后group_gift_details表数据量达到千万级别单表查询变慢。优化方案分库分表-- 按接收者ID分10张表 CREATE TABLE group_gift_details_0 (...); CREATE TABLE group_gift_details_1 (...); ... CREATE TABLE group_gift_details_9 (...); -- 路由规则receiver_id % 10// 动态路由到分表 func (s *GroupGiftService) GetReceiverDetails(receiverID int64) ([]*GroupGiftDetail, error) { tableIndex : receiverID % 10 tableName : fmt.Sprintf(group_gift_details_%d, tableIndex) var details []*GroupGiftDetail s.db.Table(tableName). Where(receiver_id ?, receiverID). Order(created_at DESC). Limit(100). Find(details) return details, nil }优化索引-- 优化前查询某用户最近收到的群送礼物耗时1.2s SELECT * FROM group_gift_details WHERE receiver_id 123456 ORDER BY created_at DESC LIMIT 20; -- 添加联合索引 ALTER TABLE group_gift_details_0 ADD INDEX idx_receiver_created (receiver_id, created_at); -- 优化后查询耗时 18ms5.2 Redis优化问题热点Key导致Redis单点瓶颈大主播房间的礼物记录Key被高频访问单个Redis实例QPS达到10万。优化方案本地缓存 Redis两级缓存type GiftCache struct { localCache *lru.Cache // 本地LRU缓存 redis *redis.Client } func (c *GiftCache) GetGiftInfo(giftID int) (*GiftInfo, error) { // 1. 先查本地缓存内存 if val, ok : c.localCache.Get(giftID); ok { return val.(*GiftInfo), nil } // 2. 查Redis key : fmt.Sprintf(gift_info:%d, giftID) data, err : c.redis.Get(ctx, key).Bytes() if err nil { var info GiftInfo json.Unmarshal(data, info) // 写入本地缓存 c.localCache.Add(giftID, info) return info, nil } // 3. 查数据库 var info GiftInfo db.First(info, giftID) // 回写Redis和本地缓存 data, _ json.Marshal(info) c.redis.Set(ctx, key, data, 1*time.Hour) c.localCache.Add(giftID, info) return info, nil }效果Redis QPS100,000 →15,000本地缓存命中率87%平均响应时间45ms →8ms5.3 Kafka消息优化问题高并发下Kafka消息堆积晚高峰时段群送礼物消息堆积严重推送延迟达到5秒。优化方案批量发送// 优化后批量发送 type GiftMessageBatcher struct { buffer []*GiftMessage mu sync.Mutex timer *time.Timer maxSize int // 最大批次大小100 maxDelay time.Duration // 最大延迟50ms } func (b *GiftMessageBatcher) Add(msg *GiftMessage) { b.mu.Lock() defer b.mu.Unlock() b.buffer append(b.buffer, msg) // 达到批次大小立即发送 if len(b.buffer) b.maxSize { b.flush() return } // 启动定时器50ms后发送 if b.timer nil { b.timer time.AfterFunc(b.maxDelay, func() { b.mu.Lock() b.flush() b.mu.Unlock() }) } } func (b *GiftMessageBatcher) flush() { if len(b.buffer) 0 { return } // 批量发送 batch : KafkaBatch{Messages: b.buffer} s.kafka.SendBatch(gift_notification, batch) b.buffer b.buffer[:0] if b.timer ! nil { b.timer.Stop() b.timer nil } }效果消息批次大小平均43条/批Kafka吞吐量提升3.2倍推送延迟5秒 →200ms六、监控告警体系6.1 核心监控指标// Prometheus指标定义 var ( // 群送成功率 groupGiftSuccessRate prometheus.NewGaugeVec( prometheus.GaugeOpts{ Name: group_gift_success_rate, Help: Group gift success rate, }, []string{room_type}, // 房间类型voice/video/pk ) // 群送延迟P50/P95/P99 groupGiftLatency prometheus.NewHistogramVec( prometheus.HistogramOpts{ Name: group_gift_latency_ms, Help: Group gift processing latency, Buckets: []float64{10, 50, 100, 200, 500, 1000, 2000}, }, []string{stage}, // 阶段validate/deduct/notify ) // 动画帧率 animationFPS prometheus.NewGauge(prometheus.GaugeOpts{ Name: group_gift_animation_fps, Help: Animation frames per second, }) // 全局广播推送成功率 broadcastDeliveryRate prometheus.NewGauge(prometheus.GaugeOpts{ Name: global_broadcast_delivery_rate, Help: Global broadcast message delivery rate, }) )6.2 告警规则# Prometheus告警规则 groups: - name: group_gift_alerts rules: # 群送成功率低于95% - alert: GroupGiftLowSuccessRate expr: group_gift_success_rate 0.95 for: 5m labels: severity: critical annotations: summary: 群送成功率低于95% description: 当前成功率: {{ $value }} # P99延迟超过500ms - alert: GroupGiftHighLatency expr: histogram_quantile(0.99, group_gift_latency_ms) 500 for: 10m labels: severity: warning annotations: summary: 群送P99延迟过高 description: 当前P99延迟: {{ $value }}ms # 全局广播推送成功率低于90% - alert: BroadcastLowDeliveryRate expr: global_broadcast_delivery_rate 0.90 for: 5m labels: severity: critical annotations: summary: 全局广播推送成功率低于90%七、连击 群送的组合场景7.1 业务逻辑用户可以在群送的基础上叠加连击实现群送 × 连击的效果。示例场景房间6人语音麦 用户操作连续点击10次群送玫瑰 结果60朵玫瑰6人 × 10次同时飞向麦位7.2 技术实现难点难点1如何合并连击动画如果每次点击都触发6个飞行动画10次点击 60个动画同时播放性能无法承受。解决方案时间窗口合并class GroupGiftComboManager { private comboWindow 1500; // 1.5秒合并窗口 private pendingCombos new Mapstring, ComboState(); // 收到群送消息 onGroupGiftMessage(msg: GiftMessage) { const key ${msg.roomId}_${msg.senderId}_${msg.giftId}; // 检查是否有待合并的连击 if (this.pendingCombos.has(key)) { const state this.pendingCombos.get(key); // 累加连击次数 state.comboCount msg.comboCount; state.lastTime Date.now(); // 重置定时器 clearTimeout(state.timer); state.timer setTimeout(() { this.flushCombo(key); }, this.comboWindow); } else { // 首次收到创建新的合并状态 const state: ComboState { msg: msg, comboCount: msg.comboCount, lastTime: Date.now(), timer: setTimeout(() { this.flushCombo(key); }, this.comboWindow) }; this.pendingCombos.set(key, state); } } // 合并窗口结束播放动画 private flushCombo(key: string) { const state this.pendingCombos.get(key); if (!state) return; // 播放合并后的动画 this.animator.startGroupGift( state.msg.senderId, state.msg.receivers, state.msg.giftId, state.comboCount // 合并后的连击次数 ); this.pendingCombos.delete(key); } }难点2后端防刷机制问题恶意用户可能利用脚本快速连击绕过前端限制。解决方案后端滑动窗口限流// Redis限流器 type GroupGiftRateLimiter struct { redis *redis.Client } func (l *GroupGiftRateLimiter) CheckLimit(userID int64) error { key : fmt.Sprintf(group_gift_limit:%d, userID) // 使用滑动窗口限流 now : time.Now().Unix() windowStart : now - 10 // 10秒窗口 // 1. 移除过期的记录 l.redis.ZRemRangeByScore(ctx, key, 0, fmt.Sprintf(%d, windowStart)) // 2. 统计窗口内的请求数 count, _ : l.redis.ZCard(ctx, key).Result() // 3. 检查是否超限10秒内最多20次 if count 20 { return errors.New(操作过于频繁请稍后再试) } // 4. 记录本次请求 l.redis.ZAdd(ctx, key, redis.Z{ Score: float64(now), Member: fmt.Sprintf(%d_%d, now, rand.Int63()), }) l.redis.Expire(ctx, key, 10*time.Second) return nil }八、数据分析与业务洞察8.1 关键技术指标上线3个月性能数据群送成功率99.7%P50延迟85msP95延迟180msP99延迟320ms前端动画帧率55-60 FPS全局广播到达率96.3%8.2 用户行为洞察通过分析运营数据我们发现了一些有趣的模式可用于指导产品优化。洞察1晚间为群送高峰时段各时段群送活跃度相对值08:00-12:00较低12:00-18:00中等18:00-20:00较高20:00-22:00高峰22:00-24:00较高洞察2连击次数呈现阶梯分布连击次数分布1-5次45%6-10次28%11-20次15%21-50次8%50次以上4%洞察用户倾向于整数次连击5、10、20、50。8.3 产品优化建议基于数据洞察可以从技术侧支撑以下产品优化方向快捷连击添加x5、x10、x20快捷按钮减少用户重复点击PK阵营加成PK期间群送享受分数加成需要在计分逻辑中增加系数全局广播分级展示高价值礼物增加全屏特效需要评估客户端渲染性能九、技术难点总结回顾整个群送系统的开发过程核心技术难点包括以下几个方面。9.1 分布式事务一致性挑战扣款、创建记录、增加收入必须原子化涉及多个用户的余额变动需要保证要么全部成功要么全部失败解决方案使用MySQL事务 SELECT FOR UPDATERedis分布式锁防止并发幂等性设计request_id去重9.2 高并发下的性能优化挑战晚高峰QPS达到5000单次群送涉及6人 × N次连击的计算数据库、Redis、Kafka都面临压力解决方案数据库分库分表 索引优化Redis本地缓存 Redis两级缓存Kafka消息批量发送前端CSS Transform代替Canvas绘制9.3 动画性能与用户体验挑战6个礼物同时飞行 6个SVGA特效同时播放低端Android机型性能不足需要保持55 FPS解决方案使用CSS TransformGPU加速SVGA特效分批播放限制同时播放数量连击合并1.5秒窗口内合并动画9.4 数据一致性问题挑战MySQL主从延迟导致收入显示不一致PK结束后麦位变化导致礼物飞向错误的人全局广播消息可能丢失或延迟解决方案Redis缓存兜底保证实时性消息中携带接收者快照避免麦位变化消息去重合并 分组推送降低风暴压力十、总结与展望10.1 经验教训教训1不要低估简单需求的复杂度最初以为群送只是循环发送礼物结果遇到了并发、事务、性能、动画等一系列问题。在高并发、强一致性的场景下没有真正简单的需求。教训2生产环境会放大所有问题开发环境测试时一切正常上线后才发现并发扣款导致超支、主从延迟导致数据不一致、全局广播引发消息风暴。在设计阶段就要考虑极端场景和边界条件。教训3监控告警是生命线多次生产问题都是通过监控告警第一时间发现的。如果没有完善的监控体系很多问题会被投诉淹没。投入20%的时间做监控能避免大量生产事故。10.2 未来规划AI推荐群送时机基于用户行为分析在PK关键时刻、房间气氛热烈时推荐群送更丰富的动画效果3D礼物特效、粒子系统、跨房间联动动画群送社交化群送排行榜、成就系统10.3 写在最后从0到1搭建群送礼物系统历时3个月经历了2次重构和多次优化。这个过程中积累的技术经验包括如何设计高并发、强一致性的分布式系统如何在性能和体验之间找到平衡以及如何通过数据驱动产品优化。技术的价值不在于使用了多少高深的算法而在于解决了多少实际的业务问题。希望这篇文章能帮助到正在做类似系统的同行。
上一篇/下一篇内容由系统自动关联
返回资讯列表 →