ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

Spring Boot WebSocket选型:官方Starter与Netty实战对比

Spring Boot WebSocket选型:官方Starter与Netty实战对比

1. 先聊明白:WebSocket 选型到底在纠结什么

做 Java 后端实时通信的,迟早都会遇到这个选择题:Spring Boot 项目里要上 WebSocket,到底用官方 Starter 还是直接上 Netty?网上搜一圈,有人夸官方 Starter 简单省事,有人说 Netty 性能吊打一切,看得越多越不敢下手。

先说结论,这个问题没有标准答案,但在选之前必须搞清楚一件事:你做的到底是"给 Servlet 容器加个长连接入口",还是"从零构建一个独立的实时通信服务"。这两个目标决定了你的技术路线完全不一样。

我用最直白的方式解释一下两个方案的本质区别。官方 Starter(即spring-boot-starter-websocket)本质上是把你现有的 Servlet 容器(Tomcat、Jetty 等)能力暴露出来,给你的 Spring MVC 项目开了个 WebSocket 口子,往里走的是 HttpServletRequest 那套熟悉的老路。而 Netty 方案是你自己拉起一个独立的网络服务进程,HTTP 升级为 WebSocket 的流程、握手、编解码、背压,全部由你自己的代码接管,Spring 在这个过程中只是个"业务层入驻者"。

这就像同样是做餐厅,官方 Starter 是在你已有的食堂里加一个窗口,菜品还是食堂中央厨房做的;Netty 是让你自己在隔壁新开一个厨房,锅碗瓢盆全自己配,然后给食堂供菜。食堂大了(连接多了)厨房跟不上,就得自己开;食堂不大,你新开厨房纯属折腾。

我自己带过的几个项目里,有选了官方 Starter 结果到 3 万连接就频繁掉线的,也有用了 Netty 结果业务代码写得太粗糙、网关层内存溢出的。两种坑我都踩过,这篇文章就把两边从原理、代码、参数到生产环境细节全部摊开讲,最后给出一张可以照着做的选型清单。

2. 先看整体对比:两个方案不是一个维度的东西

很多人在选型时犯的最大的错误,就是把官方 Starter 和 Netty 放在同一个天平上比性能。实际上这俩压根不是一个维度的产物,先看一张整体对比表,后面再逐个讲解。

对比维度官方 StarterNetty 自实现
底层依托Servlet 容器(Tomcat/Jetty)独立 NIO 事件循环
连接上限受限于容器线程模型远超容器上限(万级到十万级)
依赖耦合与 Spring MVC 共享生命周期和线程池完全独立,可与 Spring 解耦
开发速度极快,配置即用较慢,需要自己搭 Pipeline 和编解码
对开发者的要求熟悉 HTTP/WebSocket 基本概念即可需要理解 NIO、背压、Netty 内存管理
心跳保活需自己写定时器方案内置 IdleStateHandler,天然适合长连接治理
与 Spring Security 集成流程自然,拦截器顺手需要从 Channel 里拿 HTTP 信息做鉴权
集群扩展依赖网关层做会话路由会话路由也自己做,难度更高
生产环境依赖容器调优决定上限代码质量决定上限

看完这张表,你应该明白我说的"不是一个维度的东西"是什么意思了。官方 Starter 的价值曲线很陡:几十到几千连接时开发效率极高,但到了万级连接后,你会开始跟容器的线程模型和内存分配做斗争。Netty 的价值曲线在前期的陡坡很缓,它要求你付出更多精力和代码量,但当连接量增长到容器的三倍甚至十倍时,你的服务架构依然能撑住,前提是你没把 Netty 写坏。

下面我分别拆开讲两个方案的完整实践,所有代码片段都是可以直接拿来用的。

3. 官方 Starter 的实践细节:快速上线,但要知道天花板在哪

3.1 配置与核心类的最小可用版本

如果项目里已经有 Spring Boot,引入官方 Starter 只需要在pom.xml里加一行:

<dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-websocket</artifactId> </dependency>

然后定义一个配置类,注册 WebSocket 端点:

@Configuration public class WebSocketConfig implements WebSocketConfigurer { @Override public void registerWebSocketHandlers(WebSocketHandlerRegistry registry) { registry.addHandler(chatHandler(), "/chat") .addInterceptors(new ChatHandshakeInterceptor()) .setAllowedOrigins("*"); } @Bean public WebSocketHandler chatHandler() { return new ChatWebSocketHandler(); } }

对应的消息处理器:

public class ChatWebSocketHandler extends TextWebSocketHandler { private final Map<String, WebSocketSession> sessions = new ConcurrentHashMap<>(); private final ObjectMapper objectMapper = new ObjectMapper(); @Override public void afterConnectionEstablished(WebSocketSession session) throws Exception { String userId = (String) session.getAttributes().get("userId"); sessions.put(userId, session); session.sendMessage(new TextMessage("{\"type\":\"connected\",\"content\":\"welcome\"}")); } @Override protected void handleTextMessage(WebSocketSession session, TextMessage message) throws Exception { // 解析消息、路由给其他 session String payload = message.getPayload(); ChatMessage chatMessage = objectMapper.readValue(payload, ChatMessage.class); WebSocketSession target = sessions.get(chatMessage.getTargetUserId()); if (target != null && target.isOpen()) { target.sendMessage(new TextMessage(payload)); } } @Override public void afterConnectionClosed(WebSocketSession session, CloseStatus status) throws Exception { String userId = (String) session.getAttributes().get("userId"); sessions.remove(userId); } }

这样搭建起来,一个能收发消息的 WebSocket 服务几分钟就能跑起来。我的经验是:这种写法适合内部工具、管理后台、轻量通知这类业务,特点是连接数不大、逻辑简单、要求快速交付。

3.2 握手拦截器里的门道

上面的代码里有一个很多人忽略的点:拦截器。官方 Starter 的握手过程是你做鉴权、参数传递最好的切入点。比如,在 WebSocket 握手时,你要拿到 HTTP 请求里的 Token、用户 ID,再根据这些信息来决定是否允许握手建立连接。

public class ChatHandshakeInterceptor implements HandshakeInterceptor { @Override public boolean beforeHandshake(ServerHttpRequest request, ServerHttpResponse response, WebSocketHandler wsHandler, Map<String, Object> attributes) throws Exception { if (request instanceof ServletServerHttpRequest) { ServletServerHttpRequest servletRequest = (ServletServerHttpRequest) request; HttpSession session = servletRequest.getServletRequest().getSession(); // 拿到用户信息注入 attributes String userId = (String) session.getAttribute("userId"); if (userId == null) { // 未登录,拒绝握手 response.setStatusCode(HttpStatus.UNAUTHORIZED); return false; } attributes.put("userId", userId); } return true; } @Override public void afterHandshake(ServerHttpRequest request, ServerHttpResponse response, WebSocketHandler wsHandler, Exception exception) { // 握手完成后的钩子,可用来记录日志 } }

这里必须注意一个细节:attributes里放的东西会直接进入WebSocketSession.getAttributes(),后续在 Handler 里用同一个 key 取出来。但别放太大的对象,因为这里经过序列化/反序列化的环节,放一个大对象容易带来莫名奇妙的性能问题。

握手拦截器的另一个坑是不要在beforeHandshake里做耗时操作。握手是阻塞的,前端页面上 WebSocket 连接建立期间,浏览器是等着的,你在这里做一次数据库查询或远程调用,前端连接建立的延迟会被飞速放大。

3.3 心跳保活与断线重连:官方 Starter 的短板

这是我踩过的最深的坑之一。官方 Starter 的TextWebSocketHandler没有内置心跳机制,浏览器和服务器之间的 TCP 连接可能被中间设备(比如负载均衡、运营商 NAT)静默杀掉。对服务端来说,连接还在;对客户端来说,连接"死"而不知。结果就是:连接泄漏、服务端资源被无效连接耗尽、消息发送失败不报错。

我的解决思路是双端心跳:服务端定时向客户端发 Ping 帧,客户端收到后回 Pong 帧,同时在客户端侧每 30 秒发应用层心跳。

服务端心跳代码可以放在一个管理类里,用ScheduledExecutorService做:

@Component public class HeartbeatManager { private final Map<String, WebSocketSession> sessions = new ConcurrentHashMap<>(); private final ScheduledExecutorService scheduler = Executors.newScheduledThreadPool(1); public void start() { scheduler.scheduleAtFixedRate(() -> { long now = System.currentTimeMillis(); sessions.forEach((id, session) -> { if (!session.isOpen()) { sessions.remove(id); return; } long lastHeartbeat = (long) session.getAttributes().getOrDefault("lastHeartbeat", now); if (now - lastHeartbeat > 60_000) { // 超过 60 秒没有心跳,主动关闭 try { session.close(CloseStatus.SESSION_NOT_RELIABLE); } catch (IOException e) { // 关闭失败,直接从 map 移除 sessions.remove(id); } } }); }, 30, 30, TimeUnit.SECONDS); } public void register(WebSocketSession session) { session.getAttributes().put("lastHeartbeat", System.currentTimeMillis()); sessions.put((String) session.getAttributes().get("userId"), session); // 也要在 onMessage 里更新 lastHeartbeat } }

提示:这里的定时任务调度器ScheduledExecutorService需要随着应用生命周期做优雅关闭,否则会出现线程泄漏。

实际测试下来,这种方案在 1 万连接以下没有问题,但连接数到了 2 万以上,定时遍历所有 session 的线性扫描会开始消耗 CPU,并且容器本身的线程模型也会成为瓶颈。

3.4 官方 Starter 何时该放弃

我总结了几个信号,只要出现其中两个,就该考虑迁移到 Netty 或者其他独立方案:

  • 连接数突破 1.5 万,且在持续增长,Tomcat 的 acceptor 线程出现排队
  • 心跳扫描线程占用 CPU 超过 30%
  • 你开始在 YAML 文件里调整各种 Tomcat 参数(maxThreads、maxConnections、acceptCount),调完效果仍不明显
  • 需要精细控制服务端向客户端推送的速率、背压或者做消息优先级
  • 长连接服务与主站流量在一起,偶尔一次主站流量尖峰就把所有 WebSocket 连接全部拖垮

出现这些信号时,不是官方 Starter 不行,而是你的业务已经超出了"容器能力暴露"这个设计定位。

4. Netty 方案的实践细节:自己动手,建立长连接基础设施

4.1 核心依赖与启动骨架

Netty 方案不需要引入 Spring 的 WebSocket Starter,只需要引入 Netty 基础包,然后用 Netty 自带的 HTTP + WebSocket 编解码器,自己搭建服务端。

<dependency> <groupId>io.netty</groupId> <artifactId>netty-all</artifactId> <version>4.1.100.Final</version> </dependency>

启动代码:

public class WebSocketServer { private final EventLoopGroup bossGroup = new NioEventLoopGroup(1); private final EventLoopGroup workerGroup = new NioEventLoopGroup(); private Channel channel; public void start(int port) throws InterruptedException { ServerBootstrap bootstrap = new ServerBootstrap(); bootstrap.group(bossGroup, workerGroup) .channel(NioServerSocketChannel.class) .option(ChannelOption.SO_BACKLOG, 1024) .childOption(ChannelOption.TCP_NODELAY, true) .childOption(ChannelOption.SO_KEEPALIVE, true) .childHandler(new WebSocketServerInitializer()); ChannelFuture future = bootstrap.bind(port).sync(); channel = future.channel(); } public void stop() { if (channel != null) { channel.close(); } bossGroup.shutdownGracefully(); workerGroup.shutdownGracefully(); } }

注意bossGroup只用 1 个线程就够了,它是用来接受新连接的,主要的工作量在workerGroup。workerGroup的线程数默认是 CPU 核数乘以 2,对于长连接场景,这个默认值是比较合理的起点。

4.2 Pipeline 的搭建:每一条连接都是一条工作流水线

Netty 的核心思想是每个连接对应一条 ChannelPipeline,所有数据在这个流水线上被逐级处理。WebSocket 服务端的典型 Pipeline 是:

public class WebSocketServerInitializer extends ChannelInitializer<SocketChannel> { @Override protected void initChannel(SocketChannel ch) { ChannelPipeline pipeline = ch.pipeline(); // 1. 处理 HTTP 编解码 pipeline.addLast(new HttpServerCodec()); // 2. HTTP 聚合,把 HttpRequest 和 HttpContent 合并为 FullHttpRequest pipeline.addLast(new HttpObjectAggregator(65536)); // 3. 处理 WebSocket 升级握手 pipeline.addLast(new WebSocketServerProtocolHandler("/ws")); // 4. 自定义业务 Handler pipeline.addLast(new ChatFrameHandler()); } }

这段配置我建议你复制下来逐行背熟,因为每个组件的顺序都有讲究。HttpServerCodec和HttpObjectAggregator的顺序不能颠倒,WebSocketServerProtocolHandler必须在它们之后。如果 Agilegregator 的 maxContentLength 设置太小,握手时携带的大 HTTP Headers 会被拒绝。

WebSocketServerProtocolHandler会自动处理升级握手、Ping/Pong,以及关闭帧。如果你什么都自己做,手动解析 WebSocket 帧再回 Ping/Pong,心智负担和 Bug 率会成倍增长。

4.3 业务 Handler 的关键设计

自定义 Handler 继承SimpleChannelInboundHandler<TextWebSocketFrame>,代码如下:

public class ChatFrameHandler extends SimpleChannelInboundHandler<TextWebSocketFrame> { private final ChannelGroup channels = new DefaultChannelGroup(GlobalEventExecutor.INSTANCE); @Override public void handlerAdded(ChannelHandlerContext ctx) throws Exception { channels.add(ctx.channel()); super.handlerAdded(ctx); } @Override public void handlerRemoved(ChannelHandlerContext ctx) throws Exception { channels.remove(ctx.channel()); super.handlerRemoved(ctx); } @Override protected void channelRead0(ChannelHandlerContext ctx, TextWebSocketFrame msg) throws Exception { String text = msg.text(); // 在这里做业务路由,比如根据消息类型分发 channels.writeAndFlush(new TextWebSocketFrame(text)); } @Override public void exceptionCaught(ChannelHandlerContext ctx, Throwable cause) throws Exception { // 记录异常日志,关闭连接 ctx.close(); } }

这里我用ChannelGroup管理所有连接,它本身是线程安全的,支持并发写入。需要注意的是,同一个 ChannelGroup 会保存所有业务线的连接,如果将来一个 Netty 服务要承载多种业务消息(比如通知、聊天、设备控制),建议按业务域拆分成多个 ChannelGroup,否则群发消息时会把无关业务的连接也带上。

另一个关键细节是writeAndFlush方法需要传入WebSocketFrame对象,每次写之前都要 new 一个,不能复用,因为 Netty 的引用计数机制会在写完之后自动释放 Frame 占用的内存。

4.4 Netty 心跳:比官方 Starter 强在哪

Netty 自带的IdleStateHandler可以在连接空闲超过指定时间后触发事件,你只需要在 Pipeline 里加一行:

pipeline.addLast(new IdleStateHandler(60, 0, 0, TimeUnit.SECONDS));

然后在 Handler 里覆写userEventTriggered:

@Override public void userEventTriggered(ChannelHandlerContext ctx, Object evt) throws Exception { if (evt instanceof IdleStateEvent) { IdleStateEvent event = (IdleStateEvent) evt; if (event.state() == IdleState.READER_IDLE) { // 60 秒没读到任何数据,说明客户端可能已经挂掉,关闭连接 ctx.close(); } } else { super.userEventTriggered(ctx, evt); } }

这个做法的效率远远高于官方 Starter 下的定时扫描方案。因为 Netty 是事件驱动的,IdleStateHandler在每个 Channel 上独立计时,没有全量扫描的过程,时间精度也更高。连接数再多,心跳检测的成本也不会呈线性增长。

4.5 Netty 的背压与流量控制

这是 Netty 方案最容易被新手写坏的地方。当服务端向客户端推送消息的速度大于客户端消费速度时,writeAndFlush不会阻塞,而是把数据不断写入 Channel 的发送缓冲区,最终导致内存暴涨。Netty 提供了Channel.isWritable()方法和ChannelOutboundBuffer的高水位标记来应对。

我常用的流量控制策略是:

// 在 Handler 里,写之前检查 if (ctx.channel().isWritable()) { ctx.channel().writeAndFlush(new TextWebSocketFrame(message)); } else { // 写不进去了,要么丢弃并计入指标,要么放到待发队列 pendingQueue.offer(message); }

还要配合设置水位:

ChannelConfig config = ch.config(); WriteBufferWaterMark waterMark = new WriteBufferWaterMark(8 * 1024 * 1024, 16 * 1024 * 1024); config.setWriteBufferWaterMark(waterMark);

8MB 是低水位,16MB 是高水位。当缓冲超过高水位时,isWritable()会返回 false,等降到低水位以下再恢复 true。通过这个机制,你可以决定是丢弃还是暂存,避免把服务端内存打爆。

4.6 与 Spring 生态集成的注意点

如果你是在 Spring Boot 项目里用 Netty,通常做法是让 Netty Handler 拿到 Spring 容器管理的 Service。可以通过ApplicationContext静态持有来获取:

@Component public class SpringContextHolder implements ApplicationContextAware { private static ApplicationContext applicationContext; @Override public void setApplicationContext(ApplicationContext applicationContext) { SpringContextHolder.applicationContext = applicationContext; } public static <T> T getBean(Class<T> clazz) { return applicationContext.getBean(clazz); } }

然后在 Netty Handler 里调用:

ChatService chatService = SpringContextHolder.getBean(ChatService.class);

注意:这种做法会让 Netty Handler 与 Spring 容器耦合,纯 Netty 的推土机式写法不推荐,但在 Spring Boot 项目里这是最务实的选择。

5. 选型决策思路:照着你的业务场景做对照

5.1 什么场景闭眼选官方 Starter

如果你的业务符合下面任何一条,别纠结,直接用官方 Starter:

  • 内部管理系统:比如后台实时看板、审批流程通知、简单的在线状态展示
  • 连接数在几千以内,且未来一年没有指数级增长的可能
  • 开发团队人数少,没有人深入理解 Netty 的核心模型
  • 业务需要快速上线,第一版功能优先级远高于高性能
  • 已经有成熟的 Spring Security 权限体系,需要 WebSocket 握手时无缝沿用

这种场景用官方 Starter,最大的好处是"少写代码少犯错"。WebSocket 协议本身的复杂度被容器屏蔽掉了,你专心写业务。

5.2 什么场景建议直接上 Netty

反过来,以下情况建议直接上 Netty 甚至独立成单独的服务:

  • 用户量大的公开产品,连接数预期从上线第一天就破万
  • 实时性要求高,比如在线协作、实时对战、行情推送
  • 需要服务端频繁主动推送消息,且消息量巨大
  • 需要做精细的流量控制、消息优先级、断线重连
  • 后端服务已经有了微服务拆分的趋势,实时通信模块单独部署更合适

这种场景用 Netty,前期多花的时间和精力,会在连接量上来之后成倍还给你。

5.3 第三种选择:两个都用的混合架构

其实在生产环境里,还有第三种思路:WebSocket 入口由 Netty 统一管理,但 Netty 只做协议接入和连接管理,真正的业务逻辑通过调用内部 HTTP API 或消息队列转发给 Spring Boot 服务。这种架构把"连接"和"业务"拆开,Netty 专注长连接,Spring Boot 专注业务,互不干扰。

我参与过的某物联网项目中就是类似的结构,Netty 负责承载几万设备的 TCP 长连接与 WebSocket 接入,消息解析后投递到消息队列,业务侧再消费。核心思路就一句话:不要让业务逻辑的复杂度污染连接层,也不让长连接的资源占用拖垮业务服务。

这个架构的代价是多引入一层服务,部署和运维都要多一份精力。如果团队没有足够的后端基础设施经验,这个方案的风险反而更高。

6. 生产环境核心参数调优实录

6.1 官方 Starter 容器参数调整

官方 Starter 基于 Tomcat 时,有几个参数值得关注(application.yml示例):

server: tomcat: max-threads: 200 max-connections: 10000 accept-count: 1000 connection-timeout: 20000 servlet: session: timeout: 30m

注意max-threads控制的是处理请求的线程数,对于 WebSocket 场景,长连接占用的线程会一直被占着,这个数字不宜设得过大,否则线程上下文切换和内存开销会带来副作用。连接数上来之后,更合理的做法是调max-connections,让更多连接可以进入,但业务处理线程只负责握手和消息路由。

6.2 Netty 参数调优实录

Netty 服务端的参数设置我一般按下表作为起步参考,再根据压测结果微调:

参数建议值说明
bossGroup 线程数1只做 accept,一个线程足够
workerGroup 线程数CPU 核数 * 2处理 IO 事件,可以再手动压测调整
SO_BACKLOG1024accept 队列长度,连接突增时防止握手失败
SO_KEEPALIVEtrue启用 TCP 层保活,作为应用层心跳的兜底
TCP_NODELAYtrue禁用 Nagle 算法,降低小消息延迟
WRITE_BUFFER_WATER_MARK8MB / 16MB控制发送缓冲区的水位
ALLOCATOR推荐使用池化内存分配器(PooledByteBufAllocator)大幅降低频繁分配小内存带来的 GC 压力

池化内存分配器的配置方式:

.childOption(ChannelOption.ALLOCATOR, PooledByteBufAllocator.DEFAULT)

这一行我是在压测时翻源码才注意到的,加上之后 GC 从每分钟几次降到了几分钟一次,效果非常明显。

6.3 大规模连接时的消息路由设计

连接数上万之后,消息路由策略就成了决定系统成败的核心。我之前见过一个团队,把每个用户的连接放在一个全局ConcurrentHashMap里,每次消息发送都直接遍历 Map 找目标连接。连接数少没问题,但到了几万连接时,某个环节稍有不慎就会出现链路超时。

更好的做法是引入分层路由:用 Redis 或者内存 Grid 维护userId -> 服务节点 -> 连接的映射,消息投递时先查节点,再到节点内部的 ChannelGroup 里找具体的 Channel 发送。这样每个服务节点只维护自己本地的连接,跨节点消息通过内部通道转发。

这一块涉及很多分布式细节,如果你有信心,可以从简单的两节点开始设计。先把本地路由做好,再逐步扩展。

7. 常见问题与排查技巧实录

7.1 握手失败:一直 403

最常见原因是跨域配置。官方 Starter 里setAllowedOrigins("*")如果写成/ *这种格式,就可能被容器拒绝。另外,allowedOrigins在较新版本里不再支持通配符加 Cookie 的组合,如果前端要求跨域携带 Cookie,必须显式列出前端域名。

7.2 连接建立了,但消息发不过去

这种问题的排查路径非常固定:

  1. 先确认连接是否正常建立(握手成功)
  2. 再确认消息走到了哪个环节,在 Handler 的channelRead0里加日志
  3. 用浏览器开发者工具的 Network 面板看 WebSocket 帧的收发情况
  4. 检查服务端有没有报异常,特别是writeAndFlush之前的 Frame 构建有没有报错

我见过的情况里,90% 都是业务逻辑里把消息对象发错了类型。比如用 Netty 时,服务端要向客户端发消息,必须是WebSocketFrame类型,而新手很容易直接发一个字符串对象,Netty 在编码时找不到对应 Encoder,于是静默失败。

7.3 连接数上来后,内存越用越高

这类问题优先查两个点:一是有没有把ByteBuf引用计数正确释放,二是发送缓冲区有没有堆积。

Netty 里如果你在 Handler 中read了消息但不release,就会造成直接内存泄漏。规避手段之一是在内网环境打开 Netty 自带的泄漏检测器:

-Dio.netty.leakDetection.level=PARANOID

这个参数只建议在测试环境开。生产环境开它会有性能损耗。

发送缓冲区堆积的话,用channel.unsafe().outboundBuffer().totalPendingWriteBytes()做监控。如果一直高企,说明下游消费速度跟不上,要做流量控制,而不是盯着 Netty 调参数。

7.4 WebSocket 连接被中间设备断开

中间设备的空闲超时往往比应用层心跳快得多。你设置 60 秒心跳,但中间设备如果空闲 30 秒就断连,你的心跳就没有意义。

对策是心跳周期要小于中间设备的最短空闲超时,通常是其三分之一。比如中间设备空闲 60 秒断开,心跳周期就设置 20 秒。这一块没有经验值能套用,需要结合部署环境的实际链路来做测试。另外,Ping/Pong 帧是 WebSocket 协议层的规范,所以尽量用协议层的心跳,不要去设计一套应用层心跳来替代它。

8. 我踩过的坑和最终选择建议

文章写到最后,分享一些我个人的体会。

我对官方 Starter 的态度是:它很适合承载业务价值验证期的产品。前期快速上线、快速迭代,把实时通信的业务逻辑跑通,积累用户反馈。但它的天花板很清晰,如果你明确能看到业务将会有几万甚至几十万的连接,那第一版就不该用官方 Starter,因为迁移成本永远高于一开始就设计好方案的成本。

Netty 方案要做的准备比很多人想象的多。不是引入依赖、写个初始化类、跑起来就完事的,内存管理、线程模型、Pipeline 设计、背压策略都是决定成败的细节。我见过太多把 Netty 写成"高并发版的阻塞 IO"的代码,结果还不如直接用官方 Starter。

假如你今天是第一次做 WebSocket 选型,我给一条非常朴素的经验:

  • 连接数 5000 以下,无脑选官方 Starter,把节省下来的时间去打磨业务
  • 连接数 1 万到 5 万,选 Netty,但要认真设计心跳、背压和内存管理
  • 连接数 5 万以上,不建议自己写 WebSocket 服务端,直接考虑成熟的网关或云厂商方案,或者让 Netty 服务专注做连接、业务完全解耦

最后再分享一个小技巧:无论选哪个方案,上线前都要做连接数压测,并且要模拟真实场景中客户端频繁掉线、重连、再掉线的过程。长连接服务的崩溃往往不是发生在正常运行时,而是在大规模断线重连的抖动期。把重连风暴防住了,你的服务才算真正稳定。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进