如何设计弹幕系统
一、问题描述
1.1 业务背景
弹幕系统是视频平台的特色功能,实时展示用户评论。
典型应用:
- B站弹幕:视频弹幕
- 抖音弹幕:直播弹幕
- 腾讯视频:弹幕互动
1.2 核心功能
- 发送弹幕:用户发送弹幕
- 展示弹幕:按时间轴展示
- 过滤弹幕:敏感词过滤
- 轨道分配:避免碰撞
1.3 技术挑战
高并发:
热门视频:每秒1万条弹幕
如何快速处理?实时性:
弹幕延迟要求:<500ms
如何保证实时?1.4 面试考察点
- 弹幕存储:MySQL分表、Redis缓存
- 弹幕推送:WebSocket长连接
- 敏感词过滤:DFA算法
- 轨道分配:碰撞检测算法
二、系统设计
2.1 架构图
mermaid
graph TB
subgraph 客户端
A[Web/App]
end
subgraph 接入层
B[WebSocket Gateway]
end
subgraph 业务层
C[弹幕服务]
D[敏感词过滤]
end
subgraph 存储层
E[Redis<br/>热门视频弹幕]
F[MySQL<br/>历史弹幕]
end
A <--> B
B <--> C
C --> D
C --> E
C --> F2.2 数据库设计
sql
CREATE TABLE bullet_screen (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
video_id BIGINT NOT NULL COMMENT '视频ID',
user_id BIGINT NOT NULL,
content VARCHAR(500) NOT NULL,
video_time INT NOT NULL COMMENT '视频时间点(秒)',
color VARCHAR(20) DEFAULT '#FFFFFF',
font_size TINYINT DEFAULT 25,
is_deleted TINYINT DEFAULT 0,
create_time DATETIME DEFAULT CURRENT_TIMESTAMP,
KEY idx_video_time (video_id, video_time),
KEY idx_user (user_id, create_time)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
COMMENT='弹幕表';
-- 分表:按video_id % 256三、核心实现
3.1 发送弹幕
go
type BulletScreenService struct {
db *gorm.DB
redis *redis.Client
ws *WebSocketServer
}
type BulletScreen struct {
ID int64
VideoID int64
UserID int64
Content string
VideoTime int
Color string
}
// Send 发送弹幕
func (s *BulletScreenService) Send(ctx context.Context, bullet *BulletScreen) error {
// 1. 敏感词过滤
if s.containsSensitiveWord(bullet.Content) {
return errors.New("包含敏感词")
}
// 2. 去重(同一用户5秒内不能重复发送相同内容)
if s.isDuplicate(bullet.UserID, bullet.Content) {
return errors.New("请勿重复发送")
}
// 3. 存储到MySQL
err := s.db.Create(bullet).Error
if err != nil {
return err
}
// 4. 写入Redis(热门视频)
if s.isHotVideo(bullet.VideoID) {
key := fmt.Sprintf("bullet:%d", bullet.VideoID)
data, _ := json.Marshal(bullet)
// 使用ZSet,score为视频时间点
s.redis.ZAdd(ctx, key, &redis.Z{
Score: float64(bullet.VideoTime),
Member: data,
})
// 只保留最近1小时的弹幕
s.redis.ZRemRangeByScore(ctx, key, "-inf",
fmt.Sprint(bullet.VideoTime-3600))
}
// 5. WebSocket推送给当前观看该视频的用户
s.ws.BroadcastToRoom(bullet.VideoID, bullet)
return nil
}3.2 获取弹幕
go
// GetBullets 获取指定时间段的弹幕
func (s *BulletScreenService) GetBullets(videoID int64, startTime, endTime int) ([]*BulletScreen, error) {
// 1. 尝试从Redis获取
key := fmt.Sprintf("bullet:%d", videoID)
results, err := s.redis.ZRangeByScore(ctx, key, &redis.ZRangeBy{
Min: fmt.Sprint(startTime),
Max: fmt.Sprint(endTime),
}).Result()
if err == nil && len(results) > 0 {
// Redis命中
bullets := make([]*BulletScreen, 0)
for _, data := range results {
var bullet BulletScreen
json.Unmarshal([]byte(data), &bullet)
bullets = append(bullets, &bullet)
}
return bullets, nil
}
// 2. 从MySQL查询
var bullets []*BulletScreen
err = s.db.Where("video_id = ? AND video_time BETWEEN ? AND ?",
videoID, startTime, endTime).
Find(&bullets).Error
return bullets, err
}四、敏感词过滤
4.1 DFA算法
数据结构:Trie树(前缀树)
go
type DFAFilter struct {
root *TrieNode
}
type TrieNode struct {
children map[rune]*TrieNode
isEnd bool
}
// AddWord 添加敏感词
func (f *DFAFilter) AddWord(word string) {
node := f.root
for _, char := range word {
if _, exists := node.children[char]; !exists {
node.children[char] = &TrieNode{
children: make(map[rune]*TrieNode),
}
}
node = node.children[char]
}
node.isEnd = true
}
// Contains 检查是否包含敏感词
func (f *DFAFilter) Contains(text string) bool {
runes := []rune(text)
for i := 0; i < len(runes); i++ {
node := f.root
j := i
for j < len(runes) {
char := runes[j]
if child, exists := node.children[char]; exists {
if child.isEnd {
return true // 找到敏感词
}
node = child
j++
} else {
break
}
}
}
return false
}
// Replace 替换敏感词为***
func (f *DFAFilter) Replace(text string) string {
runes := []rune(text)
result := make([]rune, len(runes))
copy(result, runes)
for i := 0; i < len(runes); i++ {
node := f.root
j := i
matchEnd := -1
for j < len(runes) {
char := runes[j]
if child, exists := node.children[char]; exists {
if child.isEnd {
matchEnd = j
}
node = child
j++
} else {
break
}
}
// 替换敏感词
if matchEnd != -1 {
for k := i; k <= matchEnd; k++ {
result[k] = '*'
}
i = matchEnd
}
}
return string(result)
}五、弹幕轨道分配
5.1 碰撞检测算法
原理:将屏幕分为多个轨道,每个轨道独立分配弹幕
go
type TrackManager struct {
tracks []*Track
}
type Track struct {
id int
bullets []*BulletInfo
lastEndTime float64 // 上一条弹幕结束时间
}
type BulletInfo struct {
content string
width float64 // 弹幕宽度
speed float64 // 移动速度
startTime float64 // 开始时间
endTime float64 // 结束时间
}
// AllocateTrack 分配轨道
func (m *TrackManager) AllocateTrack(bullet *BulletInfo) int {
currentTime := time.Now().UnixMilli() / 1000.0
// 遍历所有轨道,找到最早可用的
for _, track := range m.tracks {
if currentTime >= track.lastEndTime {
// 该轨道可用
track.lastEndTime = bullet.endTime
track.bullets = append(track.bullets, bullet)
return track.id
}
}
// 所有轨道都占用,返回-1(丢弃弹幕)
return -1
}
// CalculateEndTime 计算弹幕结束时间
func (m *TrackManager) CalculateEndTime(bullet *BulletInfo) float64 {
screenWidth := 1920.0 // 屏幕宽度
// 弹幕完全移出屏幕的时间
distance := screenWidth + bullet.width
duration := distance / bullet.speed
return bullet.startTime + duration
}可视化:
轨道1: [弹幕A======> ]
轨道2: [ 弹幕B====> ]
轨道3: [弹幕C========> ]
轨道4: [空闲 ]六、WebSocket推送
go
type WebSocketServer struct {
rooms map[int64][]*websocket.Conn // videoID -> connections
mu sync.RWMutex
}
// Join 用户加入房间
func (s *WebSocketServer) Join(videoID int64, conn *websocket.Conn) {
s.mu.Lock()
defer s.mu.Unlock()
s.rooms[videoID] = append(s.rooms[videoID], conn)
}
// Leave 用户离开房间
func (s *WebSocketServer) Leave(videoID int64, conn *websocket.Conn) {
s.mu.Lock()
defer s.mu.Unlock()
conns := s.rooms[videoID]
for i, c := range conns {
if c == conn {
s.rooms[videoID] = append(conns[:i], conns[i+1:]...)
break
}
}
}
// BroadcastToRoom 广播到房间
func (s *WebSocketServer) BroadcastToRoom(videoID int64, bullet *BulletScreen) {
s.mu.RLock()
conns := s.rooms[videoID]
s.mu.RUnlock()
data, _ := json.Marshal(bullet)
for _, conn := range conns {
go conn.WriteMessage(websocket.TextMessage, data)
}
}七、性能优化
7.1 冷热分离
go
// 热门视频:缓存到Redis
// 冷门视频:直接查MySQL
func (s *BulletScreenService) isHotVideo(videoID int64) bool {
// 查询视频播放量
var playCount int64
s.redis.Get(ctx, fmt.Sprintf("video:play:%d", videoID)).Scan(&playCount)
return playCount > 10000 // 播放量>1万为热门
}7.2 弹幕聚合
go
// 每100ms批量推送一次
func (s *WebSocketServer) batchBroadcast() {
ticker := time.NewTicker(100 * time.Millisecond)
for range ticker.C {
for videoID, bullets := range s.pendingBullets {
if len(bullets) > 0 {
s.BroadcastToRoom(videoID, bullets)
s.pendingBullets[videoID] = nil
}
}
}
}7.3 性能数据
| 指标 | 优化前 | 优化后 | 提升 |
|---|---|---|---|
| 弹幕延迟 | 2s | 300ms | 6.7x |
| 支持并发 | 1000 | 1万 | 10x |
| CPU使用 | 80% | 30% | - |
八、面试要点
8.1 常见追问
Q1: 弹幕如何存储?
A:
- MySQL:历史弹幕,按video_id分表
- Redis ZSet:热门视频弹幕,score为视频时间点
- 冷热分离:播放量>1万的视频缓存到Redis
Q2: 敏感词过滤如何实现?
A: DFA算法(Trie树)
- 时间复杂度:O(n),n为文本长度
- 空间复杂度:O(m*k),m为敏感词数量,k为平均长度
Q3: 弹幕轨道如何分配?
A: 碰撞检测算法
- 将屏幕分为N个轨道
- 新弹幕到来时,找到最早可用的轨道
- 计算弹幕结束时间,更新轨道状态
Q4: B站的弹幕架构是怎样的?
A:
- 存储:HBase存储海量弹幕
- 推送:WebSocket长连接
- 防护:敏感词过滤 + 反垃圾
- 优化:CDN加速弹幕加载
8.2 扩展知识点
相关场景题:
九、总结
弹幕系统设计要点:
- 存储:MySQL分表 + Redis缓存
- 推送:WebSocket长连接 + 房间广播
- 过滤:DFA算法敏感词过滤
- 轨道:碰撞检测算法
面试重点:
- 能画出弹幕架构图
- 能实现DFA敏感词过滤
- 能设计弹幕轨道分配算法
- 能说出B站弹幕架构
参考资料:
- B站弹幕技术分享
- 抖音直播弹幕架构
