群送礼物系统技术架构设计与实现

本文拆解多人语音连麦场景下群送礼物系统的完整技术架构,涵盖多接收者消息分发、独立特效播放、并发控制、幂等去重、性能优化及生产环境问题解决方案。

本文记录了多人语音连麦场景下群送礼物系统从原型到生产级系统的完整技术演进过程,包括架构设计、性能优化和生产环境问题解决方案。

一、业务场景分析

1.1 群送礼物功能定义

传统单人送礼:用户点击礼物,送给一个主播,播放一次动画。

群送礼物:用户点击一次,同时送给麦上所有主播,礼物从发送者头像发散飞向所有接收者,每个人都播放独立的礼物动效。

1.2 核心应用场景

场景1:多人语音连麦房(6人/9人麦位)

┌─────────────────────────────┐
│  派对房 - 6人麦位布局        │
├─────────────────────────────┤
│  [主持人]  [嘉宾1]  [嘉宾2] │
│  [用户A]   [用户B]  [用户C] │
│                              │
│  [观众席:用户X]             │
│  └─ 点击"群送玫瑰"           │
└─────────────────────────────┘

用户X点击一次 → 6朵玫瑰同时飞向6个麦位

业务逻辑要点:

  • 只送给麦上用户(on_mic状态),观众席不计算

  • 空麦位不计费(有人才扣钱)

  • 支持连击:连续点击10次 = 60朵玫瑰发散

场景2:PK连麦(双方各3-4人)

┌──────────┬──────────┐
│  红方    │   蓝方   │
├──────────┼──────────┤
│ [主播A]  │ [主播B]  │
│ [助攻1]  │ [助攻1]  │
│ [助攻2]  │ [助攻2]  │
└──────────┴──────────┘

用户给红方群送 → 只给红方3人发散
用户给蓝方群送 → 只给蓝方3人发散

特殊规则:

  • PK期间,群送只能选择一方阵营

  • 礼物价值计入对应阵营的PK分数

  • 红方观众不能给蓝方群送(业务规则)

场景3:全局广播礼物

用户A在房间001送出"超级火箭"
↓
全平台所有在线用户看到顶部飘屏:
"用户A 在 派对房001 送出了 超级火箭 x 6人"

技术挑战:

  • 大量在线用户同时收到推送

  • 消息体积要求小(<1KB)

  • 送达时效性要求高(3秒内送达率 >95%)

1.3 系统上线后核心数据

业务指标:

  • 群送功能占总礼物流水比例:显著提升

  • 多人语音房ARPU:大幅增长

  • 平均群送人数:5.2人

  • 礼物发送成功率:99.7%

技术指标:

  • 动画渲染帧率:55-60 FPS

  • 群送接口P99延迟:180ms

  • 全局广播到达率:96.3%

二、架构设计演进

2.1 V1版本:循环发送方案

初始方案采用循环发送的简单实现:前端点击群送按钮,后端循环给每个麦上用户发送单人礼物。

// V1前端代码示例
async function sendGroupGift(giftId, micUsers) {
  for (let user of micUsers) {
    await sendSingleGift(giftId, user.userId);
  }
  showSuccessToast('群送成功');
}

系统上线后暴露的问题:

问题1:用户被重复扣款

  • 现象:用户点击群送因网络抖动触发2次请求

  • 后果:收到2次请求,发送了双倍礼物

  • 影响:用户投诉被多扣费用

问题2:部分成功导致分账错乱

  • 现象:群送6人时第3个人发送失败

  • 后果:前2个人已经收到礼物并分成,但交易未完整完成

  • 影响:用户要求退款,但资金已部分分配

问题3:动画不同步

  • 现象:6个礼物逐个发送,动画也逐个播放

  • 后果:视觉效果呈现为"卡顿连续发送"而非"同时发散"

  • 影响:用户体验不符合预期

2.2 V2版本:原子化事务 + 批量推送

针对V1版本的问题,重新设计了系统架构。

核心设计思路

  • 群送作为独立业务:不是多次单人送礼,而是一次群送事务

  • 原子化扣费:要么全部成功,要么全部失败

  • 批量推送:后端一次性推送所有礼物消息

  • 前端协调动画:收到消息后同时播放所有发散动画

系统架构图

┌──────────┐    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列表',
  `status` TINYINT NOT NULL DEFAULT 0 COMMENT '0:处理中 1:成功 2:失败',
  `combo_count` INT NOT NULL DEFAULT 1 COMMENT '连击次数',
  `pk_side` TINYINT COMMENT 'PK阵营',
  `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`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='群送礼物记录';

-- 礼物接收明细表(分表)
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`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='群送礼物明细';

2.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,
        comboCount: combo,
        onComplete: () => {
          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;
    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 };
  }
}

移动端性能优化

系统上线后发现,Android中低端机型在6人群送时会掉帧(30 FPS以下)。

原因分析:

  • Canvas每帧都要clearRect + 重绘所有动画

  • SVGA特效同时播放6个,占用大量GPU

  • 低端机型硬件加速不足

优化方案:

// 使用CSS Transform代替Canvas绘制
class OptimizedGroupGiftAnimator {
  startGroupGift(sender: User, receivers: User[], giftId: number, combo: number) {
    receivers.forEach((receiver, index) => {
      const giftEl = this.createGiftElement(giftId, combo);
      const startPos = this.getUserPosition(sender.seatIndex);
      const endPos = this.getUserPosition(receiver.seatIndex);

      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)`;

        setTimeout(() => {
          giftEl.remove();
          this.playSVGAEffect(receiver.userId, giftId, combo);
        }, 800);
      }, index * 50);
    });
  }
}

优化效果:

  • 优化后帧率稳定在 55-60 FPS

  • 低端机型也能流畅运行

三、生产环境问题与解决方案

3.1 并发送礼导致超发问题

问题现象:

  • 用户余额不足以支付两次群送

  • 快速点击导致两次请求都通过了余额检查

  • 最终用户余额变成负数(超支)

技术原因:

// 问题代码
func (s *GroupGiftService) SendGroupGift(req *GroupGiftRequest) error {
    balance := s.GetUserBalance(req.SenderID)
    if balance < req.TotalCost {
        return errors.New("余额不足")
    }

    // ⚠️ 并发问题:两个请求同时通过了余额检查

    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()

    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
}

3.2 PK结束后群送分账错乱

问题现象:

  • 用户在PK房间给红方群送

  • PK在礼物飞行途中结束

  • 到达时麦位已经变化,礼物发给了错误的人

技术原因:

  • 前端发起请求时麦位状态为A

  • 后端处理时麦位状态仍为A

  • 消息推送到前端时(1秒后)麦位状态变为B

  • 前端用当前麦位播放动画,导致礼物飞向错误的人

解决方案:消息中携带接收者快照

// 后端消息结构
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"` // 发送时的座位位置
}

3.3 全局广播消息风暴

问题现象:

  • 大主播在线,大量观众同时观看

  • 用户连续群送多次昂贵礼物

  • 触发多次全局广播,推送给全平台用户

  • WebSocket服务器CPU飙升,大量连接断开

技术原因:

  • 每次广播都推送给所有用户

  • 多次广播产生海量推送

  • 没有消息合并和限流机制

解决方案1:消息合并

// 广播消息去重合并
type GlobalBroadcastAggregator struct {
    mu          sync.Mutex
    pending     map[string]*AggregatedMessage
    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) Start() {
    a.flushTicker = time.NewTicker(3 * time.Second)

    go func() {
        for range a.flushTicker.C {
            a.flush()
        }
    }()
}

优化效果:

  • 消息合并率:72%

  • 推送时长:从瞬时峰值 → 分散到5秒内

  • WebSocket服务器CPU:大幅下降

3.4 MySQL主从延迟导致收入不一致

问题现象:

  • 主播收到群送礼物,查看收入显示某数值

  • 刷新页面后数值变化

  • 再次刷新又变回原值

技术原因:

  • 写操作在主库,读操作在从库

  • 主从延迟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)
    }

    return nil
}

// 查询收入时优先读Redis
func (s *UserService) GetUserIncome(userID int64) (int, error) {
    incomeKey := fmt.Sprintf("user_income:%d", userID)

    val, err := s.redis.Get(ctx, incomeKey).Int()
    if err == nil {
        return val, nil
    }

    var user User
    s.slaveDB.First(&user, userID)

    s.redis.Set(ctx, incomeKey, user.Balance, 10*time.Minute)

    return user.Balance, nil
}

优化效果:

  • 收入查询一致性:99.9%

  • 用户投诉大幅下降

四、性能优化

4.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

索引优化

-- 优化前:查询耗时较长
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);

-- 优化后:查询耗时显著降低

4.2 Redis优化

问题:热点Key导致单点瓶颈

热门房间的礼物记录Key被高频访问,单个Redis实例QPS过高。

优化方案:本地缓存 + Redis两级缓存

type GiftCache struct {
    localCache *lru.Cache
    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)

    data, _ = json.Marshal(info)
    c.redis.Set(ctx, key, data, 1*time.Hour)
    c.localCache.Add(giftID, &info)

    return &info, nil
}

优化效果:

  • Redis QPS大幅下降

  • 本地缓存命中率高

  • 平均响应时间显著降低

4.3 Kafka消息优化

问题:高并发下消息堆积

高峰时段,群送礼物消息堆积严重,推送延迟较高。

优化方案:批量发送

type GiftMessageBatcher struct {
    buffer   []*GiftMessage
    mu       sync.Mutex
    timer    *time.Timer
    maxSize  int
    maxDelay time.Duration
}

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
    }

    if b.timer == nil {
        b.timer = time.AfterFunc(b.maxDelay, func() {
            b.mu.Lock()
            b.flush()
            b.mu.Unlock()
        })
    }
}

优化效果:

  • Kafka吞吐量提升

  • 推送延迟降低

五、监控告警体系

5.1 核心监控指标

// Prometheus指标定义
var (
    groupGiftSuccessRate = prometheus.NewGaugeVec(
        prometheus.GaugeOpts{
            Name: "group_gift_success_rate",
            Help: "群送成功率",
        },
        []string{"room_type"},
    )

    groupGiftLatency = prometheus.NewHistogramVec(
        prometheus.HistogramOpts{
            Name:    "group_gift_latency_ms",
            Help:    "群送处理延迟",
            Buckets: []float64{10, 50, 100, 200, 500, 1000, 2000},
        },
        []string{"stage"},
    )

    animationFPS = prometheus.NewGauge(prometheus.GaugeOpts{
        Name: "group_gift_animation_fps",
        Help: "动画帧率",
    })

    broadcastDeliveryRate = prometheus.NewGauge(prometheus.GaugeOpts{
        Name: "global_broadcast_delivery_rate",
        Help: "全局广播推送成功率",
    })
)

5.2 告警规则

# Prometheus告警规则
groups:
  - name: group_gift_alerts
    rules:
      - alert: GroupGiftLowSuccessRate
        expr: group_gift_success_rate < 0.95
        for: 5m
        labels:
          severity: critical
        annotations:
          summary: "群送成功率低于95%"

      - alert: GroupGiftHighLatency
        expr: histogram_quantile(0.99, group_gift_latency_ms) > 500
        for: 10m
        labels:
          severity: warning
        annotations:
          summary: "群送P99延迟过高"

六、技术难点总结

6.1 分布式事务一致性

挑战:

  • 扣款、创建记录、增加收入必须原子化

  • 涉及多个用户的余额变动

  • 需要保证要么全部成功,要么全部失败

解决方案:

  • 使用MySQL事务 + SELECT FOR UPDATE

  • Redis分布式锁防止并发

  • 幂等性设计(request_id去重)

6.2 高并发性能优化

挑战:

  • 高峰期QPS较高

  • 单次群送涉及多人多次连击的计算

  • 数据库、Redis、Kafka都面临压力

解决方案:

  • 数据库:分库分表 + 索引优化

  • Redis:本地缓存 + Redis两级缓存

  • Kafka:消息批量发送

  • 前端:CSS Transform代替Canvas绘制

6.3 动画性能与用户体验

挑战:

  • 多个礼物同时飞行 + 多个特效同时播放

  • 低端机型性能不足

  • 需要保持高帧率

解决方案:

  • 使用CSS Transform(GPU加速)

  • 特效分批播放(限制同时播放数量)

  • 连击合并(时间窗口内合并动画)

6.4 数据一致性问题

挑战:

  • MySQL主从延迟导致收入显示不一致

  • PK结束后麦位变化导致礼物飞向错误的人

  • 全局广播消息可能丢失或延迟

解决方案:

  • Redis缓存兜底(保证实时性)

  • 消息中携带接收者快照(避免麦位变化)

  • 消息去重合并 + 分组推送(降低风暴压力)

七、总结

群送礼物系统从原型到生产级系统的演进过程中,积累了丰富的技术经验:

  • 系统设计:在高并发、强一致性场景下,需要充分考虑边界条件和极端场景

  • 性能优化:通过分库分表、多级缓存、批量处理等手段提升系统性能

  • 用户体验:前端动画优化对用户体验影响显著,需要在视觉效果和性能之间找到平衡

  • 监控告警:完善的监控体系是生产环境稳定性的保障

技术的价值在于解决实际业务问题,通过不断迭代优化,最终实现业务目标和技术目标的双赢。

最后更新:

相关素材

哈利法塔·鎏金盛典战神降临斋月灯笼