粘包半包问题

粘包/半包

比如发送两条消息:ABCDEF,正常情况对方应该是收到两条消息ABCDEF

非正常情况,对方可能一次性就收到两条消息的内容,即ABCDEF,也可能会分多次收到,比如:ABCDEF

对方一次性接收了多条消息,称之为粘包现象。比如收到ABCDEF一条消息。

对方多次接收了不完整消息,称之为半包现象。比如收到ABCDEF三条消息。

粘包/半包原因

粘包原因

我们知道,TCP 发送消息的时候是有缓冲区的,当消息的内容远小于缓冲区的时候,这条消息不会立马发送出去,而是跟其它的消息合并之后再发送出去,这样合并发送是明显能够提高效率的。

接收消息也是会通过 TCP 的缓冲区的,如果接收方读取得不及时,也有可能出现粘包现象

比如,缓冲区里面的ABC还没来得及读取,又来了一条消息DEF,这时候两条消息就合并在一起了,也就出现了粘包了。

出现粘包的两个主要原因:

  • 发送方发送的消息 < 缓冲区大小
  • 接收方读取消息不及时

半包原因

如果发送的消息太大,已经超过了缓冲区的大小,这时候就必须拆分发送,也就形成了半包现象

还有一种情况,网络协议各层是有最大负载的,所以,对应到各种协议它们是有最大发送数据的限制的,这种可以发送的最大数据称作 MTU(Maximum Transmission Unit,最大传输单元)。

出现半包的两个主要原因:

  • 发送方发送的消息 > 缓冲区的大小
  • 发送方发送的消息 > 协议的 MTU

本质原因

TCP 是流式协议,消息无边界。

TCP 协议本身像水流一样,源源不断,完全不知道消息的边界在哪里。

UDP 协议不会出现粘包 / 半包现象,它的消息是有明确边界的,不会像 TCP 一样出现粘包 / 半包现象。

解决粘包/半包

三种常用的解决粘包 / 半包问题的方法:定长法、分割符法、长度 + 内容法

定长法

固定长度确定消息的边界,比如传输的消息分别为ABCDEF

就找最长的那条消息,这里是ABC,那就以 3 为固定长度,不足三位的补足三位。

发送三条消息:ABCDEF

发送方缓冲区:ABCDXXEFX

接收方缓冲区:ABCDXXEFX

接收到三条消息:ABCDEF

这种方式最大的缺点就是浪费空间,所以不推荐。

分隔符法

使用固定的分割符分割消息,比如传输的消息分别为ABCDEFGHI\n,假如使用\n作为分割符。

发送三条消息:ABCDEFGHI\n

发送方缓冲区:ABC\nDEFG\nHI\n\n

接收方缓冲区:ABC\nDEFG\nHI\n\n

接收到三条消息:ABCDEFGHI\n

那么,就在消息的边界处加一个\n作为分割符,这样接收方拿到消息之后使用 \n 去分割消息即可。

这种方式的缺点:一是分割符本身作为传输内容时要转义,二是要扫描消息的内容才能确定消息的边界在哪里,所以也不是特别推荐。

长度+内容法

固定的字节数存储消息的长度,后面跟上消息的内容,读取消息的时候先读取长度,再一次性把消息的内容读取出来。

比如,传输的消息分别为 ABCDEFGHI

那么,就在消息前面分别加上长度一起传输,后面再跟上内容,这样即使三条消息一起传输也可以分得清清楚楚。

发送三条消息:ABCDEFGHI

发送方缓冲区:3ABC4DEFG2HI

接收方缓冲区:3ABC4DEFG2HI

接收到三条消息:ABCDEFGHI

这种方式的缺点是需要预先知道消息的最大长度

比较

方法如何确定消息边界优点缺点推荐度
定长法使用固定长度分割消息简单空间浪费不推荐
分割符法使用固定分割符分割消息简单分割符本身需要转义,且需要扫描消息的内容不特别推荐
长度 + 内容法先获取消息的长度,再按长度读取内容精确获取消息的内容需要预先知道消息的最大长度推荐

Netty解决粘包/半包

方法编码解码
定长法FixedLengthFrameDecoder
分割符法DelimiterBasedFrameDecoder
长度 + 内容法LengthFieldPrependerLengthFieldBasedFrameDecoder

定长法和分割符法没有编码对应的类,Netty没有实现。

使用的时候只需要在 childHandler 中添加一个解码器就可以了。

省略......
.childHandler(new ChannelInitializer<SocketChannel>() {
    @Override
    public void initChannel(SocketChannel ch) throws Exception {
        ChannelPipeline p = ch.pipeline();
        // 可以添加多个子Handler
        p.addLast(new LoggingHandler(LogLevel.INFO));
        // 只需要改这一个地方就可以了
        // 添加固定长度解码器,长度为3
        // p.addLast(new FixedLengthFrameDecoder(3));
        // 添加分割符法解码器, 设置最大长度为10, 分隔符为#
        // p.addLast(new DelimiterBasedFrameDecoder(10, Unpooled.wrappedBuffer(new byte[] {'#'})));
        ......
    }
});

Netty编解码方式

一次编解码与二次编解码

这里以解码为例

一次解码主要用于解决粘包 / 半包的问题,将缓冲区中的字节数组按照协议本身的格式进行分割,分割后的数据还是字节数组。

分割后的字节数组如何转换成 Java 里面使用的对象?通过二次解码,可以将字节数组转换成 Java 对象,然后传入我们自定义的 Handler 里面进行业务逻辑的处理。

例如:使用固定长度为3的解码器,缓冲区的消息内容为123456

  1. 运用一次解码将123456的字节数组拆分成123456的字节数组
  2. 运用二次解码将123456字节数组转换成JavaString类型的对象

Netty定义了下面两组类来分别表示一次编解码和二次编解码:

  • 一次编解码:MessageToByteEncoderByteToMessageDecoder
  • 二次编解码:MessageToMessageEncoderMessageToMessageDecoder

服务端接收请求的过程也是先拿到字节数组(在Netty中可以理解为ByteBuf),然后通过ByteToMessageDecoder转换成协议格式的字节数组,再把协议格式的字节数组通过MessageToMessageDecoder转换成Java对象。

常见的二次编解码方式

比如XML、JSON、Java 序列化等。特别是 JSON,基本上基于 Web 开发都使用 JSON 来传输数据。

还有一种序列化方式比较流行 ——Google 的Protobuf,它主要运用在客户端与服务端需要长连接的场景,比如游戏行业,另外,Go 语言中也喜欢用 Protobuf,非常方便,而且高效。

方式优点缺点
serialization(优化过的 Java 序列化)Java 原生,使用方便报文太大,不便于阅读,只能 Java 使用
JSON结构清晰,便于阅读,效率较高,跨语言报文较大
Protobuf使用方便,效率很高,报文很小,跨语言不便于阅读

对于性能要求不是特别高的系统,推荐使用 JSON 这种方式的,写起来简单,看起来也简单。

如果对于性能要求比较高,推荐使用 Protobuf,性能非常高,而且也不用写多少代码,还能很好地定义客户端与服务端之间的协议。

长度内容编解码

LengthFieldPrepender

LengthFieldPrepender继承MessageToMessageEncoder<ByteBuf>,是一个长度前置编码器,它负责在消息的头部设置消息的长度。

/**
 * 例如原始数据为12个字节
 * <pre>
 * +----------------+
 * | "HELLO, WORLD" |
 * +----------------+
 * </pre>
 * 消息头加入数据长度, 数据长度占用2个字节
 * <pre>
 * +--------+----------------+
 * + 0x000C | "HELLO, WORLD" |
 * +--------+----------------+
 * </pre>
 * If you turned on the {@code lengthIncludesLengthFieldLength} flag in the
 * constructor, the encoded data would look like the following
 * (12 (original data) + 2 (prepended data) = 14 (0xE)):
 * <pre>
 * +--------+----------------+
 * + 0x000E | "HELLO, WORLD" |
 * +--------+----------------+
 * </pre>
 */

LengthFieldPrepender主要有四个成员变量

public class LengthFieldPrepender extends MessageToMessageEncoder<ByteBuf> {
    // 设置字节序, 默认大字端, 在缓冲区处理数据是以大字端方式, 还是以小字端方式
    private final ByteOrder byteOrder;
    // 数据长度所占用的字节数, 没有默认值, 必须设置
    private final int lengthFieldLength;
    // 默认false, 数据长度中是否包含数据长度本身的长度
    private final boolean lengthIncludesLengthFieldLength;
    // 默认0, 长度调整字节数, 消息体的长度等于数据长度加上长度调整字节数
    private final int lengthAdjustment;
  	
}

查看构造函数可以知道,lengthFieldLength长度字段的值只能为 1,2,3,4,8,否则抛异常。这是因为:

  • byte类型的数据占用 1 个字节,可以writeBytereadByt,一次读写一个字节。

  • short类型的数据占用2个字节,可以writeShortreadShort,一次读写2个字节。

  • medium类型的字段占用3个字节,可以writeMediumreadMedium,一次读写3个字节。

  • int类型的字段占用 4 个字节,可以writeIntreadInt,一次读写4个字节。

  • double类型的数据占用 8 个字节,可以writeDoublereadDouble,一次读写8个字节。

下面看LengthFieldPrepender的核心方法encode方法,看看它是如何在消息的头部加入数据长度的。

public class LengthFieldPrepender extends MessageToMessageEncoder<ByteBuf> {
    // 设置字节序, 默认大字端, 在缓冲区处理数据是以大字端方式, 还是以小字端方式
    private final ByteOrder byteOrder;
    // 数据长度所占用的字节数, 没有默认值, 必须设置
    private final int lengthFieldLength;
    // 默认false, 数据长度中是否包含数据长度本身的长度
    private final boolean lengthIncludesLengthFieldLength;
    // 默认0, 长度调整字节数, 消息体的长度等于数据长度加上长度调整字节数
    private final int lengthAdjustment;

    @Override
    protected void encode(ChannelHandlerContext ctx, ByteBuf msg, List<Object> out) throws Exception {
        // 长度等于消息体的可读字节数加上长度调整字节数
        int length = msg.readableBytes() + lengthAdjustment;
        if (lengthIncludesLengthFieldLength) {
            // 如果数据长度字节中包含长度本身的长度时, 把长度本身的长度也加上。
            length += lengthFieldLength;
        }
        // 检查length不能小于0
        checkPositiveOrZero(length, "length");

        switch (lengthFieldLength) {
        case 1:
            if (length >= 256) {
                throw new IllegalArgumentException(
                        "length does not fit into a byte: " + length);
            }
            // 分配1个字节分缓冲区并写入length
            out.add(ctx.alloc().buffer(1).order(byteOrder).writeByte((byte) length));
            break;
        case 2:
            if (length >= 65536) {
                throw new IllegalArgumentException(
                        "length does not fit into a short integer: " + length);
            }
            // 分配2个字节分缓冲区并写入length
            out.add(ctx.alloc().buffer(2).order(byteOrder).writeShort((short) length));
            break;
        case 3:
            if (length >= 16777216) {
                throw new IllegalArgumentException(
                        "length does not fit into a medium integer: " + length);
            }
            // 分配3个字节分缓冲区并写入length
            out.add(ctx.alloc().buffer(3).order(byteOrder).writeMedium(length));
            break;
        case 4:
            // 分配4个字节分缓冲区并写入length
            out.add(ctx.alloc().buffer(4).order(byteOrder).writeInt(length));
            break;
        case 8:
            // 分配8个字节分缓冲区并写入length
            out.add(ctx.alloc().buffer(8).order(byteOrder).writeLong(length));
            break;
        default:
            throw new Error("should not reach here");
        }
        // 将传递过来的msg写入缓存, 交给下一个编码器处理
        out.add(msg.retain());
    }
}

LengthFieldBasedFrameDecoder

LengthFieldBasedFrameDecoder继承ByteToMessageDecoder,它是一个解码器,根据消息中的长度动态拆分ByteBuf。对设置了数据长度的消息体解析特别有用。

public class LengthFieldBasedFrameDecoder extends ByteToMessageDecoder {
    // 字节序,默认大字端
    private final ByteOrder byteOrder;
    // 一个数据包允许的最大长度, 初始化时必须设置
    private final int maxFrameLength;
    // 数据长度所在位置偏移量, 从第几位开始读数据长度
    private final int lengthFieldOffset;
    // 数据长度所占用的字节数
    private final int lengthFieldLength;
    // 默认值为 0, 结束偏移量
    private final int lengthFieldEndOffset;
    // 默认值为 0, 长度调整字节数
    private final int lengthAdjustment;
    // 默认值为0, 要剥离的初始字节
    private final int initialBytesToStrip;
    // 快速失败, 默认 true, 如果为 true 时, 不读完数据包就抛出异常, 否则读完数据包再抛出异常
    private final boolean failFast;
    // 是否跳过超出存储范围的字节, 默认false
    private boolean discardingTooLongFrame;
    // 最长的包长
    private long tooLongFrameLength;
    // 需要跳过的字节数
    private long bytesToDiscard;
}

在类的注释上,有很多范例,可以根据这些范例来理解这些成员变量是如何使用的。

第一种数据格式

  • 解码前:数据长度(消息体的长度)+ 消息体

  • 解码后:数据长度(消息体的长度)+ 消息体

/**
 * <b>lengthFieldOffset</b>   = <b>0</b>
 * <b>lengthFieldLength</b>   = <b>2</b>
 * lengthAdjustment    = 0
 * initialBytesToStrip = 0 (= do not strip header)
 *
 * BEFORE DECODE (14 bytes)         AFTER DECODE (14 bytes)
 * +--------+----------------+      +--------+----------------+
 * | Length | Actual Content |----->| Length | Actual Content |
 * | 0x000C | "HELLO, WORLD" |      | 0x000C | "HELLO, WORLD" |
 * +--------+----------------+      +--------+----------------+
 */

解码前的协议,数据长度占用2个字节,消息体为HELLO, WORLD字符串,消息体的长度为12个字节,因此数据长度位置填充12的十六进制数据0x000C
解码后的协议,解码后的协议和解析前的协议保持一致。

第二种数据格式

  • 解码前:数据长度(消息体的长度)+ 消息体
  • 解码后:消息体
/**
 * lengthFieldOffset   = 0
 * lengthFieldLength   = 2
 * lengthAdjustment    = 0
 * initialBytesToStrip = 2 (= the length of the Length field)
 *
 * BEFORE DECODE (14 bytes)         AFTER DECODE (12 bytes)
 * +--------+----------------+      +----------------+
 * | Length | Actual Content |----->| Actual Content |
 * | 0x000C | "HELLO, WORLD" |      | "HELLO, WORLD" |
 * +--------+----------------+      +----------------+
 */

解码后的数据长度不见了,只剩消息体。只需要把要跳过的初始字节initialBytesToStrip设置为数据长度所占用的字节数即可。

第三种数据格式

  • 解码前:数据长度(数据长度的长度 + 消息体的长度)+ 消息体

  • 解码后:数据长度(数据长度的长度 + 消息体的长度)+ 消息体

/**
 * lengthFieldOffset   =  0
 * lengthFieldLength   =  2
 * lengthAdjustment    = -2 (= the length of the Length field)
 * initialBytesToStrip =  0
 *
 * BEFORE DECODE (14 bytes)         AFTER DECODE (14 bytes)
 * +--------+----------------+      +--------+----------------+
 * | Length | Actual Content |----->| Length | Actual Content |
 * | 0x000E | "HELLO, WORLD" |      | 0x000E | "HELLO, WORLD" |
 * +--------+----------------+      +--------+----------------+
 */

解码前和解码后的数据格式是一样的,唯一的不同是数据位的长度。消息体的长度只有12个字节,这里是14个字节,多了2个字节,多出的这2个字节是数据长度本身所占用的字节。
也就是说,数据长度位置的数据包含了数据长度本身所占用的字节数,数据长度位置的数据是整个数据包的数据长度,而不单单是消息体的长度。
在设置参数时,除了设置数据长度lengthFieldLength所占用的字节数,还需要将数据长度调整参数lengthAdjustment设置为-2 。

暂时先看这几种数据格式。

Demo

服务端代码

public class TestServer {
    static final int PORT = 8001;
    public static void main(String[] args) {
        // 1. 声明线程池
        EventLoopGroup bossGroup = new NioEventLoopGroup(1);
        EventLoopGroup workerGroup = new NioEventLoopGroup();
        try {
            // 2. 服务端引导器
            ServerBootstrap serverBootstrap = new ServerBootstrap();
            // 3. 设置线程池
            serverBootstrap.group(bossGroup, workerGroup)
                    // 4. 设置ServerSocketChannel的类型
                    .channel(NioServerSocketChannel.class)
                    // 5. 设置参数
                    .option(ChannelOption.SO_BACKLOG, 100)
                    // 6. 设置ServerSocketChannel对应的Handler,只能设置一个
                    .handler(new LoggingHandler(LogLevel.INFO))
                    // 7. 设置SocketChannel对应的Handler
                    .childHandler(new ChannelInitializer<SocketChannel>() {
                        @Override
                        public void initChannel(SocketChannel ch) throws Exception {
                            ChannelPipeline p = ch.pipeline();
                            // 可以添加多个子Handler
                            // p.addLast(new LoggingHandler(LogLevel.INFO));

                            // 解码器
                            // 数据包最大长度65535, 长度偏移量0, 长度2个字节, 长度调整0个字节, 要剥离2个初始字节
                            ch.pipeline().addLast(new LengthFieldBasedFrameDecoder(65535, 0, 2, 0, 2));
                            // ByteBuf转字符串
                            ch.pipeline().addLast(new StringDecoder(CharsetUtil.UTF_8));

                            // 编码器
                            // 数据长度占用两个字节,数据长度字段中不包含数据长度本身所占用的字节数
                            ch.pipeline().addLast(new LengthFieldPrepender(2));
                            // 字符串转ByteBuf
                            ch.pipeline().addLast(new StringEncoder(CharsetUtil.UTF_8));

                            p.addLast(new ServerHandler());
                        }
                    });

            // 8. 绑定端口
            ChannelFuture f = serverBootstrap.bind(PORT).sync();
            // 服务端启动监听事件
            f.addListener(new GenericFutureListener<Future<? super Void>>() {
                public void operationComplete(Future<? super Void> future) throws Exception {
                    //启动成功后的处理
                    if (future.isSuccess()) {
                        System.out.println("Started Successed:" + PORT);
                    } else {
                        System.out.println("Started Failed:" + PORT);
                    }
                }
            });
            // 9. 等待服务端监听端口关闭,这里会阻塞主线程
            f.channel().closeFuture().sync();
        } catch (InterruptedException e) {
            e.printStackTrace();
        } finally {
            // 10. 优雅地关闭两个线程池
            bossGroup.shutdownGracefully();
            workerGroup.shutdownGracefully();
        }
    }
}

class ServerHandler extends SimpleChannelInboundHandler<String>{
    @Override
    protected void channelRead0(ChannelHandlerContext ctx, String msg) throws Exception {
        System.out.println(ctx.channel() + ":" + msg);
        // 返回信息给客户端
        ctx.writeAndFlush("已收到:" + msg);
    }
}

客户端代码

public class TestClient {
    static final int PORT = 8001;
    public static void main(String[] args) throws Exception {
        // 工作线程池
        NioEventLoopGroup workerGroup = new NioEventLoopGroup();
        try {
            Bootstrap bootstrap = new Bootstrap();
            bootstrap.group(workerGroup);
            bootstrap.channel(NioSocketChannel.class);
            bootstrap.handler(new ChannelInitializer<SocketChannel>() {
                @Override
                protected void initChannel(SocketChannel ch) throws Exception {
                    ChannelPipeline pipeline = ch.pipeline();
                    // pipeline.addLast(new LoggingHandler(LogLevel.INFO));
                    // 解码器
                    // 数据包最大长度65535, 长度偏移量0, 长度2个字节, 长度调整0个字节, 要剥离2个初始字节
                    pipeline.addLast(new LengthFieldBasedFrameDecoder(65535, 0, 2, 0, 2));
                    // ByteBuf转字符串
                    pipeline.addLast(new StringDecoder(CharsetUtil.UTF_8));

                    // 编码器
                    // 数据长度占用两个字节,数据长度字段中不包含数据长度本身所占用的字节数
                    pipeline.addLast(new LengthFieldPrepender(2));
                    // 字符串转ByteBuf
                    pipeline.addLast(new StringEncoder(CharsetUtil.UTF_8));
                    pipeline.addLast(new ClientHandler());
                }
            });
            // 连接到服务端
            ChannelFuture future = bootstrap.connect(new InetSocketAddress(PORT)).sync();
            System.out.println("connect to server success");

            // 调用后这里会阻塞
            // future.channel().closeFuture().sync();

            // 这里实现可以在控制台输入发送信息
            Channel channel = future.channel();
            Scanner scanner = new Scanner(System.in);
            while (true) {
                String msg = scanner.nextLine();
                channel.writeAndFlush(Unpooled.copiedBuffer(msg, CharsetUtil.UTF_8));
                if ("quit".equals(msg)) {
                    channel.close();
                    break;
                }
            }
        } finally {
            workerGroup.shutdownGracefully();
        }
    }
}

class ClientHandler extends SimpleChannelInboundHandler<String> {
    @Override
    protected void channelRead0(ChannelHandlerContext ctx, String msg) throws Exception {
        System.out.println(ctx.channel() + ":" + msg);
    }
}
  • 20
    点赞
  • 18
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值