Skip to content

如何设计弹幕系统

一、问题描述

1.1 业务背景

弹幕系统是视频平台的特色功能,实时展示用户评论。

典型应用

  • B站弹幕:视频弹幕
  • 抖音弹幕:直播弹幕
  • 腾讯视频:弹幕互动

1.2 核心功能

  1. 发送弹幕:用户发送弹幕
  2. 展示弹幕:按时间轴展示
  3. 过滤弹幕:敏感词过滤
  4. 轨道分配:避免碰撞

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 --> F

2.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 性能数据

指标优化前优化后提升
弹幕延迟2s300ms6.7x
支持并发10001万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: 碰撞检测算法

  1. 将屏幕分为N个轨道
  2. 新弹幕到来时,找到最早可用的轨道
  3. 计算弹幕结束时间,更新轨道状态

Q4: B站的弹幕架构是怎样的?

A:

  • 存储:HBase存储海量弹幕
  • 推送:WebSocket长连接
  • 防护:敏感词过滤 + 反垃圾
  • 优化:CDN加速弹幕加载

8.2 扩展知识点

相关场景题

九、总结

弹幕系统设计要点:

  1. 存储:MySQL分表 + Redis缓存
  2. 推送:WebSocket长连接 + 房间广播
  3. 过滤:DFA算法敏感词过滤
  4. 轨道:碰撞检测算法

面试重点

  • 能画出弹幕架构图
  • 能实现DFA敏感词过滤
  • 能设计弹幕轨道分配算法
  • 能说出B站弹幕架构

参考资料

  • B站弹幕技术分享
  • 抖音直播弹幕架构

正在精进