Live Gift Backend Architecture: Message Push, Deduplication, Idempotency, and Distributed Locks

Explore the backend architecture behind live-streaming gifts, including message delivery, duplicate-charge prevention, idempotent playback, distributed locks, and combo batching.

The first four posts covered the client side — runtime optimization for low-end phones, asset size reduction, iOS integration, and quality tiering. But the full gift effect pipeline starts when a user taps “send gift” and ends when the animation finishes playing on-screen. Between those two events sits an entire backend system.

This post covers the server-side architecture for live streaming gifts: how messages reach the room, how to prevent duplicate charges, how to guarantee idempotent playback, and how distributed locks enable combo batching.

1. Message push architecture

20260904153513_d7a3a9.png

Gift effect example: Castle Secrets

User A sends a gift. The streamer and all other viewers need to see it in real time. The core requirement: low latency + high concurrency.

Typical architecture: long-connection gateway + message queue + push service.

[Client] --WebSocket--> [Gateway] --MQ--> [Push Service] --fanout--> [All clients in room]

Long-connection gateway maintains a userId -> connection map. It only handles keepalive and message forwarding, no business logic.

Message queue (Kafka / Pulsar) absorbs traffic spikes and decouples layers. After a user sends a gift, the business service writes a message to the queue; the push service subscribes and consumes.

Push service consumes gift messages from MQ, queries the list of online users in the room (Redis sorted set, score = heartbeat timestamp), and batches delivery to the gateway.

Message format:

{
  "msgId": "gift_1234567890_001",
  "roomId": "room_9527",
  "senderId": "user_A",
  "giftId": "gift_aurora_town",
  "giftCount": 1,
  "timestamp": 1735689600000,
  "seq": 12345
}

msgId is globally unique for deduplication. seq is monotonically increasing for client-side out-of-order detection and reordering.

2. Deduplication: prevent duplicate charges

Gifts involve payment. Double-charging is a red line. Common triggers: user double-taps the button, network timeout triggers a retry, business layer consumes the same MQ message twice.

Approach 1: client-generated requestId + Redis dedup

The client generates a unique requestId (UUID) on each tap. The server uses Redis SET NX:

String key = "gift:dedup:" + requestId;
Boolean success = redis.setIfAbsent(key, "1", 10, TimeUnit.SECONDS);
if (!success) return Result.error("Duplicate request")

10-second TTL covers the typical request lifespan.

Approach 2: database unique index as fallback

Add a unique index (userId, requestId) to the gift order table. Insert conflicts return “already processed”:

INSERT INTO gift_order (user_id, request_id, gift_id, amount, created_at)
VALUES (?, ?, ?, ?, NOW())
ON DUPLICATE KEY UPDATE updated_at = NOW();

Database-level dedup ensures no double charge even if Redis fails.

3. Idempotency: prevent duplicate playback

After preventing duplicate charges, the push side must also guarantee idempotency — the same message can’t play twice on the client.

Client-side idempotency check

The client maintains Set<String> playedMsgIds. On receiving a message:

if (playedMsgIds.contains(msg.msgId)) return;
playedMsgIds.add(msg.msgId);
playQueue.offer(msg);

Server-side dedup table

Before pushing, the push service writes to a Redis dedup table:

String key = "gift:push:" + roomId + ":" + msgId;
Boolean pushed = redis.setIfAbsent(key, "1", 60, TimeUnit.SECONDS);
if (!pushed) return;

60-second TTL covers the typical message lifespan in the room.

4. Distributed lock: combo batching

20260904153513_6bc8d4.png

Gift effect example: Cloud Whale Dream

User taps send gift 10 times in 3 seconds. Pushing 10 separate messages would spam the room. Better: batch them into one giftCount=10 message.

Scenario: multiple push servers handle the same user’s combo simultaneously

User A taps 5 times in 1 second. MQ distributes these 5 messages across different consumers (push service instances). Without a lock, 5 independent messages get pushed.

Distributed lock implementation:

String lockKey = "gift:batch:" + roomId + ":" + senderId + ":" + giftId;
RLock lock = redisson.getLock(lockKey);
try {
    if (lock.tryLock(100, 3000, TimeUnit.MILLISECONDS)) {
        String countKey = "gift:batch:count:" + roomId + ":" + senderId + ":" + giftId;
        Long currentCount = redis.increment(countKey, msg.giftCount);
        redis.expire(countKey, 3, TimeUnit.SECONDS);        if (currentCount >= 10 || isWindowExpired(countKey)) {
            pushBatchedGift(roomId, senderId, giftId, currentCount);
            redis.delete(countKey);
        }
    }
} finally {
    lock.unlock();
}

Key points:

Lock granularity: roomId + senderId + giftId — only locks combos from the same user for the same gift.

Window counting: Redis INCR for atomic increment, EXPIRE for 3-second TTL.

Threshold trigger: push when count reaches 10 or window expires.

5. Message ordering

Gift messages must arrive in send order. If a later gift displays first, the experience breaks.

Kafka partition ordering

Kafka guarantees order within a partition. Route all messages for the same room to the same partition:

ProducerRecord<String, GiftMsg> record = new ProducerRecord<>(
    "gift_topic", msg.roomId, msg  // key = roomId
);
producer.send(record);

Client-side out-of-order detection and reordering

The client maintains expectedSeq. On receiving a message:

if (msg.seq == expectedSeq) {
    play(msg);
    expectedSeq++;
    while (buffer.containsKey(expectedSeq)) {
        play(buffer.remove(expectedSeq));
        expectedSeq++;
    }
} else if (msg.seq > expectedSeq) {
    buffer.put(msg.seq, msg);  // buffer for later
}

Buffer has a size cap (e.g. 50) to prevent memory overflow.

6. Message expiration and discard

After switching rooms or reconnecting, users should not receive gift messages from minutes ago.

Server-side expiration check:

if (System.currentTimeMillis() - msg.timestamp > 10_000) return;

Client reconnect seq alignment:

Client reports lastSeq on reconnect. Server only pushes seq > lastSeq messages.

7. Distributed transaction: payment and push consistency

20260904153514_70e4db.png

Gift effect example: Starlight Princess

Sending a gift involves two steps: charge (write order table) and push message. If the charge succeeds but the push fails, the user paid but the streamer didn’t see it.

Approach 1: local message table + scheduled retry

During payment, also insert into a local message table:

BEGIN;
INSERT INTO gift_order (...);
INSERT INTO outbox_message (msg_id, payload, status) VALUES (?, ?, 'PENDING');
COMMIT;

Push service polls outbox_message, retries PENDING messages, marks SENT on success.

Approach 2: transactional messages (RocketMQ / Pulsar)

RocketMQ supports transactional messages. Send a half-message (not visible), commit it after the local transaction succeeds. If the local transaction rolls back, the half-message auto-deletes.

Wrapping up

Six core problems in live gift backend architecture:

Message push: long-connection gateway + MQ + push service for low latency and high concurrency.

Deduplication: client requestId + Redis SET NX + database unique index.

Idempotency: client playedMsgIds set + server-side push dedup table.

Combo batching: distributed lock + time-window counting to prevent spam.

Ordering: Kafka partition routing + client-side seq check and reordering.

Consistency: local message table + scheduled retry, or transactional messages.

Together with the four client-side posts, this system covers the full gift effect pipeline — from user tap to animation playback.

Next up: gift queuing and concurrency control — how the client handles multiple simultaneous gifts with queueing, batching, and graceful dropping to keep the stream smooth.

Last updated:

Related assets

Leaderboard Gifting B1 Avatar FrameSecrets of the Ancient CastleCloud Whale Drifts into a DreamStarlight Princess