V2EX = way to explore
V2EX 是一个关于分享和探索的地方
现在注册
已注册用户请  登录
ReinerShir
V2EX  ›  程序员

请教一个关于 netty 的问题, TCP 客户端在短时间内重连,旧的通道不关闭会一直触发未接收到心跳事件

  •  
  •   ReinerShir · 2021-05-11 10:44:28 +08:00 · 2381 次点击
    这是一个创建于 1074 天前的主题,其中的信息可能已经有所发展或是发生改变。

    首先我添加了一个 netty 自带的心跳检测事件:

     channel.pipeline(). 
     //定义超时时间,参数分别为接收超时、发送超时、所有超时的时间
     addLast(new IdleStateHandler(60,0,0)).
    

    即 1 分钟未收到数据包就断开连接

    这是断开事件的处理:

    /**
    	 * TCP 事件触发管理
    	 */
    	@Override
    	public void userEventTriggered(ChannelHandlerContext ctx, Object evt) throws Exception {
        if (evt instanceof IdleStateEvent) {//超时事件
        IdleStateEvent idleEvent = (IdleStateEvent) evt;
        //超时一段时间未接收到消息
        if (idleEvent.state() == IdleState.READER_IDLE) {//读
        //断开连接
        ctx.channel().close();
        }
    
    }
    super.userEventTriggered(ctx, evt);
    }
    

    现在问题来了,某个设备断电了,两秒后通电又重连了,然而在过了 60 秒后这个事件被触发了!因为老的连接通道一直未接收到数据,这时我会断开老的连接:ctx.channel().close(); ,我理想中的情况是老的连接就废弃了就让它断开嘛,新的连接通道继续使用就是,然而在我断开老的连接时新的连接也会断开。

    这就导致了刚刚才通电恢复连接的设备又得重连,因此,想请教一下ctx.channel().close() 难道不是关闭当前的连接通道吗?为什么会将重新连接的通道也关闭了?

    我也试过如果重连了就不关闭通道,但是不关闭的话该事件会一直触发,每 60 秒触发一次。

    20 条回复    2021-05-21 13:37:37 +08:00
    dallaslu
        1
    dallaslu  
       2021-05-11 11:41:31 +08:00
    断开之前打印一下,即将断开的到底是哪一个 channel
    ReinerShir
        2
    ReinerShir  
    OP
       2021-05-11 13:24:36 +08:00
    @dallaslu 打印过,断开的 channel 和新连接的 channel ID 是不一样的,但是奇怪的是我断开老 channel,新的连接也跟着一起断了
    3dwelcome
        3
    3dwelcome  
       2021-05-11 14:02:18 +08:00
    是不是掉电后,系统两个 TCP 都用了一个 source port,打印一下呢。

    比如你服务器发送 close()断开老通道,是向 192.168.1.5:4567 发送 FIN 消息。

    结果新通道刚巧也是 192.168.1.5:4567,就被一起断开了。
    ReinerShir
        4
    ReinerShir  
    OP
       2021-05-11 14:36:16 +08:00
    @3dwelcome 这个我也怀疑过,但是并不是这种情况,以下是我断电后打印的日志:

    断电后重新连接:
    重新连接 ,通道信息:[id: 0x15d2a4e1, L:/xxxx:8100 - R:/xxxx:27877]

    重连后还是触发是未接收到心跳包:
    将断开连接 ,连接通道:[id: 0x71bcb2a6, L:/xxxx:8100 - R:/xxxx:33776]

    断开后再次重连:
    重新连接,通道信息:[id: 0x6dc6bf85, L:/xxxx:8100 - R:/xxxx:50792]

    可以看到每次重连源端口都是变化的
    4kingRAS
        5
    4kingRAS  
       2021-05-11 15:40:26 +08:00
    你断开不要在 userEventTriggered 里写,直接 pipeline.addLast(new ReadTimeoutHandler(35)); 用自带的超时 handler

    注意 pipeline 添加 handler 的顺序
    xinhochen
        6
    xinhochen  
       2021-05-11 17:47:28 +08:00
    可以抓下 TCP 包来看看新连接的断开是谁主动发起的,看下是 netty 还是设备
    ReinerShir
        7
    ReinerShir  
    OP
       2021-05-12 09:13:13 +08:00
    @xinhochen 新连接的断开是应该 netty 发起的,通过日志看到执行断开老的 channel,但是新的连接也跟着一块断开了
    dallaslu
        8
    dallaslu  
       2021-05-12 11:00:50 +08:00
    来个可复现问题的代码吧
    xinhochen
        9
    xinhochen  
       2021-05-12 14:35:52 +08:00
    @ReinerShir 代码中有没有其它主动关闭 channel 的地方?如果没有,最好是抓 tcp 包,看设备日志,看程序日志。这三个放在一起分析,基本就能发现问题了。程序日志只能说明 channel 断开了,并不能说明是谁断开的。
    ReinerShir
        10
    ReinerShir  
    OP
       2021-05-13 09:33:45 +08:00
    @xinhochen 设备因为条件限制没法看日志,通过程序的日志可以判断不是设备主动断开,因为我断开老 channel 后没有收到心跳包了,而且其它每个可能会断开 channel 的地方我都加了日志的,并没有触发


    @dallaslu 核心代码是就上面那些了,复现的流程是,设备断电再打开 -> 两秒后设备重连 -> 60 秒后触发 netty 超时(IdleState.READER_IDLE)事件 -> 断开老的通道连接(ctx.channel().close()) -> 然后就没有心跳包了,新连接也断开了
    xinhochen
        11
    xinhochen  
       2021-05-13 21:57:18 +08:00
    @ReinerShir 设备如果用的 SIM 卡,要考虑运营商核心网的影响:设备断电后,netty 与运营商核心网的连接不会断开。建议把相关日志全部发上来看下,而不是截取部分,避免因为思维盲点遗漏了关键信息。
    xinhochen
        12
    xinhochen  
       2021-05-13 21:58:22 +08:00
    补充运营商核心网的相关信息:设备断电后,netty 与运营商核心网的连接不会"马上"断开
    ReinerShir
        13
    ReinerShir  
    OP
       2021-05-17 16:01:38 +08:00
    @xinhochen 确实是用的 SIM 卡,用了个投机取巧的办法解决了

    ```java
    @Override
    public void userEventTriggered(ChannelHandlerContext ctx, Object evt) throws Exception {
    if (evt instanceof IdleStateEvent) {//超时事件
    IdleStateEvent idleEvent = (IdleStateEvent) evt;
    //超时一段时间未接收到消息
    if (idleEvent.state() == IdleState.READER_IDLE) {//读
    String deviceNo = NettyChannelManager.getKey(ctx.channel());

    if(!StringUtils.isEmpty(deviceNo)) {
    Channel savedChannel = NettyChannelManager.getChannel(deviceNo);
    Integer count = sessionRemoveMap.get(deviceNo);
    //断开连接事件有可能在重连后触发,因此要判断是否已经重连了,如果未重连才真正的断开连接
    if(count!=null||!savedChannel.isActive()) {
    if(count>0) {
    //移除标记
    sessionRemoveMap.remove(deviceNo);
    logger.warn("未接收到客户端消息,断开连接,设备号:{} ,{}",deviceNo,ctx.channel());
    //断开连接并移除保存的状态
    NettyChannelManager.removeAndClose(deviceNo);
    ctx.close();
    }else {
    //如果已经重连,则本通道不要再触发无心跳包事件
    //移除标记
    sessionRemoveMap.remove(deviceNo);
    return;
    }
    }else {
    logger.info("断线标记:{} channel:{}",deviceNo,ctx.channel());
    sessionRemoveMap.put(deviceNo, 1);
    }
    }else {
    logger.warn("将直接断开连接,channel:{}",ctx.channel());
    //直接断开
    ctx.close();
    }
    }
    }
    super.userEventTriggered(ctx, evt);
    }
    ```

    第一次断线只是标记一下,第二次断线才真正断开,如果重连了,清除标记

    ```java
    //如果已重连,将超时标记清除
    if((action & 0x000000ff)==EventContract.EVENT_REPORT_AUTHENTICATION){
    deviceId = NettyChannelManager.getKey(channel);
    //如果之前确实断线过一次
    if(sessionRemoveMap.remove(deviceId)!=null) {
    //移除老通道
    NettyChannelManager.removeChannel(ctx.channel());
    }
    sessionRemoveMap.put(deviceId,0);

    }

    ```
    ReinerShir
        14
    ReinerShir  
    OP
       2021-05-19 09:50:54 +08:00
    @dallaslu
    @xinhochen
    再请教一个问题,服务器发送指令到设备时有时候会和心跳包回复粘一块,比如心跳包用 channel.writeAndFlush(resp) 回复了 0x01 ,发送指令时同样用 channel.wrteAndFlush 返回 0x02,通过抓包发现偶尔会出现心跳包和指令连在一块的情况,即变成了 0x010x02
    xinhochen
        15
    xinhochen  
       2021-05-19 10:03:21 +08:00
    @ReinerShir 这种情况只能从消息定义上着手了,方案很多:分隔符(需要额外定义转义符,对应 netty 里的 DelimiterBasedFrameDecoder )、长度字段(对应 netty 里的 LengthFieldBasedFrameDecoder)
    ReinerShir
        16
    ReinerShir  
    OP
       2021-05-19 13:54:54 +08:00
    @xinhochen 我的错,没描述完整,我接收设备的消息是没问题的,服务端是做了分割的,代码如下:

    @Override
    protected void initChannel(SocketChannel channel) throws Exception {
    channel.pipeline().
    //定义超时时间,参数分别为接收超时、发送超时、所有超时的时间
    addLast(new IdleStateHandler(60,0,0)).
    //包尾以 EEFF 结束,使用 netty 自带的粘包处理器,false 参数表示不去掉包尾字符
    addLast(new DelimiterBasedFrameDecoder(1024,false,Unpooled.copiedBuffer(TCPServerUtils.hexStr2bytes("EEFF")))).
    //addLast(new LengthFieldBasedFrameDecoder(1024,2,1)).
    //addLast(new LengthFieldPrepender(1)).
    addLast(new CustomDecode()). //自定义解码器
    addLast(new ServerEventHandlerAdapter(event)) //自定义处理器
    ;
    }

    和设备通信是用分割符的,服务端做了粘包和半包处理。

    现在问题是出在设备端,有时候心跳回复和指令连在一起发过去了,我问了下设备那边的开发,他说是我发的不对,猜测可能是在心跳回复的那 0.几秒的时候正好指令过来了,所以就连在一块了
    xinhochen
        17
    xinhochen  
       2021-05-20 19:45:05 +08:00
    @ReinerShir 只有 Decoder,没有 Encoder 么?一般来说,发消息过去,也需要有分隔符之类的。要不然就会遇到你说的这种心跳和指令在一起的情况。
    ReinerShir
        18
    ReinerShir  
    OP
       2021-05-21 10:20:45 +08:00
    @xinhochen 我查了下发现 netty 发现没有 DelimiterBasedFrameEncoder .唯一找到一个 MessageToByteEncoder 不明白怎么用,官方文档根本没提起该类。

    我和设备之间通信是有自定义分割符的,例如返回信息给设备:
    AABB0201EEFF ,其中 EEFF 就是包尾分割符。

    现在的问题是我在代码中 writeAndFlush(AABB0201EEFF). 另一个线程 AABB0201EEFF(AABB0302EEFF),结果设备收到的是:AABB0201EEFFAABB0302EEFF ,这样子
    xinhochen
        19
    xinhochen  
       2021-05-21 12:06:46 +08:00
    @ReinerShir Encoder 就是自己继承 MessageToByteEncoder,然后实现 encode 方法。当然你那种把 AABB 和 EEFF 放在 writeAndFlush 里也是可以的,但是万一协议有变化,修改的工作量就大了,这就是为什么有 Encoder 存在的原因。
    设备收到这种是非常正常的,需要设备那边对收到数据做处理,就和你在 netty 里对 EEFF 做处理是一样的。TCP 里的数据是流式的,一次收到的数据不全,或者收到多余的数据都是再正常不过的事了。
    ReinerShir
        20
    ReinerShir  
    OP
       2021-05-21 13:37:37 +08:00
    @xinhochen 明白了,之所以来这里提问是因为设备开发那边说没办法做分割,所以才想能不能服务端这边确保每次发送数据都量独立一个包,谢谢啦。
    关于   ·   帮助文档   ·   博客   ·   API   ·   FAQ   ·   我们的愿景   ·   实用小工具   ·   2169 人在线   最高记录 6543   ·     Select Language
    创意工作者们的社区
    World is powered by solitude
    VERSION: 3.9.8.5 · 28ms · UTC 00:27 · PVG 08:27 · LAX 17:27 · JFK 20:27
    Developed with CodeLauncher
    ♥ Do have faith in what you're doing.