直播礼物的队列与并发控制:多人刷屏送礼时,客户端如何排队、合批、丢弃

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

前几篇拆完了客户端运行时优化、资源分发预加载,以及后端的消息推送 、去重、幂等。这些解决的是「一条礼物消息如何可靠地产生、到达、就位」。但真实直播间里,礼物从来不是一条一条来的——热门主播的 PK 时刻,几百个用户在同一秒疯狂刷屏,连击、全屏大礼物、小飘屏礼物混在一起涌向客户端。

如果收到一条就播一条,结果只有一个:特效疯狂打断、内存爆炸、低端机直接卡死。这篇拆客户端侧的队列与并发控制——礼物消息到端之后,如何排队、如何合批、什么时候该果断丢弃,既保证直播间热闹,又不让端上崩掉。

9df738203728454fa607d517c3596155.png

登天云阶:场景级全屏大特效独占屏幕,一次只能播一个,是串行队列要解决的典型场景(星集礼物商城)

一、为什么必须有队列

先想清楚不加队列会发生什么。假设直播间高峰期,客户端每秒收到 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 次。

9719edc524c0467fb463511560e4db72.png

富豪出场:高价值全屏入场礼物,必须靠优先级队列插队优先播,不能被廉价礼物挤到几分钟后(星集礼物商城)

三、全屏通道:串行队列 + 优先级 + 最大等待

全屏特效是最稀缺的资源——同一时刻屏幕上只能有一个。所以它的核心是一个带优先级的串行队列

优先级排序

高价值礼物(大哥送的火箭)不能被一堆廉价礼物挤在后面几分钟才播,那对付费用户是致命的体验。用优先级队列,按礼物价值排序:

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();          // 尝试播等待队列里的下一个
            });
        }
    }
}

等待队列也要设上限,超过就丢最老的飘屏礼物——飘屏本身价值低,丢了对体验影响小。

493104f111024698b373c6a09bb20f74.png

双子座永恒项链:低价高频礼物,靠合并成一个跳动数字,而不是叠 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;

  • 防抖驱动动画:连击数字的跳动动画用防抖节流,避免高频重绘打满主线程。

一句话:连击在视觉上是一个持续跳动的特效,在数据上是一串消息的聚合,千万别一一对应。

67c6272feaed4b95bdf93ab5065d723e.png

鎏金羽梦:全站最贵最长的顶级特效,背压丢弃时必须优先保护,宁可丢一百个廉价礼物(星集礼物商城)

六、背压与丢弃策略:扛不住时怎么优雅降级

即便分了通道,极端刷屏下消息产生的速度仍可能超过客户端消费的速度。这时候必须有背压(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 分发、客户端就位、到最终有节奏地播放,全链路闭环。

最后更新:

相关素材

情人节榜单送礼 Top 6–8 头像框情人节榜单收礼Top4-5房间边框情人节榜单收礼Top 4-5资料卡