📖 开场:体育场的呐喊
想象一场足球比赛 ⚽:
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: 三大优化:
- 采样(只推送10%):
if (Math.random() < 0.1) {
broadcast(danmaku);
}
- 消息合并(1秒合并一次):
@Scheduled(fixedRate = 1000)
public void pushDanmakus() {
List<Danmaku> toBePushed = getPendingDanmakus();
broadcast(toBePushed); // 合并推送
}
- 消息队列(削峰填谷):
弹幕 → 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) │
│ - 回放功能 │
│ - 数据分析 │
└────────────────────────────────────┘
🎉 恭喜你!
你已经完全掌握了直播弹幕系统的设计!🎊
核心要点:
- WebSocket长连接:实时推送
- 限流:令牌桶,每人每秒1条
- 敏感词过滤:DFA算法,毫秒级
- 消息广播优化:采样10% + 消息合并 + Kafka
- 弹幕持久化: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⭐!