Group Gifting in Multi-User Voice Rooms: Architecture and Implementation

This article explains the architecture of a group gifting system for multi-user voice rooms, covering multi-recipient message delivery, independent effect playback, concurrency control, idempotency, performance optimization, and production deployment.

This article documents the complete technical evolution of a group gift system from prototype to production-grade in multi-user voice room scenarios, including architecture design, performance optimization, and production environment problem-solving solutions.

1. Business Scenario Analysis

1.1 Group Gift Feature Definition

Traditional Single Gifting: User clicks a gift, sends it to one streamer, plays one animation.

Group Gifting: User clicks once, simultaneously sends to all streamers on mic, gifts scatter from sender's avatar to all receivers, each plays independent gift animation.

1.2 Core Application Scenarios

Scenario 1: Multi-User Voice Room (6/9 mic positions)

┌─────────────────────────────┐
│  Party Room - 6 Mic Layout  │
├─────────────────────────────┤
│  [Host]    [Guest1] [Guest2]│
│  [UserA]   [UserB]  [UserC] │
│                              │
│  [Audience: UserX]           │
│  └─ Clicks "Group Send Rose"│
└─────────────────────────────┘

UserX clicks once → 6 roses fly to 6 mic positions simultaneously

Business Logic Points:

  • Only send to on-mic users (on_mic status), audience seats don't count

  • Empty mic positions don't charge (only charge for occupied seats)

  • Supports combos: 10 consecutive clicks = 60 roses scattering

Scenario 2: PK Battle (3-4 people per side)

┌──────────┬──────────┐
│  Red     │   Blue   │
├──────────┼──────────┤
│ [Host A] │ [Host B] │
│ [Help 1] │ [Help 1] │
│ [Help 2] │ [Help 2] │
└──────────┴──────────┘

User group sends to Red → Only scatters to Red's 3 people
User group sends to Blue → Only scatters to Blue's 3 people

Special Rules:

  • During PK, group sending can only choose one faction

  • Gift value counts toward corresponding faction's PK score

  • Red audience cannot group send to Blue (business rule)

Scenario 3: Global Broadcast Gifts

UserA sends "Super Rocket" in room 001
↓
All online users platform-wide see top banner:
"UserA sent Super Rocket x 6 people in Party Room 001"

Technical Challenges:

  • Large number of online users receive push simultaneously

  • Message size requirement small (<1KB)

  • High delivery timeliness requirement (95%+ delivery rate within 3 seconds)

1.3 Core Metrics After System Launch

Business Metrics:

  • Group gift % of total revenue: Significant increase

  • Multi-user voice room ARPU: Substantial growth

  • Average group send size: 5.2 people

  • Gift send success rate: 99.7%

Technical Metrics:

  • Animation rendering FPS: 55-60 FPS

  • Group send API P99 latency: 180ms

  • Global broadcast delivery rate: 96.3%

2. Architecture Design Evolution

2.1 V1: Loop Sending Approach

The initial approach adopted a simple loop sending implementation: frontend clicks group send button, backend loops through each on-mic user sending single gifts.

// V1 Frontend Code Example
async function sendGroupGift(giftId, micUsers) {
  for (let user of micUsers) {
    await sendSingleGift(giftId, user.userId);
  }
  showSuccessToast('Group send successful');
}

Problems Exposed After System Launch:

Problem 1: Users Charged Multiple Times

  • Symptom: User clicked group send, network fluctuation triggered 2 requests

  • Consequence: Received 2 requests, sent double gifts

  • Impact: User complained about being overcharged

Problem 2: Partial Success Caused Accounting Chaos

  • Symptom: Group send to 6 people, 3rd person send failed

  • Consequence: First 2 already received gifts and commission, but transaction incomplete

  • Impact: User demands refund, but funds already partially distributed

Problem 3: Animations Not Synchronized

  • Symptom: 6 gifts sent one by one, animations also played sequentially

  • Consequence: Visual effect appeared as "laggy sequential sending" rather than "simultaneous scattering"

  • Impact: User experience didn't meet expectations

2.2 V2: Atomic Transactions + Batch Push

Addressing V1 issues, the system architecture was redesigned.

Core Design Approach

  • Group send as independent business: Not multiple single sends, but one group send transaction

  • Atomic deduction: Either all succeed or all fail

  • Batch push: Backend pushes all gift messages at once

  • Frontend coordinates animation: After receiving message, plays all scatter animations simultaneously

System Architecture Diagram

┌──────────┐    1.Initiate    ┌──────────────┐
│  Client  │ ───────────────> │ API Gateway  │
└──────────┘                  └──────────────┘
                                     │
                                     ↓
                            2.Idempotency+Deduct
                            ┌──────────────┐
                            │ Gift Service │
                            └──────────────┘
                                     │
                  ┌──────────────────┼──────────────────┐
                  ↓                  ↓                  ↓
          3.Write Record      4.Update Income    5.Send Message
       ┌───────────────┐   ┌───────────────┐  ┌────────────┐
       │ gift_records  │   │ user_balance  │  │   Kafka    │
       │   (MySQL)     │   │    (MySQL)    │  │  (Queue)   │
       └───────────────┘   └───────────────┘  └────────────┘
                                                     │
                    ┌────────────────────────────────┼────────┐
                    ↓                                ↓        ↓
            6.Room Push                      7.Global   8.Analytics
       ┌─────────────────┐            ┌────────────┐  ┌──────┐
       │ WebSocket Push  │            │   Notify   │  │ Stats│
       └─────────────────┘            └────────────┘  └──────┘

Database Design

-- Group gift records table
CREATE TABLE `group_gift_records` (
  `id` BIGINT UNSIGNED PRIMARY KEY AUTO_INCREMENT,
  `request_id` VARCHAR(64) NOT NULL COMMENT 'Request ID (idempotency)',
  `sender_id` BIGINT NOT NULL COMMENT 'Sender ID',
  `room_id` BIGINT NOT NULL COMMENT 'Room ID',
  `gift_id` INT NOT NULL COMMENT 'Gift ID',
  `gift_price` INT NOT NULL COMMENT 'Unit price',
  `receiver_count` TINYINT NOT NULL COMMENT 'Receiver count',
  `total_cost` INT NOT NULL COMMENT 'Total cost',
  `receiver_ids` JSON NOT NULL COMMENT 'Receiver ID list',
  `status` TINYINT NOT NULL DEFAULT 0 COMMENT '0:processing 1:success 2:failed',
  `combo_count` INT NOT NULL DEFAULT 1 COMMENT 'Combo count',
  `pk_side` TINYINT COMMENT 'PK faction',
  `is_broadcast` TINYINT NOT NULL DEFAULT 0 COMMENT 'Global broadcast',
  `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;

-- Gift receiver detail table (sharded)
CREATE TABLE `group_gift_details` (
  `id` BIGINT UNSIGNED PRIMARY KEY AUTO_INCREMENT,
  `group_gift_id` BIGINT NOT NULL COMMENT 'Group gift record ID',
  `receiver_id` BIGINT NOT NULL COMMENT 'Receiver ID',
  `gift_id` INT NOT NULL COMMENT 'Gift ID',
  `gift_count` INT NOT NULL COMMENT 'Gift count',
  `income_diamonds` INT NOT NULL COMMENT 'Income diamonds',
  `income_cash` DECIMAL(10,2) NOT NULL COMMENT 'Income cash',
  `split_ratio` DECIMAL(5,2) NOT NULL COMMENT 'Split ratio',
  `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;

2.3 Frontend Animation Implementation: Bézier Curve Flight

The core visual effect of group gifting is the "scatter flight animation" where gifts fly from sender's avatar to all receiver avatars.

Animation Design Approach

Sender position: (x0, y0)
Receiver positions: [(x1,y1), (x2,y2), ..., (xn,yn)]

Each flight path:
1. Start: Sender avatar center
2. Control point: Offset point in path middle (creates arc effect)
3. End: Receiver avatar center
4. Flight duration: 800ms
5. On arrival: Play SVGA gift effect

Bézier Curve Implementation

// Group gift animation manager
class GroupGiftAnimator {
  private canvas: HTMLCanvasElement;
  private ctx: CanvasRenderingContext2D;
  private animations: GiftAnimation[] = [];

  // Start group gift animation
  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);

      // Create flight animation for each receiver
      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();
  }

  // Calculate control point offset (creates scatter effect)
  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
    };
  }

  // Quadratic Bézier curve formula
  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 };
  }
}

Mobile Performance Optimization

After system launch, mid-low-end Android phones dropped frames (below 30 FPS) during 6-person group sends.

Root Cause Analysis:

  • Canvas clears and redraws all animations every frame

  • 6 SVGA effects playing simultaneously consume massive GPU

  • Low-end phones lack hardware acceleration

Optimization Approach:

// Use CSS Transform instead of Canvas drawing
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);
    });
  }
}

Optimization Results:

  • Post-optimization FPS stable at 55-60 FPS

  • Low-end phones can also run smoothly

3. Production Environment Issues and Solutions

3.1 Concurrent Gifting Caused Overdrafts

Problem Symptom:

  • User balance insufficient to pay for two group sends

  • Rapid clicking caused two requests both passed balance check

  • User's final balance became negative (overdraft)

Technical Cause:

// Problem code
func (s *GroupGiftService) SendGroupGift(req *GroupGiftRequest) error {
    balance := s.GetUserBalance(req.SenderID)
    if balance < req.TotalCost {
        return errors.New("Insufficient balance")
    }

    // ⚠️ Concurrency issue: two requests both passed balance check

    s.DeductBalance(req.SenderID, req.TotalCost)
}

Solution: Redis Distributed Lock + Database Pessimistic Lock

func (s *GroupGiftService) SendGroupGift(req *GroupGiftRequest) error {
    // 1. Redis distributed lock (prevent duplicate submission)
    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("Do not submit repeatedly")
    }
    defer lock.Release()

    // 2. Database transaction + pessimistic lock
    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("Insufficient balance")
    }

    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 Wrong Payouts After PK Ended

Problem Symptom:

  • User group sends to Red team in PK room

  • PK ends during gift flight

  • On arrival, mic positions changed, gifts went to wrong people

Technical Cause:

  • Frontend initiated request, mic position state was A

  • Backend processed, mic position state still A

  • When message pushed to frontend (1 second later), mic position state became B

  • Frontend used current mic positions to play animation, gifts flew to wrong people

Solution: Include Receiver Snapshot in Message

// Backend message structure
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"`

    // Receiver snapshot (includes seat info)
    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"` // Seat position at send time
}

3.3 Global Broadcast Message Storm

Problem Symptom:

  • Major streamer online with large concurrent audience

  • User consecutively group sends expensive gifts multiple times

  • Triggered multiple global broadcasts, pushed to platform-wide users

  • WebSocket server CPU spiked, many connections dropped

Technical Cause:

  • Each broadcast pushes to all users

  • Multiple broadcasts produce massive pushes

  • No message merging or rate limiting mechanism

Solution 1: Message Aggregation

// Broadcast message deduplication and merging
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()
        }
    }()
}

Optimization Results:

  • Message aggregation rate: 72%

  • Push duration: from instant spike → spread over 5 seconds

  • WebSocket server CPU: significant decrease

3.4 MySQL Master-Slave Lag Caused Income Inconsistency

Problem Symptom:

  • Streamer receives group gift, checks income shows certain value

  • Refreshes page, value changes

  • Refreshes again, back to original value

Technical Cause:

  • Write operations on master, read operations on slave

  • Master-slave lag 200-500ms

  • User refreshes immediately after receiving gift, slave data not synced yet

Solution: Redis Cache Failover

func (s *GroupGiftService) SendGroupGift(req *GroupGiftRequest) error {
    // 1. Write to MySQL master
    tx := s.masterDB.Begin()
    tx.Commit()

    // 2. Synchronously write to Redis (ensure real-time)
    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
}

// Query income prioritizes 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
}

Optimization Results:

  • Income query consistency: 99.9%

  • User complaints significantly decreased

4. Performance Optimization

4.1 Database Optimization

Problem: Detail Table Data Explosion

After running for some time, group_gift_details table reached tens of millions of rows, single table queries became slow.

Optimization Approach: Database Sharding

-- Shard into 10 tables by receiver ID
CREATE TABLE `group_gift_details_0` (...);
CREATE TABLE `group_gift_details_1` (...);
...
CREATE TABLE `group_gift_details_9` (...);

-- Routing rule: receiver_id % 10

Index Optimization

-- Before optimization: Query takes longer
SELECT * FROM group_gift_details
WHERE receiver_id = 123456
ORDER BY created_at DESC
LIMIT 20;

-- Add composite index
ALTER TABLE group_gift_details_0
ADD INDEX idx_receiver_created (receiver_id, created_at);

-- After optimization: Query time significantly reduced

4.2 Redis Optimization

Problem: Hot Key Caused Single Point Bottleneck

Popular room gift record keys accessed at high frequency, single Redis instance QPS too high.

Optimization Approach: Local Cache + Redis Two-Tier Caching

type GiftCache struct {
    localCache *lru.Cache
    redis      *redis.Client
}

func (c *GiftCache) GetGiftInfo(giftID int) (*GiftInfo, error) {
    // 1. Check local cache first
    if val, ok := c.localCache.Get(giftID); ok {
        return val.(*GiftInfo), nil
    }

    // 2. Check 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. Check database
    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
}

Optimization Results:

  • Redis QPS significantly decreased

  • Local cache hit rate high

  • Average response time significantly reduced

4.3 Kafka Message Optimization

Problem: Message Backlog Under High Concurrency

During peak hours, group gift messages backed up severely, push latency high.

Optimization Approach: Batch Sending

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

Optimization Results:

  • Kafka throughput increased

  • Push latency decreased

5. Monitoring and Alerting System

5.1 Core Monitoring Metrics

// Prometheus metric definitions
var (
    groupGiftSuccessRate = prometheus.NewGaugeVec(
        prometheus.GaugeOpts{
            Name: "group_gift_success_rate",
            Help: "Group gift success rate",
        },
        []string{"room_type"},
    )

    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"},
    )

    animationFPS = prometheus.NewGauge(prometheus.GaugeOpts{
        Name: "group_gift_animation_fps",
        Help: "Animation FPS",
    })

    broadcastDeliveryRate = prometheus.NewGauge(prometheus.GaugeOpts{
        Name: "global_broadcast_delivery_rate",
        Help: "Global broadcast delivery rate",
    })
)

5.2 Alert Rules

# Prometheus alert rules
groups:
  - name: group_gift_alerts
    rules:
      - alert: GroupGiftLowSuccessRate
        expr: group_gift_success_rate < 0.95
        for: 5m
        labels:
          severity: critical
        annotations:
          summary: "Group gift success rate below 95%"

      - alert: GroupGiftHighLatency
        expr: histogram_quantile(0.99, group_gift_latency_ms) > 500
        for: 10m
        labels:
          severity: warning
        annotations:
          summary: "Group gift P99 latency too high"

6. Technical Challenges Summary

6.1 Distributed Transaction Consistency

Challenge:

  • Deduction, record creation, income addition must be atomic

  • Involves multiple users' balance changes

  • Must ensure either all succeed or all fail

Solution:

  • Use MySQL transactions + SELECT FOR UPDATE

  • Redis distributed locks prevent concurrency

  • Idempotency design (request_id deduplication)

6.2 High Concurrency Performance Optimization

Challenge:

  • High QPS during peak periods

  • Single group send involves multi-person multi-combo calculations

  • Database, Redis, Kafka all under pressure

Solution:

  • Database: Sharding + index optimization

  • Redis: Local cache + Redis two-tier caching

  • Kafka: Batch message sending

  • Frontend: CSS Transform instead of Canvas drawing

6.3 Animation Performance and User Experience

Challenge:

  • Multiple gifts flying simultaneously + multiple effects playing simultaneously

  • Low-end phones lack performance

  • Need to maintain high frame rate

Solution:

  • Use CSS Transform (GPU acceleration)

  • Effects play in batches (limit simultaneous playback count)

  • Combo merging (merge animations within time window)

6.4 Data Consistency Issues

Challenge:

  • MySQL master-slave lag caused income display inconsistency

  • Mic position changes after PK ended caused gifts to fly to wrong people

  • Global broadcast messages might be lost or delayed

Solution:

  • Redis cache failover (ensure real-time)

  • Message includes receiver snapshot (avoid mic position changes)

  • Message deduplication and merging + group push (reduce storm pressure)

7. Summary

The evolution of the group gift system from prototype to production-grade accumulated rich technical experience:

  • System Design: In high-concurrency, strong-consistency scenarios, must fully consider edge cases and extreme scenarios

  • Performance Optimization: Improve system performance through database sharding, multi-tier caching, batch processing, and other methods

  • User Experience: Frontend animation optimization significantly impacts user experience, need to find balance between visual effects and performance

  • Monitoring and Alerting: Complete monitoring system is the guarantee of production environment stability

The value of technology lies in solving real business problems. Through continuous iteration and optimization, ultimately achieving a win-win for business and technical objectives.

Last updated:

Related assets

Burj Khalifa · Gilded GalaGod of War DescendsRamadan Lantern