粘包和半包问题的出现原因主要是因为 TCP 协议是面向流的,而不是面向报文的。即发送方给接收方传输的是一整个数据流,但是接收方并不知道数据流中的哪一部分才是一个完整的数据报,需要自行判断。
如果是在发送方解决,通常采用的策略是在发送数据前将数据按照固定长度拆分成多个数据包,每个数据包附带特殊标记;如果发送变长数据,则在发送时加上数据的长度信息,接收方在接收到指定长度的数据后就可以认为是一个数据包。这样可以保证每个数据包都是完整的,从而避免了粘包和半包问题的出现。
示例代码
服务方
public class HelloWorldServer {
static final Logger log = LoggerFactory.getLogger(HelloWorldServer.class);
void start() {
NioEventLoopGroup boss = new NioEventLoopGroup(1);
NioEventLoopGroup worker = new NioEventLoopGroup();
try {
ServerBootstrap serverBootstrap = new ServerBootstrap();
serverBootstrap.channel(NioServerSocketChannel.class);
serverBootstrap.group(boss, worker);
serverBootstrap.childHandler(new ChannelInitializer() {
@Override
protected void initChannel(SocketChannel ch) throws Exception {
ch.pipeline().addLast(new LoggingHandler(LogLevel.DEBUG));
ch.pipeline().addLast(new ChannelInboundHandlerAdapter() {
@Override
public void channelActive(ChannelHandlerContext ctx) throws Exception {
log.debug("connected {}", ctx.channel());
super.channelActive(ctx);
}
@Override
public void channelInactive(ChannelHandlerContext ctx) throws Exception {
log.debug("disconnect {}", ctx.channel());
super.channelInactive(ctx);
}
});
}
});
ChannelFuture channelFuture = serverBootstrap.bind(8080);
log.debug("{} binding...", channelFuture.channel());
channelFuture.sync();
log.debug("{} bound...", channelFuture.channel());
channelFuture.channel().closeFuture().sync();
} catch (InterruptedException e) {
log.error("server error", e);
} finally {
boss.shutdownGracefully();
worker.shutdownGracefully();
log.debug("stoped");
}
}
public static void main(String[] args) {
new HelloWorldServer().start();
}
}
客户端
TODO
粘包
现象,发送 abc def,接收 abcdef
原因
应用层:接收方 ByteBuf 设置太大(Netty 默认 1024)
滑动窗口:假设发送方 256 bytes 表示一个完整报文,但由于接收方处理不及时且窗口大小足够大,这 256 bytes 字节就会缓冲在接收方的滑动窗口中,当滑动窗口中缓冲了多个报文就会粘包
Nagle 算法:会造成粘包
半包
现象,发送 abcdef,接收 abc def
原因
应用层:接收方 ByteBuf 小于实际发送数据量
滑动窗口:假设接收方的窗口只剩了 128 bytes,发送方的报文大小是 256 bytes,这时放不下了,只能先发送前 128 bytes,等待 ack 后才能发送剩余部分,这就造成了半包
MSS 限制:当发送的数据超过 MSS 限制后,会将数据切分发送,就会造成半包
本质是因为 TCP 是流式协议,消息无边界
滑动窗口
TCP 以一个段(segment)为单位,每发送一个段就需要进行一次确认应答(ack)处理,但如果这么做,缺点是包的往返时间越长性能就越差
为了解决此问题,引入了窗口概念,窗口大小即决定了无需等待应答而可以继续发送的数据最大值
窗口实际就起到一个缓冲区的作用,同时也能起到流量控制的作用
图中深色的部分即要发送的数据,高亮的部分即窗口
窗口内的数据才允许被发送,当应答未到达前,窗口必须停止滑动
如果 1001~2000 这个段的数据 ack 回来了,窗口就可以向前滑动
接收方也会维护一个窗口,只有落在窗口内的数据才能允许接收
Nagle 算法
即使发送一个字节,也需要加入 tcp 头和 ip 头,也就是总字节数会使用 41 bytes,非常不经济。因此为了提高网络利用率,tcp 希望尽可能发送足够大的数据,这就是 Nagle 算法产生的缘由
该算法是指发送端即使还有应该发送的数据,但如果这部分数据很少的话,则进行延迟发送
如果 SO_SNDBUF 的数据达到 MSS,则需要发送
如果 SO_SNDBUF 中含有 FIN(表示需要连接关闭)这时将剩余数据发送,再关闭
如果 TCP_NODELAY = true,则需要发送
已发送的数据都收到 ack 时,则需要发送
上述条件不满足,但发生超时(一般为 200ms)则需要发送
除上述情况,延迟发送
ctx.writeAndFlush(buffer);
// 发完即关
ctx.close();
每一条消息采用固定长度,缺点浪费空间
// 服务端优化, 此 Inbound 请加入到LoggingHandler 之前只能使用这一个解码器
ch.pipeline().addLast(new FixedLengthFrameDecoder(8));
// io.netty.channel.ChannelInboundHandlerAdapter#channelActive 执行的方法请使用此段
private static void sendMessage1(ChannelHandlerContext ctx) {
log.debug("Send sticky packet message");
Random r = new Random();
char c = 'a';
ByteBuf buffer = ctx.alloc().buffer();
for (int i = 0; i < 10; i++) {
// 每次都分配8字节长度的数组
byte[] bytes = new byte[8];
for (int j = 0; j < r.nextInt(8); j++) {
bytes[j] = (byte) c;
}
c++;
buffer.writeBytes(bytes);
}
ctx.writeAndFlush(buffer);
}
每一条消息采用分隔符,例如 \n,缺点需要转义 FixedLengthFrameDecoder
// 服务端优化, 此 Inbound 请加入到LoggingHandler 之前,只能使用这一个解码器
ch.pipeline().addLast(new LineBasedFrameDecoder(1024));
// io.netty.channel.ChannelInboundHandlerAdapter#channelActive 执行的方法请使用此段
private static void sendMessage3(ChannelHandlerContext ctx) {
log.debug("sending...");
Random r = new Random();
char c = 'a';
ByteBuf buffer = ctx.alloc().buffer();
for (int i = 0; i < 10; i++) {
for (int j = 1; j <= r.nextInt(16)+1; j++) {
buffer.writeByte((byte) c);
}
// 在 ASCII 表中,byte 10 表示换行符(\n)
buffer.writeByte(10);
c++;
}
ctx.writeAndFlush(buffer);
}
每一条消息分为 head 和 body,head 中包含 body 的长度
// 服务端替换解码器
ch.pipeline().addLast(new LengthFieldBasedFrameDecoder(1024, 0, 1, 0, 1));
// 服务端替换发送数据方法
// 预设长度
private static void sendMessage4(ChannelHandlerContext ctx) {
log.debug("sending...");
Random r = new Random();
char c = 'a';
ByteBuf buffer = ctx.alloc().buffer();
for (int i = 0; i < 10; i++) {
byte length = (byte) (r.nextInt(16) + 1);
// 先写入长度
buffer.writeByte(length);
// 再写入数据
for (int j = 1; j <= length; j++) {
buffer.writeByte((byte) c);
}
c++;
}
ctx.writeAndFlush(buffer);
}
该解码器关系两部分数据,一部分是数据长度(可能是消息的总长度 16bytes 也可能是仅实际消息长度12 bytes).
参数1 lengthFieldOffset 表示lengthField的偏移位置
参数2 lengthFieldLength 表示lengthField占用的长度(第一行表示 2字节长度)
参数3 lengthAdjustment 表示( 当前 lengthField为16, 要接受的内容(实际内容+hed2)长度总和是13 所lengthField值要减小 -3)
参数4 initialBytesToStrip 表示(发送前数据的长度与接受数据的长度差)
// 更换连接端口 6379
bootstrap.connect("127.0.0.1", 6379).sync();
// 加入 类静态字段
static byte[] LINE = {13, 10};
// io.netty.channel.ChannelInboundHandlerAdapter#channelActive 内方法换为此段
private static void set(ChannelHandlerContext ctx) {
ByteBuf buf = ctx.alloc().buffer();
buf.writeBytes("*3".getBytes());
buf.writeBytes(LINE);
buf.writeBytes("$3".getBytes());
buf.writeBytes(LINE);
buf.writeBytes("set".getBytes());
buf.writeBytes(LINE);
buf.writeBytes("$3".getBytes());
buf.writeBytes(LINE);
buf.writeBytes("aaa".getBytes());
buf.writeBytes(LINE);
buf.writeBytes("$3".getBytes());
buf.writeBytes(LINE);
buf.writeBytes("bbb".getBytes());
buf.writeBytes(LINE);
ctx.writeAndFlush(buf);
}
edis 协议是 Redis 使用的一种文本协议,它是一种行协议,即在一个 TCP 连接中,将多条 Redis 指令分别写入不同的行中,Redis 服务器读取每一行指令并返回执行结果。
Redis 协议的格式很简单,一条 Redis 指令由以下几个部分组成:
一个简单的例子:
*3\r\n
$3\r\n SET\r\n
$4\r\n key1\r\n
$5\r\n value\r\n
// 换行符和制表符是不必要的,此处是为了数据工整
上述指令表示执行 Redis 的 SET key1 value 指令,其中 *3
表示有 3 个参数,$3
表示第一个参数长度为 3,即 SET, $4
表示第二个参数长度为 4,即 key1,$5
表示第三个参数长度为 5,即 value。
import io.netty.bootstrap.ServerBootstrap;
import io.netty.channel.ChannelHandlerContext;
import io.netty.channel.ChannelInboundHandlerAdapter;
import io.netty.channel.ChannelInitializer;
import io.netty.channel.SimpleChannelInboundHandler;
import io.netty.channel.nio.NioEventLoopGroup;
import io.netty.channel.socket.nio.NioServerSocketChannel;
import io.netty.channel.socket.nio.NioSocketChannel;
import io.netty.handler.codec.http.DefaultFullHttpResponse;
import io.netty.handler.codec.http.HttpRequest;
import io.netty.handler.codec.http.HttpResponseStatus;
import io.netty.handler.codec.http.HttpServerCodec;
import io.netty.handler.logging.LoggingHandler;
import lombok.extern.slf4j.Slf4j;
import static io.netty.handler.codec.http.HttpHeaderNames.CONTENT_LENGTH;
@Slf4j
public class TestHttp {
public static void main(String[] args) throws InterruptedException {
ChannelInboundHandlerAdapter handler = new ChannelInboundHandlerAdapter() {
@Override
public void channelRead(ChannelHandlerContext ctx, Object msg) throws Exception {
// 会收到客户端的两个请求 DefaultHttpRequest LastHttpContent$1 // get方式 Content 的内容是空的。
System.out.println(msg.getClass());
super.channelRead(ctx, msg);
}
};
new ServerBootstrap()
.channel(NioServerSocketChannel.class)
.group(new NioEventLoopGroup())
.childHandler(new ChannelInitializer() {
@Override
protected void initChannel(NioSocketChannel ch) throws Exception {
ch.pipeline().addLast(new HttpServerCodec());
ch.pipeline().addLast(new LoggingHandler());
ch.pipeline().addLast(handler);
ch.pipeline().addLast(new SimpleChannelInboundHandler() {
@Override
protected void channelRead0(ChannelHandlerContext ctx, HttpRequest msg) throws Exception {
log.debug(msg.getUri());
// 给客户端响应数据
DefaultFullHttpResponse response = new DefaultFullHttpResponse(msg.protocolVersion(), HttpResponseStatus.OK);
String content = "Hello World
";
// 需要传递头信息,长度,不然客户端会一直接受消息
response.headers().setInt(CONTENT_LENGTH, content.length());
response.content().writeBytes(content.getBytes());
ctx.writeAndFlush(response);
}
});
}
})
.bind(8080)
.sync().channel();
}
}
魔数,用来在第一时间判定是否是无效数据包
版本号,可以支持协议的升级
序列化算法,消息正文到底采用哪种序列化反序列化方式,可以由此扩展,例如:json、protobuf、hessian、jdk
指令类型,是登录、注册、单聊、群聊... 跟业务相关
请求序号,为了双工通信,提供异步能力
正文长度
消息正文
编解码器
根据上面的要素,设计一个登录请求消息和登录响应消息,并使用 Netty 完成收发
@Slf4j
public class MessageCodec extends ByteToMessageCodec {
@Override
protected void encode(ChannelHandlerContext ctx, Message msg, ByteBuf out) throws Exception {
// 1. 4 字节的魔数
out.writeBytes(new byte[]{1, 2, 3, 4});
// 2. 1 字节的版本,
out.writeByte(1);
// 3. 1 字节的序列化方式 jdk 0 , json 1
out.writeByte(0);
// 4. 1 字节的指令类型
out.writeByte(msg.getMessageType());
// 5. 4 个字节
out.writeInt(msg.getSequenceId());
// 无意义,对齐填充
out.writeByte(0xff);
// 6. 获取内容的字节数组
ByteArrayOutputStream bos = new ByteArrayOutputStream();
ObjectOutputStream oos = new ObjectOutputStream(bos);
oos.writeObject(msg);
byte[] bytes = bos.toByteArray();
// 7. 长度
out.writeInt(bytes.length);
// 8. 写入内容
out.writeBytes(bytes);
}
@Override
protected void decode(ChannelHandlerContext ctx, ByteBuf in, List
测试
EmbeddedChannel channel = new EmbeddedChannel(
new LoggingHandler(),
new LengthFieldBasedFrameDecoder(
1024, 12, 4, 0, 0),
new MessageCodec()
);
// encode
LoginRequestMessage message = new LoginRequestMessage("zhangsan", "123", "张三");
// channel.writeOutbound(message);
// decode
ByteBuf buf = ByteBufAllocator.DEFAULT.buffer();
new MessageCodec().encode(null, message, buf);
ByteBuf s1 = buf.slice(0, 100);
ByteBuf s2 = buf.slice(100, buf.readableBytes() - 100);
s1.retain(); // 引用计数 2
channel.writeInbound(s1); // release 1
channel.writeInbound(s2);
什么时候可以加 @Sharable
当 handler 不保存状态(无字段)时,就可以安全地在多线程下被共享
但要注意对于编解码器类,不能继承 ByteToMessageCodec 或 CombinedChannelDuplexHandler 父类,他们的构造方法对 @Sharable 有限制
如果能确保编解码器不会保存状态,可以继承 MessageToMessageCodec 父类
对MessageCodec 的优化
@Slf4j
@ChannelHandler.Sharable
/**
* 必须和 LengthFieldBasedFrameDecoder 一起使用,确保接到的 ByteBuf 消息是完整的
*/
public class MessageCodecSharable extends MessageToMessageCodec {
@Override
protected void encode(ChannelHandlerContext ctx, Message msg, List