做TCP长连接服务端这些年我先后用原生Socket、Mina、Netty写过生产级项目。坦白说只要连接数一上千原生Socket的代码就会让人怀疑人生——不是跑不起来是线程一多就到处是坑维护成本高得离谱。后来全面切到Netty这个问题才算真正被根治。这篇文章不讲大而全的理论就是围绕基于Netty的TCP协议的Socket服务端这件事把我从启动类到线上调优、从粘包处理到心跳机制的实际经验整理出来。适合刚接触Netty的服务端开发也适合已经写了一阵子但总觉得能跑但不敢上生产的朋友。看完你应该能照着搭出一个结构清晰、抗压能力尚可的TCP服务端并且知道哪些地方容易埋雷、为什么要这么设计。1. 为什么选Netty做TCP服务端我对比原生Socket之后得出的结论1.1 原生Socket服务端到底卡在哪很多教程一开始就让你写这种代码ServerSocket serverSocket new ServerSocket(9090); while (true) { Socket socket serverSocket.accept(); // 阻塞等待连接 new Thread(() - handle(socket)).start(); // 一个连接一个线程 }这段代码在100个连接以内没什么问题但连接数一旦到了几千问题就非常明显线程数跟着连接数线性增长每个线程默认栈内存1MB左右5000个连接就是5GB的虚拟内存开销GC和上下文切换会把CPU拖垮。更气人的是绝大多数线程都在read()调用上阻塞着根本没读到数据白白占着资源。即使你改用NIO自己写Selector处理OP_READ、OP_WRITE、半包、空轮询这些边角问题工作量也不是一般的大。Netty把这些脏活全封装好了事件驱动的Reactor模型、零拷贝、内存池、丰富的编解码器还有背压机制。我实测下来同样的业务逻辑Netty扛住的连接数大概是手写NIO的3到4倍代码量反而少一半。1.2 Netty和Mina怎么选这里得提一下Mina。它和Netty同源都出自同一作者的开源思路早期很多项目用Mina后来Netty社区明显更活跃版本迭代快对Reactor模型的支持更彻底。我把两者对比过维度NettyMina社区活跃度高更新频繁偏低节奏慢文档和示例丰富Stack Overflow一搜一大把相对少内存管理PooledByteBufAllocator很成熟一般HTTP/WebSocket支持内置较弱生产环境案例很多大厂中间件在用逐渐减少Netty不一定是每个场景的最优解但选它踩坑的概率最低。所以这篇文章后面全部以Netty为例。2. 线程模型先吃透双层EventLoop是Netty高性能的底座2.1 Reactor模型和传统阻塞模型的区别Netty的核心是Reactor模型。用餐厅来类比bossGroup是前台接待只负责领客人进门接受TCP连接workerGroup是传菜员负责给落座的客人上菜处理连接上的读写事件。接待员不会自己去后厨炒菜传菜员也不会到门口拉客各司其职。传统阻塞模型相当于每个客人配一个专属服务员客人不点菜服务员就只能干等着资源浪费严重。Reactor模型是少量服务员服务大量客人客人需要服务时通过事件通知服务员再响应效率完全不同。2.2 bossGroup和workerGroup怎么配置直接上代码EventLoopGroup bossGroup new NioEventLoopGroup(1); EventLoopGroup workerGroup new NioEventLoopGroup();bossGroup通常设置1个线程就够了因为server端的accept事件本身不重一个线程处理绰绰有余多了反而浪费。workerGroup的线程数默认是CPU核数 * 2可以通过系统属性io.netty.eventLoopThreads覆盖。每个EventLoop就是一个线程一个EventLoop上可能挂了很多个Channel这些Channel的所有事件都由这个线程串行处理。好处是同一个Channel上的逻辑天然无锁不用考虑并发竞争坏处是如果你在Handler里做了一次耗时很长的阻塞调用这个EventLoop上的所有其他连接都得排队等着这就是很多人遇到的一个连接卡住整台服都卡住的根源。2.3 为什么不推荐开大量业务线程有人会想那我把EventLoop线程数调成100不就行了不行。线程多了上下文切换和锁竞争带来的开销可能比收益还高。Netty的设计哲学是IO线程只做IO和编解码耗时的业务逻辑丢给独立的业务线程池两者用队列解耦。这就像餐厅传菜员只管把菜从窗口端到桌上绝不会在传菜过程中帮你炖一个小时的汤。这条纪律守住了Netty的高性能底座才算真正被你用起来了。3. 服务端骨架搭建把第一个连接跑起来需要几步3.1 Maven依赖和最小启动类先加依赖dependency groupIdio.netty/groupId artifactIdnetty-all/artifactId version4.1.100.Final/version /dependency然后用ServerBootstrap组装服务端public class NettyTcpServer { public static void main(String[] args) throws InterruptedException { EventLoopGroup bossGroup new NioEventLoopGroup(1); EventLoopGroup workerGroup new NioEventLoopGroup(); try { ServerBootstrap bootstrap new ServerBootstrap() .group(bossGroup, workerGroup) .channel(NioServerSocketChannel.class) .option(ChannelOption.SO_BACKLOG, 1024) .childOption(ChannelOption.TCP_NODELAY, true) .childHandler(new ChannelInitializerSocketChannel() { Override protected void initChannel(SocketChannel ch) { ch.pipeline().addLast(new LengthFieldBasedFrameDecoder(1024, 0, 4, 0, 4)); ch.pipeline().addLast(new StringDecoder(StandardCharsets.UTF_8)); ch.pipeline().addLast(new StringEncoder(StandardCharsets.UTF_8)); ch.pipeline().addLast(new ServerHandler()); } }); ChannelFuture future bootstrap.bind(9090).sync(); System.out.println(TCP服务端启动成功端口9090); future.channel().closeFuture().sync(); } finally { bossGroup.shutdownGracefully(); workerGroup.shutdownGracefully(); } } }这段代码里每个配置都有讲究.group(bossGroup, workerGroup)前面说过的接待员和传菜员的组合。.channel(NioServerSocketChannel.class)指定用NIO模型。如果要换EpollLinux高并发场景改成EpollServerSocketChannel并引入netty-transport-native-epoll依赖即可。.option(ChannelOption.SO_BACKLOG, 1024)这是服务端accept队列的长度后面踩坑部分细讲。.childOption(ChannelOption.TCP_NODELAY, true)关闭Nagle算法减少小包的等待延迟。这是TCP层的关键参数实测不开这个交互性强的消息会有肉眼可见的延迟。childHandler里的ChannelInitializer每个新连接建立后都会执行一次往Pipeline里装处理器。3.2 ChannelInitializer和Pipeline到底做了什么Pipeline是一条责任链每个Handler负责一个环节。你发一个消息进来会依次通过Pipeline里的每个Handler有人负责拆包有人负责解码成字符串有人负责业务处理。ChannelInitializer的特殊之处在于它在Channel注册完成后自动执行一次initChannel作用是给Pipeline装好第一轮Handler。注意它只执行一次之后连接的生命周期就交给Pipeline中现有的Handler了。3.3 编译、启动、用nc和Python客户端验证先写最简单的Handlerpublic class ServerHandler extends SimpleChannelInboundHandlerString { Override protected void channelRead0(ChannelHandlerContext ctx, String msg) { System.out.println(收到客户端消息: msg); ctx.writeAndFlush(服务端已收到: msg); } Override public void channelActive(ChannelHandlerContext ctx) { System.out.println(新连接接入: ctx.channel().remoteAddress()); } Override public void exceptionCaught(ChannelHandlerContext ctx, Throwable cause) { cause.printStackTrace(); ctx.close(); } }编译启动后先用netcat快速验证nc 127.0.0.1 9090输入一行hello netty看到服务端打印日志并发来响应说明骨架通了。也可以用Python写个更完整的测试客户端import socket s socket.socket(socket.AF_INET, socket.SOCK_STREAM) s.connect((127.0.0.1, 9090)) s.sendall(bhello netty) print(s.recv(1024)) s.close()注意服务端Handler里的ctx.writeAndFlush(服务端已收到: msg)在字符串模式下Netty会通过StringEncoder自动编码后写回。这一步跑通了说明编解码链路没问题可以开始玩真的了。4. 粘包/拆包必须正面处理TCP字节流的消息边界问题4.1 粘包是怎么产生的这是TCP服务端绕不开的话题。TCP是面向字节流的协议它只保证字节的顺序和完整交付不保证你的应用消息有边界。你把两条消息发出去TCP可能把两条合在一个包里送过来这就是粘包也可能把一条消息分拆成多个包送过来这就是拆包。产生的原因主要有发送端开了Nagle算法小包会合并成大包一起发送。接收端缓冲区一次读取到了多个包的数据。单条消息超过MSS最大报文段长度在网络上被分段。生活化理解TCP像一条水管你往里面倒水水管不会管你是怎么分杯子倒的到了对面只会看到持续不断的水流。你要自己从水流中切分出这一杯和那一杯。4.2 Netty四类解码器怎么选Netty一共提供了四种现成的拆包解码器省去你自己处理ByteBuf的苦差事解码器原理适用场景LineBasedFrameDecoder按换行符\n或\r\n切分文本协议日志采集类DelimiterBasedFrameDecoder按自定义分隔符切分自定义文本协议如\0结尾FixedLengthFrameDecoder每条消息固定长度帧长度固定的二进制协议LengthFieldBasedFrameDecoder从长度字段读帧长度二进制协议通用性最强这四选一即可千万别同时放多个拆包解码器在Pipeline里否则会出现消息被第一层截断、第二层怎么都对不齐的诡异bug。我见过有人同时加了LineBasedFrameDecoder和LengthFieldBasedFrameDecoder结果文本消息和二进制消息互相干扰排查了一整天才发现。4.3 LengthFieldBasedFrameDecoder的5个参数到底是什么意思这个解码器参数多但也是最常用的。以new LengthFieldBasedFrameDecoder(1024, 0, 4, 0, 4)为例maxFrameLength最大帧长度这里1024字节防止恶意客户端传超长消息直接打爆内存。lengthFieldOffset长度字段在帧里的偏移量0表示从帧头开始就是长度字段。lengthFieldLength长度字段占用字节数4表示用int32表示长度。lengthAdjustment长度字段的值与实际帧长度的差值。这里是最容易迷糊的地方。initialBytesToStrip解码后剥掉的字节数4表示把长度字段剥掉让后面的Handler直接拿到body。重点说lengthAdjustment。这个公式要记牢实际帧长度 lengthFieldOffset lengthFieldLength lengthAdjustment lengthFieldValue如果你的协议里长度字段存的是body的长度不含长度字段自身那lengthFieldValue bodyLen实际帧长度应该是4 bodyLen所以lengthAdjustment为0代码就是(1024, 0, 4, 0, 4)。如果你的协议里长度字段存的是整帧的总长度含长度字段自身比如lengthFieldValue 4 bodyLen实际帧长度也是4 bodyLen那么lengthAdjustment就得是-4代码要写成(1024, 0, 4, -4, 4)。这两个场景很容易搞混建议定协议的时候把字段含义写清楚长度字段表示后续负载长度不含自身。这样其他同事读代码的时候不会骂你。4.4 自定义二进制协议的字段设计如果从零设计一个二进制协议推荐这样排[4字节 magic] [4字节 bodyLength] [body bytes]magic用固定值0xCAFEBABE之类的做协议头校验bodyLength表示body的长度。解码器配置就是new LengthFieldBasedFrameDecoder(1024, 4, 4, 0, 4)——因为长度字段从第4字节开始body从第8字节开始剥掉前8字节后Handler只看到body。这样一个协议既不粘包又防脏数据生产环境里非常常见。5. Handler里别乱写业务代码IO线程的纪律和业务线程池的边界5.1 SimpleChannelInboundHandler和ChannelInboundHandlerAdapter的区别很多新手搞不清这两个Handler的区别。关键点在消息释放SimpleChannelInboundHandlerT处理完消息后会自动释放ReferenceCounted消息的引用计数你不用管内存回收。ChannelInboundHandlerAdapter不会自动释放你自己得调ReferenceCountUtil.release(msg)或者ctx.fireChannelRead往下传否则ByteBuf资源泄漏。所以只要你的业务逻辑只消费这一条消息、不再往下一个Handler传用SimpleChannelInboundHandlerString最省心。如果消息还要继续传给后面的Handler做链路处理就用ChannelInboundHandlerAdapter并且在代码里手动ctx.fireChannelRead(msg)。5.2 Handler的状态管理Handler分两种有状态和无状态。无状态的Handler比如只做转发、打日志、解码可以在类上标注ChannelHandler.Sharable然后声明成静态单例放到Pipeline里避免每个连接创建一个实例。有状态的Handler比如每个连接维护一个登录状态、一个待确认消息队列必须在ChannelInitializer里每连接new一个。千万别把一个有状态的Handler标成Sharable否则多个连接共享一份状态数据串号是必然的。这属于踩过就会记住的坑。5.3 业务逻辑如何异步化DefaultEventExecutorGroup的正确用法前面强调过不能在EventLoop线程里做重活。但实际业务里查库、调接口这些操作躲不掉。最简单的做法是把消息投递到业务线程池然后立即返回ExecutorService businessPool new ThreadPoolExecutor( 8, 16, 60L, TimeUnit.SECONDS, new ArrayBlockingQueue(10000), new ThreadFactoryBuilder().setNameFormat(business-pool-%d).build()); Override protected void channelRead0(ChannelHandlerContext ctx, String msg) { businessPool.execute(() - { // 这里做数据库查询、远程调用等耗时操作 String result handleBusiness(msg); ctx.writeAndFlush(result); // 注意这里写回操作还是走的Netty线程 }); }这个方案虽然简单但有个隐患如果同一个客户端连续发A、B两条消息A和B可能被两个业务线程同时处理或者A被B先执行完导致乱序。Netty官方推荐的方案是给Pipeline指定一个独立的EventExecutorGroup让这个连接的业务消息全部串行在这个线程组里的一个线程上处理EventExecutorGroup businessGroup new DefaultEventExecutorGroup(8); bootstrap.childHandler(new ChannelInitializerSocketChannel() { Override protected void initChannel(SocketChannel ch) { ch.pipeline().addLast(frameDecoder, new LengthFieldBasedFrameDecoder(1024, 0, 4, 0, 4)); ch.pipeline().addLast(stringDecoder, new StringDecoder(StandardCharsets.UTF_8)); ch.pipeline().addLast(stringEncoder, new StringEncoder(StandardCharsets.UTF_8)); // 后面的业务Handler挂到独立的业务线程组里 ch.pipeline().addLast(businessGroup, new ServerHandler()); } });这样IO线程只管把消息解码成字符串然后消息在进入ServerHandler时切到businessGroup线程执行。同一连接的消息会绑到businessGroup里的同一个线程串行处理乱序问题天然避免。5.4 异常处理和连接状态记录ServerHandler里必须实现exceptionCaught否则异常会一路冒泡连接可能处于半死不活的状态。规范做法是打印日志、记录关键信息、关闭连接Override public void exceptionCaught(ChannelHandlerContext ctx, Throwable cause) { log.error(连接处理异常, channel{}, ctx.channel().id(), cause); ctx.close(); }我还习惯在channelActive里把远程地址和连接时间存起来在channelInactive里打印连接存活时长。线上排查谁连过我、连了多久的时候这些日志能救命。6. 连接管理与心跳服务端不能傻等也要会分手6.1 连接泄漏问题客户端拔网线、断电、进程被kill -9服务端不会立刻知道。TCP层面没有数据来往时服务端根本感知不到对端已经人间蒸发。这些半开连接会一直占着服务端的文件描述符和内存积累到一定数量新连接就进不来了这就是连接泄漏。TCP协议层的keepalive默认要等2小时才探测一次太慢了生产环境绝对不能指望它。我们得在业务层面自己做心跳。6.2 IdleStateHandler心跳方案Netty提供了一个现成的空闲检测HandlerIdleStateHandler。参数分别是读空闲、写空闲、全空闲的超时时间秒。在我做的业务里客户端每30秒发一次心跳包服务端就设置一个略宽松的读空闲时间ch.pipeline().addLast(new IdleStateHandler(60, 0, 0, TimeUnit.SECONDS));然后在Handler里处理userEventTriggeredOverride public void userEventTriggered(ChannelHandlerContext ctx, Object evt) throws Exception { if (evt instanceof IdleStateEvent) { IdleStateEvent event (IdleStateEvent) evt; if (event.state() IdleState.READER_IDLE) { log.info(60秒未读到客户端数据关闭连接: {}, ctx.channel().remoteAddress()); ctx.close(); } } else { super.userEventTriggered(ctx, evt); } }为什么只检测读空闲因为服务端主动发心跳的场景通常没有客户端定时上报业务或心跳数据服务端只要持续收到数据就认为连接健康。如果服务端需要主动探测客户端那就检测写空闲定期writeAndFlush一个Ping包把任务丢给客户端。6.3 活跃连接怎么管理如果服务端需要主动往某个连接推消息就需要维护一张连接表。我常用的方案是ConcurrentHashMap按ChannelId存public class ConnectionManager { private static final ConcurrentHashMapString, Channel ONLINE new ConcurrentHashMap(); public static void add(Channel channel) { ONLINE.put(channel.id().asLongText(), channel); } public static void remove(Channel channel) { ONLINE.remove(channel.id().asLongText()); } public static void broadcast(String message) { for (Channel channel : ONLINE.values()) { if (channel.isActive()) { channel.writeAndFlush(message); } } } }在Handler的channelActive调addchannelInactive调remove。广播时注意先判断channel.isActive()而且要做好背压——调用channel.writeAndFlush时如果消息队列堆积会撑爆内存。Netty提供了channel.isWritable()判断不满足条件时可以先不写或者走丢弃策略。还有一个细节writeAndFlush返回一个ChannelFuture不要只调不查。建议异步监听失败回调否则消息没发出去你不知道channel.writeAndFlush(message).addListener(future - { if (!future.isSuccess()) { log.warn(消息发送失败, channel{}, 原因{}, channel.id(), future.cause()); } });7. 高并发下的调优与踩坑端口冲突、队列溢出和真实抓包分析7.1 bind失败Only one usage of each socket address这是我见过最多的启动报错之一java.net.BindException: Address already in use原因就是端口被占。排查非常简单lsof -i :9090 ss -lntp | grep 9090找到占用进程后kill掉或者换端口。有一种情况容易被忽略上一次服务启动用的是9090后来服务关了但socket处于TIME_WAIT状态短时间内不能复用同一个四元组。Netty默认开启SO_REUSEADDR情况会好很多。如果遇到TIME_WAIT导致的绑不上可以显式设置.option(ChannelOption.SO_REUSEADDR, true)另外要注意Linux下1024以下的端口需要root权限开发环境别没事绑8080或8080以下端口容易因为权限问题收到Permission denied。7.2 accept队列溢出和TCP三次握手的真实抓包分析有一次线上压测客户端大量反馈连接超时。我用tcpdump抓包tcpdump -i any port 9090 -nn -w /tmp/tcp.cap然后用Wireshark打开发现了一个典型现象服务端对客户端发来的SYN包不回复SYNACK。客户端的SYN反复重传最后放弃。这就是经典的accept队列溢出——内核里保存已完成握手连接的队列满了新的SYN直接被丢弃。TCP三次握手的过程服务端一侧是这样的客户端发SYN。服务端内核收到SYN放到半连接队列回复SYNACK。客户端回ACK连接进入全连接队列accept队列。应用调用accept从队列取走连接。如果应用层accept不够快全连接队列堆积SO_BACKLOG设置得太小新来的连接就只能排队甚至被丢弃。调大SO_BACKLOG能缓解.option(ChannelOption.SO_BACKLOG, 4096)但这不是越大越好太大反而会让积压的连接等待过久客户端都超时了连接还没被accept走。合理值取决于你服务端的消费速度一般1024到4096之间比较稳妥。7.3 参数调优清单参数作用我的建议SO_BACKLOGaccept队列长度1024起压测后调整TCP_NODELAY关闭Nagle算法必须true交互类服务尤其重要SO_KEEPALIVETCP层保活探测设为true但别依赖它做业务心跳SO_SNDBUF / SO_RCVBUF发送/接收缓冲区一般不用动让系统自调ALLOCATORByteBuf分配器生产用PooledByteBufAllocator.DEFAULTNetty默认就是池化分配器这里提醒一点测试本地小流量时可能觉得池化没什么但高并发下池化能显著减少GC压力别为了图省事改成Unpooled。7.4 一个真实的坑在Netty IO线程里直连MySQL有次业务上线后整个服务突然卡顿所有连接都像被冻住一样。我第一反应是Netty的IO线程出问题了立刻jstack看线程栈结果发现大量EventLoop线程阻塞在JDBC驱动的socket read上。代码如下差不多的写法Override protected void channelRead0(ChannelHandlerContext ctx, String msg) { // 直接在IO线程里查数据库 User user userDao.findByToken(msg); ctx.writeAndFlush(user); }问题在于findByToken发起了JDBC连接而MySQL又没有及时响应类似报错ERROR 2002 (HY000): Cant connect to local MySQL server through socket /tmp/mysql.sock导致这条EventLoop线程一直阻塞在数据库调用上。而这条EventLoop上还挂着几百个其他连接所有连接全部跟着遭殃。这正好印证了前面说的纪律IO线程只做IO不做任何可能阻塞的调用。修复方案就是把数据库操作丢到业务线程池或DefaultEventExecutorGroup里。那次之后我立了条规矩Netty Handler代码里禁止出现同步JDBC、禁止调用第三方RPC必须全部走异步或线程池。写到这里最后分享一个小技巧排查Netty服务卡顿时用jstack抓线程栈然后grep线程名里的nioEventLoopGroup。如果发现某个EventLoop线程长时间停在业务代码的堆栈上而不是停在Selector.select()上说明有IO线程被阻塞了赶紧把那块业务代码挪出Pipeline。这个排查方法和前面所有内容一样都是我踩过的坑换来的照着做能帮你省下很多排查时间。