百万并发直播弹幕系统设计全解:看懂了扇出,你就看懂了高并发的八成

0 阅读35分钟

📌 本文速览|建议先收藏再看

主播一句“来波 666”,背后是每秒十万级写入、一条弹幕送达百万人的残酷现实。逐条推送?10 万 QPS × 100 万在线 = 100 亿次/秒,物理上不可能。这篇文章不画空架构图,从真实口径算账开始,把百万并发弹幕系统拆成五层:接入层扛百万长连接,业务层做校验过滤,分发层用“节点级扇出 + 本地房间表 + 时间窗聚合”把 100 万次网络调用降到约 100 次,存储层冷热分离降成本。核心代码全带:Netty 网关调优、Kafka 独立 consumer group 广播语义、CAS 环形缓冲削峰、脏连接队列批量 flush、推 offset 拉消息体、房间 seq 号段、Redis Stream ACK 可靠通道、四层限流与加权采样。文末还有 14 个难点速查表 + 性能指标,面试和实战都能直接抄作业。

一句话戳破本质:弹幕系统的核心矛盾不是“写不写得进”,而是“扇出降不降得下来”。看懂了扇出,你就看懂了高并发的八成。

本文亮点,先睹为快:

  • 扇出降 4 个数量级:节点级扇出 + 本地房间表 + 时间窗聚合,100 万次 socket write 变约 100 次节点投递;
  • 百万长连接:Netty Epoll、堆外池化、心跳踢死连接,HyperLogLog 用 12KB 统计百万 UV;
  • 上行三级削峰:本地环形缓冲背压 → MQ 按 roomId 分区 → 消费端批量聚合,附无锁 CAS 核心代码;
  • 下行最硬核:脏连接队列、MPSC 无锁入队、背压丢慢客户端、一次编码百万人共享;
  • 推拉结合:小房间纯推,超大房间推 offset 指针 + 客户端拉消息体,O(N) 广播变 O(1);
  • 可靠分级:普通弹幕可丢,礼物/系统通知走 Redis Stream ACK,一条不能丢;
  • 保命符:四层限流、热度采样、加权采样、L1-L3 降级与故障兜底;
  • 冷热分离:Redis 只留最近 5 分钟热弹幕,历史异步落 ClickHouse/HBase,存储成本降 80%。

适合谁读:做系统设计、准备面试、负责直播/IM/实时消息的你。读完能带走一套可落地的架构骨架、核心代码思路和面试表达。

一句话记忆:推拉结合保实时,异步批量抗洪峰,背压隔离护全局。

最后留个互动话题:如果你的直播间突然涌入 100 万在线,你第一步会先扩容哪一层?为什么? 评论区聊聊,我会逐条回复。觉得文章有用,点赞、在看、收藏、转发走一波;下一篇拆秒杀系统,关注不迷路。

百万并发直播弹幕系统设计全解:看懂了扇出,你就看懂了高并发的八成

主播在直播间喊一句"来波 666",弹幕瞬间刷屏——这背后是每秒十万级的写入,和一条弹幕要送达百万人的残酷现实。逐条推送?系统三秒就得趴下。这篇文章从真实场景出发,把弹幕系统的五层架构、推拉结合、批量聚合、分级降级一次讲透,核心代码全部带上。做系统设计、准备面试的朋友,值得收藏细看。

一、先算笔账:我们到底在怕什么

实际开发中,接到"设计一个弹幕系统"这种需求,很多人上来就开始画架构图:Netty + Kafka + Redis,一套组合拳打出去。且慢,先问清楚几个口径,因为量级差一个数量级,方案就完全是两回事:

维度需要确认的点典型假设
规模单房间在线人数?弹幕峰值 QPS?单房间 100 万在线,全局 10 万条/秒
延迟端到端可接受延迟?≤ 200ms,超过用户就觉得"卡住了"
一致性弹幕能不能丢?要不要落库?普通弹幕允许丢;礼物、系统通知不能丢
合规要不要实时审核?必须审核,本地词库同步拦 + 模型异步审
地域单机房还是多机房?多机房,按地域就近接入

其中"弹幕能不能丢"是整道题的分水岭——它直接决定了系统走"可靠消息队列"还是"尽力而为的流式广播"。

弹幕不是 IM,是"高频短命的广播流"

很多同学会下意识套用 IM 的思路,这是方向性错误。弹幕和微信消息完全是两个物种:

  • 写扩散比极高:万人直播间里,一条弹幕的读扩散比是 1 : 10000,而 IM 是点对点;
  • 生命周期极短:一条弹幕的价值窗口通常只有 3~5 秒,过了这个窗口再到达,用户根本感知不到;
  • 流量是脉冲式的:平时几千 QPS,进球、明星出场、主播喊口号的瞬间,几秒内暴增几十倍。

所以设计的第一原则不是"把所有消息可靠地存下来",而是:在百毫秒内把当前屏幕该看到的弹幕送到,过期的该丢就丢。

真正的难点:扇出爆炸

来算一笔让人头皮发麻的账:

写入:10 万 QPS 弹幕
下发:10 万 QPS × 100 万在线 = 100 亿次/秒  ❌ 物理上不可能

一句话戳破本质:弹幕系统的核心矛盾不是"写不写得进",而是"扇出降不降得下来"。 海量写入、实时分发、低成本存储,三者不可兼得,必须分层取舍。

后面所有的设计决策,都从这句话推导出来。

二、整体架构:五层各司其职

围绕"写入削峰、分发降量"八个字,系统拆成五层,全链路异步化,每层独立水平扩容:

image.png

每层的职责边界划得很干净:

  • 接入层只管长连接保活,不碰复杂业务;
  • 业务层只管校验过滤,无状态随便扩容;
  • 分发层只管消息路由推送;
  • 存储层冷热分离,热数据扛并发,冷数据省成本。

这样拆的好处是故障隔离性强:业务层挂了连接不断,某个房间炸了不影响全平台,哪层不够就扩哪层。

一个最容易踩的坑:Kafka 消费组

这里埋一个面试和实战中都极容易翻车的点:

如果每个网关节点用同一个 consumer group 消费 Kafka,分区会被负载均衡给不同节点,一条弹幕只会被一个节点消费——其他节点上的用户就收不到弹幕了。

正确姿势是:每个网关节点用独立的 consumer group(或者直接 assign 全部分区),实现"广播语义",保证消息到达所有持有该房间连接的节点。

类似的选型问题还有一个高频追问:为什么用 Kafka 而不是 Redis Pub/Sub? 答案很直接——Pub/Sub 无持久化、无削峰能力,断线就丢消息;Kafka 有堆积能力,洪峰时消费端可以"慢慢追",天然削峰填谷。

那 MQ 能不能直接把消息推给客户端?也不行。MQ 是面向存储与可靠的,不擅长百万长连接的扇出和协议处理。Kafka 负责跨节点可靠广播,网关负责最后一公里的聚合投递,各司其职。

架构骨架搭好了,接下来从接入层开始,逐层往里拆。

三、接入层:百万长连接怎么扛

百万用户在线 = 百万条 TCP 连接。Tomcat 这种一连接一线程的模型直接出局,必须上 Netty + WebSocket。

选型:为什么是 Netty,而不是 Spring WebSocket

对比项NettySpring WebSocket
单机连接数调优后轻松 10 万+默认配置 1 万都费劲
协议自定义编解码器完全可控封装太重,定制困难
内存调优堆外池化内存随便调不方便
心跳/空闲检测原生 IdleStateHandler支持较弱

网关启动配置:几个参数调对了才能扛量

public class DanmakuGateway {

    public void start(int port) throws InterruptedException {
        // boss 只负责 accept,worker 负责读写,主从 Reactor 互不阻塞
        EventLoopGroup boss = new NioEventLoopGroup(1);
        EventLoopGroup worker = new NioEventLoopGroup(
                Runtime.getRuntime().availableProcessors() * 2);

        new ServerBootstrap()
            .group(boss, worker)
            .channel(NioServerSocketChannel.class)   // Linux 上换 EpollServerSocketChannel
            .option(ChannelOption.SO_BACKLOG, 2048)
            .childOption(ChannelOption.TCP_NODELAY, true)    // 关闭 Nagle,低延迟
            .childOption(ChannelOption.SO_KEEPALIVE, true)
            .childOption(ChannelOption.SO_RCVBUF, 32 * 1024)
            .childOption(ChannelOption.SO_SNDBUF, 32 * 1024)
            // 堆外池化内存:减少 GC、转发时避免内核态/用户态拷贝
            .childOption(ChannelOption.ALLOCATOR, PooledByteBufAllocator.DEFAULT)
            .childHandler(new ChannelInitializer<SocketChannel>() {
                @Override
                protected void initChannel(SocketChannel ch) {
                    ChannelPipeline p = ch.pipeline();
                    p.addLast(new HttpServerCodec());
                    p.addLast(new HttpObjectAggregator(64 * 1024));
                    p.addLast(new WebSocketServerProtocolHandler("/danmaku"));
                    // 读空闲 30s 心跳检测,自动剔除僵尸连接
                    p.addLast(new IdleStateHandler(30, 60, 0, TimeUnit.SECONDS));
                    p.addLast(new DanmakuServerHandler());
                }
            }).bind(port).sync();
    }
}

配置之外,单机扛 10 万+ 连接还需要一整套系统级调优,这些都是踩过坑才知道的:

  • 文件描述符:ulimit -n 调到百万级,否则连接数上不去;
  • 内核参数:tcp_mem、wmem 调优,不然内存先炸;
  • Epoll 模式 + ET 边沿触发,比 NIO 性能更好;
  • 自定义二进制协议:消息头带房间 ID、消息类型,比 JSON 省解析开销,Protobuf 编码比 JSON 体积压缩 60% 以上;
  • GC 三件套:池化堆外内存减少堆内 GC、弹幕对象走对象池复用、所有耗时业务逻辑丢业务线程池——千万别在 IO 线程做任何重活。

网关本身要做无状态化(连接信息可重建),这样宕机后客户端能自动重连到其他节点。入口还要做连接维度限流和单机连接数熔断,慢连接及时检测断开。

冷知识:在线人数用 HyperLogLog 统计

直播间"10 万+ 人在看"这个数字,千万别用 Set 存——100 万个 userId 要几十 MB 内存。用 Redis 的 HyperLogLog:

// 进房
redis.opsForHyperLogLog().add("danmu:online:room:" + roomId, userId);
// 统计:近似值,百万 UV 只占 12KB 内存,误差 < 1%
Long onlineCount = redis.opsForHyperLogLog().size("danmu:online:room:" + roomId);

12KB 统计百万级 UV,误差不到 1%,用户完全感知不到。这种"用精度换内存"的思路,在高并发场景里到处都是。

连接扛住了,弹幕消息进来之后该怎么处理?往下看。

四、上行链路:三级削峰,把洪峰变细流

用户发一条弹幕,网关既不能同步写库,也不能同步广播。正确的链路是:

image.png

注意第 ⑤ 步:校验通过写入 MQ 后接口立即返回,不等下发结果。前端配合做本地回显——用户看到自己的弹幕"发出去了",体验无损。哪怕这条弹幕后面被限流丢弃,用户也无感知。这是个很便宜但极其好用的体验优化。

上行核心代码:频控 → 过滤 → 保序投递

@Service
public class DanmakuSendService {
    @Autowired private RocketMQTemplate rocketMQTemplate;
    @Autowired private SensitiveWordFilter sensitiveWordFilter;
    @Autowired private RateLimiterService rateLimiterService;

    public void sendDanmaku(DanmakuDTO dto) {
        // 1. 频控校验:用户 + 房间双维度,防恶意刷屏
        if (!rateLimiterService.tryAcquire(dto.getUserId(), dto.getRoomId())) {
            throw new BusinessException("发送太频繁,请稍后再试");
        }
        // 2. DFA 敏感词实时过滤
        if (sensitiveWordFilter.containsSensitiveWord(dto.getContent())) {
            throw new BusinessException("内容包含违规词汇");
        }
        // 3. 按房间 ID 哈希分区发送,保证同房间消息顺序;失败重试
        rocketMQTemplate.asyncSendOrderly("danmaku_topic", dto, dto.getRoomId(),
            new SendCallback() {
                @Override public void onSuccess(SendResult r) {}
                @Override public void onException(Throwable e) { retrySend(dto); }
            });
    }
}

(Kafka 还是 RocketMQ 看团队技术栈,思路完全一致:MessageQueueSelector / partition key 按 roomId 哈希。)

三级削峰:光有 MQ 还不够

弹幕峰值每秒几十万条,直接写 MQ 也可能被打懵。工业级的做法是三级削峰:

  1. 网关本地环形缓冲:先入本地队列,满了就丢最老的(背压机制,宁可丢消息也不能阻塞 IO 线程);
  2. MQ 按 roomId 分区:同房间消息进同一分区,既削峰又保序;
  3. 消费端批量聚合:不是来一条推一条,而是攒够一批再推。

第一级的本地聚合器是整个写入链路的亮点,直接上代码:

/**
 * 弹幕本地聚合器(Disruptor 思想):
 * 把单条写 Kafka 的 10 万 QPS,聚合成 200 批/秒
 * 环形数组 + CAS + 批量 flush,全程无锁,近乎零 GC
 */
public class DanmakuAggregator {
    private final DanmakuMsg[] ring = new DanmakuMsg[8192];  // 环形缓冲
    private final AtomicInteger writeIndex = new AtomicInteger(0);
    private final AtomicInteger flushedIndex = new AtomicInteger(0);
    private final KafkaTemplate<String, String> kafka;

    private static final int BATCH = 512;
    private static final long FLUSH_INTERVAL_MS = 10;

    public boolean offer(DanmakuMsg msg) {
        while (true) {
            int idx = writeIndex.get();
            int next = (idx + 1) & (ring.length - 1);   // 位运算取模
            if (next == flushedIndex.get()) {           // 满了直接丢,保实时性
                metrics.increment("danmaku_dropped");
                return false;
            }
            if (writeIndex.compareAndSet(idx, next)) {  // CAS 无锁
                ring[idx] = msg;
                return true;
            }
        }
    }

    /** 定时批量 flush:一次网络 IO 发送 512 条 */
    @Scheduled(fixedDelay = FLUSH_INTERVAL_MS)
    public void flush() {
        int end = writeIndex.get();
        int start = flushedIndex.get();
        if (end == start) return;

        List<ProducerRecord<String, String>> batch = new ArrayList<>(BATCH);
        for (int i = start; i != end; i = (i + 1) & (ring.length - 1)) {
            DanmakuMsg m = ring[i];
            batch.add(new ProducerRecord<>("danmaku_topic", m.getRoomId(), JsonUtil.write(m)));
            ring[i] = null;   // 及时置 null,避免跨代引用导致 GC 问题
        }
        kafka.send(batch);
        flushedIndex.set(end);
    }
}

亮点拆解:位运算取模、CAS 无锁、满了直接丢保实时性、批量 IO、及时置 null 防 GC 泄漏。面试时把这五个点主动说出来,基本就是加分项。

分区与优先级:Kafka 的两个关键设计

image.png

  • 按 roomId 哈希分片,同一直播间消息落同一分区,天然有序,消费端无需额外排序;
  • 分区数随流量水平扩容,吞吐线性增长。

另外,不是所有弹幕都平等。建议按消息类型拆 Topic 分级:

Topic消息类型消费优先级
danmaku-normal普通弹幕低
danmaku-gift礼物弹幕高
danmaku-system系统公告最高

高级消息优先消费,确保付费用户体验不受洪峰影响——这是实打实的"成本意识"。

消费端拿到批量消息后,按房间分组喂给聚合器:

@KafkaListener(topics = "live-danmu",
        containerFactory = "batchKafkaListenerContainerFactory")
public void consume(List<ConsumerRecord<String, Danmu>> records) {
    records.stream()
           .map(ConsumerRecord::value)
           .collect(Collectors.groupingBy(Danmu::getRoomId))
           .forEach((roomId, list) -> list.forEach(aggregator::add));
}

幂等:防重复的最后防线

客户端弱网重发、MQ 重复消费都会产生重复消息。服务端用 Redis 存 弹幕ID + 用户ID 的短时效 Key(5 秒过期)做幂等校验;客户端上行时带 clientMsgId,重复消息直接返回成功,不再入队。

消息进了 MQ,洪峰算是稳住了。但真正的硬仗才刚开始——这些弹幕怎么送到百万人手上?

五、内容审核:DFA 同步拦截 + 异步模型复审

弹幕是 UGC 重灾区,审核必须做,但 50 万 QPS 同步调审核模型等于自杀。方案是两层防线:

第一层:DFA 本地过滤,微秒级

为什么用 DFA(确定有限状态自动机)而不是正则?正则每来一条弹幕都要把所有敏感词全匹配一遍,1000 个词就是 1000 次匹配;DFA 把所有词构建成一棵前缀树,一次扫描完成,时间复杂度 O(n),只和文本长度有关,跟敏感词数量无关。实测比正则快 100 倍以上。

public class SensitiveWordFilter {
    private final TrieNode root = new TrieNode();

    /** 构建前缀树:逐字符插入,词尾打标 */
    public void addWord(String word) {
        TrieNode p = root;
        for (char c : word.toCharArray()) {
            p = p.next.computeIfAbsent(c, k -> new TrieNode());
        }
        p.end = true;
    }

    /** 匹配:一次扫描完成;fail 失败指针处理后缀重叠(AC 自动机) */
    public boolean hit(String text) {
        TrieNode p = root;
        for (int i = 0; i < text.length(); i++) {
            char c = text.charAt(i);
            while (p != root && p.next.get(c) == null) p = p.fail;  // 失败指针跳转
            p = p.next.getOrDefault(c, root);
            if (p.end) return true;
        }
        return false;
    }
}

工程上还有三个细节:

  • 词库全量加载到本地内存,单条匹配 < 1ms,扛住几十万 QPS 同步过滤;
  • 后台异步增量更新词库,热更新无需重启服务;
  • 流量峰值时启动分级策略:只匹配高危词库,普通词库降级为异步审核。

第二层:先发后审 + 撤回指令

本地 DFA 只能拦词库里的东西,变体字、阴阳怪气它管不了。所以完整链路是:

DFA 同步拦截(μs) → 弹幕先下发 → Kafka → 异步模型审核(~200ms) → 违规则下发撤回指令 + 账号惩罚

配合内容指纹缓存(相同内容只审一次),审核成本又能降一大截。这套"先发后审"是用极小的违规曝光窗口,换来了全链路不被审核拖慢,是典型的工程权衡。

六、下行分发:这道题的胜负手

前面说过,扇出才是核心矛盾。这一章是全文最硬核的部分,也是面试和实战中最能拉开差距的地方。

6.1 分层扇出:从 100 万次网络调用到 100 次

先看朴素方案错在哪:

image.png

关键思路是三级降压:

  1. 节点级扇出:消息只投递到"持有该房间观众连接的网关节点"(100 个节点,而不是 100 万个连接);
  2. 本地房间表:网关内存维护 roomId → 本地 Channel 集合,精准投递而非全量扫描;
  3. 时间窗聚合:一个窗口内的弹幕合并成一个包发出,系统调用数降低 1~2 个数量级。

网络开销从 100 万次降到 100 次,下降 4 个数量级。

6.2 房间路由:先回答"推给谁"

弹幕是房间内广播,不是全局广播,所以必须有一张路由表。两种模式都常见:

模式 A:网关直接消费。每个网关节点独立消费组订阅 MQ,消息广播到所有节点,节点查本地房间表决定是否下发。链路最短,延迟最低。

模式 B:独立分发服务。分发服务消费 MQ,查 Redis 路由表,把消息推给对应网关。路由表的典型结构:

room:1001:gateways = [gateway-1, gateway-2, gateway-7]   # 正向:房间在哪些网关
gateway:gateway-1:rooms = [1001, 2003, ...]              # 反向:网关持有哪些房间
对比项网关直接消费独立分发服务
链路延迟最短多一跳
路由复杂度无需路由表需维护路由表 + 心跳续期
灵活性消费逻辑耦合在网关分发策略可独立演进
适用场景网关数量可控网关极多、需集中调度

路由表有几个容易忽略的运维细节:心跳续期 + TTL(防止网关宕机后路由残留)、网关上下线事件同步、反向索引便于节点下线时快速清理。

而网关本地房间表,是精准投递的基石:

@Component
public class RoomRegistry {
    /** roomId -> 本机该房间的连接集合 */
    private final ConcurrentHashMap<Long, Set<Channel>> roomChannels = new ConcurrentHashMap<>();

    public void join(long roomId, Channel ch) {
        roomChannels.computeIfAbsent(roomId, k -> ConcurrentHashMap.newKeySet()).add(ch);
        // 本机首次有人进这个房间 -> 订阅该房间,避免无效扇出
        MqSubscriber.subscribeIfAbsent(roomId);
    }

    public void leave(long roomId, Channel ch) {
        Set<Channel> set = roomChannels.get(roomId);
        if (set == null) return;
        set.remove(ch);
        // 空房间必须移除,否则百万房间累积 -> 内存泄漏
        // CAS 双参删除,避免并发下误删新加入的连接集合
        if (set.isEmpty()) {
            roomChannels.remove(roomId, set);
            MqSubscriber.unsubscribe(roomId);
        }
    }

    public Set<Channel> localViewers(long roomId) {
        return roomChannels.getOrDefault(roomId, Collections.emptySet());
    }
}

亮点:remove(roomId, set) 这个 CAS 双参删除,防的是"判断为空"和"删除"之间恰好有新连接加入导致误删的并发问题;空房间及时移除 + 取消订阅,防内存泄漏和无效扇出。这两个细节,没踩过坑的人写不出来。

连接集合也可以用 Netty 的 DefaultChannelGroup 管理,配合 closeFuture() 监听自动清理断开的连接。但注意:百万连接下更推荐 ConcurrentHashMap.newKeySet() 或分片 Set,CopyOnWriteArraySet 在写多的场景会有复制开销。

6.3 批量聚合:整个系统最核心的一段代码

很多方案写到 channelGroup.writeAndFlush(msg) 就交卷了,但 DefaultChannelGroup 的 writeAndFlush 是同步遍历所有 channel 逐个写入——在线超过 5000、弹幕频率一高,IO 线程直接阻塞,EventLoop 积压到抛 RejectedExecutionException。

正确的聚合下发器长这样:

/**
 * 弹幕聚合下发:把时间窗内的弹幕合并成 1 次 write
 * 三大亮点:
 *   ① 脏连接队列 —— 只遍历"有数据"的连接,百万连接下从 O(总连接) 降到 O(活跃数)
 *   ② MPSC 无锁入队 —— 业务线程不加锁(jctools 单消费者队列)
 *   ③ 背压感知 —— 慢客户端直接丢弃,绝不拖垮服务端
 */
public class DanmuFlusher {
    static final AttributeKey<MpscArrayQueue<Danmu>> QUEUE = AttributeKey.valueOf("dq");
    static final AttributeKey<AtomicBoolean> DIRTY = AttributeKey.valueOf("dirty");
    private static final int MAX_BATCH = 64;

    private final ConcurrentLinkedQueue<Channel> dirtyQueue = new ConcurrentLinkedQueue<>();

    /** 入队 O(1);队列满则丢——弹幕允许丢,绝不能 OOM */
    public boolean offer(Channel ch, Danmu d) {
        MpscArrayQueue<Danmu> q = ch.attr(QUEUE).get();
        if (!q.offer(d)) return false;
        // CAS 保证同一连接在脏队列中只出现一次
        if (ch.attr(DIRTY).get().compareAndSet(false, true)) {
            dirtyQueue.offer(ch);
        }
        return true;
    }

    /** 每 30ms flush 一次 */
    @Scheduled(fixedDelay = 30)
    public void flush() {
        Channel ch;
        while ((ch = dirtyQueue.poll()) != null) {
            ch.attr(DIRTY).get().set(false);
            if (!ch.isActive()) continue;

            // 背压:TCP 发送缓冲区积压 = 客户端慢/弱网 -> 清空队列保命
            if (!ch.isWritable()) {
                ch.attr(QUEUE).get().clear();
                continue;
            }
            List<Danmu> batch = new ArrayList<>(MAX_BATCH);
            ch.attr(QUEUE).get().drain(batch::add, MAX_BATCH);
            if (!batch.isEmpty()) {
                // 1 次 syscall 发 N 条:Protobuf 批量编码
                ch.writeAndFlush(new BinaryWebSocketFrame(
                        Unpooled.wrappedBuffer(Codec.encode(batch))));
            }
        }
    }
}

窗口时间怎么选? 10ms、30ms、100ms 都有人用。人眼对 100ms 以内的延迟基本无感,所以窗口开到 100ms 体验都还能接受;窗口越大,聚合效果越好。一般小房间 10~30ms 保体验,热点房间拉到 100ms 甚至更长保吞吐。也可以用 HashedWheelTimer 做时间轮触发,配合 AtomicBoolean CAS 防止重复调度:

private final HashedWheelTimer mergeTimer = new HashedWheelTimer(10, TimeUnit.MILLISECONDS, 512);
private final ConcurrentLinkedQueue<DanmakuMsg> pendingQueue = new ConcurrentLinkedQueue<>();
private final AtomicBoolean flushScheduled = new AtomicBoolean(false);

public void onDanmakuReceived(DanmakuMsg msg) {
    pendingQueue.offer(msg);
    // 每个窗口只提交一次 flush 任务
    if (flushScheduled.compareAndSet(false, true)) {
        mergeTimer.newTimeout(t -> flushBatch(), 10, TimeUnit.MILLISECONDS);
    }
}

聚合带来的收益是数量级的:

指标朴素逐条推聚合下发
write 次数/秒千亿级~1000 万
单条网络开销每条独占 TCP 头 + 帧头N 条共用一个包头
服务端 CPUsys 态打满下降 70%+

6.4 写入保序与字节复用:两个教科书写法

Netty 只保证单个 channel 内写入有序,多个业务线程并发调 channel.write() 顺序就乱了。解法是强制所有写入走该 channel 所属的 EventLoop,天然串行:

public void safeBroadcast(Channel ch, ByteBuf payload) {
    ch.eventLoop().submit(() -> {
        if (!ch.isActive()) return;
        ch.writeAndFlush(new BinaryWebSocketFrame(payload));
    });
}

另一个是一次编码、百万人共享:同一批弹幕只序列化一次,所有连接复用同一份字节,用引用计数控制生命周期:

public void push(Long roomId, DanmakuMsg msg) {
    Set<Channel> channels = roomChannels.get(roomId);
    if (channels == null) return;

    ByteBuf buf = encode(msg);   // 只编码一次,百万人共享同一份字节
    for (Channel ch : channels) {
        if (ch.isWritable()) {
            ch.writeAndFlush(buf.duplicate().retain());   // 引用计数复用
        } else {
            metrics.increment("slow_client");
            if (ch.bytesBeforeUnwritable() == 0) {
                ch.close();   // 宁可踢掉一人,不拖垮一个房间
            }
        }
    }
    buf.release();
}

写广播时还可以用 voidPromise() 替代默认 promise,省掉 listener 对象分配的开销。这些细节单个看不起眼,叠加起来就是 70% 的 CPU。

6.5 推拉结合:不要无脑对所有房间写扩散

纯推模式在超大房间会遇到物理瓶颈:一条弹幕复制 N 份写出去,CPU 和带宽随在线数线性增长。工业界按房间规模分级处理:

房间规模策略理由
小房间(< 5 万在线)纯推,WebSocket 直接广播延迟最低,实现简单
中大房间批量推送 + 客户端定时拉取补偿平衡延迟与扇出成本
超大房间(10 万+)推 offset 指针 + 客户端拉消息体O(N) 广播变 O(1) 指针推送

超大房间的"推信号、拉消息体"是这样的:

// 网关只推 offset 指针,不推全量弹幕
public void pushOffsetSignal(String roomId, long latestOffset) {
    OffsetSignal signal = OffsetSignal.newBuilder()
            .setRoomId(roomId)
            .setOffset(latestOffset)
            .build();
    roomChannels.get(roomId).writeAndFlush(
            new BinaryWebSocketFrame(Unpooled.wrappedBuffer(signal.toByteArray())));
}
// 客户端收到信号后按需拉取:
// GET /danmaku/pull?roomId=1001&sinceSeq=10234&limit=50

即使 50 万人在线,网关每秒也只需推几十个 offset 信号,真正的消息体由客户端自己拉——带宽和 CPU 都从 O(N) 降到 O(1)。

拉取接口直接打在 Redis 上,服务端不维护任何客户端进度,天然无状态、可水平扩展:

/**
 * 断线补漏 / 增量拉取
 * Redis ZSet 按 seq 存最近 5 分钟弹幕,O(logN) 范围查询
 */
@GetMapping("/danmaku/pull")
public List<DanmakuMsg> pull(@RequestParam Long roomId,
                             @RequestParam Long sinceSeq,
                             @RequestParam(defaultValue = "200") int limit) {
    return redis.opsForZSet()
                .rangeByScore("dm:buf:" + roomId, sinceSeq + 1, "+inf", 0, limit)
                .stream().map(JsonUtil::read).toList();
}

新进直播间的用户也走这套:直接从 Redis 拉最近的热弹幕,100ms 内完成画面补全,不用查历史库。热弹幕缓存的维护:

@Service
public class DanmakuCacheService {
    @Resource private StringRedisTemplate redisTemplate;

    private static final int MAX_HOT_COUNT = 1000;   // 单房间最多 1000 条热弹幕
    private static final int MAX_FETCH_COUNT = 50;   // 单次最多拉 50 条,控制前端渲染压力

    /** 增量拉取:客户端传上次拉到的位置 */
    public List<DanmakuVO> fetchIncrementDanmaku(String roomId, long lastIndex) {
        String key = "danmaku:hot:" + roomId;
        Long totalLen = redisTemplate.opsForList().size(key);
        if (totalLen == null || totalLen <= lastIndex) {
            return Collections.emptyList();
        }
        long endIndex = Math.min(lastIndex + MAX_FETCH_COUNT - 1, totalLen - 1);
        return redisTemplate.opsForList().range(key, lastIndex, endIndex).stream()
                .map(json -> JSON.parseObject(json, DanmakuVO.class))
                .collect(Collectors.toList());
    }

    /** 写入热缓存:rightPush + trim 固定长度,内存占用恒定 */
    public void addHotDanmaku(String roomId, DanmakuVO danmaku) {
        String key = "danmaku:hot:" + roomId;
        redisTemplate.opsForList().rightPush(key, JSON.toJSONString(danmaku));
        redisTemplate.opsForList().trim(key, -MAX_HOT_COUNT, -1);
    }
}

亮点:LPUSH/LTRIM(或 rightPush + trim)把 Redis 变成一个定长环形缓冲,热数据内存恒定不膨胀;拉取接口无状态,集群随便加机器。轮询间隔按房间热度调,200ms~2s 都可以。

到这里,"怎么推"讲完了。但还有两个问题悬着:弹幕乱了怎么办?礼物消息丢了怎么办?

七、顺序、去重与分级可靠

7.1 房间维度的单调 seq:一石三鸟

给每条弹幕发一个房间维度单调递增的序列号,能同时解决三件事:客户端按 seq 排序渲染(不依赖到达顺序,网络轻微乱序无感知)、断线重连按 seq 去重补帧、推拉结合按 seq 补漏。

发号器推荐号段模式,批量借号,把 Redis 的 QPS 降 1000 倍:

/**
 * 房间维度递增 seq:批量借号段(1000 个/次),
 * 避免每条弹幕都打一次 Redis
 */
public class SegmentSeqGenerator {
    private final ConcurrentHashMap<Long, long[]> roomSeg = new ConcurrentHashMap<>();
    private final StringRedisTemplate redis;

    public long next(long roomId) {
        long[] seg = roomSeg.computeIfAbsent(roomId, k -> new long[]{0, 0}); // {cursor, end}
        long cur = ++seg[0];
        if (cur >= seg[1]) {
            synchronized (seg) {                    // 只锁单个房间,粒度极细
                if (cur >= seg[1]) {
                    long end = redis.opsForValue().increment("seq:" + roomId, 1000);
                    seg[0] = end - 1000;
                    seg[1] = end;
                }
            }
            cur = ++seg[0];
        }
        return cur;
    }
}

(也可以用简化版 Snowflake:时间戳 << 22 | 机器位 | 序列,好处是去中心化,坏处是跨机器不严格连续,按场景选。)

顺序性上还有一个容易被忽略的点:多节点并发下发时,消费端可以按 serverTime + seq 做归并(小顶堆),保证合流后依然有序;同一房间统一 seq 体系,Web / App / TV 多端各自按 seq 补拉,天然对齐。

7.2 去重:服务端幂等 + 客户端幂等窗口

重复投递的来源很多:网络重试、ACK 超时重发、MQ 重复消费。对策是双保险——服务端 clientMsgId 幂等(前面讲过),客户端按 msgId / seq 维护一个去重窗口,重复到达的直接丢弃。

7.3 分级可靠:普通弹幕尽力而为,礼物一条不能丢

普通文本弹幕可以"尽力而为",但礼物、系统通知、付费弹幕必须不丢不重。让它们走独立的可靠通道,核心组合是 Redis Stream 的 Consumer Group + ACK + 客户端 seq 去重:

Producer → XADD stream:room:1001 * uid 20345 content "666" ts ...
Consumer → XREADGROUP GROUP live CONSUMER gateway-1 COUNT 50 BLOCK 2000
           STREAMS stream:room:1001 >
成功     → XACK stream:room:1001 live <msgId>
失败     → XCLAIM 转移 / 延迟重试,pending 消息不丢

Redis Stream 的 ID 本身是"毫秒时间戳 + 序号",单 Stream 内天然递增有序。这套"确认 + 重试 + 幂等"是可靠投递的标准三件套。

消息的优先级分级建议直接固化成表:

消息类型优先级处理策略
系统公告P0永不丢弃,可靠通道
礼物 / 上舰P0永不丢弃,可靠通道
房管 / 主播发言P0~P1优先保障
付费 / 等级用户弹幕P1高权重保留
普通用户弹幕P2采样下发
点赞 / 进场动画 / 重复刷屏P3带宽紧张时优先丢

image.png

知道"普通弹幕和礼物走不同的可靠性等级",说明你有分级保障的成本意识——这一点在面试里是明确的加分项。

八、限流与降级:系统的保命符

写这篇的时候我反复想强调一句话:降级预案是架构的一部分,不是出事后的补救。 弹幕系统的稳定性,一半靠正常链路的设计,另一半靠极端场景下"优雅地丢东西"。

8.1 四层限流:从用户到集群

限流层级限流维度典型阈值降级策略
用户级单用户发送频率5 条/10 秒返回"发送太快了"
房间级单房间总 QPS按直播间热度动态调整丢弃低优先级弹幕
网关级单机总连接数10 万/台新连接排队或返回"系统繁忙"
全局级集群总 QPS按压测结果设定非核心接口降级,只保弹幕通路

用户级限流推荐 Redis ZSet + Lua 滑动窗口,毫秒级精度,原子执行:

-- KEYS[1]: danmaku:limit:{roomId}:{userId}
-- ARGV: now(毫秒), window(窗口毫秒), max(上限)
local key    = KEYS[1]
local now    = tonumber(ARGV[1])
local window = tonumber(ARGV[2])
local max    = tonumber(ARGV[3])

-- 清理窗口外的过期数据
redis.call('ZREMRANGEBYSCORE', key, 0, now - window)
local count = redis.call('ZCARD', key)

if count < max then
    -- 写入当前请求,随机后缀避免同毫秒 member 冲突
    redis.call('ZADD', key, now, now .. ':' .. math.random(100000))
    redis.call('PEXPIRE', key, window)
    return 1    -- 放行
end
return 0        -- 限流

三种常见限流算法的取舍,一张表说清:

算法优点缺点适用场景
固定窗口(INCR + EXPIRE)实现最简单窗口边界有突刺粗粒度房间级统计
滑动窗口(ZSet + Lua)毫秒级精准,无边界突刺内存略高用户级频控(推荐)
令牌桶允许一定突发流量实现稍复杂网关入口平滑限流

令牌桶可以用 Redis Lua 原子实现(Hash 存 tk/ts,按时间补令牌),也可以在网关本地做 JVM 版预限流,把明显超频的请求在入口就丢掉,连 Redis 都不用打:

// 网关本地令牌桶:超频弹幕直接丢,用户无感知
public class TokenBucket {
    private final int capacity = 5;       // 桶容量
    private final double rate = 1.0;      // 每秒补 1 个令牌
    private long tokens = capacity;
    private long lastRefill = System.currentTimeMillis();

    public synchronized boolean tryAcquire() {
        long now = System.currentTimeMillis();
        tokens = Math.min(capacity, tokens + (long) ((now - lastRefill) * rate / 1000));
        lastRefill = now;
        if (tokens > 0) { tokens--; return true; }
        return false;
    }
}

顺带一个体验细节:被限流的弹幕,服务端丢弃的同时前端保持"已发送"的本地回显——用户爽了,系统也没压力,双赢。

8.2 热度采样:百万房间的救命稻草

热门房间不可能给每个人全量下发,带宽不允许。按房间热度做采样,关键是稳定分桶——同一个用户始终看同一个桶的弹幕,画面不会忽闪忽现:

/** 稳定分桶:用户只收到属于自己桶的弹幕 */
public boolean shouldDeliver(Danmu d, long uid, int sampleRate) {
    if (d.priority() >= PRIORITY_GIFT) return true;   // 礼物/舰长/主播:永不丢弃
    if (sampleRate <= 1) return true;                 // 小房间全量
    return (d.seq() % sampleRate) == ((uid & 0x7FFF_FFFF) % sampleRate);
}

/** 房间热度 -> 采样率(房间服务定时计算,推送到各节点本地缓存) */
static int sampleRate(long viewers) {
    if (viewers < 1_000)   return 1;    // 小房间:全量,聊天氛围最重要
    if (viewers < 10_000)  return 2;
    if (viewers < 100_000) return 5;
    return 20;                          // 百万房间:1/20,反正用户也看不过来
}

客户端也可以配合做采样展示(比如每秒最多渲染 20 条),服务端和客户端两头一起削。

8.3 加权采样:既控量,又保付费体验

峰值场景下单房间每秒上万条弹幕,屏幕根本展示不完。服务端按 100ms 窗口做加权随机采样,把下游流量压到原来的 1/10:

/**
 * 100ms 窗口加权采样:
 * 高等级弹幕 100% 保底,普通弹幕按权重抽样,总量控制在 maxCount
 */
public List<Danmaku> weightSampling(List<Danmaku> list, int maxCount) {
    if (list.size() <= maxCount) return list;

    Map<Integer, Integer> levelWeight = Map.of(
        0, 1,     // 普通用户
        1, 5,     // 贵族用户
        2, 20,    // 礼物弹幕
        3, 100    // 系统公告
    );

    List<Danmaku> result = new ArrayList<>(maxCount);
    List<Danmaku> normalPool = new ArrayList<>();
    for (Danmaku d : list) {
        if (d.getLevel() >= 2) result.add(d);   // 高优先级 100% 保留
        else normalPool.add(d);
    }

    int remainSlot = maxCount - result.size();
    int totalWeight = normalPool.stream()
            .mapToInt(d -> levelWeight.getOrDefault(d.getLevel(), 1)).sum();
    Random random = new Random();
    for (int i = 0; i < remainSlot && !normalPool.isEmpty(); i++) {
        int rand = random.nextInt(totalWeight);
        int acc = 0;
        Iterator<Danmaku> it = normalPool.iterator();
        while (it.hasNext()) {
            Danmaku d = it.next();
            int w = levelWeight.getOrDefault(d.getLevel(), 1);
            acc += w;
            if (acc > rand) {
                result.add(d);
                totalWeight -= w;
                it.remove();
                break;
            }
        }
    }
    return result;
}

亮点:分层保底(礼物、公告 100% 保留,不伤付费体验)+ 加权随机(严格控制输出数量,又保证高等级用户曝光权重)+ 100ms 批量窗口(降低 CPU 计算频次)。

还有个顺手的优化:热点重复弹幕合并。万人刷"666"的时候,与其推一万条,不如合并成"666 x82",前端一次渲染,氛围还在,流量没了。

8.4 多级降级与故障兜底

image.png

功能降级按压力分级执行:

  • L1:聚合窗口从 30ms 拉到 100ms~3s,牺牲少量延迟换吞吐;
  • L2:采样下发,只推 10%~20% 的普通弹幕;
  • L3:客户端关闭弹幕通道,只收礼物和系统消息;
  • 负载 > 80%:关闭弹幕特效、历史弹幕查询等非核心功能。

故障兜底预案也要提前写好:

故障场景兜底方案
Redis 故障网关直接透传弹幕,降级为纯实时分发,暂不缓存、不落库
MQ 积压临时扩容消费组,或直接丢弃低优先级弹幕保核心链路
单机房故障多机房多活,直播间按地域调度到就近机房,自动切流
网关宕机网关无状态水平扩展,客户端指数退避重连到其他节点

说到重连,还有个必须防的坑:重连风暴。网关一宕机,百万连接同时重连,能把新集群直接打垮。客户端必须做指数退避 + 随机抖动(jitter);网关下线要走优雅下线流程——先摘流量,等存量连接迁移完再停进程。

写到这里,系统的"攻"和"防"都齐了。最后聊聊数据怎么存。

九、存储层:热数据要快,冷数据要省

弹幕每天几十亿条,千万别直接写 MySQL。冷热分离是唯一出路:

image.png

数据类型存储选型核心作用设计考量
热弹幕(最近 5 分钟)Redis List / Stream进房拉取、增量轮询、断线补齐定长环形缓冲,单房间 200~1000 条
历史弹幕ClickHouse回放、数据分析、弹幕报表列存压缩比高,查询毫秒级
历史弹幕(回放索引)HBase按"房间ID+时间戳"行键 + 二级索引任意时间段回放加载 < 1s
弹幕检索Elasticsearch(可配 MongoDB 分片)关键词检索、运营查询与主链路异步解耦
超冷数据OSS / S3合规归档、长尾需求成本极低,按需回源
用户/房间基础数据MySQL + 本地缓存基础业务查询缓存降低查询压力

核心逻辑一句话:只有最近 5 分钟的热弹幕存 Redis 供实时拉取,历史弹幕全部异步落冷存储,只有回放、审计场景才查冷库——避免热库被历史请求打穿。实测存储成本能降 80%,回溯查询依然是毫秒级。

落库必须异步批量,把万级 QPS 的写库压到百级:

@Component
public class DanmakuBatchWriter {
    private final BlockingQueue<DanmakuMsg> buffer = new LinkedBlockingQueue<>(10000);
    private final ScheduledExecutorService scheduler = Executors.newScheduledThreadPool(1);

    @PostConstruct
    public void init() {
        // 每 200ms 定时刷盘
        scheduler.scheduleAtFixedRate(this::flush, 200, 200, TimeUnit.MILLISECONDS);
    }

    public void add(DanmakuMsg msg) {
        buffer.offer(msg);
        if (buffer.size() >= 500) flush();   // 满 500 条立即刷盘
    }

    private void flush() {
        List<DanmakuMsg> batch = new ArrayList<>();
        buffer.drainTo(batch, 500);          // 原子批量取出,无并发问题
        if (!batch.isEmpty()) {
            danmakuMapper.batchInsert(batch);
        }
    }
}

内存缓冲 + 定时/定量双触发,DB IO 次数直接降两个数量级。回放场景再按时间分片存储,进度条拖到哪加载到哪。

十、难点速查表与性能指标

把全文的硬仗汇总成一张速查表,建议截图保存:

#技术难点根因解决方案
1广播扇出爆炸一条弹幕复制 N 份,CPU/带宽随在线数线性涨节点级扇出 + 本地房间表 + 时间窗聚合 + 推拉结合 + 热度采样
2百万长连接句柄、内存、GC 三座大山Netty Epoll + 堆外池化 + 对象复用 + ZGC;ulimit -n 百万级;tcp_mem 调优;心跳踢死连接
3写入突发洪峰脉冲式流量瞬间几十倍三级削峰:本地环形缓冲背压 → MQ 分区 → 消费端批量聚合
4Netty 广播阻塞 IO 线程ChannelGroup.writeAndFlush 同步遍历时间窗批量合并 + 异步写(voidPromise / ChannelPromise)
5弹幕乱序多线程并发写、跨节点无全局序roomId 分区保序 + 同一 EventLoop 串行写 + 单调 seq,客户端按 seq 渲染
6重复投递网络重试 / ACK 重发 / MQ 重复消费clientMsgId 服务端幂等 + 客户端 msgId/seq 去重窗口
7慢客户端拖垮服务弱网写不动,缓冲区积压 OOMisWritable 水位背压 → 清空队列 → 连续超时主动断开
8重连风暴网关宕机,百万连接同时重连客户端指数退避 + 随机抖动;网关优雅下线
9热点房间单点单房间百万人,单节点/单分区扛不住房间分片 + 独立集群 + 房间内再分片(按用户哈希拆子流)+ 多级分发树
10实时审核同步模型审核直接雪崩本地 DFA 微秒拦截 → 异步模型审核 → 先发后审 + 撤回指令
11垃圾刷屏恶意带节奏、机器刷屏四层限流 + DFA/AC 自动机 + 异步 AI 复审,违规拦截率 99.9%
12历史弹幕回放数据量大查询慢HBase "房间ID+时间戳" 行键 + 二级索引;ClickHouse 列存,回放加载 < 1s
13Redis 大 Key房间在线集合巨大,读写阻塞拆成多个小 Set 分段存储 + 网关本地维护子集
14跨机房延迟跨地域投递慢就近接入 + 同机房优先消费 + 专线 Topic 复制,同机房延迟 < 100ms

再说性能指标。面试和汇报时,能说出具体数字,可信度直接拉满:

指标目标值说明
弹幕端到端延迟平均 < 150ms,99 分位 < 300ms从发送到别人看到
单机长连接数10 万+(4 核 8G),内存 < 800MNetty + 系统调优
单机消费推送 QPS5 万+批量推送模式
削峰效果削峰 80%,核心消息零丢失MQ + 批量聚合
消息丢失率< 0.1%极端峰值下可接受
下行流量推拉结合降低 90%+对比全量推送
存储成本冷热分离降低 80%Redis 只留热数据
集群扩展性线性扩容加机器就能扛量

十一、还能继续深挖的方向

如果你以为到这就结束了,其实还有几个值得深挖的坑,也是面试时体现技术判断力的好素材:

  • 边缘节点推送:把接入网关下沉到 CDN 边缘,用户就近接入,中心网关压力骤降;
  • QUIC 替代 WebSocket:弱网下比 TCP 表现更好,断线重连更快,适合移动直播场景;
  • JDK 21 虚拟线程:在消费端批量拉取这类场景有收益,但要注意 synchronized 的 pinning 问题;
  • 弹幕智能分级:AI 识别高质量弹幕优先展示,水弹幕自动合并("666"刷屏合并成"×233");
  • 回放弹幕对齐:按时间分片存储,回放时按播放进度精准加载;
  • 前端轨道分配算法:服务端预分配轨道号,分层渲染避免弹幕重叠,降低前端计算量;
  • 实时风控反作弊:内容相似度 + 用户行为特征,识别机器刷屏;
  • Flink 实时热度统计:弹幕关键词统计、直播间实时热度值计算;
  • 多机房多活:单元化部署 + 跨区域同步,设计"区域弹幕 + 全局精选"双层体验;
  • 弹幕撤回、弹幕置顶等进阶玩法,本质都是"消息指令 + 客户端状态机"。

这些方向每一个都能单独写一篇,感兴趣的可以在评论区告诉我,点赞高的优先安排。

写在最后

回头看,弹幕系统的设计哲学其实就一句话:在实时性、可靠性、成本三者之间做 trade-off。

实时性
        /\
       /  \
      /    \
 完整性 —— 吞吐量

直播弹幕场景选的是实时性 + 吞吐量,牺牲完整性——所以全链路都围绕"可丢弃"三个字做优化:环形缓冲满了就丢、慢客户端踢掉、极端房间采样。而 seq + 推拉补漏机制保证了"丢得起,也补得回"。

落地建议给三条:

  1. 先压测再上线,四层限流的阈值全部来自压测数据,拍脑袋的阈值都是定时炸弹;
  2. 降级预案写进发布检查单,演练过才算数;
  3. 监控先行:单机连接数、MQ 堆积、端到端延迟、丢弃率,这四个指标必须有大盘和告警。

一句话记忆:推拉结合保实时,异步批量抗洪峰,背压隔离护全局。

如果这篇拆解对你有帮助,点个关注不迷路,公众号后续还会持续拆解更多高并发经典场景(下一篇预告:秒杀系统)。

最后留个互动话题:如果你的直播间突然涌入 100 万在线,你第一步会先扩容哪一层?为什么? 评论区聊聊,我会逐条回复。觉得文章有用,点赞、在看、转发走一波,你的支持是我肝长文的最大动力。