直播间的消息系统看起来和普通 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 站、虎牙等平台都有类似的实践。具体实现细节各家不同,但核心思路——消息分类、分层广播、礼物可靠投递、弹幕限流采样——是通用的。