粘包、半包
Netty 是一个基于传输层协议构建的异步事件驱动的网络应用框架,可以非常方便构建应用层协议,Netty本身也提供了一些应用层协议的实现,如HTTP这些。
传输层负责端到端的数据传输,协议主要有:
- TCP 面向字节:发送的最小单位是字节 有连接,可靠交付。
- UDP: 面向报文:每次发送和接受都是以报文为单位。 无连接,不可靠交付 : 不需要建立连接,直接发送,不管是否正常收到。
数据传输: 数据先写到TCP 连接的发送缓冲区* *,TCP 协议栈后续会从这个缓冲区中取出数据,封装成 TCP 段(segments),交给 IP 层发送。
- netty中flush,是指立即写入TCP发送缓冲区。
数据发出的时机:TCP采用滑动窗口机制,以字节为单位考虑多方面的因素将应用层数据封装成多个TCP段,交给IP层发送出去。TCP段的大小需要考虑拥塞控制(网络还能承受多少数据)、接收方窗口(接受方还能接受多少数据)、 MSS(Maximum Segment Size)等因素。
- 所以我们每次写入的数据,不一定封装成一个完整TCP段同时发出去,这就是编程时(应用层)需要考虑的问题。
写数据-->用户缓冲区->OS缓冲区->确定这次能发送TCP段->发送TCP段->tcp段到达接受方缓冲区->接受方对应应用读取数据
- 这里tcp段可能会因为网络原因是乱序到达,乱序到达不会立即触发读取数据。
Nagle 算法:根据一系列机制减少小数据包数量、提升网络效率的优化机制,也就是说多次写入的数据可能会放在同一个一起TCP段发出。
根据TCP发送数据的机制(面向字节,分段发送和接受,Nagle算法 ),接受方缓冲区数据的情况,基于TCP的应用层需要考虑的问题有:粘包、半包
导致这些原因主要是是应用层调用send时,传输层并没有和我们想象中的那样,立即全部将我们要发送数据一起发送出去。
粘包
多条消息被合并成一条消息,接收方无法区分数据边界。
- Nagle算法优化
- 网络问题多段同时到达客户端,且接收方能接收
- 发送方发送速度过快,多条数据被合并成一个段发送
半包
一条消息被拆分成多段发送,接收方处理时无法区分数据是否完整
- 数据量过大,被拆分成多段发送,网络问题读完一段下一段才到。
解决方案
- 短连接:一次连接只发送一条消息,以断开连接为边界 缺点:消息长度短的时候效率低,频繁的握手和挥手。
- 固定消息长度:接收方接收够固定长度消息后再处理 缺点:不够灵活,长度短的浪费空间,不方便处理长度长的消息
- 采用分割符号:对于数据中包含分割符需要对分割符号进行转义处理
- 消息分为head和body两部分,head固定长度,存放一些和body长度相关的元数据
解码器
可以用于解决半包、粘包问题的同时将字符串数据转换为对应的消息类型。属于入站处理器,通常来说需要放在入站的第一个。
Netty提供的现成的解码器:
- netty中固定长度解码器:FixedLengthFrameDecoder
-
netty中预设长度解码器:LengthFieldBasedFrameDecoder
new LengthFieldBasedFrameDecoder(1024, 0, 1, 0, 1); // 1024:数据最大1024字节 // 0:定义长度字段的起始偏移量,即从数据包开头跳过多少字节后才能找到长度字段 // 1:指定长度字段自身占用的字节数(如2字节可表示0~65535) // 0:长度字段后,0字节用于表示其他内容 // 1:解码后需要跳过的字节数量,这里跳过长度字段(1字节) // 还有一个构造方法,最后会有一个failFast的boolean值,设置true,数据包长度超过maxFrameLength立即抛出异常
自定义解码器:解码器是一个入站处理器,点开FixedLengthFrameDecoder源码,仿照其写一个自己的解码器即可。想了解为什么这么做,只要往上找到对应的channelRead方法,阅读对应源码。
下面Netty解码器的核心逻辑,文末是一个总结实现一个解码器需要做那些事情。
// io.netty.handler.codec.ByteToMessageDecoder#channelRead
@Override
public void channelRead(ChannelHandlerContext ctx, Object msg) throws Exception {
if (msg instanceof ByteBuf) {
// 和解码相关的一个类:就是一个容器,存放数据解码结果
CodecOutputList out = CodecOutputList.newInstance();
try {
ByteBuf data = (ByteBuf) msg;
first = cumulation == null;
if (first) {
// ByteBuf cumulation;
cumulation = data;
} else {
// 积累数据:用ctx.alloc创建新的cumulation
cumulation = cumulator.cumulate(ctx.alloc(), cumulation, data);
}
// 解码:显然已经猜到用了模板方法模式,具体解码过程放到子类实现,且先往后看整个方法。然后猜测out干啥
callDecode(ctx, cumulation, out);
} catch (DecoderException e) {
throw e;
} catch (Exception e) {
throw new DecoderException(e);
} finally {
if (cumulation != null && !cumulation.isReadable()) {
// 读取完毕,释放内存
numReads = 0;
cumulation.release();
cumulation = null;
} else if (++ numReads >= discardAfterReads) {
// 读取次数达到上限,尝试释放一些数据,防止OOM
// We did enough reads already try to discard some bytes so we not risk to see a OOME.
numReads = 0;
discardSomeReadBytes();
}
// out的长度
int size = out.size();
// 按位或:insertSinceRecycled 返回true就是本次积累已经达到解码要求(说明已经完成解码)
// 如果完成解码:异或操作会为true,
// firedChannelRead需要配合channelReadComplete去看:如果已经完成了一次复合要求的数据累计,就允许channelReadComplete往下进行传播,否则就继续从channel中读数据
firedChannelRead |= out.insertSinceRecycled();
// 往pipeline中传播读事件:点开方法,重点还是在out,依靠size往下传播
// 看到这里能大致猜测,会在解码过程中,对out进行一些设置,以至于out的insertSinceRecycled和是size行为使得整个pipeline能够符合完整解码的行为。
fireChannelRead(ctx, out, size);
// 回收out:这里的数据是在 cumulation中,不关注out的recycle的行为
// 点进去也只是把对应数组重置为空,如果好奇为什么,查看初始化这个out的方法,会发现用的是一个线程变量缓存了16个对象,这里保证每次执行完这个方法后out是干净的,至于cumulation,handler在初始化的时候始终是new,所以每个channel的cumulation是隔离的。这倒是提醒了我们,如果某些handler的执行过程是线程安全,完全可以只创建一次。
out.recycle();
}
} else {
ctx.fireChannelRead(msg);
}
}
callDecode中的解码流程
// io.netty.handler.codec.ByteToMessageDecoder#callDecode
protected void callDecode(ChannelHandlerContext ctx, ByteBuf in, List<Object> out) {
try {
while (in.isReadable()) {
int outSize = out.size();
if (outSize > 0) {
// out 存放解码数据的结果集合
// out有值,就传播一下读事件
fireChannelRead(ctx, out, outSize);
out.clear();
// Check if this handler was removed before continuing with decoding.
// If it was removed, it is not safe to continue to operate on the buffer.
//
if (ctx.isRemoved()) {
// handler被移除了
break;
}
outSize = 0;
}
//in 就是cumulation 保存可读的长度
int oldInputLength = in.readableBytes();
// 解码:同时提供移除重入保护
decodeRemovalReentryProtection(ctx, in, out);
// Check if this handler was removed before continuing the loop.
// If it was removed, it is not safe to continue to operate on the buffer.
if (ctx.isRemoved()) {
break;
}
// outSize:为解码前的容量
if (outSize == out.size()) {
// 经历过一次解码后,out中没有解码成功的数据
if (oldInputLength == in.readableBytes()) {
// cumulation 的可读长度没有变化: 说明数据不够一次完整的解码,需要继续积累
break;
} else {
// 数据有被消费了,说明cumulation中数据被进行了一次解码操作,会重新执行循环,但是out中没有数据,说明可能是请求头的数据,这个时候会空转一次循环,然后退出循环
continue;
}
}
if (oldInputLength == in.readableBytes()) {
// 经历过一次解码后,out解码成功的数据量有变化,同时积累量的buff数据没变,属于不正常情况。
throw new DecoderException(
StringUtil.simpleClassName(getClass()) +
".decode() did not read anything but decoded a message.");
}
// 单次解码模式:循环只执行一次,结束本次解码,cumulation中其余数据,得等到下次被分配到线程资源的时候再执行,解码器对外提供的一种优化机制,避免线程资源被某个channel独占。
if (isSingleDecode()) {
break;
}
}
} catch (DecoderException e) {
throw e;
} catch (Exception cause) {
throw new DecoderException(cause);
}
}
具体调用子类的解码流程:decodeRemovalReentryProtection
// io.netty.handler.codec.ByteToMessageDecoder#decodeRemovalReentryProtection
final void decodeRemovalReentryProtection(ChannelHandlerContext ctx, ByteBuf in, List<Object> out)
throws Exception {
// handler状态标注为在解码中
decodeState = STATE_CALLING_CHILD_DECODE;
try {
// 调用子类解码流程
decode(ctx, in, out);
} finally {
// 解码过程中状态变了:只能是被移除了
boolean removePending = decodeState == STATE_HANDLER_REMOVED_PENDING;
// 重新修改为初始化
decodeState = STATE_INIT;
if (removePending) {
// 被移除了,调用handlerRemoved,执行一些在解码后,需要在移除时执行的操作。
// 为什么有一个protection:这里保证了处于移除状态时,即使handler状态被外部修改了,也能保证这个移除操作延迟到这里这里来执行。外部调用移除,触发移除事件也掉了 handlerRemoved,有对应修改为STATE_HANDLER_REMOVED_PENDING的逻辑
handlerRemoved(ctx);
}
}
}
到这里,netty能帮我们做的就这么多,核心解码算法都在decode中,需要我们自己去完成。如果你不想了解netty帮助我们做了那些事情,凭借多年的CV嗅觉,点开FixedLengthFrameDecoder代码,FixedLengthFrameDecoder的decode部分就是和你脑子里面想象的那样,拷贝过来稍微加一点逻辑即可。当然顶级的CV工程师,熟练掏出了AI工具,还没等你反应过来他已经在调试了,不幸的是有时候你下班了他还在调试。那些有追求的工程师抄完代码后,晚上下班后打开了别人博客和Netty官网偷偷学起来,摸着自己逐渐稀疏头顶,笑着迎接明日的朝阳。
// FixedLengthFrameDecoder
@Override
protected final void decode(ChannelHandlerContext ctx, ByteBuf in, List<Object> out) throws Exception {
Object decoded = decode(ctx, in);
if (decoded != null) {
out.add(decoded);
}
}
protected Object decode(
@SuppressWarnings("UnusedParameters") ChannelHandlerContext ctx, ByteBuf in) throws Exception {
if (in.readableBytes() < frameLength) {
// 长度不够,返回空
return null;
} else {
// 长度够了,从in中读取frameLength的长度
return in.readRetainedSlice(frameLength的长度);
}
}
对于自己的解码器,只需要继承ByteToMessageDecoder ,在decode中完成即可对于逻辑,符合要求后从in中读取数据,得到解码结果放入out对象中(list)中。
我们只要完成核心的解码逻辑,粘包和半包的问题就解决了。
- 解码逻辑会读一个完整的消息才会放入out集合中
- Netty会从out集合中获取一个完整消息,然后继续往下传播读事件