直播弹幕系统的高并发实时推送架构设计与实现

从单体直推到分层扩散,系统讲解支撑百万级在线的直播弹幕实时推送架构设计,涵盖 WebSocket 长连接管理、消息合并、分级限流等核心方案与工程细节。

一、引子:弹幕系统为什么难做

弹幕乍看只是"发消息":用户输入一句话,服务端推给房间里的其他人。但当房间人数从几百涨到百万级时,这个看似简单的模块会迅速成为整个直播系统里最脆弱的一环。

原因在于它的扩散特性。假设一个房间有 80 万人同时在线,峰值弹幕量达到每秒 12 万条,若每条弹幕都原样推送给每个人,理论推送量将是天文数字——任何单体架构都会在短时间内被 CPU 和带宽打满,延迟从数百毫秒恶化到数秒,用户侧表现为"弹幕刷不出来、画面卡顿"。

因此弹幕系统远不是"推消息"这么简单。它需要在"实时性、到达率、成本"三者之间做精细权衡。本文将按照架构演进的顺序,拆解从单体直推到分层扩散的完整设计思路,以及其中容易被忽视的工程细节。

二、业务场景分析

2.1 弹幕系统的特殊性

弹幕看起来和普通IM聊天很像,但有几个本质区别:

  • 读写比极度不均衡:一个人发弹幕,房间里几十万人要收到。写少读多,是典型的"一写多读"扩散模型。

  • 实时性要求高:弹幕延迟超过3秒,用户就会觉得"主播没理我",体验断崖式下降。

  • 可丢弃性:和支付消息不同,弹幕丢几条用户基本无感。这个特性非常关键,是后面所有降级策略的基础。

  • 热点极端集中:平时每个房间几百人,但一场大型直播能瞬间聚集百万人,流量高度倾斜。

2.2 核心场景

场景1:普通直播间(百人~万人级)

主播房间 房间人数:8000
├─ 用户发弹幕 "主播好帅"
├─ 服务端接收 → 校验 → 存储
└─ 扩散推送给房间内 8000 个连接

这是最常见的场景,8000次推送在单机就能轻松处理。

场景2:热门直播间(十万~百万级)

头部主播生日会 房间人数:800000
├─ 峰值弹幕量:120000 条/秒
├─ 单条弹幕需扩散:800000 次推送
└─ 理论推送量:120000 × 800000 = 960 亿次/秒 (不可能实现)

这是最具挑战的场景。如果每条弹幕都原样推给每个人,推送量是天文数字,任何集群都扛不住。这里必须引入合并、采样、限流。

场景3:全平台广播(跨房间)

用户在房间A送出"超级火箭"
↓
触发全站飘屏,所有在线用户(含其他房间)顶部看到:
"用户X 在 某直播间 送出了 超级火箭"

这类消息量不大但覆盖面全,需要独立的广播通道,不能和房间内弹幕混在一起。

2.3 核心指标要求

  • 弹幕端到端延迟 P99:< 1 秒

  • 单房间支持在线:100 万+

  • 消息到达率(普通弹幕):> 90%(允许降级丢弃)

  • 消息到达率(付费弹幕/礼物):> 99.9%(不可丢)

  • 单机 WebSocket 长连接数:10 万+

三、架构演进

3.1 V1版本:单体WebSocket直推

最朴素的架构:一台网关服务器,用户直连,发弹幕就遍历房间内所有连接推送。

         ┌──────────────────┐
用户 ───→ │  WebSocket 网关   │ ───→ 遍历房间连接列表
         │  (单机/少量机器)  │      逐个 conn.Send()
         └──────────────────┘

瓶颈所在:

  • 单机连接数上限约 5 万,百万级在线根本连不上

  • 推送是同步遍历,一条热门弹幕要循环几十万次,CPU 瞬间打满

  • 一台网关挂了,上面所有用户全部掉线

这个版本在小房间可用,一遇到大型直播就会迅速崩溃。

3.2 V2版本:网关集群 + 消息队列解耦

第一步优化,核心思路是接入层和逻辑层分离,并用消息队列削峰。

                  ┌─────────────┐
用户 ──长连接──→  │  接入网关层  │ (多机,只管连接)
                  │ Gateway×N   │
                  └──────┬──────┘
                         │ 上行弹幕
                         ↓
                  ┌─────────────┐
                  │   Kafka     │ 削峰缓冲
                  └──────┬──────┘
                         ↓
                  ┌─────────────┐
                  │  逻辑处理层  │ 校验/存储/过滤
                  │  Logic×N    │
                  └──────┬──────┘
                         │ 下行推送指令
                         ↓
                  ┌─────────────┐
                  │ 推送回接入层 │ → 扩散给用户
                  └─────────────┘

关键设计:

  • 接入网关只负责维护长连接和收发字节,无业务逻辑,可水平扩展

  • 用户连接信息(哪个用户在哪台网关的哪个房间)注册到 Redis

  • 上行弹幕先进 Kafka,逻辑层按消费能力拉取,天然削峰

  • 逻辑层处理完,通过内部 RPC 把推送指令发回对应网关

这个版本把单机瓶颈解决了,但大直播时扩散推送仍然是灾难——百万人房间,一条弹幕要跨多台网关推百万次。

3.3 V3版本:分层扩散 + 合并限流(最终形态)

这一步才真正解决热点问题。核心是三板斧:房间分片、消息合并、分级限流。

                    ┌──────────────────────┐
   上行弹幕 ──────→  │  逻辑层:弹幕处理      │
                    │  - 敏感词过滤          │
                    │  - 频率限制(单用户)    │
                    │  - 优先级标记          │
                    └──────────┬───────────┘
                               ↓
                    ┌──────────────────────┐
                    │  合并缓冲层(100ms窗口) │
                    │  高频弹幕打包成批次    │
                    └──────────┬───────────┘
                               ↓
            ┌──────────────────┼──────────────────┐
            ↓                  ↓                  ↓
      ┌──────────┐      ┌──────────┐      ┌──────────┐
      │ 网关分片1 │      │ 网关分片2 │      │ 网关分片N │
      │ 10万连接  │      │ 10万连接  │      │ 10万连接  │
      └──────────┘      └──────────┘      └──────────┘
      
   大房间按连接数拆到多台网关,逻辑层只需给每台网关发一次批次,
   由网关本地扇出给自己管理的连接(本地扇出不跨网络,极快)

三个核心优化:

  1. 本地扇出:逻辑层不直接推给每个用户,而是把"这个房间的这批弹幕"发给每台持有该房间连接的网关一次,网关在本地内存里扇出。跨网络推送从百万次降到几十次(网关台数)。

  1. 消息合并:100ms 时间窗口内的弹幕打包成一个批次下发,前端一次性渲染。弹幕量越大,合并收益越高。

  1. 分级限流:普通弹幕在高峰期按比例采样丢弃,付费弹幕/礼物弹幕走高优先级通道绝不丢弃。

四、核心技术实现

4.1 长连接管理

单机扛 10 万+ 连接,关键在于用 epoll + 协程模型,避免一个连接一个线程。以 Go 为例:

// 连接管理器:每个房间维护一个连接集合
type Room struct {
    ID    string
    conns sync.Map // connID -> *Connection
    count int64    // 原子计数
}

type Connection struct {
    ID     string
    UserID int64
    ws     *websocket.Conn
    sendCh chan []byte // 每连接一个发送缓冲通道
}

// 每个连接独立的写协程,避免并发写同一个 ws(会 panic)
func (c *Connection) writeLoop() {
    for msg := range c.sendCh {
        // 带超时写,防止慢连接拖垮整体
        c.ws.SetWriteDeadline(time.Now().Add(2 * time.Second))
        if err := c.ws.WriteMessage(websocket.BinaryMessage, msg); err != nil {
            c.close() // 写失败直接关闭
            return
        }
    }
}

// 推送:非阻塞,缓冲满了直接丢(弹幕可丢弃特性)
func (c *Connection) push(msg []byte) {
    select {
    case c.sendCh <- msg:
    default:
        // 缓冲满说明这个用户网络慢,丢弃本条,不阻塞扇出
        atomic.AddInt64(&droppedCount, 1)
    }
}

注意点:如果使用阻塞式 sendCh <- msg,一旦某个网络差的用户缓冲写满,整个房间的扇出协程就会卡在他身上,导致全房间弹幕停滞。应改成非阻塞发送 + 缓冲满即丢,这样慢用户只影响自己,不会拖垮整个房间。

4.2 房间内本地扇出

// 网关收到逻辑层下发的批次,在本地扇出
func (r *Room) broadcast(batch []byte) {
    r.conns.Range(func(_, v interface{}) bool {
        conn := v.(*Connection)
        conn.push(batch) // 非阻塞推送
        return true
    })
}

扇出纯内存操作,10 万连接的房间扇一轮大约 10-20ms,完全可接受。

4.3 消息合并缓冲

// 合并器:按房间聚合 100ms 窗口内的弹幕
type Merger struct {
    buffer map[string][]*Barrage // roomID -> 弹幕列表
    mu     sync.Mutex
}

func (m *Merger) add(b *Barrage) {
    m.mu.Lock()
    m.buffer[b.RoomID] = append(m.buffer[b.RoomID], b)
    m.mu.Unlock()
}

// 每 100ms 刷新一次,打包下发
func (m *Merger) flushLoop() {
    ticker := time.NewTicker(100 * time.Millisecond)
    for range ticker.C {
        m.mu.Lock()
        batches := m.buffer
        m.buffer = make(map[string][]*Barrage)
        m.mu.Unlock()

        for roomID, list := range batches {
            // 单房间超量时采样,只保留部分普通弹幕
            list = sample(roomID, list)
            packed := encodeBatch(list) // 一次编码,多条弹幕
            dispatchToGateways(roomID, packed)
        }
    }
}

合并带来的收益:某次大直播峰值弹幕 12 万条/秒,合并后下发批次只有约 2000 批/秒(每批平均 60 条),下行推送频率降低了 60 倍。前端收到批次后用 requestAnimationFrame 分帧渲染,不卡顿。

4.4 分级限流与采样

弹幕"可丢弃"的特性在这里发挥到极致:

func sample(roomID string, list []*Barrage) []*Barrage {
    online := getRoomOnline(roomID)
    // 根据在线人数动态计算这个房间每批允许的弹幕上限
    limit := calcLimit(online) // 比如百万房间每批最多展示50条

    var kept []*Barrage
    var normal []*Barrage
    for _, b := range list {
        if b.Priority == HighPriority {
            kept = append(kept, b) // 付费/礼物弹幕无条件保留
        } else {
            normal = append(normal, b)
        }
    }

    // 普通弹幕采样到剩余配额
    remain := limit - len(kept)
    if remain > 0 && len(normal) > remain {
        normal = randomSample(normal, remain)
    }
    return append(kept, normal...)
}

产品层面的妙处:百万人房间,就算每秒只展示 50 条弹幕,用户也完全感觉不到被丢弃——屏幕本来就放不下更多。但付费弹幕一条不丢,这是收入保障。这个策略能在流量洪峰时同时保住体验和核心数据。

这里补充一点业务层面的观察:付费弹幕/礼物弹幕之所以要"一条不丢、还要优先展示",本质是因为它直接关系到主播和平台的收入,也是用户花钱买的那份"被看见"的仪式感。因此这条高优先级通道上跑的内容,在视觉呈现上同样值得投入——一个设计精良的动画礼物飘屏,带来的付费转化和互动,往往远高于纯文字弹幕。XingJi Shop 正是专注于这一环,为语音/直播类 App 提供可直接接入的动画虚拟礼物与特效素材。技术和内容其实是一体的:底层保证"礼物弹幕不丢、低延迟到达",上层用好的动效让这条弹幕值回票价。

4.5 连接路由与状态管理

百万连接分散在几十台网关上,逻辑层怎么知道该把房间批次发给哪些网关?

Redis 维护两级映射:

room:{roomID}:gateways → Set{gw-01, gw-07, gw-15, ...}
  (这个房间的连接分布在哪些网关上)

gw:{gwID}:rooms → Set{room-A, room-B, ...}
  (这台网关承载了哪些房间)

逻辑层下发时,先查 room:{roomID}:gateways 拿到网关列表,只给这几台发,而不是广播给所有网关。网关上下线时更新这两个集合,用 Redis 的过期 + 心跳续期保证最终一致。

注意点:若不做这层路由,逻辑层会把每个房间的批次广播给全部网关,网关收到后再判断"我没有这个房间就丢弃",结果网关集群内部网络带宽被大量无效消息占满。引入精准路由后,内部流量通常可下降约 90%。

4.6 上行频率限制

防止单个用户或脚本刷屏:

// 基于 Redis 的滑动窗口限流,单用户每秒最多 2 条弹幕
func allowSend(userID int64) bool {
    key := fmt.Sprintf("rate:barrage:%d", userID)
    cnt, _ := redis.Incr(key)
    if cnt == 1 {
        redis.Expire(key, time.Second)
    }
    return cnt <= 2
}

超限的弹幕直接在接入层丢弃,连 Kafka 都不进,从源头挡住刷屏流量。

五、常见工程问题与应对

5.1 惊群:主播进场瞬间的连接风暴

大主播开播的瞬间,几十万粉丝的客户端收到推送同时涌入建立 WebSocket 连接,网关瞬间被连接请求打垮。

解决方案:客户端加随机抖动(0-3 秒随机延迟再连接),把尖峰摊平;网关侧对新建连接做令牌桶限速,超过速率的连接排队重试。

5.2 消息乱序:合并后弹幕顺序错乱

合并批次经过多台网关、多个协程后,用户看到的弹幕顺序容易和发送顺序对不上,尤其礼物弹幕的展示次序错乱会直接影响付费体验。

解决方案:每条弹幕带服务端生成的递增序列号(基于房间维度),前端按序列号排序后再渲染。合并批次内部保持有序。

5.3 内存泄漏:掉线连接没清理干净

压测中容易观察到网关内存持续增长。根因通常是用户异常断网(没有正常 close 帧)时,服务端的连接对象和 Redis 路由没被及时清理,sync.Map 里积压了大量僵尸连接。

解决方案:每个连接加心跳检测(30 秒无 pong 判定离线),离线立即清理内存对象 + Redis 路由 + 发送通道,内存即可保持稳定。

5.4 热点房间打垮单台网关

某台网关恰好承载了多个大房间,连接数远超其他网关,被打爆。

解决方案:引入连接数感知的负载均衡——新连接优先分配给当前负载最低的网关;大房间强制分散到多台网关,单台承载同一房间的连接数设上限。

5.5 全站广播冲击房间通道

早期全站飘屏(火箭广播)和房间弹幕共用同一套下发通道,一次全站广播把所有网关的下发队列挤占,房间弹幕集体延迟。

解决方案:广播消息走独立通道和独立协程池,与房间弹幕物理隔离,互不影响。

六、性能数据对比

指标

V1单体

V2集群

V3分层扩散

单房间最大在线

5 万

20 万

100 万+

弹幕端到端延迟 P99

8 秒(高峰)

2 秒

800 毫秒

单机长连接数

5 万

8 万

12 万

峰值弹幕处理

2 万/秒

6 万/秒

15 万/秒

大直播稳定性

必崩

偶发延迟

稳定

可以看到,随着架构从单体演进到分层扩散,单房间在线能力提升了约 20 倍,而高峰延迟则从秒级压缩到亚秒级。这些指标的改善并非依赖堆硬件,而是源于对弹幕"可丢弃、读多写少、热点集中"等特性的充分利用。

七、一些思考

弹幕系统设计中一个核心认知是:它的关键不在于"怎么把消息都推出去",而在于"怎么在扛不住的时候优雅地少推一点"。

和支付、订单这些强一致系统不同,弹幕的"可丢弃"特性是一把利器。想明白"用户其实看不完所有弹幕"这件事之后,采样、合并、限流这些策略才敢大胆地用。技术方案的选择,很多时候是对业务特性理解的直接映射。

另外,热点问题几乎是所有实时系统的终极考验。平时跑得好好的架构,一遇到流量倾斜就原形毕露。分层扩散、本地扇出、精准路由,本质上都是在和"热点"做斗争。

真正的高并发,不是让系统能处理所有请求,而是让系统在任何流量下都不崩溃。

希望这篇文章能为正在设计实时推送系统的工程师提供一些参考思路。

最后更新:

相关素材

浪漫花语璃光仙姿告白流星雨仓鼠转转球