ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

Netty服务端:客户端收不到消息的常见原因与排查指南

Netty服务端:客户端收不到消息的常见原因与排查指南 做Netty服务端开发我估计不少人都经历过这种“灵异事件”客户端明明连上了服务端日志也显示连接建立成功你用ctx.writeAndFlush(hello)往客户端发字符串客户端那边就是没反应既不报错也没数据。抓包一看好家伙数据包压根没出去或者出去了但客户端解析不出来。这类问题折腾起来非常耗时间今天我就把channel、channelGroup、ctx.writeAndFlush()发送字符串消息客户端收不到的原因按我踩坑的频率从高到低完整梳理一遍顺便把排查套路也交给你。先说明白这篇内容主要写给两类人看一是刚接触Netty没多久照着demo写完服务端却发现客户端收不到消息的新手二是已经能熟练写出handler但遇到“消息发不出去”的诡异问题需要一个排查清单的老手。下面所有结论都基于Netty 4.1.x版本代码示例也是这套版本下的写法。1. 先搞清楚“收不到”到底是什么意思1.1 三种“收不到”的典型表现“客户端接收不到”这个描述太模糊了它至少包含三种完全不同的情况而排查方向是截然相反的。第一种是数据压根没离开服务端进程。你在服务端调用了writeAndFlush但消息根本没写入socket缓冲区客户端那边用wireshark都抓不到包。这种情况下问题几乎都出在服务端自身的pipeline或者channel状态上。第二种是数据离开了服务端客户端网卡也收到了字节流但业务层没解析出来。比如服务端发了一大串字符串客户端按行解码却等不到换行符或者服务端用了UTF-8编码客户端用GBK解码结果全是乱码被解码器丢弃。这种问题最容易误判成“服务端没发”实际上客户端已经是“收到了但看不懂”。第三种是数据正常发到客户端客户端也正常打印了但因为你打印的日志级别设置不对或者发到了另一个连接实例上导致看起来像没收到。这个属于“假性故障”本质上是你自己把连接对象搞混了或者客户端的事件回调根本没绑定到正确的channel上。所以排查任何收发类问题第一步永远是先把这个“收不到”定义清楚否则后面你做的一切都是在猜。1.2 channel、channelGroup、ctx.writeAndFlush 分别负责什么这三个概念是Netty网络模型的基础但它们的职责边界经常被搞混所以导致很多误用。channel是Netty对一条网络连接的抽象它封装了socket的状态、读写方法、事件回调。你拿到一个channel就等于拿到了这条连接的操作句柄。不过要注意channel.writeAndFlush()是从pipeline的尾部开始向前outbound方向传播数据而ctx.writeAndFlush()是从当前handler的位置开始向前传播。这个区别非常关键我在后面第2部分会专门展开。channelGroup是一个连接组管理器它内部维护了一个channel的集合常见的用法是ChannelGroup.broadcast或者channelGroup.writeAndFlush实现向所有连接的客户端广播消息。它本质上是帮你遍历了所有channel并分别调用写操作但这也带来了新的问题——遍历过程中只要有一个channel状态不对就可能出现静默失败。ctx.writeAndFlush()则是你在自定义handler里最常用的发送方法。ctx是ChannelHandlerContext它绑定了当前handler在pipeline中的位置。调用ctx.writeAndFlush()时数据会从当前handler开始沿着outbound方向依次经过后续的handler最终写入socket。如果你在pipeline的末尾调用ctx.writeAndFlush()那么数据可能没有任何编码器处理就直接到了head节点结果可想而知。2. 最高频原因pipeline里少了StringEncoder2.1 为什么字符串会变成“不支持的message type”先给出结论八成以上的“字符串收不到”问题都是因为pipeline里没有添加StringEncoder。很多人不理解我明明调用了ctx.writeAndFlush(hello)为啥客户端收不到因为Netty本身不知道你发的这个String对象该怎么变成字节。Netty的底层传输单位是ByteBuf你在pipeline里写任何对象最终都必须由某个MessageToByteEncoder把它转成ByteBuf再写入socket。如果你在初始化pipeline时只加了StringDecoder和StringEncoder这样的配置那么StringEncoder会负责把字符串编码成UTF-8字节。但如果你的pipeline里压根没有StringEncoderString对象就会被一直向后传最终传到pipeline的head节点。head节点检查到这不是ByteBuf也不是FileRegion就会返回一个错误。这个错误通常长这样Unsupported message type: String (expected: ByteBuf or FileRegion)你可能会问那为什么我没看到报错因为writeAndFlush是异步操作真正执行这一段代码的是Netty的I/O线程异常被封装在返回的ChannelFuture里。你如果没有给这个future添加ChannelFutureListener异常就像石沉大海客户端那边自然“什么都收不到”。还有一种变体情况是有些项目为了做Java对象传输在pipeline里加了ObjectEncoder和ObjectDecoder。这种情况下你writeAndFlush(hello)并不会报错因为ObjectEncoder会用JDK自带的序列化机制把字符串变成一串Java序列化字节流。如果接收端恰好是Java程序且配了ObjectDecoder那没问题但如果你客户端是C、Python、前端通过WebSocket网关转发那这串字节根本没法解析表现也是客户端收不到。2.2 正确的Pipeline配置顺序这里我直接给一个标准的服务端配置示例你看完再对比一下你自己的代码。ServerBootstrap bootstrap new ServerBootstrap(); bootstrap.group(bossGroup, workerGroup) .channel(NioServerSocketChannel.class) .childHandler(new ChannelInitializerSocketChannel() { Override protected void initChannel(SocketChannel ch) { ChannelPipeline pipeline ch.pipeline(); pipeline.addLast(decoder, new StringDecoder(StandardCharsets.UTF_8)); pipeline.addLast(encoder, new StringEncoder(StandardCharsets.UTF_8)); pipeline.addLast(handler, new MyServerHandler()); } });对应的handler里发送字符串消息正确姿势是public class MyServerHandler extends SimpleChannelInboundHandlerString { Override protected void channelRead0(ChannelHandlerContext ctx, String msg) { System.out.println(服务端收到消息: msg); ctx.writeAndFlush(服务端回应: msg); } }看到区别了吗StringEncoder必须加在StringDecoder之后、业务handler之前这样业务handler里ctx.writeAndFlush(xxx)时字符串会从当前handler位置向后传播经过StringEncoder编码成ByteBuf再走到pipeline尾部写入socket。如果你把StringEncoder加在业务handler的后面那么业务handler调用ctx.writeAndFlush(xxx)时outbound方向会先经过业务handler自己然后才到StringEncoder这种情况也可以但如果你把StringEncoder加在业务handler的前面outbound方向的反向传播就会绕过它字符串到了tail节点后报“Unsupported message type”。2.3 非Java客户端怎么配对编码如果你写的是跨语言通信比如客户端用的是Python的socket、C的tcp库或者是Go语言写的网关那StringEncoder也不是万能的。StringEncoder默认按UTF-8编码字符串如果你的对端按GBK解码文字就会变成乱码。更麻烦的是StringEncoder只负责把字符串变成字节流它不处理消息边界。也就是说服务端连续writeAndFlush(abc)两次客户端对端收到的字节可能是abcdef连在一起这就引出了粘包问题。跨语言通信的场景下我一般会在字符串后面追加\r\n然后客户端用LineBasedFrameDecoder按行读取这样才能保证每条消息是一条完整的数据。如果不想手动拼换行符你可以在管道里加一个LineEncoder它会自动在消息后面追加换行符。3. channelGroup批量发送的四个隐蔽坑3.1 write和flush是两步别以为writeAndFlush一定可靠很多人用channelGroup做广播代码写完发现消息发不出去就开始怀疑是channelGroup本身的问题。这里先解释一个关键机制的细节。channelGroup.writeAndFlush(msg)这个方法名很误导人它看起来就像是“对每个channel调用writeAndFlush”但Netty实际做的事是分两阶段执行的。第一阶段对group里的每个channel调用write(msg)这一步只是把消息对象写入channel的pipeline传播链路里如果是非ByteBuf对象就走一遍encoder最终把字节写入ChannelOutboundBuffer缓冲区但此时并没有真正把数据flush到socket。第二阶段Netty会把所有channel的write future收集起来在其全部完成之后再遍历这些channel依次调用flush()这次才真正把缓冲的数据发送到网络。问题就出在这个“等所有write完成再flush”的设计上。如果group里有一个channel正处于不可写状态或者已经被关闭但还没移除它的write阶段就会失败。而这并不会阻止其他channel继续发送所以你看到的现象可能是大部分客户端都收到了只有一两个客户端一直收不到。而且如果group里的channel没有注册到event loop上比如连接刚建立就加入group还没完成registerwrite阶段的future会直接返回失败flush阶段可能根本不会触发。我遇到过一种情况客户端连接建立完服务端在channelActive回调里立刻把它加入group并广播欢迎语结果客户端一个消息都收不到。原因就是channel还未完全准备好触发写操作时channel内部状态不对。这里给一个安全做法发送前先判断channel状态并通过future监听明确感知失败channelGroup.writeAndFlush(hello).addListener((ChannelGroupFutureListener) future - { ChannelGroupFuture groupFuture future; if (!groupFuture.isSuccess()) { IteratorChannel iterator groupFuture.iterator(); while (iterator.hasNext()) { Channel channel iterator.next(); if (!groupFuture.isSuccess(channel)) { System.out.println(向channel发送失败: channel.id()); } } } });3.2 channel未注册/已关闭时group的静默失败channelGroup内部维护的是一个SetChannel它不会自动帮你剔除坏连接。如果一个channel因为网络原因被服务端关闭或者因为心跳超时被主动close只要你没显式调用group.remove(channel)它就会一直留在group里。之后你再调用group.writeAndFlush(msg)这个死连接也会被遍历到。对死连接执行write之后future会以失败状态结束但channelGroup不会因为这个失败抛异常外部不监听future的话什么都看不到。久而久之group里积累了大量死连接广播的效率越来越低甚至出现虚假的成功——你的广播返回的future是成功的但实际有一部分channel根本没发出去。所以使用channelGroup时一定要在连接关闭位置手动移除channelOverride public void channelInactive(ChannelHandlerContext ctx) { groups.remove(ctx.channel()); ctx.close(); }3.3 在group里混入“幽灵连接”的后果还有一种很头疼的情况是服务端为了管理方便在一个handler里把连接加入了某个channelGroup在另一个handler里往这个group里广播消息。如果这两个handler位于不同线程、不同生命周期阶段就会产生一些看不见的“幽灵连接”。比如连接建立后你在channelActive里执行groups.add(ctx.channel())但客户端非常快地发送数据然后断开整个过程可能发生在几百毫秒内。服务端在channelInactive里执行groups.remove(ctx.channel())之前广播逻辑已经执行了一次group.writeAndFlush此时channel已经是半关闭状态数据自然发不出去。解决思路很简单不要在channelActive和channelInactive这种回调里直接管理group而是在业务消息处理的地方先判断连接状态再决定是否加入和广播。如果实在要在这里管理至少要在group内做一次channel.isActive()的过滤。4. 客户端解码侧不匹配也能造成“收不到”4.1 字符集不一致一个UTF-8一个GBK有一次帮别人排查问题服务端用StringEncoder(StandardCharsets.UTF_8)发送中文消息客户端用Python的GBK解码结果打印出来全是乱码。乱码如果出现在你预期打印字符串的地方你通常能看到异常输出但如果客户端做了数据校验比如校验前几个字节是否为固定前缀那么乱码消息就会被直接丢弃表现就是客户端日志里什么都没有看起来像没收到。处理这种问题没有银弹必须保证两端字符集一致。我的习惯是统一为UTF-8并在建立连接时协商好协议版本和编码类型尽量避免“一个项目里既有UTF-8又有GBK”的情况。4.2 按行解码却没发换行符另一个非常高发的情况是客户端pipeline设计成了这样pipeline.addLast(new LineBasedFrameDecoder(1024)); pipeline.addLast(new StringDecoder()); pipeline.addLast(new ClientHandler());这种配置下客户端会按行读取数据——它必须等到\n才认为一条消息结束了。而服务端那边只是ctx.writeAndFlush(早上好)字符串末尾没有任何换行符。结果就是客户端一直读不到完整的行数据全都积压在解码器的缓冲里。你服务端抓包能看到数据发过去了客户端缓冲区也有字节但业务回调就是不触发。解决办法有三种服务端发送时拼上\nctx.writeAndFlush(早上好\n)。服务端pipeline加LineEncoder自动在消息后追加换行符。改掉客户端的解码策略用DelimiterBasedFrameDecoder指定分隔符。类似的如果客户端用FixedLengthFrameDecoder而服务端发送的字符串长度不固定那消息也会被切得稀碎客户端收到的全是乱码或无效数据。4.3 粘包半包把消息切成碎片粘包和半包问题常出现在长连接场景。比如服务端连续发送aaa、bbb、ccc三条消息客户端如果没做拆包处理收到的字节可能直接把三条消息拼接成了aaabbbccc。如果客户端协议里每条消息有固定头尾标识拼接后会导致一条消息的尾部跑到了另一条消息的开头解析器直接拒绝。反过来一条消息被拆成两个TCP包也是常事。比如发送一个很大的字符串客户端如果只读完第一个TCP分片就认为一条消息结束那后面的半截就会留在缓冲区里导致下一条消息解析错位。这类问题属于典型的TCP流式传输问题解决方案就是在pipeline里加解码器进行消息边界划分。字符串协议最推荐的是LineBasedFrameDecoderStringDecoder或者用LengthFieldBasedFrameDecoder自己定义长度字段。5. 发送时机和线程模型这些细节决定成败5.1 isActive与isWritable发送前先确认连接状态我见过不少人写代码拿到channel就直接writeAndFlush完全不判断连接状态。结果就是服务端在连接已经断开的情况下还在循环往channel里写数据每次写都返回一个失败future。Netty的channel有两个状态要区分清楚isActive()表示连接是否建立且未被关闭isWritable()表示底层写缓冲区是否还有空间。如果连接活跃但写缓冲超过了高水位isWritable()会变成false继续往里面写数据虽然不会立即报错但消息会积压在ChannelOutboundBuffer里延迟发送。对于实时性要求高的业务发送前加一个判断能避免很多无效IOif (ctx.channel().isActive() ctx.channel().isWritable()) { ctx.writeAndFlush(msg); } else { System.out.println(channel不可写或不可用跳过发送); }5.2 在channelRegistered和channelActive里发送的区别Netty的channel生命周期回调顺序是handlerAdded-channelRegistered-channelActive-channelRead-channelInactive-channelUnregistered-handlerRemoved。很多人把“连接建立后发个欢迎语”的逻辑放在channelActive里这本身没问题因为此时channel已经绑定了event loop并且socket已经就绪。但如果你放在channelRegistered里这个回调触发时channel尚未正式激活调用writeAndFlush可能会失败。更谨慎的做法是在channelActive里通过eventLoop().execute()把发送动作推迟到当前事件循环的下一个任务队列里。还有如果在handlerAdded里发送消息此时channel可能还没注册到event loopwriteAndFlush会返回一个失败future而且同样不会打印日志。所以我的建议是不要在handlerAdded和channelRegistered里做任何写操作。5.3 外部线程写数据别忘了flush和异常回调Netty的channel是线程安全的外部线程调用channel.writeAndFlush(msg)本身没问题它会自动把任务提交到channel对应的event loop线程。但有两种常见错误需要注意。第一种外部线程调用channel.write(msg)以为和writeAndFlush一样会发送结果数据只进入了缓冲区没有被flush到网络。这个问题本质上是对API语义不熟悉造成的write只入栈flush才真正发送。我建议统一使用writeAndFlush。第二种外部线程发送数据时没有监听future。比如你在一个定时任务里往所有客户端广播数据某个channel因客户端异常断电关闭了此时writeAndFlush返回的future会以失败结束。如果你没有通过addListener去查看失败原因这条消息就悄无声息地丢了你还会误以为广播全部成功。正确写法是ctx.writeAndFlush(心跳包).addListener((ChannelFutureListener) future - { if (!future.isSuccess()) { System.out.println(心跳包发送失败channel关闭); future.channel().close(); } });这一招能直接揪出90%“莫名收不到”的问题——错误原因就挂在future的cause上。6. 一次完整排查从日志到抓包6.1 服务端自测先用Loopback验证遇到客户端收不到消息的问题我会先改一下服务端handler把发送逻辑简化成“收到消息就原样返回”然后在本机写一个最简单的socket客户端去连服务端。这个方法能快速排除客户端的业务复杂性。Socket socket new Socket(127.0.0.1, 8080); OutputStream out socket.getOutputStream(); out.write(ping.getBytes(StandardCharsets.UTF_8)); out.flush(); BufferedReader reader new BufferedReader( new InputStreamReader(socket.getInputStream(), StandardCharsets.UTF_8)); System.out.println(reader.readLine()); socket.close();如果这个自测客户端能正常收到服务端返回的数据说明服务端发送链路没问题问题大概率出在真实客户端的解码器或协议上。如果这个自测客户端也收不到那服务端的pipeline或者发送逻辑就要重点检查。6.2 加日志和ChannelFutureListener排查的第二步是给所有发送点加上日志和future监听。你不必等出问题再排查直接在写代码时就把发送失败的日志打出来这是成本最低的预防措施。ctx.writeAndFlush(msg).addListener((ChannelFutureListener) future - { if (future.isSuccess()) { System.out.println(消息发送成功: msg); } else { System.out.println(消息发送失败: msg , 原因: future.cause()); } });一旦打了日志很多问题瞬间清晰。比如你经常会看到ClosedChannelException、NotYetConnectedException或者“channel is nt open”之类的错误。这类异常翻译过来就是连接往往已经断了或者在写操作执行时根本没有建立成功。日志能告诉你是什么错误、在哪个连接上失败这比靠猜效率高太多。6.3 抓包确认数据是否真的到了客户端如果日志显示isSuccess()为true但客户端依然收不到那就需要从网络层面确认。服务端和客户端在同一台机器上时可以用wireshark抓loopback流量过滤规则tcp.port 8080。不在同一台机器就用tcpdump抓包。抓包要看两层内容第一服务端的端口上是否出现了发往客户端IP端口的TCP数据段第二如果出现了数据段里的应用层字节和你预期发送的字符串是否一致。如果字节对不上那就是编码器问题如果字节对了但客户端业务没反应那就是客户端解码器或业务回调的问题。这一步能把问题范围精准地切开。6.4 常见问题速查表现象可能原因排查方向客户端一个包都抓不到pipeline没加StringEncoder检查childHandler里的编码器配置客户端抓到字节但业务回调没触发消息边界不匹配如缺换行符检查客户端的FrameDecoder配置客户端看到乱码两端字符集不一致统一为UTF-8大部分客户端收到、个别收不到channelGroup里残留死连接发送前过滤channel状态future返回成功但对方没反应write没调用flush改用writeAndFlush偶发消息丢失发送时channel已关闭监听future并关闭失败channel结合一些外围的报错也能帮你定位比如某些HTTP服务返回unexpected status 503 service unavailable: no available channel这类“no available channel”的本质就是目标线程/连接池里没有可用连接资源和Netty里对不可用channel执行写操作是同一个核心问题——对端不可用而你还在往它身上写数据。7. 我个人的心得与一个小技巧写了这么多年Netty我的体感是字符串消息客户端收不到十次有八次是pipeline里少了编码器或编码器位置不对剩下两次要么是channelGroup的批量flush机制没摸透要么是发送时channel已经悄悄关闭。很多问题其实都不是什么玄学而是Netty的API设计太异步默认吞掉了太多失败信号。最后分享一个我非常推荐的小习惯项目里封装一个统一的sendMsg方法把发送前的状态判断、发送后的future监听、失败日志全部收敛在一起。这样以后任何“客户端收不到”的问题第一件事就是看这方法打出来的日志而不是在几十个handler里翻代码。用好了这个方法你会发现所谓“灵异事件”九成都能在日志里找到答案。
返回列表