弹幕系统看着高大上,其实核心原理并不复杂。本文从最基础的实现开始,一步步带你演进到能支撑百万用户的架构。文章包含完整代码,可直接用于项目。
一、先搞懂弹幕系统是啥
简单来说,弹幕系统就是让一群人能在同一个直播间里实时收发消息。
想象你在看直播:
- 你发了一条"666",要立刻让房间里所有人都看到
- 如果延迟3秒,大家已经在说"主播好帅",你还在说"主播在干嘛"
- 如果有10万人在线,系统不能崩、不能卡
核心就两件事:
- 消息怎么快速送到每个人手里?(推送机制)
- 消息怎么存下来?(存储设计)
就这么简单,咱们一个一个解决。
二、怎么让消息"嗖"一下送出去?
2.1 方式一:HTTP轮询(不停问)
新手最容易想到的方案:
text
前端:服务器,有新弹幕吗?
服务器:没有。
前端:服务器,有新弹幕吗?
服务器:没有。
前端:服务器,有新弹幕吗?
服务器:有,给你。
这叫轮询,就像你每隔1秒问一次快递到了没。
问题在哪?
- 10万人在线 = 每秒10万个"有没有新消息"的请求
- 大部分时候都是"没有",白白浪费服务器资源
- 即使1秒问一次,还是有1秒延迟
实测数据:万级并发下,HTTP轮询的CPU开销比WebSocket高出3.2倍。
2.2 方式二:WebSocket(一直连着)
更好的方式:把电话接通,一直在线。
text
前端:帮我连上服务器(握手)
服务器:好的,连上了
前端:我发了一条弹幕
服务器:收到,我转给所有人
服务器:张三说"666"(直接推过去)
服务器:李四说"哈哈哈"(直接推过去)
WebSocket就像打电话,连上了就不挂断,随时可以说话。
好处:
- 不用一直问,有消息直接推
- 延迟从秒级降到毫秒级(< 300ms)
- 服务器压力大幅降低
核心指标对比:
| 指标 | HTTP轮询 | WebSocket |
|---|---|---|
| 端到端延迟 | 500ms+ | < 100ms |
| 服务器CPU占用 | 高(频繁建连) | 低(长连接) |
| 实时性 | 差 | 好 |
| 实现复杂度 | 简单 | 中等 |
2.3 WebSocket原理(一句话版)
WebSocket基于TCP,通过一次HTTP握手升级为长连接,之后双方可以随时互发数据,不用重复握手。
text
HTTP握手:
客户端:我要升级成WebSocket
服务器:同意,协议切换
WebSocket通信:
客户端:随时发消息 → 服务器
服务器:随时推消息 → 客户端
三、单机版弹幕系统(从0开始写)
3.1 服务端核心代码(Spring Boot + Netty)
Netty是Java生态中高性能网络通信框架,非常适合做WebSocket服务器。
头件事,先导包
xml
<dependency>
<groupId>io.netty</groupId>
<artifactId>netty-all</artifactId>
<version>4.1.53.Final</version>
</dependency>
第一步:启动WebSocket服务
java
@Component
@Slf4j
public class WebSocketServer {
@Value("${websocket.port:8088}")
private int port;
@PostConstruct // 项目启动时自动运行
public void start() throws InterruptedException {
// Netty的主从Reactor线程模型
EventLoopGroup bossGroup = new NioEventLoopGroup(1); // 处理连接
EventLoopGroup workerGroup = new NioEventLoopGroup(); // 处理IO
ServerBootstrap bootstrap = new ServerBootstrap();
bootstrap.group(bossGroup, workerGroup)
.channel(NioServerSocketChannel.class)
.childHandler(new WebSocketServerInitializer());
bootstrap.bind(port).sync();
log.info("WebSocket服务已启动,端口: {}", port);
}
}
第二步:初始化器(配置协议)
java
public class WebSocketServerInitializer extends ChannelInitializer<SocketChannel> {
@Override
protected void initChannel(SocketChannel ch) {
ChannelPipeline pipeline = ch.pipeline();
// HTTP编解码器(用于WebSocket握手)
pipeline.addLast(new HttpServerCodec());
// HTTP聚合器
pipeline.addLast(new HttpObjectAggregator(65536));
// WebSocket协议处理器(升级HTTP为WebSocket)
pipeline.addLast(new WebSocketServerProtocolHandler("/ws"));
// 自定义业务处理器
pipeline.addLast(new DanmakuHandler());
}
}
第三步:核心业务处理器
java
@Component
@Slf4j
public class DanmakuHandler extends SimpleChannelInboundHandler<TextWebSocketFrame> {
// 房间 → 该房间所有用户的连接
private static final Map<String, Set<Channel>> ROOM_CHANNELS = new ConcurrentHashMap<>();
// 连接 → 用户ID
private static final Map<Channel, String> CHANNEL_USER = new ConcurrentHashMap<>();
// 连接 → 房间ID
private static final Map<Channel, String> CHANNEL_ROOM = new ConcurrentHashMap<>();
@Autowired
private DanmakuService danmakuService;
/**
* 收到消息时触发(核心方法)
*/
@Override
protected void channelRead0(ChannelHandlerContext ctx, TextWebSocketFrame frame) {
String text = frame.text();
Channel channel = ctx.channel();
try {
// 解析JSON消息
DanmakuMessage msg = JSON.parseObject(text, DanmakuMessage.class);
switch (msg.getType()) {
case "join": // 加入房间
handleJoin(channel, msg);
break;
case "danmaku": // 发弹幕
handleDanmaku(channel, msg);
break;
case "ping": // 心跳
sendPong(channel);
break;
default:
log.warn("未知消息类型: {}", msg.getType());
}
} catch (Exception e) {
sendError(channel, "消息格式错误");
}
}
/**
* 用户加入房间
*/
private void handleJoin(Channel channel, DanmakuMessage msg) {
String roomId = msg.getRoomId();
String userId = msg.getUserId();
// 1. 记录用户信息
CHANNEL_USER.put(channel, userId);
CHANNEL_ROOM.put(channel, roomId);
// 2. 加入房间连接池
ROOM_CHANNELS.computeIfAbsent(roomId, k -> ConcurrentHashMap.newKeySet())
.add(channel);
// 3. 推送最近弹幕(新用户看历史消息)
List<DanmakuMessage> history = danmakuService.getRecent(roomId, 50);
for (DanmakuMessage h : history) {
sendMessage(channel, h);
}
// 4. 发送系统欢迎
sendMessage(channel, "系统", "欢迎进入直播间");
log.info("用户{}加入房间{}", userId, roomId);
}
/**
* 处理弹幕消息
*/
private void handleDanmaku(Channel channel, DanmakuMessage msg) {
String roomId = msg.getRoomId();
String userId = msg.getUserId();
String content = msg.getContent();
// 1. 敏感词过滤
if (danmakuService.containsSensitive(content)) {
sendError(channel, "内容包含敏感词");
return;
}
// 2. 防刷限流(每人每秒最多5条)
if (!danmakuService.checkRateLimit(userId)) {
sendError(channel, "发送过于频繁,请稍后再试");
return;
}
// 3. 完善消息
msg.setTimestamp(System.currentTimeMillis());
msg.setSender(userId);
// 4. 异步存储
danmakuService.saveAsync(msg);
// 5. 广播给房间所有人(除了自己)
broadcastToRoom(roomId, msg, channel);
}
/**
* 广播给房间所有人(除了发送者自己)
*/
private void broadcastToRoom(String roomId, DanmakuMessage msg, Channel exclude) {
Set<Channel> channels = ROOM_CHANNELS.get(roomId);
if (channels == null || channels.isEmpty()) return;
String json = JSON.toJSONString(msg);
for (Channel ch : channels) {
// 不给发送者自己发,其他人全发
if (ch != exclude && ch.isActive()) {
ch.writeAndFlush(new TextWebSocketFrame(json));
}
}
}
/**
* 连接断开时清理资源
*/
@Override
public void handlerRemoved(ChannelHandlerContext ctx) {
Channel channel = ctx.channel();
String roomId = CHANNEL_ROOM.remove(channel);
String userId = CHANNEL_USER.remove(channel);
if (roomId != null) {
Set<Channel> channels = ROOM_CHANNELS.get(roomId);
if (channels != null) {
channels.remove(channel);
if (channels.isEmpty()) {
ROOM_CHANNELS.remove(roomId);
}
}
}
log.info("用户{}断开连接", userId);
}
private void sendMessage(Channel channel, DanmakuMessage msg) {
if (channel.isActive()) {
channel.writeAndFlush(new TextWebSocketFrame(JSON.toJSONString(msg)));
}
}
private void sendMessage(Channel channel, String sender, String content) {
DanmakuMessage msg = new DanmakuMessage();
msg.setType("system");
msg.setSender(sender);
msg.setContent(content);
sendMessage(channel, msg);
}
private void sendError(Channel channel, String error) {
DanmakuMessage msg = new DanmakuMessage();
msg.setType("error");
msg.setContent(error);
sendMessage(channel, msg);
}
private void sendPong(Channel channel) {
DanmakuMessage pong = new DanmakuMessage();
pong.setType("pong");
sendMessage(channel, pong);
}
}
代码看起来长,核心就三件事:
- 有人进来 → 记下他在哪个房间,推送历史消息
- 有人发弹幕 → 检查敏感词和刷屏,存起来,广播出去
- 有人离开 → 清理房间里的记录
3.2 前端连接(浏览器原生支持WebSocket)
javascript
// 1. 连接弹幕服务器
const ws = new WebSocket('wss://你的域名:8088/ws');
// 2. 连上后加入房间
ws.onopen = function() {
ws.send(JSON.stringify({
type: 'join',
roomId: 'live_1001',
userId: getUserId() // 从登录信息获取
}));
};
// 3. 收到弹幕 → 显示在屏幕上
ws.onmessage = function(event) {
const data = JSON.parse(event.data);
if (data.type === 'danmaku') {
showDanmaku(data.content, data.color, data.sender);
}
};
// 4. 发弹幕
function sendDanmaku(text) {
ws.send(JSON.stringify({
type: 'danmaku',
roomId: 'live_1001',
userId: getUserId(),
content: text
}));
}
// 5. 断开自动重连
ws.onclose = function() {
setTimeout(() => {
connect(); // 3秒后重新连接
}, 3000);
};
3.3 单机能撑多少人?
用Netty实现,单台服务器(4核8G)大约能支撑 5-10万 个WebSocket连接。
人再多怎么办?加机器!
四、集群部署:一台不够怎么办?
4.1 问题:消息怎么跨服务器?
假设你有3台服务器,用户分布在不同机器上:
text
css
┌─────────────┐ ┌─────────────┐ ┌─────────────┐
│ 服务器1 │ │ 服务器2 │ │ 服务器3 │
│ 用户A │ │ 用户B │ │ 用户C │
└─────────────┘ └─────────────┘ └─────────────┘
用户A在服务器1上发了一条弹幕,服务器2上的用户B怎么收到?
需要引入消息中间件做"广播"!
4.2 方案对比
| 方案 | 实现方式 | 可靠性 | 性能 | 适用场景 |
|---|---|---|---|---|
| Redis Pub/Sub | 类似大喇叭广播 | 较低 | 极高 | 小规模,能接受丢消息 |
| 消息队列(Kafka/RocketMQ) | 像快递站分发 | 高 | 高 | 生产环境推荐 |
4.3 方案一:Redis Pub/Sub(简单版)
text
服务器1:大喇叭喊一声 "用户A说666"
Redis:转发给所有订阅的服务器
服务器2:收到,转给用户B
服务器3:收到,转给用户C
java
@Component
public class RedisMessageRouter {
@Autowired
private StringRedisTemplate redisTemplate;
// 发布消息
public void publish(String roomId, DanmakuMessage msg) {
String channel = "danmaku:room:" + roomId;
redisTemplate.convertAndSend(channel, JSON.toJSONString(msg));
}
// 订阅消息(每个服务实例启动时订阅)
@PostConstruct
public void subscribe() {
// 使用MessageListener监听所有"danmaku:room:*"频道
}
}
优点 :实现简单,速度快
缺点:如果某个服务器掉线,重连期间的消息就丢了
4.4 方案二:消息队列(推荐)
用RocketMQ或Kafka,每个服务器都能收到全量消息:
java
@Component
public class DanmakuMQProducer {
@Autowired
private RocketMQTemplate rocketMQTemplate;
public void sendDanmaku(DanmakuMessage msg) {
// 广播模式:所有消费者都能收到
rocketMQTemplate.convertAndSend("danmaku_topic", msg);
}
}
@Component
public class DanmakuMQConsumer {
@RocketMQMessageListener(topic = "danmaku_topic",
consumerGroup = "danmaku_group",
messageModel = MessageModel.BROADCASTING)
public class Consumer implements RocketMQListener<DanmakuMessage> {
@Override
public void onMessage(DanmakuMessage msg) {
// 每个服务器都会收到,然后广播给本机用户
danmakuHandler.broadcastLocal(msg.getRoomId(), msg);
}
}
}
优点 :消息可靠,支持重试,能削峰填谷
缺点:引入组件,运维成本稍高
💡 选择建议:
- 小项目(< 10万用户)用Redis就够了
- 大项目(百万级)用RocketMQ/Kafka
五、弹幕怎么存?冷热分离
5.1 数据特点
弹幕有个特点:越新的越重要,越旧的越没人看。
- 最近500条:新用户进来要看的
- 1小时前的:基本没人翻了
- 所有历史:直播结束后数据分析用
5.2 两级存储架构
text
用户发弹幕
│
▼
┌─────────────────────┐
│ 热数据:Redis │ ← 存最近500条,毫秒级读写
│ ZSet按时间排序 │
│ 过期时间:1小时 │
└──────────┬──────────┘
│
▼
┌─────────────────────┐
│ 冷数据:MySQL │ ← 存所有历史,异步写入
│ 可查历史记录 │
└─────────────────────┘
5.3 Redis存储(热数据)
用 ZSet(有序集合) ,时间戳作为分数,天然按时间排序:
java
@Component
public class DanmakuStorage {
@Autowired
private StringRedisTemplate redisTemplate;
private static final String KEY_PREFIX = "danmaku:room:";
private static final int MAX_RECENT = 500; // 每房间最多500条
/**
* 存储弹幕(热数据)
*/
public void saveRecent(String roomId, DanmakuMessage msg) {
String key = KEY_PREFIX + roomId;
String value = JSON.toJSONString(msg);
double score = (double) msg.getTimestamp();
// 存入ZSet
redisTemplate.opsForZSet().add(key, value, score);
// 只保留最近500条
redisTemplate.opsForZSet().removeRange(key, 0, -MAX_RECENT - 1);
// 1小时后自动清理
redisTemplate.expire(key, 1, TimeUnit.HOURS);
}
/**
* 获取最近弹幕(新用户进来时推送)
*/
public List<DanmakuMessage> getRecent(String roomId, int limit) {
String key = KEY_PREFIX + roomId;
Set<String> values = redisTemplate.opsForZSet()
.reverseRange(key, 0, limit - 1); // 最新的在前
return values.stream()
.map(json -> JSON.parseObject(json, DanmakuMessage.class))
.collect(Collectors.toList());
}
}
5.4 MySQL存储(冷数据)
所有弹幕最终存到数据库:
sql
CREATE TABLE danmaku_record (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
room_id VARCHAR(64) NOT NULL COMMENT '房间ID',
user_id VARCHAR(64) NOT NULL COMMENT '用户ID',
content VARCHAR(500) NOT NULL COMMENT '弹幕内容',
color VARCHAR(20) DEFAULT '#FFFFFF' COMMENT '颜色',
timestamp BIGINT NOT NULL COMMENT '发送时间戳',
create_time DATETIME DEFAULT CURRENT_TIMESTAMP,
KEY idx_room_time (room_id, timestamp),
KEY idx_user (user_id)
) ENGINE=InnoDB;
5.5 异步持久化(关键优化)
绝对不能同步写MySQL! 每条弹幕都写MySQL,数据库瞬间就被打死了。
正确做法:
text
用户发弹幕
│
▼
存Redis(快!1ms完成)
│
▼
告诉用户"发送成功"
│
▼
把弹幕丢到消息队列(异步)
│
▼
消费者批量取出(每批1000条)
│
▼
一次性批量插入MySQL
java
@Component
public class DanmakuPersistConsumer {
@Autowired
private DanmakuRecordMapper recordMapper;
@RocketMQMessageListener(topic = "danmaku_persist_topic",
consumerGroup = "persist_group")
public class Consumer implements RocketMQListener<List<DanmakuMessage>> {
@Override
public void onMessage(List<DanmakuMessage> messages) {
// 批量转换
List<DanmakuRecord> records = messages.stream()
.map(this::convert)
.collect(Collectors.toList());
// 批量插入(每批1000条)
for (int i = 0; i < records.size(); i += 1000) {
int end = Math.min(i + 1000, records.size());
recordMapper.batchInsert(records.subList(i, end));
}
}
}
}
六、防刷屏与敏感词
6.1 限流:防止刷屏(滑动窗口)
每个人1秒最多5条,用Redis实现滑动窗口:
java
@Component
public class RateLimiter {
@Autowired
private StringRedisTemplate redisTemplate;
public boolean allow(String userId, int windowSeconds, int maxRequests) {
long now = System.currentTimeMillis();
long windowStart = now - windowSeconds * 1000L;
String key = "ratelimit:danmaku:" + userId;
// Lua脚本保证原子性
String lua =
"redis.call('ZREMRANGEBYSCORE', KEYS[1], 0, ARGV[1]) " +
"local cnt = redis.call('ZCARD', KEYS[1]) " +
"if cnt < tonumber(ARGV[2]) then " +
" redis.call('ZADD', KEYS[1], ARGV[3], ARGV[4]) " +
" redis.call('EXPIRE', KEYS[1], ARGV[5]) " +
" return 1 " +
"else return 0 end";
String member = now + "-" + UUID.randomUUID().toString().substring(0, 6);
Long result = redisTemplate.execute(
new DefaultRedisScript<>(lua, Long.class),
Collections.singletonList(key),
String.valueOf(windowStart),
String.valueOf(maxRequests),
String.valueOf(now),
member,
String.valueOf(windowSeconds * 2)
);
return result != null && result == 1L;
}
}
6.2 敏感词过滤(DFA算法)
DFA(确定性有穷自动机) 的核心思想:把敏感词构建成一颗树,一次扫描文本就能匹配所有敏感词。
text
敏感词库:["色情", "赌博", "暴力"]
构建成树:
根
/ \
色 赌 暴
| | |
情 博 力
|
博
java
@Component
public class SensitiveWordFilter {
private final Map<Character, Map> dfaMap = new HashMap<>();
@PostConstruct
public void init() {
// 加载敏感词库(从数据库或配置文件)
List<String> words = Arrays.asList("色情", "赌博", "暴力", "政治");
buildDFA(words);
}
/**
* 构建DFA树
*/
private void buildDFA(List<String> words) {
for (String word : words) {
Map<Character, Map> currentMap = dfaMap;
for (char c : word.toCharArray()) {
currentMap = currentMap.computeIfAbsent(c, k -> new HashMap<>());
}
// 结束标记
currentMap.put('结束', new HashMap<>());
}
}
/**
* 检查是否包含敏感词(O(n)复杂度)
*/
public boolean contains(String text) {
if (text == null || text.isEmpty()) return false;
for (int i = 0; i < text.length(); i++) {
Map<Character, Map> currentMap = dfaMap;
for (int j = i; j < text.length(); j++) {
char c = text.charAt(j);
Map nextMap = currentMap.get(c);
if (nextMap == null) break;
if (nextMap.containsKey('结束')) {
return true; // 匹配到敏感词
}
currentMap = nextMap;
}
}
return false;
}
}
七、前端显示弹幕(Canvas渲染)
7.1 两种方案对比
| 方案 | 实现难度 | 性能 | 适用场景 |
|---|---|---|---|
| DOM渲染 | 简单 | 差(弹幕多时卡顿) | 弹幕量小 |
| Canvas渲染 | 中等 | 好 | 弹幕量大(推荐) |
7.2 Canvas渲染核心代码
javascript
class DanmakuRenderer {
constructor(canvas) {
this.canvas = canvas;
this.ctx = canvas.getContext('2d');
this.danmakuList = [];
this.tracks = []; // 轨道占用情况
this.running = false;
}
/**
* 添加弹幕
*/
addDanmaku(data) {
// 找一个空轨道
const track = this.findAvailableTrack();
const danmaku = {
text: data.content,
color: data.color || '#FFFFFF',
size: data.size || 24,
x: this.canvas.width,
y: track * 30 + 30,
speed: 2 + Math.random() * 1.5, // 随机速度
track: track
};
this.danmakuList.push(danmaku);
this.tracks[track] = true;
}
/**
* 查找可用轨道
*/
findAvailableTrack() {
const totalTracks = Math.floor(this.canvas.height / 30);
for (let i = 0; i < totalTracks; i++) {
if (!this.tracks[i]) return i;
}
return 0; // 全满则复用第一个
}
/**
* 动画循环
*/
start() {
this.running = true;
this.loop();
}
loop() {
if (!this.running) return;
// 清空画布
this.ctx.clearRect(0, 0, this.canvas.width, this.canvas.height);
const toRemove = [];
for (let i = 0; i < this.danmakuList.length; i++) {
const d = this.danmakuList[i];
d.x -= d.speed; // 向左移动
// 绘制弹幕
this.ctx.font = `${d.size}px "Microsoft YaHei"`;
this.ctx.fillStyle = d.color;
this.ctx.shadowColor = 'rgba(0,0,0,0.5)';
this.ctx.shadowBlur = 4;
this.ctx.fillText(d.text, d.x, d.y);
// 移出屏幕则回收轨道
const width = this.ctx.measureText(d.text).width;
if (d.x + width < 0) {
this.tracks[d.track] = false;
toRemove.push(i);
}
}
// 移除已消失的弹幕
for (let i = toRemove.length - 1; i >= 0; i--) {
this.danmakuList.splice(toRemove[i], 1);
}
requestAnimationFrame(() => this.loop());
}
}
// 使用
const renderer = new DanmakuRenderer(document.getElementById('canvas'));
renderer.start();
// 收到弹幕时
ws.onmessage = function(event) {
const data = JSON.parse(event.data);
if (data.type === 'danmaku') {
renderer.addDanmaku(data);
}
};
八、性能优化三板斧
8.1 批量推送(减少IO)
每收到一条弹幕就推送一次,高并发下IO开销大。
优化:每50ms攒一批,一次推送。
java
@Component
public class BatchPushScheduler {
private final Map<String, List<DanmakuMessage>> pending = new ConcurrentHashMap<>();
private final ScheduledExecutorService scheduler = Executors.newSingleThreadScheduledExecutor();
@PostConstruct
public void init() {
// 每50ms执行一次批量推送
scheduler.scheduleAtFixedRate(this::flush, 50, 50, TimeUnit.MILLISECONDS);
}
private void flush() {
for (Map.Entry<String, List<DanmakuMessage>> entry : pending.entrySet()) {
String roomId = entry.getKey();
List<DanmakuMessage> messages = entry.getValue();
if (messages.isEmpty()) continue;
// 批量推送给房间所有人
danmakuHandler.broadcastBatch(roomId, messages);
messages.clear();
}
}
public void add(String roomId, DanmakuMessage msg) {
pending.computeIfAbsent(roomId, k -> new ArrayList<>()).add(msg);
}
}
8.2 本地缓存(减少Redis访问)
热门直播间,在应用服务器本地缓存最近弹幕:
java
@Component
public class LocalDanmakuCache {
// Caffeine本地缓存,5秒过期
private final Cache<String, List<DanmakuMessage>> cache = Caffeine.newBuilder()
.maximumSize(1000) // 最多1000个房间
.expireAfterWrite(5, TimeUnit.SECONDS) // 5秒过期
.build();
public List<DanmakuMessage> get(String roomId) {
return cache.getIfPresent(roomId);
}
public void put(String roomId, List<DanmakuMessage> messages) {
cache.put(roomId, messages);
}
}
8.3 热点识别与隔离
比赛、明星开播等场景,某个直播间可能瞬间涌入百万用户。
策略:
- 热门直播间单独分配服务器资源
- 降级非核心功能(如弹幕颜色、表情)
- 开启限流,优先保证核心弹幕发送
九、架构演进路线图
弹幕系统不是一步到位的,可以根据业务发展逐步演进:
| 阶段 | 用户规模 | 技术方案 | 特点 |
|---|---|---|---|
| MVP阶段 | < 1万 | HTTP轮询 + 单机 | 快速上线,实现简单 |
| 成长阶段 | 1万-10万 | WebSocket + 单机 | 引入长连接,实时性好 |
| 成熟阶段 | 10万-100万 | WebSocket集群 + Redis + MQ | 消息路由、异步解耦 |
| 大规模 | 100万+ | 多机房 + 服务网格 | 全球加速、容灾多活 |
十、常见问题答疑
Q1:WebSocket断开怎么办?
前端监听 onclose 事件,自动重连:
javascript
ws.onclose = function() {
setTimeout(() => connect(), 3000); // 3秒后重连
};
Q2:消息队列丢了消息怎么办?
消息队列本身有重试机制,加上定时校对任务兜底:
java
@Scheduled(cron = "0 0 3 * * ?") // 凌晨3点执行
public void reconcile() {
// 对比Redis和MySQL的数据,不一致则以DB为准修正
}
Q3:弹幕丢了一两条要紧吗?
不要紧。弹幕对可靠性要求不高,丢几条不影响体验。关键是系统不崩、延迟低。
Q4:一台服务器能撑多少人?
Netty单台(4核8G)大约5-10万连接。具体取决于消息量大小。
Q5:用Go还是Java?
Java(Netty)和Go(gorilla/websocket)都能做好弹幕系统。选团队熟悉的技术栈就行。
十一、总结
弹幕系统看似复杂,核心就三件事:
| 问题 | 解决方案 | 关键技术 |
|---|---|---|
| 怎么实时推送? | 用WebSocket长连接 | Netty + WebSocket协议 |
| 集群怎么广播? | 消息中间件转发 | Redis Pub/Sub / Kafka |
| 数据怎么存? | 冷热分离 + 异步持久化 | Redis(热) + MySQL(冷) + MQ |
再记住三个"别":
- 别用HTTP轮询(浪费资源,延迟高)
- 别同步写MySQL(会堵死主流程)
- 别忽视防刷(会被刷屏刷崩)
一个完整的弹幕系统就是这么回事儿。从最简单的单机版开始,随着用户增长逐步演进到集群版,每一步都有清晰的路径。
如果你看完觉得有帮助,点个赞吧!有问题评论区聊~ 👇
(特别鸣谢```@先吃饱再说```提供的前端代码)