跳到主内容

从OOM到10万连接零卡顿:虚拟线程长连接背压实战 ​

问题背景:直播弹幕十万级长连接的稳定性痛点 ​

在直播业务峰值场景下,单头部直播间同时在线用户可达10万级,全部通过WebSocket长连接接收实时弹幕推送。传统基于平台线程池的实现方案面临三个核心痛点:第一,每个长连接绑定一个平台线程的话,10万线程的栈内存占用就超过10GB,远超出常规服务的内存配额,很容易触发OOM;第二,异步Reactor模型虽然线程数少,但回调嵌套超过3层之后业务逻辑可读性极差,异常链路追踪成本极高;第三,缺少单连接粒度的背压控制,当部分用户网络卡顿导致推送阻塞时,待发送消息会在全局队列无限积压,最终拖垮整个服务。 我们前期压测时,传统方案在连接数超过2.3万之后,弹幕平均推送延迟就突破2s,峰值场景下10分钟内触发3次Full GC,服务可用性完全不达标,急需一套兼顾开发效率和运行性能的背压控制方案。

方案对比:三类长连接并发模型选型决策 ​

我们先后对比了三类主流长连接并发方案的实测表现:

  1. 传统平台线程池+Netty EventLoop方案:IO线程数绑定CPU核数,业务线程池最大配置2000线程,支撑连接数上限约2万,单连接栈内存占用1MB,背压只能通过全局阻塞队列实现,无法针对单个网络卡顿的用户做单独限流,很容易出现局部问题扩散到全局。
  2. 异步回调Reactor方案:基于Netty的异步ChannelFuture实现全链路非阻塞,线程数可以控制在CPU核数*2,理论支撑百万级连接,但业务代码嵌套3层以上回调之后可维护性极差,背压控制只能在全局维度做,无法实现单连接粒度的优先级丢弃策略。
  3. Java 21虚拟线程方案:每个长连接绑定一个独立虚拟线程,虚拟线程初始栈内存仅几百字节,10万级连接总线程内存占用不到2GB,代码可以用同步模式编写完全没有回调地狱,背压逻辑可以下沉到单连接粒度做精准控制,开发效率和运行性能都满足业务要求。 最终我们选择虚拟线程作为核心实现方案,压测结果显示10万长连接场景下服务CPU占用率仅45%,远低于另外两套方案。

实操步骤与核心代码示例 ​

我们基于SpringBoot 3.2 + Java 21实现整套方案,核心实现分为3个步骤:开启全局虚拟线程执行器、单连接队列背压控制、全局流量削峰统计。

核心配置:虚拟线程池初始化 ​

首先通过配置类声明全局虚拟线程执行器,所有长连接的消费逻辑都运行在虚拟线程之上:

java
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import java.util.concurrent.Executor;
import java.util.concurrent.Executors;

@Configuration
public class VirtualThreadConfig {
    // 全局虚拟线程执行器,每个任务绑定独立虚拟线程
    @Bean
    public Executor virtualThreadExecutor() {
        return Executors.newVirtualThreadPerTaskExecutor();
    }
}

单连接背压控制实现 ​

每个WebSocket会话绑定独立的阻塞队列,队列满的时候按照消息优先级做差异化处理,普通弹幕直接丢弃,高优先级管理员弹幕做短时间等待,避免队列无限积压:

java
import com.alibaba.fastjson2.JSON;
import org.springframework.web.socket.TextMessage;
import org.springframework.web.socket.WebSocketSession;
import java.util.concurrent.ArrayBlockingQueue;
import java.util.concurrent.BlockingQueue;
import java.util.concurrent.Executor;
import java.util.concurrent.TimeUnit;

@Component
public class DanmakuPushSession {
    // 单连接待推送队列最大长度,超过即触发背压
    private static final int MAX_PENDING_MSG = 64;
    // 消息优先级:1=普通弹幕 2=高亮弹幕 3=管理员公告
    private final BlockingQueue<DanmakuMsg> pendingQueue = new ArrayBlockingQueue<>(MAX_PENDING_MSG);
    private final WebSocketSession session;
    private final Executor virtualThreadExecutor;
    private volatile boolean isConnected = true;

    public DanmakuPushSession(WebSocketSession session, Executor virtualThreadExecutor) {
        this.session = session;
        this.virtualThreadExecutor = virtualThreadExecutor;
        // 启动独立虚拟线程执行当前会话的推送循环
        virtualThreadExecutor.execute(this::pushLoop);
    }

    // 对外提供消息写入接口,返回是否写入成功(背压标识)
    public boolean offerMsg(DanmakuMsg msg) {
        // 优先尝试非阻塞写入队列
        if (pendingQueue.offer(msg)) {
            return true;
        }
        // 队列已满时,低优先级普通弹幕直接丢弃
        if (msg.getPriority() < 2) {
            return false;
        }
        // 高优先级消息最多等待100ms,尝试写入队列
        try {
            return pendingQueue.offer(msg, 100, TimeUnit.MILLISECONDS);
        } catch (InterruptedException e) {
            Thread.currentThread().interrupt();
            return false;
        }
    }

    // 虚拟线程执行的同步推送循环,无需任何回调嵌套
    private void pushLoop() {
        while (isConnected && session.isOpen()) {
            try {
                DanmakuMsg msg = pendingQueue.take();
                session.sendMessage(new TextMessage(JSON.toJSONBytes(msg)));
            } catch (Exception e) {
                isConnected = false;
                // 连接异常时主动销毁会话,避免虚拟线程泄漏
                SessionHolder.removeSession(this);
                break;
            }
        }
    }
}
// 弹幕消息基础实体类
class DanmakuMsg {
    private String content;
    private int priority;
    // 省略getter/setter方法
    public String getContent() { return content; }
    public void setContent(String content) { this.content = content; }
    public int getPriority() { return priority; }
    public void setPriority(int priority) { this.priority = priority; }
}

全局背压统计逻辑 ​

在直播间弹幕广播的主逻辑中,统计所有会话的消息写入成功率,当全局丢弃率超过10%时,主动通知上游弹幕接入层做流量削峰,避免服务过载。

避坑总结:虚拟线程长连接场景3个高频错误 ​

我们在压测过程中踩过多个虚拟线程相关的兼容性坑,整理3条可直接复用的实践经验:

  1. 禁止业务逻辑占用Netty EventLoop线程:很多开发者习惯直接在Netty的IO回调线程中执行业务推送逻辑,会阻塞EventLoop绑定的平台线程,导致所有IO事件调度延迟飙升。错误现象为连接数超过5万之后,IO读写延迟从平均2ms涨到1s以上,修正方案是所有业务推送、消息处理逻辑全部提交到虚拟线程执行器,绝对不要占用IO线程。
  2. 避免用synchronized做共享资源加锁:虚拟线程遇到synchronized同步块时会pin住底层绑定的平台线程,导致大量虚拟线程被阻塞在同一个平台线程上,完全失去虚拟线程的轻量调度优势。错误现象为压测时CPU使用率最高只能跑到30%,大量虚拟线程处于BLOCKED状态,修正方案是用ReentrantLock替代synchronized,或者用VarHandle的无锁实现更新共享变量。
  3. 单连接队列长度不要盲目调大:很多开发者为了尽可能少丢消息,把单连接待推送队列长度设置成1024甚至更大,10万连接场景下队列总占用内存会超过3GB,大幅提升Full GC频率。错误现象为连接数到8万之后,YoungGC频率从10s一次变成1s一次,服务吞吐量骤降,修正方案是队列长度控制在32~128区间,按消息优先级做分层丢弃,不要试图缓存所有待推送消息。 经过全链路优化之后,我们的直播弹幕推送服务在10万长连接压测场景下,平均推送延迟仅120ms,内存峰值占用1.8GB,全年线上运行未出现过一次OOM故障,完全满足业务稳定性要求。

免责声明

本工具与文章内容仅用于学习测试场景,优化建议仅供参考,请勿直接用于生产环境,由此产生的风险自行承担。