实时弹幕应该怎么搞?一篇带你从入门到生产级实战

弹幕系统看着高大上,其实核心原理并不复杂。本文从最基础的实现开始,一步步带你演进到能支撑百万用户的架构。文章包含完整代码,可直接用于项目。


一、先搞懂弹幕系统是啥

简单来说,弹幕系统就是让一群人能在同一个直播间里实时收发消息

想象你在看直播:

  1. 你发了一条"666",要立刻让房间里所有人都看到
  2. 如果延迟3秒,大家已经在说"主播好帅",你还在说"主播在干嘛"
  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);
    }
}

代码看起来长,核心就三件事:

  1. 有人进来 → 记下他在哪个房间,推送历史消息
  2. 有人发弹幕 → 检查敏感词和刷屏,存起来,广播出去
  3. 有人离开 → 清理房间里的记录

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

再记住三个"别":

  1. 别用HTTP轮询(浪费资源,延迟高)
  2. 别同步写MySQL(会堵死主流程)
  3. 别忽视防刷(会被刷屏刷崩)

一个完整的弹幕系统就是这么回事儿。从最简单的单机版开始,随着用户增长逐步演进到集群版,每一步都有清晰的路径。

如果你看完觉得有帮助,点个赞吧!有问题评论区聊~ 👇

(特别鸣谢```@先吃饱再说```提供的前端代码)

相关推荐
用户0207199207721 小时前
WebSocket 从建立连接到稳定运行
后端
你为她披上外套时我正站在窗外1 小时前
subtitler v2.0 的零拷贝设计:从 2000 次分配到 0 次
后端
tianyuanwo2 小时前
深入掌握 java -jar 命令:从基础到 Jenkins Agent 实战
java·jenkins·jar
IT_陈寒2 小时前
Vite的静态资源路径搞得我头秃,原来这样配才对
前端·人工智能·后端
Full Stack Developme2 小时前
MyBatis-Plus(MP)AST 抽象语法树 设计原理
java·tomcat·mybatis
宠友信息2 小时前
消息撤回与已读状态如何在即时通讯源码中统一管理
java·spring boot·websocket·mysql·uni-app
小小放舟、2 小时前
Windows 本地安装 Elasticsearch 8.10.0 与 IK 分词器(2026)
java·大数据·windows·spring boot·elasticsearch·搜索引擎·全文检索
zzz_23682 小时前
【Java实习面试算法冲刺】总复盘
java·算法·面试
從南走到北2 小时前
JAVA无人共享系统无人场馆预约篮球系统源码支持小程序+公众号+APP+H5
java·开发语言·小程序