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

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

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

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:




