直播礼物的队列与并发控制:多人刷屏送礼时,客户端如何排队、合批、丢弃
多人同时刷礼物时,客户端如何避免特效打断、内存暴涨与低端机卡顿?本文拆解直播礼物消息的排队、合批、优先级与丢弃策略,帮助开发者在保证热闹氛围的同时提升播放稳定性。

前几篇拆完了客户端运行时优化、资源分发预加载,以及后端的消息推送 、去重、幂等。这些解决的是「一条礼物消息如何可靠地产生、到达、就位」。但真实直播间里,礼物从来不是一条一条来的——热门主播的 PK 时刻,几百个用户在同一秒疯狂刷屏,连击、全屏大礼物、小飘屏礼物混在一起涌向客户端。
如果收到一条就播一条,结果只有一个:特效疯狂打断、内存爆炸、低端机直接卡死。这篇拆客户端侧的队列与并发控制——礼物消息到端之后,如何排队、如何合批、什么时候该果断丢弃,既保证直播间热闹,又不让端上崩掉。

登天云阶:场景级全屏大特效独占屏幕,一次只能播一个,是串行队列要解决的典型场景(星集礼物商城)
一、为什么必须有队列
先想清楚不加队列会发生什么。假设直播间高峰期,客户端每秒收到 50 条礼物消息,其中混着全屏大礼物(火箭、跑车)、连击小礼物(小心心 ×N)、以及普通飘屏礼物。
如果来一条就立刻渲染:
全屏特效互相打断:第一个火箭动效刚播 0.3 秒,第二个又来了,画面疯狂闪切,用户什么都看不清;
内存瞬间爆炸:50 个特效实例同时创建,SVGA/VAP 解码器、纹理、位图全堆在内存里,低端机直接 OOM;
主线程卡死:每条消息都触发一次 UI 布局和渲染,主线程被打满,直播画面卡顿掉帧。
所以客户端必须有一层「缓冲 + 调度」,把汹涌的消息流,变成有节奏、可控的播放序列。这就是队列的价值。
二、分通道队列:不同礼物走不同的路
第一个关键设计:不要用一个大队列装所有礼物。不同类 型的礼物,播放规则完全不同,硬塞在一个队列里只会互相干扰。
按展示形态分成三条独立通道:
————————————————
public class GiftDispatcher {
// 全屏大特效:独占屏幕,一次只能播一个,串行
private final SerialGiftChannel fullScreenChannel;
// 飘屏小礼物:可并行,但限制同屏数量
private final ParallelGiftChannel bannerChannel;
// 连击礼物:单独处理,靠合并而非排队
private final ComboGiftChannel comboChannel;
public void onGiftReceived(GiftMessage msg) {
switch (msg.getDisplayType()) {
case FULL_SCREEN:
fullScreenChannel.enqueue(msg);
break;
case BANNER:
bannerChannel.enqueue(msg);
break;
case COMBO:
comboChannel.accept(msg);
break;
}
}
}三条通道各管各的:
全屏通道:火箭、跑车、星球这类独占全屏的大特效,必须串行,一个播完再播下一个;
飘屏通道:普通礼物从屏幕一侧飘过,可以并行,但要限制同屏最大数量;
连击通道:同一用户对同一礼物的连击,靠合并成一个跳动的数字,而不是排队播 N 次。

富豪出场:高价值全屏入场礼物,必须靠优先级队列插队优先播,不能被廉价礼物挤到几分钟后(星集礼物商城)
三、全屏通道:串行队列 + 优先级 + 最大等待
全屏特效是最稀缺的资源——同一时刻屏幕上只能有一个。所以它的核心是一个带优先级的串行队列。
优先级排序
高价值礼物(大哥送的火箭)不能被一堆廉价礼物挤在后面几分钟才播,那对付费用户是致命的体验。用优先级队列,按礼物价值排序:
public class SerialGiftChannel {
private final PriorityBlockingQueue<GiftMessage> queue =
new PriorityBlockingQueue<>(64, (a, b) -> {
// 价值高的优先;同价值按到达时间先后
if (a.getPrice() != b.getPrice()) {
return Integer.compare(b.getPrice(), a.getPrice());
}
return Long.compare(a.getArriveTime(), b.getArriveTime());
});
private volatile boolean playing = false;
public void enqueue(GiftMessage msg) {
msg.setArriveTime(System.currentTimeMillis());
queue.offer(msg);
tryPlayNext();
}
private synchronized void tryPlayNext() {
if (playing || queue.isEmpty()) return;
GiftMessage next = queue.poll();
playing = true;
playFullScreen(next, () -> {
// 播放结束回调:释放资源,播下一个
playing = false;
tryPlayNext();
});
}
}最大等待时长:过期就丢
高峰期队列可能积压几十个全屏礼物。如果每个都排队播完,最后一个可能要等好几分钟——那时候用户早就划走了,播了也没意义。
给每条消息设一个最大等待时长,排队超时直接丢弃:
private synchronized void tryPlayNext() {
if (playing) return;
long now = System.currentTimeMillis();
GiftMessage next;
// 丢弃已经等待超时的礼物
while ((next = queue.poll()) != null) {
if (now - next.getArriveTime() > MAX_WAIT_MS) { // 如 15 秒
logDiscard(next, "wait_timeout");
continue;
}
break;
}
if (next == null) return;
playing = true;
playFullScreen(next, () -> {
playing = false;
tryPlayNext();
});
}这里有个权衡:贵重礼物即使超时也不能丢。可以给顶级礼物豁免超时,或者放大它们的等待上限,保证付费体验。
四、飘屏通道:并行 + 同屏数量限制
飘屏礼物(普通礼物从屏幕边缘飘过)可以多个同时存在,但不能无限。同屏几十个飘屏一起动,画面糊成一片,GPU 也扛不住。
用一个信号量控制并发数:
————————————————
public class ParallelGiftChannel {
private static final int MAX_CONCURRENT = 5; // 同屏最多 5 个飘屏
private final Semaphore slots = new Semaphore(MAX_CONCURRENT);
private final Queue<GiftMessage> waiting = new ConcurrentLinkedQueue<>();
public void enqueue(GiftMessage msg) {
waiting.offer(msg);
drain();
}
private void drain() {
while (slots.tryAcquire()) {
GiftMessage msg = waiting.poll();
if (msg == null) {
slots.release(); // 没有等待的了,归还许可
return;
}
playBanner(msg, () -> {
slots.release(); // 一个飘屏播完,释放槽位
drain(); // 尝试播等待队列里的下一个
});
}
}
}等待队列也要设上限,超过就丢最老的飘屏礼物——飘屏本身价值低,丢了对体验影响小。

双子座永恒项链:低价高频礼物,靠合并成一个跳动数字,而不是叠 N 个动画(星集礼物商城)
五、连击通道:合并,而不是排队
连击(combo)是客户端最容易被打爆的场景。一个用户狂点小心心,一秒钟几十条连击消息涌来。如果每条都入队渲染,队列瞬间爆炸。
处理连击的核心是合并:相同 senderId + giftId + comboId 的消息,合并成同一个连击特效,只维护一个跳动的数字(×1 → ×2 → ×66),而不是叠 66 个动画。
————————————————
public class ComboGiftChannel {
// key = senderId + giftId + comboId
private final Map<String, ComboView> activeCombos = new ConcurrentHashMap<>();
public void accept(GiftMessage msg) {
String key = msg.getSenderId() + "_" + msg.getGiftId() + "_" + msg.getComboId();
ComboView view = activeCombos.computeIfAbsent(key, k -> {
ComboView v = new ComboView(msg);
v.show(); // 首次出现,创建连击气泡
return v;
});
// 已存在则只更新数字,不新建动画
view.updateCount(msg.getComboCount());
view.resetExpireTimer(COMBO_IDLE_MS); // 如 3 秒没有新连击就收起
}
}配合两个技巧:
时间窗口聚合:200ms 内的同批连击攒一起,统一更新数字,而不是每条都刷一次 UI;
防抖驱动动画:连击数字的跳动动画用防抖节流,避免高频重绘打满主线程。
一句话:连击在视觉上是一个持续跳动的特效,在数据上是一串消息的聚合,千万别一一对应。

鎏金羽梦:全站最贵最长的顶级特效,背压丢弃时必须优先保护,宁可丢一百个廉价礼物(星集礼物商城)
六、背压与丢弃策略:扛不住时怎么优雅降级
即便分了通道,极端刷屏下消息产生的速度仍可能超过客户端消费的速度。这时候必须有背压(backpressure)机制,主动丢弃,而不是硬扛到崩溃。
分级丢弃原则,按价值从低到高保护:
public void onGiftReceived(GiftMessage msg) {
// 队列积压超过阈值,触发丢弃
if (totalPending() > BACKPRESSURE_THRESHOLD) {
if (msg.getPrice() < LOW_VALUE_THRESHOLD) {
// 低价值礼物直接丢,只保留一个聚合计数
aggregateDiscardCount(msg);
return;
}
// 高价值礼物:清理飘屏通道给它让路
bannerChannel.dropOldest();
}
dispatch(msg);
}低价值礼物(小心心、点赞):积压时直接丢,可以用一个“刚刚还有 99+ 人送出小心心”的聚合提示代替;
中价值礼物:限流,超过同屏上限进等待,等待超时丢;
高价值礼物:永远优先,必要时清理低价值特效给它腾资源。
核心原则:丢弃是必然的,关键是丢对的东西。宁可丢一百个小心心,也不能让一个火箭卡住或丢失。
七、生命周期 与内存:播完必须干净释放
队列跑起来之后,最容易埋雷的是内存。特效播完如果不彻底释放,长时间挂在直播间的用户几十分钟后必定 OOM。
每个特效播放结束,回调里必须做四件事:
————————————————
private void onEffectFinished(GiftView view) {
view.removeFromParent(); // 1. 移除视图
view.releasePlayer(); // 2. 归还播放器实例到对象池
view.recycleBitmaps(); // 3. 释放解码位图/纹理
view.clearListeners(); // 4. 解绑所有监听,防止泄漏
}再配合播放器对象池,避免高频 new/destroy 抖动:
public class PlayerPool {
private final Queue<SvgaPlayer> idle = new ConcurrentLinkedQueue<>();
public SvgaPlayer acquire() {
SvgaPlayer p = idle.poll();
return p != null ? p : new SvgaPlayer();
}
public void release(SvgaPlayer p) {
p.reset(); // 清空当前动画状态
idle.offer(p); // 归还池中复用
}
}上线前务必做一轮内存泄漏测试:直播间挂机 + 持续刷礼物半小时,盯内存曲线,只要持续上涨不回落,就是有特效没释放干净。
八、上线后要盯的指标
队列与并发控制的效果,靠数据验证:
礼物渲染成功率 = 成功上屏 / 收到的有效礼物消息,核心体验指标;
丢弃率(按礼物价值分层):低价值丢弃可以接受,高价值丢弃率必须趋近于 0,一旦升高立即报警;
连击合并率 = 合并后特效数 / 原始连击消息数,反映连击处理是否有效;
全屏队列平均等待时长:过长说明高峰期积压严重,要考虑更激进的超时丢弃;
直播间内存增长曲线:间接反映释放是否干净。
————————————————
void trackGiftResult(GiftMessage msg, String result) {
tracker.track("gift_render", Map.of(
"giftId", msg.getGiftId(),
"price", msg.getPrice(),
"result", result, // rendered / discarded / merged
"channel", msg.getDisplayType(),
"queueWait", msg.getQueueWaitMs()));
}总结
多人刷屏送礼时,客户端队列与并发控制的核心设计:
分通道:全屏串行、飘屏并行、连击合并,不同礼物走不同的路;
全屏通道:优先级队列 + 最大等待超时,贵重礼物优先且不丢;
飘屏通道:信号量控制同屏并发,等待队列设上限;
连击通道:合并成一个跳动数字,时间窗口聚合 + 防抖驱动;
背压丢弃:积压时按价值分级丢弃,丢对的东西;
生命周期:播完四步清理 + 播放器对象池,严防 OOM。
一句话贯穿始终:礼物流是不可控的,但客户端的播放节奏必须可控。队列的本质,就是在汹涌的消息和有限的端上资源之间,加一层能排序、能合并、能丢弃的缓冲。
至此,礼物特效从后端产生、CDN 分发、客户端就位、到最终有节奏地播放,全链路闭环。
最后更新:



