直播间消息系统设计

直播间的消息系统看起来和普通 IM 差不多——用户发消息,其他人收到。但直播场景有几个极端特性让它成为一个独立的工程问题:单个房间可能同时有百万人在线、消息量在几秒内可以从零飙到每秒数万条、不同类型的消息对可靠性的要求截然不同。

本文拆解直播间消息系统的核心设计思路。

消息分类:先想清楚要解决什么问题

直播间里的消息不是一种东西。把它们混在一起处理是很多早期系统的错误。

消息类型 典型量级 可靠性要求 延迟要求
弹幕(普通聊天) 万条/秒 低(丢几条无所谓) 1~3 秒
礼物 百条/秒 高(涉及钱,不能丢) 500ms 内
系统通知(进场、关注) 千条/秒 中(丢少量可接受) 2 秒内
主播控制(禁言、踢人) 极低 极高(必须送达) 500ms 内
点赞/互动计数 百万次/秒 极低(聚合展示) 3~5 秒

弹幕量最大但可以丢;礼物量小但不能丢;点赞量巨大但只需要展示聚合数字。用同一套管道处理这五类消息,要么礼物丢失,要么弹幕把系统打爆。正确的做法是按可靠性和优先级分离处理链路。

连接层:长连接的管理

消息推送需要服务端主动向客户端推数据,这要求长连接。主流方案有三种:

  • WebSocket:双向全双工,浏览器原生支持,是 Web 端的标准选择
  • TCP 自定义协议:App 端常用,可以做更精细的心跳和重连控制
  • HTTP 长轮询:兜底方案,在 WebSocket 不可用时降级

百万并发的核心挑战是连接数。一台普通服务器能维持的 TCP 连接数取决于文件描述符限制(默认 65535,可调到百万级)和内存(每个连接约 10~50KB 内存)。单机维持 10 万连接是常见配置,百万并发需要约 10 台接入服务器。

接入层(Gateway)只负责维持连接,不处理业务逻辑:

graph TD
    Client[Client 客户端]
    GW[Gateway 接入层 维持连接]
    Router[Router 路由层 知道谁在哪台Gateway]
    MS[Message Service 消息处理]

    Client -->|WebSocket| GW
    GW -->|内部 RPC/MQ| Router
    Router --> MS

Gateway 是无状态的(除了连接本身),可以水平扩展。连接信息(用户 ID → Gateway 地址)存在 Redis 里,消息服务通过查 Redis 找到目标用户在哪台 Gateway,再通过内部通道推送。

弹幕:限流是核心,不是优化

一个百万人的直播间,假设 1% 的用户每 10 秒发一条弹幕,就是每秒 1000 条。热门直播可以轻松达到每秒数万条。这个量直接推给所有在线用户是不可能的——客户端渲染不过来,网络也撑不住。

弹幕系统的核心不是"怎么可靠地投递",而是"怎么合理地丢弃"。

发送端限流

每个用户的发送频率限制(如每 2 秒最多 1 条),用 Redis 的滑动窗口或令牌桶实现:

-- Redis Lua 脚本,滑动窗口限流
local key = "rate:" .. user_id
local now = tonumber(ARGV[1])
local window = 2000  -- 2 秒窗口
local limit = 1      -- 最多 1 条

redis.call("ZREMRANGEBYSCORE", key, 0, now - window)
local count = redis.call("ZCARD", key)
if count < limit then
    redis.call("ZADD", key, now, now)
    redis.call("EXPIRE", key, 10)
    return 1  -- 允许
else
    return 0  -- 拒绝
end

服务端采样

即使通过了发送端限流,服务端收到的消息量仍然可能很大。对于普通弹幕,服务端按比例采样后再广播:

  • 正常情况:全量推送
  • 消息量超过阈值(如每秒 500 条):随机采样 50% 推送
  • 消息量极高(如每秒 2000 条):采样 20%,优先保留高等级用户(VIP、粉丝团)的消息

采样策略对用户来说几乎无感——弹幕本来就是刷过去的,少几条没人注意。

客户端渲染限制

客户端维护一个弹幕队列,超出渲染能力(如每秒 50 条)的消息直接丢弃,不展示。这是最后一道防线,防止客户端因弹幕过密卡顿。

礼物:可靠投递的两个问题

礼物消息涉及虚拟货币扣减,不能丢,也不能重复。这是两个独立的问题。

不能丢:消息队列保障

礼物流程:

graph TD
    A[用户点击送礼]
    B[礼物服务 扣减虚拟货币 写DB 发MQ]
    C[MQ Kafka 持久化]
    D[消息推送服务 消费MQ 推给直播间所有用户]
    E[主播端展示礼物特效]

    A --> B --> C --> D --> E

礼物写入 DB 和发 MQ 要在同一个事务里,或者用事务消息(先写 DB,再发确认消息到 MQ)。消息推送服务消费 MQ 时,如果推送失败,MQ 会重试,直到推送成功。

不能重复:幂等设计

MQ 重试可能导致同一条礼物消息被消费多次,推送多次礼物特效。解决方案是为每条礼物消息生成唯一 ID(gift_id),推送服务用 Redis 记录已推送的 gift_id:

def handle_gift_message(gift_id, room_id, gift_data):
    key = f"gift:sent:{gift_id}"
    # SET NX:只有 key 不存在时才设置,原子操作
    if redis.set(key, 1, nx=True, ex=300):
        # 第一次处理,推送特效
        push_to_room(room_id, gift_data)
    else:
        # 已推送过,忽略重复消息
        pass

房间广播:扇出问题

直播间消息需要广播给房间内所有用户,这是一个典型的扇出(Fan-out)场景。百万人的房间,一条消息要推送给 100 万个连接,这个扇出放大比是系统设计的核心挑战。

服务端广播的瓶颈

如果消息服务直接给每个用户推送,100 万用户 × 每秒 100 条消息 = 每秒 1 亿次推送操作。这显然不现实。

实际架构是分层广播:

graph TD
    MS[消息服务]
    GW1[Gateway 1]
    GW2[Gateway 2]
    GWN[Gateway 10]
    U1[用户 1~10万]
    U2[用户 10~20万]
    UN[用户 90~100万]

    MS -->|广播到所有Gateway 约10台| GW1
    MS --> GW2
    MS --> GWN
    GW1 -->|各自推送给本机连接的用户| U1
    GW2 --> U2
    GWN --> UN

消息服务只需要向 10 台 Gateway 发送,每台 Gateway 负责向自己维护的 10 万个连接推送。扇出被分散到了 Gateway 层。

热门房间的特殊处理

超大直播间(百万级)还需要进一步优化:

  • 消息合并:同一时间窗口(如 100ms)内的多条消息打包成一个包发送,减少推送次数
  • 分级推送:活跃用户(最近有操作)优先推送,长时间无操作的用户降低推送频率
  • CDN 边缘推送:对于弹幕这类可以接受轻微延迟的消息,通过 CDN 边缘节点就近推送,减少回源压力

消息顺序与去重

分布式系统里保证消息顺序是个经典难题。直播间的处理思路是:不强求全局顺序,只保证同一用户的消息有序,以及重要消息(礼物、系统通知)的相对顺序

消息序号

每个房间维护一个单调递增的序号(用 Redis INCR 生成),每条消息带上序号。客户端按序号排序展示,收到乱序消息时短暂缓冲后重排:

class MessageBuffer:
    def __init__(self):
        self.buffer = {}       # seq -> message
        self.next_seq = 1      # 期望的下一个序号
        self.max_wait = 0.5    # 最多等待 500ms

    def add(self, seq, message):
        self.buffer[seq] = message
        self._flush()

    def _flush(self):
        while self.next_seq in self.buffer:
            yield self.buffer.pop(self.next_seq)
            self.next_seq += 1

        # 等待超时,跳过缺失的序号(允许少量消息丢失)
        if self.buffer and min(self.buffer) > self.next_seq:
            oldest_time = min(msg.timestamp for msg in self.buffer.values())
            if time.now() - oldest_time > self.max_wait:
                self.next_seq = min(self.buffer)

断线重连的消息补全

用户断线重连后,需要补发断线期间的消息。弹幕不需要补全(太多了,补全没意义),但礼物和系统通知需要。

做法:客户端重连时带上最后收到的消息序号,服务端从消息存储中查询该序号之后的重要消息(礼物、系统通知)补发。弹幕只补发最近 N 条(如 20 条),让用户感知到直播间是活跃的。

风控:不只是敏感词过滤

弹幕风控的难点在于实时性要求高(消息要在发出后 100ms 内完成审核),同时要对抗各种绕过手段。

第一层:本地过滤(客户端)

基础敏感词在客户端本地过滤,减少无效请求到达服务端。但客户端过滤容易被绕过,只作为第一道防线。

第二层:服务端实时审核

消息到达服务端后,异步送入审核队列。审核通过的消息正常推送;审核不通过的消息发送方看到自己的消息,其他人看不到(俗称"影子封禁",减少被封禁用户的对抗行为)。

第三层:用户行为特征

单纯的文本过滤容易被 Unicode 变体、谐音、图片等绕过。更有效的是行为特征:

  • 短时间内大量发送相同或相似消息(刷屏)
  • 账号注册时间短、无历史互动记录
  • IP 或设备指纹与已知违规账号关联

基于行为特征的风控可以在不看消息内容的情况下识别机器刷屏,误伤率更低。

点赞计数:高频写入的聚合

点赞是直播间里频率最高的操作,一个热门直播间每秒可能有数十万次点赞。如果每次点赞都写 DB,数据库直接被打爆。

标准做法是内存聚合 + 批量写入

graph TD
    A[用户点赞]
    B[Redis INCR room:likes:room_id 内存计数 极快]
    C[定时任务 每5秒读取Redis计数 写入DB增量更新]
    D[DB 存储最终总计数]

    A --> B --> C --> D

对客户端展示的点赞数,直接读 Redis,不查 DB。DB 只用于持久化,防止 Redis 重启后数据丢失。

点赞数的展示不需要精确——显示"23.4万"比显示"234127"更好,聚合展示本身就允许一定误差。

整体架构

graph TD
    Client[客户端 App/Web\nWebSocket长连接 + HTTP短连接]
    GW[接入层 Gateway\n维持长连接 无状态 可扩展]
    DM[弹幕服务\n限流采样]
    Gift[礼物服务\nMQ保障]
    Notify[通知服务\n控制指令]
    Router[消息路由层\n查Redis找目标用户所在Gateway 分发消息]
    Redis[Redis\n连接映射 计数聚合]
    Kafka[Kafka\n礼物队列]

    Client --> GW
    GW -->|内部通道| DM
    GW -->|内部通道| Gift
    GW -->|内部通道| Notify
    DM --> Router
    Gift --> Router
    Notify --> Router
    Router --> Redis
    Router --> Kafka

几个容易忽略的细节

心跳与连接保活:客户端每 30 秒发一次心跳包,服务端超过 60 秒没收到心跳则主动断开连接。这样可以及时清理僵尸连接,释放服务器资源。

连接数统计的延迟:直播间的在线人数不能实时精确统计(太贵),通常是每 10~30 秒采样一次,显示的是近似值。用户看到的"100万在线"可能是 30 秒前的数字。

主播端的优先级:主播自己发出的消息(如主播说话)应该有最高优先级,在限流和采样中豁免,确保主播的互动消息必然到达所有观众。

消息的 TTL:弹幕消息不需要永久存储。通常保留最近 N 条(如 200 条)用于新进入用户的初始化加载,历史弹幕不做长期存储。礼物消息需要持久化,用于账单和对账。

直播结束的清理:直播结束时,需要通知所有连接的客户端断开,清理 Redis 中的房间状态,取消所有定时任务。这个清理流程如果不做,会有大量僵尸数据积累。

总结

直播间消息系统的核心设计原则可以归结为三点:

  • 按消息类型分离处理链路:弹幕可以丢,礼物不能丢,混在一起处理两头都不好
  • 扇出问题分层解决:消息服务 → Gateway 层 → 客户端,每层只承担自己的扇出规模
  • 限流是功能,不是优化:弹幕系统设计的核心是合理丢弃,而不是尽力投递

这套架构在抖音、B 站、虎牙等平台都有类似的实践。具体实现细节各家不同,但核心思路——消息分类、分层广播、礼物可靠投递、弹幕限流采样——是通用的。