🎥 设计一个直播弹幕系统:弹幕的狂欢!

35 阅读11分钟

📖 开场:体育场的呐喊

想象一场足球比赛 ⚽:

1.0版(现场观看)

10万观众在体育场
    ↓
球员进球了!⚽
    ↓
10万人同时欢呼 🎉

特点:
- 现场氛围好 ✅
- 但只有10万人能来 ❌

2.0版(电视直播)

1000万观众在家看电视
    ↓
球员进球了!⚽
    ↓
观众只能自己激动 😢

特点:
- 观众多 ✅
- 但没有互动 ❌
- 很孤独 ❌

3.0版(直播+弹幕)

1000万观众在直播平台
    ↓
球员进球了!⚽
    ↓
1000万条弹幕飞过屏幕 🎊
"牛逼!"
"666!"
"卧槽!"

特点:
- 观众多 ✅
- 有互动 ✅
- 有氛围 ✅

这就是弹幕系统:让线上直播也有现场氛围!


🤔 核心挑战

挑战1:高并发 🔥

热门直播间:100万人在线
    ↓
每秒10万条弹幕
    ↓
如何推送给100万人?💀

问题:
- QPS:100万 * 10万 = 1000亿次推送 💀
- 服务器扛不住 ❌

挑战2:消息广播 📡

用户A:发送弹幕"666"
    ↓
推送给100万在线观众
    ↓
如何高效广播?

方案:
- WebSocket推送
- 消息队列
- 消息合并

挑战3:流量削峰 🌊

主播:说了一句搞笑的话 😂
    ↓
1秒内:10万条弹幕 💀
    ↓
如何削峰?

方案:
- 限流(每人每秒最多1条)
- 采样(只推送10%的弹幕)
- 合并(多条弹幕合并推送)

挑战4:敏感词过滤 🚫

用户:发送弹幕"fuck"
    ↓
敏感词过滤
    ↓
替换为:f**k ✅

实现:
- DFA算法(有限状态机)
- 敏感词库(Redis)
- 毫秒级过滤

🎯 架构设计

整体架构

        直播弹幕系统架构

┌────────────────────────────────────────┐
│           客户端(App/Web)             │
│  - WebSocket长连接                     │
│  - 发送弹幕                            │
│  - 接收弹幕                            │
└──────────────┬─────────────────────────┘
               │ WebSocket
               ↓
┌────────────────────────────────────────┐
│        接入层(Gateway)               │
│                                        │
│  - WebSocket连接管理                  │
│  - 负载均衡                           │
│  - 心跳检测                           │
└──────────────┬─────────────────────────┘
               │
               ↓
┌────────────────────────────────────────┐
│        弹幕服务(Danmaku Service)      │
│                                        │
│  - 弹幕接收                           │
│  - 敏感词过滤                         │
│  - 限流                               │
│  - 消息广播                           │
└──────────────┬─────────────────────────┘
               │
               ↓
┌────────────────────────────────────────┐
│        消息队列(Kafka)               │
│                                        │
│  - 削峰填谷                           │
│  - 异步处理                           │
└──────────────┬─────────────────────────┘
               │
               ↓
┌────────────────────────────────────────┐
│        推送服务(Push Service)         │
│                                        │
│  - 消息采样(10%)                     │
│  - 消息合并                           │
│  - WebSocket推送                      │
└────────────────────────────────────────┘

🎯 核心设计

设计1:WebSocket长连接 🔌

为什么用WebSocket?

HTTP:
客户端 → 服务器:发送请求
服务器 → 客户端:返回响应
连接关闭 ❌

弹幕场景:
- 需要实时推送 ✅
- 需要双向通信 ✅
- HTTP不适合 ❌

WebSocket:
客户端 ←→ 服务器:长连接
服务器可以主动推送 ✅
完美!⭐⭐⭐

代码实现

WebSocket配置

@Configuration
@EnableWebSocket
public class WebSocketConfig implements WebSocketConfigurer {
    
    @Autowired
    private DanmakuWebSocketHandler danmakuHandler;
    
    @Override
    public void registerWebSocketHandlers(WebSocketHandlerRegistry registry) {
        // ⭐ 注册WebSocket处理器
        registry.addHandler(danmakuHandler, "/danmaku/{roomId}")
                .setAllowedOrigins("*");
    }
}

WebSocket处理器

@Component
public class DanmakuWebSocketHandler extends TextWebSocketHandler {
    
    // ⭐ 存储直播间的所有连接
    // Key: roomId, Value: Set<WebSocketSession>
    private static final ConcurrentHashMap<String, Set<WebSocketSession>> ROOM_SESSIONS = 
        new ConcurrentHashMap<>();
    
    @Autowired
    private DanmakuService danmakuService;
    
    /**
     * ⭐ 连接建立(进入直播间)
     */
    @Override
    public void afterConnectionEstablished(WebSocketSession session) throws Exception {
        String roomId = getRoomId(session);
        
        // 加入直播间
        ROOM_SESSIONS.computeIfAbsent(roomId, k -> ConcurrentHashMap.newKeySet())
                     .add(session);
        
        System.out.println("⭐ 用户进入直播间:" + roomId + 
                         ",当前在线人数:" + ROOM_SESSIONS.get(roomId).size());
    }
    
    /**
     * ⭐ 收到弹幕
     */
    @Override
    protected void handleTextMessage(WebSocketSession session, TextMessage message) throws Exception {
        String roomId = getRoomId(session);
        String content = message.getPayload();
        
        // 解析弹幕
        Danmaku danmaku = JSON.parseObject(content, Danmaku.class);
        danmaku.setRoomId(roomId);
        
        // 处理弹幕
        danmakuService.handleDanmaku(danmaku);
    }
    
    /**
     * ⭐ 连接关闭(离开直播间)
     */
    @Override
    public void afterConnectionClosed(WebSocketSession session, CloseStatus status) throws Exception {
        String roomId = getRoomId(session);
        
        Set<WebSocketSession> sessions = ROOM_SESSIONS.get(roomId);
        if (sessions != null) {
            sessions.remove(session);
            
            if (sessions.isEmpty()) {
                ROOM_SESSIONS.remove(roomId);
            }
        }
        
        System.out.println("⭐ 用户离开直播间:" + roomId);
    }
    
    /**
     * ⭐ 广播弹幕给直播间所有用户
     */
    public void broadcastDanmaku(String roomId, String message) {
        Set<WebSocketSession> sessions = ROOM_SESSIONS.get(roomId);
        
        if (sessions == null || sessions.isEmpty()) {
            return;
        }
        
        // 遍历推送
        TextMessage textMessage = new TextMessage(message);
        sessions.forEach(session -> {
            try {
                if (session.isOpen()) {
                    session.sendMessage(textMessage);
                }
            } catch (IOException e) {
                e.printStackTrace();
            }
        });
    }
    
    /**
     * 获取直播间ID
     */
    private String getRoomId(WebSocketSession session) {
        String path = session.getUri().getPath();
        return path.substring(path.lastIndexOf('/') + 1);
    }
}

设计2:限流 🚦

为什么要限流?

问题:
恶意用户:疯狂刷弹幕
    ↓
每秒100条弹幕 💀
    ↓
服务器压力大
正常用户受影响 ❌

解决:
限流:每人每秒最多1条弹幕 ✅

代码实现(令牌桶)

@Component
public class DanmakuRateLimiter {
    
    @Autowired
    private StringRedisTemplate redisTemplate;
    
    private static final String RATE_LIMIT_KEY = "danmaku:rate_limit:";
    
    /**
     * ⭐ 检查是否允许发送弹幕(令牌桶)
     */
    public boolean tryAcquire(Long userId) {
        String key = RATE_LIMIT_KEY + userId;
        
        // Lua脚本(原子操作)
        String luaScript = 
            "local key = KEYS[1]\n" +
            "local limit = tonumber(ARGV[1])\n" +  // 每秒1条
            "local current = redis.call('incr', key)\n" +
            "if current == 1 then\n" +
            "    redis.call('expire', key, 1)\n" +  // 1秒过期
            "end\n" +
            "if current <= limit then\n" +
            "    return 1\n" +
            "else\n" +
            "    return 0\n" +
            "end";
        
        Long result = redisTemplate.execute(
            new DefaultRedisScript<>(luaScript, Long.class),
            Collections.singletonList(key),
            "1"  // 每秒限制1条
        );
        
        return result != null && result == 1;
    }
}

设计3:敏感词过滤 🚫

DFA算法(确定有限状态自动机)

敏感词库:
- fuck
- shit
- damn

构建DFA:
           root
         /  |  \
        f   s   d
        |   |   |
        u   h   a
        |   |   |
        c   i   m
        |   |   |
        k   t   n

检测流程:
输入:"this is fuck"
    ↓
逐字符检测:
t → 不匹配
h → 不匹配
i → 不匹配
s → 不匹配
...
f → 匹配 → 继续
u → 匹配 → 继续
c → 匹配 → 继续
k → 匹配 → 找到敏感词!✅

替换为:f**k

代码实现

敏感词库

@Component
public class SensitiveWordFilter {
    
    // ⭐ DFA树的根节点
    private Map<Character, Object> dfaMap = new HashMap<>();
    
    private static final String END_FLAG = "END";
    
    /**
     * ⭐ 初始化敏感词库
     */
    @PostConstruct
    public void init() {
        // 从数据库或配置文件加载敏感词
        Set<String> sensitiveWords = loadSensitiveWords();
        
        // 构建DFA树
        for (String word : sensitiveWords) {
            addWord(word);
        }
    }
    
    /**
     * ⭐ 添加敏感词到DFA树
     */
    private void addWord(String word) {
        Map<Character, Object> currentMap = dfaMap;
        
        for (int i = 0; i < word.length(); i++) {
            char ch = word.charAt(i);
            
            Object node = currentMap.get(ch);
            
            if (node == null) {
                // 创建新节点
                Map<Character, Object> newNode = new HashMap<>();
                currentMap.put(ch, newNode);
                currentMap = newNode;
            } else {
                currentMap = (Map<Character, Object>) node;
            }
            
            // 最后一个字符,标记结束
            if (i == word.length() - 1) {
                currentMap.put(END_FLAG, true);
            }
        }
    }
    
    /**
     * ⭐ 过滤敏感词
     */
    public String filter(String text) {
        if (text == null || text.isEmpty()) {
            return text;
        }
        
        StringBuilder result = new StringBuilder();
        int i = 0;
        
        while (i < text.length()) {
            // 检查从位置i开始是否有敏感词
            int length = checkSensitiveWord(text, i);
            
            if (length > 0) {
                // 找到敏感词,替换为***
                result.append(text.charAt(i));  // 保留首字母
                for (int j = 1; j < length; j++) {
                    result.append('*');
                }
                i += length;
            } else {
                // 不是敏感词
                result.append(text.charAt(i));
                i++;
            }
        }
        
        return result.toString();
    }
    
    /**
     * 检查从位置start开始是否有敏感词
     * @return 敏感词长度,0表示不是敏感词
     */
    private int checkSensitiveWord(String text, int start) {
        Map<Character, Object> currentMap = dfaMap;
        int length = 0;
        int maxLength = 0;
        
        for (int i = start; i < text.length(); i++) {
            char ch = text.charAt(i);
            
            Object node = currentMap.get(ch);
            
            if (node == null) {
                break;
            }
            
            length++;
            currentMap = (Map<Character, Object>) node;
            
            // 检查是否到达结束标记
            if (currentMap.containsKey(END_FLAG)) {
                maxLength = length;
            }
        }
        
        return maxLength;
    }
    
    /**
     * 加载敏感词
     */
    private Set<String> loadSensitiveWords() {
        Set<String> words = new HashSet<>();
        words.add("fuck");
        words.add("shit");
        words.add("damn");
        // 从数据库或配置文件加载更多敏感词
        return words;
    }
}

设计4:消息广播优化 📡

问题:直接广播压力大

100万人在线
    ↓
每秒10万条弹幕
    ↓
100万 * 10万 = 1000亿次推送 💀
    ↓
服务器崩溃 ❌

优化1:采样(只推送部分弹幕)

方案:
10万条弹幕 → 采样10% → 推送1万条
    ↓
100万人在线
    ↓
100万 * 1万 = 100亿次推送
    ↓
降低100倍!✅

代码:
if (Math.random() < 0.1) {
    // 10%概率推送
    broadcast(danmaku);
}

优化2:消息合并

方案:
1秒内收到100条弹幕
    ↓
合并为1条消息推送
    ↓
减少推送次数 ✅

消息格式:
{
    "danmakus": [
        {"userId": 1, "content": "666"},
        {"userId": 2, "content": "牛逼"},
        {"userId": 3, "content": "哈哈"}
    ]
}

代码实现

@Component
public class DanmakuMerger {
    
    // 缓存待推送的弹幕:roomId -> List<Danmaku>
    private ConcurrentHashMap<String, List<Danmaku>> pendingDanmakus = 
        new ConcurrentHashMap<>();
    
    @Autowired
    private DanmakuWebSocketHandler webSocketHandler;
    
    /**
     * ⭐ 添加弹幕到待推送队列
     */
    public void addDanmaku(Danmaku danmaku) {
        String roomId = danmaku.getRoomId();
        
        pendingDanmakus.computeIfAbsent(roomId, k -> new CopyOnWriteArrayList<>())
                       .add(danmaku);
    }
    
    /**
     * ⭐ 定时推送(每1秒执行一次)
     */
    @Scheduled(fixedRate = 1000)
    public void pushDanmakus() {
        pendingDanmakus.forEach((roomId, danmakus) -> {
            if (danmakus.isEmpty()) {
                return;
            }
            
            // 取出所有待推送的弹幕
            List<Danmaku> toBePushed = new ArrayList<>(danmakus);
            danmakus.clear();
            
            // ⭐ 采样(只推送10%)
            List<Danmaku> sampled = sample(toBePushed, 0.1);
            
            // 合并推送
            if (!sampled.isEmpty()) {
                String message = JSON.toJSONString(sampled);
                webSocketHandler.broadcastDanmaku(roomId, message);
            }
        });
    }
    
    /**
     * 采样
     */
    private List<Danmaku> sample(List<Danmaku> danmakus, double rate) {
        return danmakus.stream()
                       .filter(d -> Math.random() < rate)
                       .collect(Collectors.toList());
    }
}

优化3:消息队列(削峰填谷)

方案:
弹幕 → Kafka → 消费者 → 推送
    ↓
削峰填谷 ✅
异步处理 ✅

代码实现

@Service
public class DanmakuService {
    
    @Autowired
    private KafkaTemplate<String, String> kafkaTemplate;
    
    @Autowired
    private DanmakuRateLimiter rateLimiter;
    
    @Autowired
    private SensitiveWordFilter sensitiveWordFilter;
    
    /**
     * ⭐ 处理弹幕
     */
    public void handleDanmaku(Danmaku danmaku) {
        // 1. 限流
        if (!rateLimiter.tryAcquire(danmaku.getUserId())) {
            throw new RateLimitException("发送太快了,请稍后再试");
        }
        
        // 2. 敏感词过滤
        String filteredContent = sensitiveWordFilter.filter(danmaku.getContent());
        danmaku.setContent(filteredContent);
        
        // 3. 发送到Kafka(异步处理)
        kafkaTemplate.send("danmaku-topic", JSON.toJSONString(danmaku));
    }
}

/**
 * ⭐ Kafka消费者
 */
@Component
public class DanmakuConsumer {
    
    @Autowired
    private DanmakuMerger danmakuMerger;
    
    @KafkaListener(topics = "danmaku-topic", groupId = "danmaku-consumer")
    public void consume(String message) {
        Danmaku danmaku = JSON.parseObject(message, Danmaku.class);
        
        // 添加到待推送队列
        danmakuMerger.addDanmaku(danmaku);
    }
}

设计5:弹幕持久化 💾

为什么要持久化?
1. 回放功能(看往期直播)
2. 数据分析(热门弹幕)
3. 违规监控(敏感词统计)

存储方案:
- MongoDB(文档数据库)
- ElasticSearch(全文检索)

代码实现

@Service
public class DanmakuStorageService {
    
    @Autowired
    private MongoTemplate mongoTemplate;
    
    /**
     * ⭐ 保存弹幕到MongoDB
     */
    public void saveDanmaku(Danmaku danmaku) {
        DanmakuDocument doc = new DanmakuDocument();
        doc.setId(idGenerator.nextId());
        doc.setRoomId(danmaku.getRoomId());
        doc.setUserId(danmaku.getUserId());
        doc.setContent(danmaku.getContent());
        doc.setTimestamp(System.currentTimeMillis());
        
        mongoTemplate.save(doc, "danmaku");
    }
    
    /**
     * ⭐ 查询直播间的弹幕历史
     */
    public List<Danmaku> queryDanmakus(String roomId, long startTime, long endTime) {
        Query query = new Query();
        query.addCriteria(Criteria.where("roomId").is(roomId)
                                   .and("timestamp").gte(startTime).lte(endTime));
        query.with(Sort.by(Sort.Direction.ASC, "timestamp"));
        
        return mongoTemplate.find(query, DanmakuDocument.class, "danmaku")
                           .stream()
                           .map(this::toDanmaku)
                           .collect(Collectors.toList());
    }
}

📊 数据库设计

MongoDB

// ⭐ 弹幕文档
{
    "_id": ObjectId("..."),
    "danmakuId": 123456789,          // 弹幕ID(雪花算法)
    "roomId": "room_001",            // 直播间ID
    "userId": 1,                     // 用户ID
    "content": "666",                // 弹幕内容
    "timestamp": 1234567890000,      // 时间戳
    "createTime": ISODate("...")     // 创建时间
}

// 索引
db.danmaku.createIndex({"roomId": 1, "timestamp": -1});
db.danmaku.createIndex({"userId": 1, "timestamp": -1});

🎓 面试题速答

Q1: 弹幕系统如何实现高并发?

A: 三大优化

  1. 采样(只推送10%):
if (Math.random() < 0.1) {
    broadcast(danmaku);
}
  1. 消息合并(1秒合并一次):
@Scheduled(fixedRate = 1000)
public void pushDanmakus() {
    List<Danmaku> toBePushed = getPendingDanmakus();
    broadcast(toBePushed);  // 合并推送
}
  1. 消息队列(削峰填谷):
弹幕 → Kafka → 消费者 → 推送

Q2: 如何实现敏感词过滤?

A: DFA算法

// 构建DFA树
private Map<Character, Object> dfaMap = new HashMap<>();

// 检查敏感词
private int checkSensitiveWord(String text, int start) {
    Map<Character, Object> currentMap = dfaMap;
    int length = 0;
    
    for (int i = start; i < text.length(); i++) {
        char ch = text.charAt(i);
        Object node = currentMap.get(ch);
        
        if (node == null) break;
        
        length++;
        currentMap = (Map<Character, Object>) node;
        
        if (currentMap.containsKey(END_FLAG)) {
            return length;  // 找到敏感词
        }
    }
    
    return 0;
}

时间复杂度:O(n),n是文本长度


Q3: 如何防止刷屏?

A: 限流(令牌桶)

public boolean tryAcquire(Long userId) {
    String key = "danmaku:rate_limit:" + userId;
    
    // Lua脚本(原子操作)
    String luaScript = 
        "local current = redis.call('incr', KEYS[1])\n" +
        "if current == 1 then\n" +
        "    redis.call('expire', KEYS[1], 1)\n" +
        "end\n" +
        "return current <= tonumber(ARGV[1])";
    
    return redisTemplate.execute(script, 
        Collections.singletonList(key), "1");
}

限制:每人每秒最多1条弹幕


Q4: 100万人在线如何推送?

A: 采样 + 合并

原始:
10万条弹幕 * 100万人 = 1000亿次推送 💀

优化后:
10万条弹幕 → 采样10% → 1万条
    ↓
1秒合并1次
    ↓
1万条 * 100万人 = 100亿次推送
    ↓
降低10倍!✅

用户体验

  • 看到部分弹幕即可
  • 不影响氛围

Q5: 弹幕如何持久化?

A: MongoDB存储

// 保存弹幕
public void saveDanmaku(Danmaku danmaku) {
    mongoTemplate.save(danmaku, "danmaku");
}

// 查询弹幕历史
public List<Danmaku> queryDanmakus(String roomId, long startTime, long endTime) {
    Query query = new Query();
    query.addCriteria(Criteria.where("roomId").is(roomId)
                               .and("timestamp").gte(startTime).lte(endTime));
    return mongoTemplate.find(query, Danmaku.class, "danmaku");
}

用途

  • 回放功能
  • 数据分析
  • 违规监控

Q6: WebSocket断开如何处理?

A: 心跳 + 自动重连

服务端

// 定时检查心跳
@Scheduled(fixedRate = 30000)
public void checkHeartbeat() {
    LAST_HEARTBEAT.forEach((session, lastTime) -> {
        if (System.currentTimeMillis() - lastTime > 60000) {
            closeSession(session);
        }
    });
}

客户端

// 断开后自动重连
websocket.onclose = function() {
    setTimeout(() => {
        connect();  // 重新连接
    }, 3000);
};

🎬 总结

       弹幕系统核心设计

┌────────────────────────────────────┐
│ 1. WebSocket长连接 ⭐               │
│    - 实时推送                      │
│    - 双向通信                      │
└────────────────────────────────────┘

┌────────────────────────────────────┐
│ 2. 限流(令牌桶)                  │
│    - 每人每秒1条                   │
│    - 防止刷屏                      │
└────────────────────────────────────┘

┌────────────────────────────────────┐
│ 3. 敏感词过滤(DFA)               │
│    - 毫秒级过滤                    │
│    - O(n)时间复杂度                │
└────────────────────────────────────┘

┌────────────────────────────────────┐
│ 4. 消息广播优化 ⭐⭐                │
│    - 采样(10%)                   │
│    - 消息合并(1秒)               │
│    - 消息队列(Kafka)             │
└────────────────────────────────────┘

┌────────────────────────────────────┐
│ 5. 弹幕持久化(MongoDB)           │
│    - 回放功能                      │
│    - 数据分析                      │
└────────────────────────────────────┘

🎉 恭喜你!

你已经完全掌握了直播弹幕系统的设计!🎊

核心要点

  1. WebSocket长连接:实时推送
  2. 限流:令牌桶,每人每秒1条
  3. 敏感词过滤:DFA算法,毫秒级
  4. 消息广播优化:采样10% + 消息合并 + Kafka
  5. 弹幕持久化:MongoDB存储

下次面试,这样回答

"弹幕系统使用WebSocket实现长连接。用户进入直播间时建立连接,连接存储在ConcurrentHashMap中,key是roomId,value是该直播间的所有WebSocketSession。

为防止刷屏,使用令牌桶限流,每人每秒最多发1条弹幕。通过Redis + Lua脚本实现,保证原子性。

敏感词过滤使用DFA算法。预先将敏感词构建成DFA树,检测时逐字符匹配,时间复杂度O(n)。找到敏感词后替换为***,如fuck替换为f**k。

消息广播优化是核心。100万人在线,每秒10万条弹幕,直接推送需要1000亿次操作,服务器无法承受。采用三层优化:首先采样,只推送10%的弹幕;然后消息合并,1秒内的弹幕合并成一条消息推送;最后使用Kafka削峰填谷,弹幕写入Kafka,消费者异步推送。这样将推送次数降低100倍以上。

弹幕持久化到MongoDB,用于回放功能和数据分析。按roomId和timestamp建立索引,支持快速查询某个时间段的弹幕历史。"

面试官:👍 "很好!你对弹幕系统的设计理解很深刻!"


本文完 🎬

上一篇: 209-设计一个IM即时通讯系统.md
下一篇: 211-设计一个日志收集和分析系统.md

作者注:写完这篇,我都想去B站当弹幕工程师了!🎥
如果这篇文章对你有帮助,请给我一个Star⭐!