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:



